diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 07e39cf0d..da1037d03 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -150,7 +150,7 @@ interface Window { discarded?: boolean; error?: string; }>; - attachNativeMacWebcamRecording: (payload: { + attachWebcamToScreenRecording: (payload: { screenVideoPath: string; recordingId: number; webcam: import("../src/lib/recordingSession").RecordedVideoAssetInput; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 3a87176d2..4b3be1b03 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -344,7 +344,7 @@ type SelectedSource = { [key: string]: unknown; }; -type AttachNativeMacWebcamRecordingInput = { +type AttachWebcamToScreenRecordingInput = { screenVideoPath?: string; recordingId?: number; webcam?: RecordedVideoAssetInput; @@ -2143,34 +2143,35 @@ export function registerIpcHandlers( // On-disk write streams for in-progress recordings, keyed by output file name. // Chunks are appended as they arrive from ondataavailable so the renderer // never buffers the full video in memory (the #616 fix). Declared before the - // handlers that consume it (attach-native-mac-webcam-recording finalizes a + // handlers that consume it (attach-webcam-to-screen-recording finalizes a // streamed webcam through this registry). const recordingStreams = new RecordingStreamRegistry(); registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath); + // Attaches a webcam sidecar to a native screen recording that is already on + // disk. The renderer only marshals the (small) in-memory webcam bytes — the + // multi-GB screen file never round-trips over IPC (issue #11; the renderer + // previously readBinaryFile'd the whole screen recording back just to re-send + // it to store-recorded-session, which rewrote the same bytes to the same path). ipcMain.handle( - "attach-native-mac-webcam-recording", - async (_, payload: AttachNativeMacWebcamRecordingInput) => { + "attach-webcam-to-screen-recording", + async (_, payload: AttachWebcamToScreenRecordingInput) => { // When a streamed webcam is finalized to disk but a later step throws, this // holds its path so the catch can remove the orphaned file. let streamedWebcamRollbackPath: string | undefined; try { - if (process.platform !== "darwin") { - return { success: false, error: "Native macOS webcam attachment requires macOS." }; - } - const screenVideoPath = normalizeVideoSourcePath(payload.screenVideoPath); if (!screenVideoPath || !isPathWithinDir(screenVideoPath, RECORDINGS_DIR)) { return { success: false, - error: "Native macOS webcam attachment requires a recording output path.", + error: "Webcam attachment requires a recording output path.", }; } await fs.access(screenVideoPath, fsConstants.R_OK); if (!payload.webcam?.fileName) { - return { success: false, error: "Native macOS webcam attachment is missing video data." }; + return { success: false, error: "Webcam attachment is missing video data." }; } const webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); @@ -2188,7 +2189,7 @@ export function registerIpcHandlers( if (!payload.webcam.videoData || payload.webcam.videoData.byteLength === 0) { return { success: false, - error: "Native macOS webcam attachment is missing video data.", + error: "Webcam attachment is missing video data.", }; } await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); @@ -2218,10 +2219,10 @@ export function registerIpcHandlers( success: true, path: screenVideoPath, session, - message: "Native macOS webcam recording attached successfully", + message: "Webcam recording attached successfully", }; } catch (error) { - console.error("Failed to attach native macOS webcam recording:", error); + console.error("Failed to attach webcam recording:", error); // A streamed webcam was already finalized to disk before this failure; // remove the orphan so no stray *-webcam.webm lingers without a session. if (streamedWebcamRollbackPath) { diff --git a/electron/preload.ts b/electron/preload.ts index b8de562bf..ec985430c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -118,14 +118,14 @@ contextBridge.exposeInMainWorld("electronAPI", { stopNativeMacRecording: (discard?: boolean) => { return ipcRenderer.invoke("stop-native-mac-recording", discard); }, - attachNativeMacWebcamRecording: (payload: { + attachWebcamToScreenRecording: (payload: { screenVideoPath: string; recordingId: number; webcam: { fileName: string; videoData: ArrayBuffer }; cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode; durationMs?: number; }) => { - return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload); + return ipcRenderer.invoke("attach-webcam-to-screen-recording", payload); }, getCursorTelemetry: (videoPath?: string) => { return ipcRenderer.invoke("get-cursor-telemetry", videoPath); diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index f4962b2de..82d56ab0d 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -480,52 +480,49 @@ export function useScreenRecorder(): UseScreenRecorderReturn { // mid-stream write error) even when streaming, where it resolves empty. const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null); const webcamStreamed = activeWebcamRecorder.isStreaming(); - const screenRead = await window.electronAPI.readBinaryFile(nativeScreenPath); const hasWebcamData = webcamStreamed || (webcamBlob != null && webcamBlob.size > 0); - const canStore = hasWebcamData && screenRead.success && !!screenRead.data; - // Once store-recorded-session is called it owns the webcam's disk stream - // (it finalizes the file). Until then, any opened stream is ours to drop. - // store-recorded-session finalizes (and thus owns) the webcam disk stream - // only once it returns success. Mark ownership after that resolves, and - // discard in `finally` so a throw in fixWebmDuration/storeRecordedSession + // attach-webcam-to-screen-recording finalizes (and thus owns) the webcam + // disk stream only once it returns success. Mark ownership after that + // resolves, and discard in `finally` so a throw in fixWebmDuration/attach // still drops the partial sidecar instead of leaking it. - let storeOwnsWebcam = false; + let attachOwnsWebcam = false; try { - if (canStore && screenRead.data) { - const nativeScreenFileName = - nativeScreenPath.split(/[\\/]/).pop() ?? - `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`; + if (hasWebcamData) { const webcamFileName = activeNativeRecording.webcamFileName ?? `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; // Streamed webcam bytes are already on disk; send an empty buffer and let - // the main process patch the WebM duration there (mirrors the screen path). + // the main process patch the WebM duration there. The screen recording is + // already on disk and never round-trips through the renderer — the main + // process attaches the webcam sidecar to it directly. const webcamVideoData = webcamStreamed ? new ArrayBuffer(0) : await (await fixWebmDuration(webcamBlob as Blob, duration)).arrayBuffer(); - const stored = await window.electronAPI.storeRecordedSession({ - screen: { - videoData: screenRead.data, - fileName: nativeScreenFileName, - }, + const attached = await window.electronAPI.attachWebcamToScreenRecording({ + screenVideoPath: nativeScreenPath, + recordingId: activeNativeRecording.recordingId, webcam: { videoData: webcamVideoData, fileName: webcamFileName, }, - createdAt: activeNativeRecording.recordingId, cursorCaptureMode, durationMs: duration, }); - storeOwnsWebcam = stored.success; - if (stored.success && stored.session) { - storedSession = stored.session; + attachOwnsWebcam = attached.success; + if (attached.success && attached.session) { + storedSession = attached.session; + } else if (!attached.success) { + // Screen-only session from the stop handler still stands; surface + // the webcam loss instead of failing the whole recording. + console.error("Failed to attach webcam recording:", attached.error); + toast.error(attached.error ?? "Failed to store webcam recording"); } } } finally { - if (!storeOwnsWebcam) { - // Webcam never reached a successful store (no usable data, missing screen - // file, a mid-stream write error, or store threw/returned failure). Drop - // any partial file/stream. No-op for an in-memory recorder. + if (!attachOwnsWebcam) { + // Webcam never reached a successful attach (no usable data, a mid-stream + // write error, or attach threw/returned failure). Drop any partial + // file/stream. No-op for an in-memory recorder. await activeWebcamRecorder.discard().catch(() => undefined); } } @@ -633,7 +630,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } if (webcamAsset && result.path) { - const attachResult = await window.electronAPI.attachNativeMacWebcamRecording({ + const attachResult = await window.electronAPI.attachWebcamToScreenRecording({ screenVideoPath: result.path, recordingId: activeNativeRecording.recordingId, webcam: webcamAsset,