From a578e659e6b060a57a0800eb8037ad54e7f70eda Mon Sep 17 00:00:00 2001 From: Siddharth Date: Tue, 14 Oct 2025 23:16:03 -0700 Subject: [PATCH] tmp files & video editor preview --- dist-electron/main.js | 92 ++++++++++++++++++++++++++++----- dist-electron/preload.mjs | 10 +++- electron/ipc/handlers.ts | 79 ++++++++++++++++++---------- electron/main.ts | 44 +++++++++++++++- electron/preload.ts | 10 +++- electron/windows.ts | 1 + src/App.tsx | 8 +-- src/components/VideoEditor.tsx | 77 ++++++++++++++++++++++++++++ src/hooks/useScreenRecorder.ts | 94 +++++++++++++++++++++++----------- src/vite-env.d.ts | 17 +++++- 10 files changed, 349 insertions(+), 83 deletions(-) create mode 100644 src/components/VideoEditor.tsx diff --git a/dist-electron/main.js b/dist-electron/main.js index 2d1e494..d3dd1ef 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -1,8 +1,8 @@ import { BrowserWindow, screen, ipcMain, desktopCapturer, app } from "electron"; import { fileURLToPath } from "node:url"; import path from "node:path"; -import { uIOhook } from "uiohook-napi"; import fs from "node:fs/promises"; +import { uIOhook } from "uiohook-napi"; const __dirname$1 = path.dirname(fileURLToPath(import.meta.url)); const APP_ROOT = path.join(__dirname$1, ".."); const VITE_DEV_SERVER_URL$1 = process.env["VITE_DEV_SERVER_URL"]; @@ -54,7 +54,8 @@ function createEditorWindow() { webPreferences: { preload: path.join(__dirname$1, "preload.mjs"), nodeIntegration: false, - contextIsolation: true + contextIsolation: true, + webSecurity: false } }); win.webContents.on("did-finish-load", () => { @@ -214,14 +215,13 @@ let selectedSource = null; function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow) { ipcMain.handle("get-sources", async (_, opts) => { const sources = await desktopCapturer.getSources(opts); - const processedSources = sources.map((source) => ({ + return sources.map((source) => ({ id: source.id, name: source.name, display_id: source.display_id, thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, appIcon: source.appIcon ? source.appIcon.toDataURL() : null })); - return processedSources; }); ipcMain.handle("select-source", (_, source) => { selectedSource = source; @@ -255,31 +255,90 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g ipcMain.handle("stop-mouse-tracking", () => { return stopMouseTracking(); }); - ipcMain.handle("save-mouse-tracking-data", async (_, videoFileName) => { + ipcMain.handle("store-recorded-video", async (_, videoData, fileName) => { + try { + const videoPath = path.join(RECORDINGS_DIR, fileName); + await fs.writeFile(videoPath, Buffer.from(videoData)); + return { + success: true, + path: videoPath, + message: "Video stored successfully" + }; + } catch (error) { + console.error("Failed to store video:", error); + return { + success: false, + message: "Failed to store video", + error: String(error) + }; + } + }); + ipcMain.handle("store-mouse-tracking-data", async (_, fileName) => { try { const data = getTrackingData(); if (data.length === 0) { return { success: false, message: "No tracking data to save" }; } - const jsonFileName = videoFileName.replace(".webm", "_tracking.json"); - const filePath = path.join(process.env.HOME || "", "Downloads", jsonFileName); - await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8"); + const trackingPath = path.join(RECORDINGS_DIR, fileName); + await fs.writeFile(trackingPath, JSON.stringify(data, null, 2), "utf-8"); return { success: true, - message: "Tracking data saved", - filePath, - eventCount: data.length + path: trackingPath, + eventCount: data.length, + message: "Mouse tracking data stored successfully" }; } catch (error) { + console.error("Failed to store mouse tracking data:", error); return { success: false, - message: "Failed to save tracking data", + message: "Failed to store mouse tracking data", error: String(error) }; } }); + ipcMain.handle("get-recorded-video-path", async () => { + try { + const files = await fs.readdir(RECORDINGS_DIR); + const videoFiles = files.filter((file) => file.endsWith(".webm")); + if (videoFiles.length === 0) { + return { success: false, message: "No recorded video found" }; + } + const latestVideo = videoFiles.sort().reverse()[0]; + const videoPath = path.join(RECORDINGS_DIR, latestVideo); + return { success: true, path: videoPath }; + } catch (error) { + console.error("Failed to get video path:", error); + return { success: false, message: "Failed to get video path", error: String(error) }; + } + }); } const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); +async function cleanupOldRecordings() { + try { + const files = await fs.readdir(RECORDINGS_DIR); + const now = Date.now(); + const maxAge = 7 * 24 * 60 * 60 * 1e3; + for (const file of files) { + const filePath = path.join(RECORDINGS_DIR, file); + const stats = await fs.stat(filePath); + if (now - stats.mtimeMs > maxAge) { + await fs.unlink(filePath); + console.log(`Deleted old recording: ${file}`); + } + } + } catch (error) { + console.error("Failed to cleanup old recordings:", error); + } +} +async function ensureRecordingsDir() { + try { + await fs.mkdir(RECORDINGS_DIR, { recursive: true }); + console.log("Recordings directory ready:", RECORDINGS_DIR); + } catch (error) { + console.error("Failed to create recordings directory:", error); + } +} process.env.APP_ROOT = path.join(__dirname, ".."); const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"]; const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron"); @@ -316,10 +375,14 @@ app.on("activate", () => { createWindow(); } }); -app.on("will-quit", () => { +app.on("before-quit", async (event) => { + event.preventDefault(); cleanupMouseTracking(); + await cleanupOldRecordings(); + app.exit(0); }); -app.whenReady().then(() => { +app.whenReady().then(async () => { + await ensureRecordingsDir(); registerIpcHandlers( createEditorWindowWrapper, createSourceSelectorWindowWrapper, @@ -330,6 +393,7 @@ app.whenReady().then(() => { }); export { MAIN_DIST, + RECORDINGS_DIR, RENDERER_DIST, VITE_DEV_SERVER_URL }; diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index 712f445..1202937 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -22,7 +22,13 @@ electron.contextBridge.exposeInMainWorld("electronAPI", { stopMouseTracking: () => { return electron.ipcRenderer.invoke("stop-mouse-tracking"); }, - saveMouseTrackingData: (videoFileName) => { - return electron.ipcRenderer.invoke("save-mouse-tracking-data", videoFileName); + storeRecordedVideo: (videoData, fileName) => { + return electron.ipcRenderer.invoke("store-recorded-video", videoData, fileName); + }, + storeMouseTrackingData: (fileName) => { + return electron.ipcRenderer.invoke("store-mouse-tracking-data", fileName); + }, + getRecordedVideoPath: () => { + return electron.ipcRenderer.invoke("get-recorded-video-path"); } }); diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 924a82b..24d225f 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -2,8 +2,8 @@ import { ipcMain, desktopCapturer, BrowserWindow } from 'electron' import { startMouseTracking, stopMouseTracking, getTrackingData } from './mouseTracking' import fs from 'node:fs/promises' import path from 'node:path' +import { RECORDINGS_DIR } from '../main' -// Store selected source let selectedSource: any = null export function registerIpcHandlers( @@ -12,21 +12,17 @@ export function registerIpcHandlers( getMainWindow: () => BrowserWindow | null, getSourceSelectorWindow: () => BrowserWindow | null ) { - // Get available desktop capturer sources ipcMain.handle('get-sources', async (_, opts) => { const sources = await desktopCapturer.getSources(opts) - const processedSources = sources.map(source => ({ + return sources.map(source => ({ id: source.id, name: source.name, display_id: source.display_id, thumbnail: source.thumbnail ? source.thumbnail.toDataURL() : null, appIcon: source.appIcon ? source.appIcon.toDataURL() : null })) - - return processedSources }) - // Select a source for recording ipcMain.handle('select-source', (_, source) => { selectedSource = source const sourceSelectorWin = getSourceSelectorWindow() @@ -36,12 +32,10 @@ export function registerIpcHandlers( return selectedSource }) - // Get the currently selected source ipcMain.handle('get-selected-source', () => { return selectedSource }) - // Open the source selector window ipcMain.handle('open-source-selector', () => { const sourceSelectorWin = getSourceSelectorWindow() if (sourceSelectorWin) { @@ -51,7 +45,6 @@ export function registerIpcHandlers( createSourceSelectorWindow() }) - // Switch from HUD overlay to editor window ipcMain.handle('switch-to-editor', () => { const mainWin = getMainWindow() if (mainWin) { @@ -60,18 +53,35 @@ export function registerIpcHandlers( createEditorWindow() }) - // Start mouse tracking ipcMain.handle('start-mouse-tracking', () => { return startMouseTracking() }) - // Stop mouse tracking ipcMain.handle('stop-mouse-tracking', () => { return stopMouseTracking() }) - // Save mouse tracking data to file - ipcMain.handle('save-mouse-tracking-data', async (_, videoFileName: string) => { + ipcMain.handle('store-recorded-video', async (_, videoData: ArrayBuffer, fileName: string) => { + try { + const videoPath = path.join(RECORDINGS_DIR, fileName) + await fs.writeFile(videoPath, Buffer.from(videoData)) + + return { + success: true, + path: videoPath, + message: 'Video stored successfully' + } + } catch (error) { + console.error('Failed to store video:', error) + return { + success: false, + message: 'Failed to store video', + error: String(error) + } + } + }) + + ipcMain.handle('store-mouse-tracking-data', async (_, fileName: string) => { try { const data = getTrackingData() @@ -79,24 +89,41 @@ export function registerIpcHandlers( return { success: false, message: 'No tracking data to save' } } - // Save to the same directory as the video, with .json extension - const jsonFileName = videoFileName.replace('.webm', '_tracking.json') - const filePath = path.join(process.env.HOME || '', 'Downloads', jsonFileName) + const trackingPath = path.join(RECORDINGS_DIR, fileName) + await fs.writeFile(trackingPath, JSON.stringify(data, null, 2), 'utf-8') - await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8') - - return { - success: true, - message: 'Tracking data saved', - filePath, - eventCount: data.length + return { + success: true, + path: trackingPath, + eventCount: data.length, + message: 'Mouse tracking data stored successfully' } } catch (error) { - return { - success: false, - message: 'Failed to save tracking data', + console.error('Failed to store mouse tracking data:', error) + return { + success: false, + message: 'Failed to store mouse tracking data', error: String(error) } } }) + + ipcMain.handle('get-recorded-video-path', async () => { + try { + const files = await fs.readdir(RECORDINGS_DIR) + const videoFiles = files.filter(file => file.endsWith('.webm')) + + if (videoFiles.length === 0) { + return { success: false, message: 'No recorded video found' } + } + + const latestVideo = videoFiles.sort().reverse()[0] + const videoPath = path.join(RECORDINGS_DIR, latestVideo) + + return { success: true, path: videoPath } + } catch (error) { + console.error('Failed to get video path:', error) + return { success: false, message: 'Failed to get video path', error: String(error) } + } + }) } diff --git a/electron/main.ts b/electron/main.ts index 3908322..98bb337 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1,12 +1,45 @@ import { app, BrowserWindow } from 'electron' import { fileURLToPath } from 'node:url' import path from 'node:path' +import fs from 'node:fs/promises' import { createHudOverlayWindow, createEditorWindow, createSourceSelectorWindow } from './windows' import { registerIpcHandlers } from './ipc/handlers' import { cleanupMouseTracking } from './ipc/mouseTracking' const __dirname = path.dirname(fileURLToPath(import.meta.url)) +export const RECORDINGS_DIR = path.join(app.getPath('userData'), 'recordings') + +// Cleanup old recordings (older than 1 day) +async function cleanupOldRecordings() { + try { + const files = await fs.readdir(RECORDINGS_DIR) + const now = Date.now() + const maxAge = 1 * 24 * 60 * 60 * 1000 + + for (const file of files) { + const filePath = path.join(RECORDINGS_DIR, file) + const stats = await fs.stat(filePath) + + if (now - stats.mtimeMs > maxAge) { + await fs.unlink(filePath) + console.log(`Deleted old recording: ${file}`) + } + } + } catch (error) { + console.error('Failed to cleanup old recordings:', error) + } +} + +async function ensureRecordingsDir() { + try { + await fs.mkdir(RECORDINGS_DIR, { recursive: true }) + console.log('Recordings directory ready:', RECORDINGS_DIR) + } catch (error) { + console.error('Failed to create recordings directory:', error) + } +} + // The built directory structure // // ├─┬─┬ dist @@ -68,12 +101,19 @@ app.on('activate', () => { } }) -app.on('will-quit', () => { +// Cleanup old recordings on quit (both macOS and other platforms) +app.on('before-quit', async (event) => { + event.preventDefault() cleanupMouseTracking() + await cleanupOldRecordings() + app.exit(0) }) // Register all IPC handlers when app is ready -app.whenReady().then(() => { +app.whenReady().then(async () => { + // Ensure recordings directory exists + await ensureRecordingsDir() + registerIpcHandlers( createEditorWindowWrapper, createSourceSelectorWindowWrapper, diff --git a/electron/preload.ts b/electron/preload.ts index 132260a..f355cdf 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -22,7 +22,13 @@ contextBridge.exposeInMainWorld('electronAPI', { stopMouseTracking: () => { return ipcRenderer.invoke('stop-mouse-tracking') }, - saveMouseTrackingData: (videoFileName: string) => { - return ipcRenderer.invoke('save-mouse-tracking-data', videoFileName) + storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { + return ipcRenderer.invoke('store-recorded-video', videoData, fileName) + }, + storeMouseTrackingData: (fileName: string) => { + return ipcRenderer.invoke('store-mouse-tracking-data', fileName) + }, + getRecordedVideoPath: () => { + return ipcRenderer.invoke('get-recorded-video-path') } }) \ No newline at end of file diff --git a/electron/windows.ts b/electron/windows.ts index 2125b65..7c3211b 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -61,6 +61,7 @@ export function createEditorWindow(): BrowserWindow { preload: path.join(__dirname, 'preload.mjs'), nodeIntegration: false, contextIsolation: true, + webSecurity: false, }, }) diff --git a/src/App.tsx b/src/App.tsx index 217006a..caa4436 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { LaunchWindow } from "./components/LaunchWindow"; import { SourceSelector } from "./components/SourceSelector"; +import { VideoEditor } from "./components/VideoEditor"; import { useEffect, useState } from "react"; export default function App() { @@ -28,12 +29,7 @@ export default function App() { } if (windowType === 'editor') { - return ( -
-

Video Editor

-

Recording stopped. Video editor interface coming soon...

-
- ); + return ; } return ( diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx new file mode 100644 index 0000000..236dc7d --- /dev/null +++ b/src/components/VideoEditor.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from "react"; + +export function VideoEditor() { + const [videoPath, setVideoPath] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + loadVideo(); + }, []); + + const loadVideo = async () => { + try { + const result = await window.electronAPI.getRecordedVideoPath(); + + if (result.success && result.path) { + setVideoPath(`file://${result.path}`); + console.log('Loading video from:', result.path); + } else { + setError(result.message || 'Failed to load video'); + } + } catch (err) { + setError('Error loading video: ' + String(err)); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( +
+
Loading video...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + return ( +
+

Video Editor

+ +
+ {videoPath && ( +
+ +
+ Video path: {videoPath} +
+
+ ); +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index d658298..8f16f18 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -28,7 +28,6 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const startRecording = async () => { try { - // Get the selected source from the main process const selectedSource = await window.electronAPI.getSelectedSource(); if (!selectedSource) { @@ -36,10 +35,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } - // Start mouse tracking await window.electronAPI.startMouseTracking(); - // Use the selected source const stream = await (navigator.mediaDevices as any).getUserMedia({ audio: false, video: { @@ -55,55 +52,90 @@ export function useScreenRecorder(): UseScreenRecorderReturn { throw new Error("Failed to get media stream"); } + const videoTrack = streamRef.current.getVideoTracks()[0]; + const settings = videoTrack.getSettings(); + const width = settings.width || 1920; + const height = settings.height || 1080; + const totalPixels = width * height; + + let bitrate: number; + if (totalPixels <= 1920 * 1080) { + bitrate = 150_000_000; // 150 Mbps for 1080p + } else if (totalPixels <= 2560 * 1440) { + bitrate = 250_000_000; // 250 Mbps for 1440p + } else { + bitrate = 400_000_000; // 400 Mbps for 4K + } + + console.log(`Recording at ${width}x${height} with bitrate: ${bitrate / 1_000_000} Mbps`); + chunksRef.current = []; - let mimeType = "video/webm;codecs=vp9"; + const mimeType = "video/webm;codecs=vp9"; const recorder = new MediaRecorder(streamRef.current, { mimeType, - videoBitsPerSecond: 16_000_000, + videoBitsPerSecond: bitrate, }); mediaRecorderRef.current = recorder; + recorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) { chunksRef.current.push(e.data); } }; + recorder.onstop = async () => { - if (streamRef.current) { - streamRef.current.getTracks().forEach((track) => track.stop()); - streamRef.current = null; - } + // Don't stop stream here - already stopped in stopRecording for immediate indicator removal + // Just cleanup the ref + streamRef.current = null; + if (chunksRef.current.length === 0) return; - const blob = new Blob(chunksRef.current, { type: mimeType }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; + const videoBlob = new Blob(chunksRef.current, { type: mimeType }); + const timestamp = Date.now(); + const videoFileName = `recording-${timestamp}.webm`; + const trackingFileName = `recording-${timestamp}_tracking.json`; - // Generate filename with timestamp - const videoFileName = `recording-${Date.now()}.webm`; - a.download = videoFileName; - - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(url), 100); - - // Save mouse tracking data alongside the video try { - const result = await window.electronAPI.saveMouseTrackingData(videoFileName); - if (!result.success) { - console.warn('Failed to save tracking data:', result.message); + const arrayBuffer = await videoBlob.arrayBuffer(); + + console.log(`Saving video: ${videoFileName} (${(arrayBuffer.byteLength / 1024 / 1024).toFixed(2)} MB)`); + + const videoResult = await window.electronAPI.storeRecordedVideo( + arrayBuffer, + videoFileName + ); + + if (videoResult.success) { + console.log('✅ Video stored:', videoResult.path); + } else { + console.error('❌ Failed to store video:', videoResult.message); } + + const trackingResult = await window.electronAPI.storeMouseTrackingData(trackingFileName); + + if (trackingResult.success) { + console.log('Mouse tracking stored:', trackingResult.path); + console.log(`Captured ${trackingResult.eventCount} mouse events`); + } else { + console.warn('Failed to store tracking:', trackingResult.message); + } + + console.log('Opening editor window...'); + await window.electronAPI.switchToEditor(); + } catch (error) { - console.error('Error saving tracking data:', error); + console.error('Error saving recording:', error); } }; + recorder.onerror = () => { setRecording(false); }; + recorder.start(1000); setRecording(true); - } catch { + } catch (error) { + console.error('Failed to start recording:', error); setRecording(false); if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); @@ -117,10 +149,14 @@ export function useScreenRecorder(): UseScreenRecorderReturn { mediaRecorderRef.current && mediaRecorderRef.current.state === "recording" ) { + // Stop stream tracks IMMEDIATELY to remove macOS status indicator + if (streamRef.current) { + streamRef.current.getTracks().forEach((track) => track.stop()); + } + mediaRecorderRef.current.stop(); setRecording(false); - // Stop mouse tracking window.electronAPI.stopMouseTracking(); } }; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 8c897ad..1871327 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -17,11 +17,24 @@ interface Window { getSelectedSource: () => Promise startMouseTracking: () => Promise<{ success: boolean; startTime?: number }> stopMouseTracking: () => Promise<{ success: boolean; data?: any }> - saveMouseTrackingData: (videoFileName: string) => Promise<{ + storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean + path?: string message: string - filePath?: string + error?: string + }> + storeMouseTrackingData: (fileName: string) => Promise<{ + success: boolean + path?: string eventCount?: number + message: string + error?: string + }> + getRecordedVideoPath: () => Promise<{ + success: boolean + path?: string + message?: string + error?: string }> } } \ No newline at end of file