diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 1b82992..cbe43be 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -81,6 +81,14 @@ interface Window { message?: string; error?: string; }>; + openRecordingStream: ( + recordingId: number, + fileName: string, + ) => Promise<{ success: boolean; error?: string }>; + appendRecordingChunk: ( + recordingId: number, + chunk: ArrayBuffer, + ) => Promise<{ success: boolean; error?: string }>; getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 669f1dd..f10cbbc 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,6 +1,6 @@ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { EventEmitter } from "node:events"; -import { constants as fsConstants } from "node:fs"; +import { createWriteStream, constants as fsConstants, type WriteStream } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -40,6 +40,7 @@ import { RECORDINGS_DIR } from "../main"; import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory"; import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession"; import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; +import { patchWebmDurationOnDisk } from "../recording/webm-duration"; import { registerNativeBridgeHandlers } from "./nativeBridge"; const PROJECT_FILE_EXTENSION = "openscreen"; @@ -2141,6 +2142,47 @@ export function registerIpcHandlers( }, ); + // Streaming chunk writers — keyed by recordingId. Chunks are appended directly + // to disk as they arrive from ondataavailable so the renderer never holds the + // full video in memory. + const activeWriteStreams = new Map(); + + ipcMain.handle( + "open-recording-stream", + async ( + _, + recordingId: number, + fileName: string, + ): Promise<{ success: boolean; error?: string }> => { + try { + const filePath = resolveRecordingOutputPath(fileName); + const ws = createWriteStream(filePath, { flags: "w" }); + activeWriteStreams.set(recordingId, ws); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }, + ); + + ipcMain.handle( + "append-recording-chunk", + async ( + _, + recordingId: number, + chunk: ArrayBuffer, + ): Promise<{ success: boolean; error?: string }> => { + const ws = activeWriteStreams.get(recordingId); + if (!ws) return { success: false, error: "No active stream for recordingId " + recordingId }; + return new Promise((resolve) => { + ws.write(Buffer.from(chunk), (err) => { + if (err) resolve({ success: false, error: err.message }); + else resolve({ success: true }); + }); + }); + }, + ); + ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => { try { return await storeRecordedSessionFiles(payload); @@ -2161,12 +2203,56 @@ export function registerIpcHandlers( : Date.now(); const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode); const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName); - await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData)); + + // Close the streaming write stream if one was used; otherwise fall back to + // writing the full buffer (short recordings that never opened a stream). + const screenWs = activeWriteStreams.get(createdAt); + let screenStreamed = false; + if (screenWs) { + await new Promise((resolve, reject) => + screenWs.end((err?: Error | null) => (err ? reject(err) : resolve())), + ); + activeWriteStreams.delete(createdAt); + screenStreamed = true; + } else if (payload.screen.videoData && payload.screen.videoData.byteLength > 0) { + await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData)); + } let webcamVideoPath: string | undefined; + let webcamStreamed = false; if (payload.webcam) { webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); - await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + const webcamWs = activeWriteStreams.get(createdAt + 1); // webcam stream keyed as recordingId+1 + if (webcamWs) { + await new Promise((resolve, reject) => + webcamWs.end((err?: Error | null) => (err ? reject(err) : resolve())), + ); + activeWriteStreams.delete(createdAt + 1); + webcamStreamed = true; + } else if (payload.webcam.videoData && payload.webcam.videoData.byteLength > 0) { + await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + } + } + + // Streamed files lack the WebM Duration header (renderer no longer holds the + // blob to patch). Patch on disk so the editor's seek bar and timeline work. + // Best-effort: log on failure but don't block, since the file is still playable. + if ( + screenStreamed && + typeof payload.durationMs === "number" && + Number.isFinite(payload.durationMs) && + payload.durationMs > 0 + ) { + await patchWebmDurationOnDisk(screenVideoPath, payload.durationMs); + } + if ( + webcamStreamed && + webcamVideoPath && + typeof payload.durationMs === "number" && + Number.isFinite(payload.durationMs) && + payload.durationMs > 0 + ) { + await patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs); } const session: RecordingSession = webcamVideoPath diff --git a/electron/preload.ts b/electron/preload.ts index 933ce9d..a7283d8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -64,6 +64,12 @@ contextBridge.exposeInMainWorld("electronAPI", { storeRecordedSession: (payload: StoreRecordedSessionInput) => { return ipcRenderer.invoke("store-recorded-session", payload); }, + openRecordingStream: (recordingId: number, fileName: string) => { + return ipcRenderer.invoke("open-recording-stream", recordingId, fileName); + }, + appendRecordingChunk: (recordingId: number, chunk: ArrayBuffer) => { + return ipcRenderer.invoke("append-recording-chunk", recordingId, chunk); + }, getRecordedVideoPath: () => { return ipcRenderer.invoke("get-recorded-video-path"); diff --git a/electron/recording/webm-duration.ts b/electron/recording/webm-duration.ts new file mode 100644 index 0000000..5b2c197 --- /dev/null +++ b/electron/recording/webm-duration.ts @@ -0,0 +1,97 @@ +import fs from "node:fs/promises"; +import { fixParsedWebmDuration } from "@fix-webm-duration/fix"; +import { WebmFile } from "@fix-webm-duration/parser"; + +export type DurationPatchResult = + | { patched: true } + | { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" }; + +/** + * Patch the WebM Duration header on a finalized recording file. + * + * Browser MediaRecorder writes WebM with no Duration EBML element. With the + * streaming-to-disk path the renderer never holds the blob, so the historical + * `fixWebmDuration(blob, durationMs)` call can't run. Patching on disk after + * `WriteStream.end()` produces an equivalent result: the editor's seek bar and + * timeline read a real duration instead of `N/A`. + * + * Atomic by design: writes the patched bytes to `.duration-patch.tmp` + * and renames in place. If the process crashes mid-rewrite, the original file + * survives intact, so the user never loses their recording to a partial write. + * + * Best-effort by intent: any failure (read, parse, write) logs and returns a + * non-`patched` result rather than throwing. The file is still playable without + * the patch (decoders walk frames sequentially); the only cost is that the + * editor's seek bar and timeline break until it is patched. + * + * Memory: reads the whole file into a main-process Buffer, the same footprint + * as the pre-streaming renderer path, just on the side without V8's heap cap. + */ +export async function patchWebmDurationOnDisk( + filePath: string, + durationMs: number, +): Promise { + try { + const fileBytes = await fs.readFile(filePath); + const webm = new WebmFile(new Uint8Array(fileBytes)); + + const patched = fixParsedWebmDuration(webm, durationMs, { logger: false }); + if (!patched) { + // fixParsedWebmDuration returns false for: missing Segment, missing + // Info, or a Duration that is already valid. The first two mean a + // malformed (most likely truncated) file; the third is a no-op. + const reason = inferUnpatchedReason(webm); + if (reason === "no-section") { + console.warn( + `[webm-duration] no Segment/Info section in ${filePath}; file may be truncated`, + ); + } + return { patched: false, reason }; + } + + if (!webm.source) { + console.error(`[webm-duration] patched but source missing for ${filePath}`); + return { patched: false, reason: "internal" }; + } + + const tmpPath = `${filePath}.duration-patch.tmp`; + const patchedBytes = Buffer.from( + webm.source.buffer, + webm.source.byteOffset, + webm.source.byteLength, + ); + try { + await fs.writeFile(tmpPath, patchedBytes); + await fs.rename(tmpPath, filePath); + return { patched: true }; + } catch (writeError) { + console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError); + // Best-effort cleanup of the temp file; if unlink also fails, leave it. + // The original recording is untouched because the rename never ran. + await fs.unlink(tmpPath).catch(() => undefined); + return { patched: false, reason: "io-error" }; + } + } catch (error) { + console.error(`[webm-duration] failed to patch ${filePath}:`, error); + return { patched: false, reason: "io-error" }; + } +} + +/** + * Distinguish "no Segment/Info section" (malformed/truncated file) from "Info + * present but Duration already valid" (patch unnecessary). + * + * The IDs are the length-descriptor-stripped form that @fix-webm-duration/parser + * uses as its lookup keys (Segment `0x8538067`, Info `0x549a966`), verified + * against the parser's `src/lib/sections.js` — not the canonical 4-byte EBML + * IDs (`0x18538067` / `0x1549A966`), which this parser's `getSectionById` would + * never match. + */ +function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" { + const segment = webm.getSectionById?.(0x8538067); + if (!segment) return "no-section"; + const info = ( + segment as unknown as { getSectionById?: (id: number) => unknown } + ).getSectionById?.(0x549a966); + return info ? "already-valid" : "no-section"; +} diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 14e55b3..665ae5e 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -77,6 +77,7 @@ type UseScreenRecorderReturn = { type RecorderHandle = { recorder: MediaRecorder; recordedBlobPromise: Promise; + streaming: boolean; }; type NativeWindowsRecordingHandle = { @@ -92,26 +93,88 @@ type NativeMacRecordingHandle = { paused: boolean; }; -function createRecorderHandle(stream: MediaStream, options: MediaRecorderOptions): RecorderHandle { +function createRecorderHandle( + stream: MediaStream, + options: MediaRecorderOptions, + recordingId?: number, + fileName?: string, +): RecorderHandle { const recorder = new MediaRecorder(stream, options); - const chunks: Blob[] = []; const mimeType = options.mimeType || "video/webm"; + + // Stream chunks to disk only when a target (recordingId + fileName) is given. + // The main screen recorder and the browser-only webcam recorder pass a target + // so long recordings never buffer the whole video in the renderer (the #616 + // fix). Native-capture webcam sidecars omit the target and buffer in-memory, + // because their finalize path reads recordedBlobPromise directly to attach the + // webcam file; an empty streamed blob would silently drop their webcam track. + const streamTarget = + recordingId !== undefined && fileName !== undefined ? { recordingId, fileName } : null; + + const pendingChunks: ArrayBuffer[] = []; + let streamReady = false; + let streamFailed = streamTarget === null; + + if (streamTarget) { + const streamOpenPromise = + window.electronAPI?.openRecordingStream?.(streamTarget.recordingId, streamTarget.fileName) ?? + Promise.resolve({ success: false }); + + streamOpenPromise.then((result) => { + if (result.success) { + streamReady = true; + for (const chunk of pendingChunks) { + void window.electronAPI.appendRecordingChunk(streamTarget.recordingId, chunk); + } + pendingChunks.length = 0; + } else { + streamFailed = true; + } + }); + } + + const fallbackChunks: Blob[] = []; + const recordedBlobPromise = new Promise((resolve, reject) => { recorder.ondataavailable = (event: BlobEvent) => { - if (event.data && event.data.size > 0) { - chunks.push(event.data); + if (!event.data || event.data.size === 0) return; + + if (streamFailed) { + fallbackChunks.push(event.data); + return; } + + void event.data.arrayBuffer().then((buf) => { + if (streamFailed) { + fallbackChunks.push(new Blob([buf], { type: mimeType })); + return; + } + if (streamReady && streamTarget) { + void window.electronAPI.appendRecordingChunk(streamTarget.recordingId, buf); + } else { + pendingChunks.push(buf); + } + }); }; + recorder.onerror = () => { reject(new Error("Recording failed")); }; + recorder.onstop = () => { - resolve(new Blob(chunks, { type: mimeType })); + if (streamFailed) { + // Not streaming, or the stream failed to open — return the full + // in-memory blob (the buffered fallback). + resolve(new Blob(fallbackChunks, { type: mimeType })); + } else { + // Streaming succeeded — the main process already has the data on disk. + resolve(new Blob([], { type: mimeType })); + } }; }); recorder.start(RECORDER_TIMESLICE_MS); - return { recorder, recordedBlobPromise }; + return { recorder, recordedBlobPromise, streaming: !streamFailed }; } export function useScreenRecorder(): UseScreenRecorderReturn { @@ -361,34 +424,44 @@ export function useScreenRecorder(): UseScreenRecorderReturn { window.electronAPI?.discardCursorTelemetry(activeRecordingId); return; } - if (screenBlob.size === 0) { + // When streaming succeeded the blob is empty — the data is already on disk. + if (!activeScreenRecorder.streaming && screenBlob.size === 0) { return; } - const fixedScreenBlob = await fixWebmDuration(screenBlob, duration); - let fixedWebcamBlob: Blob | null = null; - if (activeWebcamRecorder) { - const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null); - if (webcamBlob && webcamBlob.size > 0) { - fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); - } - } - const screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`; const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; + + // Only fix duration / convert to ArrayBuffer if we have in-memory data. + let screenVideoData: ArrayBuffer = new ArrayBuffer(0); + if (!activeScreenRecorder.streaming && screenBlob.size > 0) { + const fixedScreenBlob = await fixWebmDuration(screenBlob, duration); + screenVideoData = await fixedScreenBlob.arrayBuffer(); + } + + let webcamVideoData: ArrayBuffer | undefined; + if (activeWebcamRecorder) { + const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null); + if (!activeWebcamRecorder.streaming && webcamBlob && webcamBlob.size > 0) { + const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); + webcamVideoData = await fixedWebcamBlob.arrayBuffer(); + } else if (activeWebcamRecorder.streaming) { + webcamVideoData = new ArrayBuffer(0); + } + } + const result = await window.electronAPI.storeRecordedSession({ screen: { - videoData: await fixedScreenBlob.arrayBuffer(), + videoData: screenVideoData, fileName: screenFileName, }, - webcam: fixedWebcamBlob - ? { - videoData: await fixedWebcamBlob.arrayBuffer(), - fileName: webcamFileName, - } - : undefined, + webcam: + webcamVideoData !== undefined + ? { videoData: webcamVideoData, fileName: webcamFileName } + : undefined, createdAt: activeRecordingId, cursorCaptureMode, + durationMs: duration, }); if (!result.success) { @@ -1336,13 +1409,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recordingId.current = Date.now(); const activeRecordingId = recordingId.current; - screenRecorder.current = createRecorderHandle(stream.current, { - mimeType, - videoBitsPerSecond, - ...(hasAudio - ? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE } - : {}), - }); + screenRecorder.current = createRecorderHandle( + stream.current, + { + mimeType, + videoBitsPerSecond, + ...(hasAudio + ? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE } + : {}), + }, + activeRecordingId, + `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`, + ); screenRecorder.current.recorder.addEventListener( "error", () => { @@ -1352,10 +1430,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { ); if (webcamStream.current) { - webcamRecorder.current = createRecorderHandle(webcamStream.current, { - mimeType, - videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE), - }); + webcamRecorder.current = createRecorderHandle( + webcamStream.current, + { mimeType, videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE) }, + activeRecordingId + 1, + `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`, + ); } accumulatedDurationMs.current = 0; diff --git a/src/lib/recordingSession.ts b/src/lib/recordingSession.ts index f5ebf9c..12a6afd 100644 --- a/src/lib/recordingSession.ts +++ b/src/lib/recordingSession.ts @@ -20,6 +20,14 @@ export interface StoreRecordedSessionInput { webcam?: RecordedVideoAssetInput; createdAt?: number; cursorCaptureMode?: CursorCaptureMode; + /** + * Recording wall-clock duration in milliseconds. Used by the main process + * to patch the WebM Duration header on streamed recordings, since the + * renderer no longer holds the bytes. Browser MediaRecorder writes WebM + * with no/zero duration; without this patch, the editor's seek bar and + * timeline break for any recording that took the streaming path. + */ + durationMs?: number; } export function normalizeCursorCaptureMode(value: unknown): CursorCaptureMode | undefined {