diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 18d44f1ae..9189ec6fe 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -155,6 +155,7 @@ interface Window { recordingId: number; webcam: import("../src/lib/recordingSession").RecordedVideoAssetInput; cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode; + durationMs?: number; }) => Promise<{ success: boolean; path?: string; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 1eb569efe..a85cfbfbd 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -349,6 +349,12 @@ type AttachNativeMacWebcamRecordingInput = { recordingId?: number; webcam?: RecordedVideoAssetInput; cursorCaptureMode?: CursorCaptureMode; + /** + * Recording duration in ms. Present when the webcam took the streaming path so + * its on-disk WebM (which lacks a Duration header) can be patched here, exactly + * as store-recorded-session patches streamed screen/webcam files. + */ + durationMs?: number; }; let selectedSource: SelectedSource | null = null; @@ -2134,9 +2140,20 @@ 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 + // streamed webcam through this registry). + const recordingStreams = new RecordingStreamRegistry(); + registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath); + ipcMain.handle( "attach-native-mac-webcam-recording", async (_, payload: AttachNativeMacWebcamRecordingInput) => { + // 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." }; @@ -2152,12 +2169,30 @@ export function registerIpcHandlers( await fs.access(screenVideoPath, fsConstants.R_OK); - if (!payload.webcam?.fileName || !payload.webcam.videoData) { + if (!payload.webcam?.fileName) { return { success: false, error: "Native macOS webcam attachment is missing video data." }; } const webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); - await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + // Streamed webcam bytes are already on disk (appended chunk-by-chunk during + // recording, the #616 fix); finalize() closes the stream and keeps the file. + // Otherwise the renderer sent the whole clip in memory and we write it here. + const webcamStreamed = await recordingStreams.finalize(payload.webcam.fileName); + if (webcamStreamed) { + // The file is now kept on disk; mark it so a later failure rolls it back. + streamedWebcamRollbackPath = webcamVideoPath; + if (isValidDurationMs(payload.durationMs)) { + await patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs); + } + } else { + if (!payload.webcam.videoData || payload.webcam.videoData.byteLength === 0) { + return { + success: false, + error: "Native macOS webcam attachment is missing video data.", + }; + } + await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + } const createdAt = typeof payload.recordingId === "number" && Number.isFinite(payload.recordingId) @@ -2187,6 +2222,11 @@ export function registerIpcHandlers( }; } catch (error) { console.error("Failed to attach native macOS 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) { + await fs.unlink(streamedWebcamRollbackPath).catch(() => undefined); + } return { success: false, error: error instanceof Error ? error.message : String(error), @@ -2195,11 +2235,6 @@ export function registerIpcHandlers( }, ); - // On-disk write streams for in-progress recordings, keyed by output file name. - // Chunks append as they arrive so the renderer never buffers the full video (#616). - const recordingStreams = new RecordingStreamRegistry(); - registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath); - ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => { try { return await storeRecordedSessionFiles(payload); diff --git a/electron/preload.ts b/electron/preload.ts index 96cc831fd..0a7f30780 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -123,6 +123,7 @@ contextBridge.exposeInMainWorld("electronAPI", { recordingId: number; webcam: { fileName: string; videoData: ArrayBuffer }; cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode; + durationMs?: number; }) => { return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload); }, diff --git a/src/hooks/recorderHandle.ts b/src/hooks/recorderHandle.ts index 6264b9be5..9b916c215 100644 --- a/src/hooks/recorderHandle.ts +++ b/src/hooks/recorderHandle.ts @@ -132,7 +132,22 @@ export function createRecorderHandle( return new Blob(memoryChunks, { type: mimeType }); } + let discarded = false; async function discard(): Promise { + // Idempotent: several early-exit paths may each call discard() for the same + // recorder (e.g. an inner catch plus an outer error handler). Set the flag + // synchronously so concurrent or repeated calls don't double-close the stream. + if (discarded) { + return; + } + discarded = true; + // Wait for a pending open to settle before deciding. Otherwise an open that + // resolves just after an early discard() would set streamOpened = true and + // flush chunks to a file this discard already skipped — orphaning the stream + // and partial file. Draining writeChain too keeps the close ordered after any + // flushed chunks. + await openPromise.catch(() => undefined); + await writeChain; if (streamOpened && fileName && api?.closeRecordingStream) { await api.closeRecordingStream(fileName); } diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index ce0e0b77f..f4962b2de 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -79,12 +79,18 @@ type NativeWindowsRecordingHandle = { finalizing: boolean; paused: boolean; webcamRecorder: RecorderHandle | null; + // File name the webcam sidecar streams to on disk, captured at start so finalize + // targets the exact same file regardless of the native recordingId echoed back. + webcamFileName?: string; }; type NativeMacRecordingHandle = { recordingId: number; finalizing: boolean; paused: boolean; + // File name the webcam sidecar streams to on disk, captured at start so finalize + // targets the exact same file regardless of the native recordingId echoed back. + webcamFileName?: string; }; export function useScreenRecorder(): UseScreenRecorderReturn { @@ -454,12 +460,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { try { const result = await window.electronAPI.stopNativeWindowsRecording(discard); if (discard || result.discarded) { + // Streamed webcam left an open stream + partial file; drop it. + await activeWebcamRecorder?.discard().catch(() => undefined); clearNativeRecordingState(); return true; } if (!result.success) { console.error("Failed to stop native Windows recording:", result.error); toast.error(result.error ?? "Failed to stop native Windows recording"); + await activeWebcamRecorder?.discard().catch(() => undefined); activeNativeRecording.finalizing = false; return true; } @@ -467,28 +476,57 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const nativeScreenPath = result.session?.screenVideoPath ?? result.path; let storedSession = result.session; if (activeWebcamRecorder && nativeScreenPath) { + // Await the blob promise to drain in-flight chunk writes (and surface a + // 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); - if (webcamBlob && webcamBlob.size > 0 && screenRead.success && screenRead.data) { - const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); - const nativeScreenFileName = - nativeScreenPath.split(/[\\/]/).pop() ?? - `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`; - const webcamFileName = `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; - const stored = await window.electronAPI.storeRecordedSession({ - screen: { - videoData: screenRead.data, - fileName: nativeScreenFileName, - }, - webcam: { - videoData: await fixedWebcamBlob.arrayBuffer(), - fileName: webcamFileName, - }, - createdAt: activeNativeRecording.recordingId, - cursorCaptureMode, - }); - if (stored.success && stored.session) { - storedSession = stored.session; + 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 + // still drops the partial sidecar instead of leaking it. + let storeOwnsWebcam = false; + try { + if (canStore && screenRead.data) { + const nativeScreenFileName = + nativeScreenPath.split(/[\\/]/).pop() ?? + `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`; + 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). + 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, + }, + webcam: { + videoData: webcamVideoData, + fileName: webcamFileName, + }, + createdAt: activeNativeRecording.recordingId, + cursorCaptureMode, + durationMs: duration, + }); + storeOwnsWebcam = stored.success; + if (stored.success && stored.session) { + storedSession = stored.session; + } + } + } 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. + await activeWebcamRecorder.discard().catch(() => undefined); } } } @@ -540,17 +578,30 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (activeWebcamRecorder.recorder.state !== "inactive") { activeWebcamRecorder.recorder.stop(); } + // Await the blob promise to drain in-flight chunk writes (and surface a + // mid-stream write error) even when streaming, where it resolves empty. const webcamBlob = await activeWebcamRecorder.recordedBlobPromise; + const webcamFileName = + activeNativeRecording.webcamFileName ?? + `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; + if (activeWebcamRecorder.isStreaming()) { + // Streamed webcam bytes are already on disk; hand the attach step an + // empty buffer and let the main process patch the duration there. + return { videoData: new ArrayBuffer(0), fileName: webcamFileName }; + } if (!webcamBlob || webcamBlob.size === 0) { return undefined; } const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); return { videoData: await fixedWebcamBlob.arrayBuffer(), - fileName: `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`, + fileName: webcamFileName, }; } catch (error) { console.error("Failed to finalize native macOS webcam recording:", error); + // A streamed recorder that errored mid-flight left a partial file and an + // open disk stream; discard both so nothing is orphaned. + await activeWebcamRecorder.discard().catch(() => undefined); return undefined; } })(); @@ -568,12 +619,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const result = await window.electronAPI.stopNativeMacRecording(discard); const webcamAsset = await webcamAssetPromise; if (discard || result.discarded) { + // Streamed webcam left an open stream + partial file; drop it. + await activeWebcamRecorder?.discard().catch(() => undefined); clearNativeRecordingState(); return true; } if (!result.success) { console.error("Failed to stop native macOS recording:", result.error); toast.error(result.error ?? "Failed to stop native macOS recording"); + await activeWebcamRecorder?.discard().catch(() => undefined); activeNativeRecording.finalizing = false; return true; } @@ -584,6 +638,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recordingId: activeNativeRecording.recordingId, webcam: webcamAsset, cursorCaptureMode, + // Lets the main process patch the WebM duration of a streamed webcam, + // whose bytes are on disk and so were never duration-fixed in memory. + durationMs: duration, }); if (attachResult.success) { result.session = attachResult.session; @@ -591,6 +648,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn { console.error("Failed to attach native macOS webcam recording:", attachResult.error); toast.error(attachResult.error ?? "Failed to store webcam recording"); } + } else if (webcamAsset && activeWebcamRecorder?.isStreaming()) { + // Streamed webcam with no screen output to attach to: drop the partial + // file and close its stream so it isn't orphaned on disk. + await activeWebcamRecorder.discard().catch(() => undefined); } clearNativeRecordingState(); @@ -815,12 +876,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return true; } } + const windowsWebcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; const browserWebcamRecorder = webcamEnabled && webcamStream.current - ? createRecorderHandle(webcamStream.current, { - mimeType: selectMimeType(), - videoBitsPerSecond: BITRATE_BASE, - }) + ? createRecorderHandle( + webcamStream.current, + { + mimeType: selectMimeType(), + videoBitsPerSecond: BITRATE_BASE, + }, + // Stream webcam chunks to disk instead of buffering the whole clip in + // renderer memory, so a long recording can't OOM-crash on stop (#616). + windowsWebcamFileName, + ) : null; if (webcamEnabled && !browserWebcamRecorder) { stopWebcamPreviewStream(); @@ -869,6 +937,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ) { browserWebcamRecorder.recorder.stop(); } + // The sidecar may already be streaming to disk; drop the partial file + // and close its stream so a failed start doesn't orphan it. + await browserWebcamRecorder?.discard().catch(() => undefined); throw new Error(result.error ?? "Native Windows capture failed."); } @@ -878,6 +949,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { finalizing: false, paused: false, webcamRecorder: browserWebcamRecorder, + webcamFileName: browserWebcamRecorder ? windowsWebcamFileName : undefined, }; webcamRecorder.current = browserWebcamRecorder; accumulatedDurationMs.current = 0; @@ -926,6 +998,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { Number(selectedSource.display_id) || parseMacDisplayIdFromSourceId(selectedSource.id); const windowId = parseMacWindowIdFromSourceId(selectedSource.id); let nativeWebcamRecorder: RecorderHandle | null = null; + let macWebcamFileName: string | undefined; if (webcamEnabled) { if (!webcamReady.current) { await new Promise((resolve) => { @@ -945,10 +1018,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return true; } if (webcamStream.current) { - nativeWebcamRecorder = createRecorderHandle(webcamStream.current, { - mimeType: selectMimeType(), - videoBitsPerSecond: BITRATE_BASE, - }); + macWebcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; + nativeWebcamRecorder = createRecorderHandle( + webcamStream.current, + { + mimeType: selectMimeType(), + videoBitsPerSecond: BITRATE_BASE, + }, + // Stream webcam chunks to disk instead of buffering the whole clip in + // renderer memory, so a long recording can't OOM-crash on stop (#616). + macWebcamFileName, + ); } else { webcamAcquireId.current++; setWebcamEnabledState(false); @@ -958,6 +1038,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { nativeWebcamRecorder.recorder.stop(); } + // Drop the partial streamed sidecar so a cancelled countdown can't orphan it. + await nativeWebcamRecorder?.discard().catch(() => undefined); return true; } const request: NativeMacRecordingRequest = { @@ -1007,12 +1089,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { nativeWebcamRecorder.recorder.stop(); } + // Drop the partial streamed sidecar so a failed start doesn't orphan it. + await nativeWebcamRecorder?.discard().catch(() => undefined); throw new Error(result.error ?? "Native macOS capture failed."); } if (!isCountdownRunActive(countdownRunToken)) { if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") { nativeWebcamRecorder.recorder.stop(); } + // Drop the partial streamed sidecar before bailing on the cancelled run. + await nativeWebcamRecorder?.discard().catch(() => undefined); await window.electronAPI.stopNativeMacRecording(true); return true; } @@ -1022,6 +1108,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recordingId: result.recordingId, finalizing: false, paused: false, + webcamFileName: macWebcamFileName, }; webcamRecorder.current = nativeWebcamRecorder; accumulatedDurationMs.current = 0;