diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index abb688d16..84e6d9504 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 009ade60a..15f61382a 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"; @@ -2066,6 +2066,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); @@ -2086,12 +2127,31 @@ 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); + if (screenWs) { + await new Promise((resolve, reject) => + screenWs.end((err?: Error | null) => (err ? reject(err) : resolve())), + ); + activeWriteStreams.delete(createdAt); + } else if (payload.screen.videoData && payload.screen.videoData.byteLength > 0) { + await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData)); + } let webcamVideoPath: string | undefined; 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); + } else if (payload.webcam.videoData && payload.webcam.videoData.byteLength > 0) { + await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData)); + } } const session: RecordingSession = webcamVideoPath diff --git a/electron/preload.ts b/electron/preload.ts index 361eb18de..8e29ad7cb 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/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 9941dc411..2d2147bc5 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -79,6 +79,7 @@ type UseScreenRecorderReturn = { type RecorderHandle = { recorder: MediaRecorder; recordedBlobPromise: Promise; + streaming: boolean; }; type NativeWindowsRecordingHandle = { @@ -92,26 +93,77 @@ 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"; + + // Try to open a disk-backed stream. Falls back to in-memory if IPC unavailable. + const streamOpenPromise = + window.electronAPI?.openRecordingStream?.(recordingId, fileName) ?? + Promise.resolve({ success: false }); + + const pendingChunks: ArrayBuffer[] = []; + let streamReady = false; + let streamFailed = false; + + streamOpenPromise.then((result) => { + if (result.success) { + streamReady = true; + for (const chunk of pendingChunks) { + void window.electronAPI.appendRecordingChunk(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) { + void window.electronAPI.appendRecordingChunk(recordingId, buf); + } else { + pendingChunks.push(buf); + } + }); }; + recorder.onerror = () => { reject(new Error("Recording failed")); }; + recorder.onstop = () => { - resolve(new Blob(chunks, { type: mimeType })); + if (streamFailed) { + // Streaming failed — return full in-memory blob as fallback. + resolve(new Blob(fallbackChunks, { type: mimeType })); + } else { + // Streaming succeeded — 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 { @@ -365,32 +417,41 @@ 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; + 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 (webcamBlob && webcamBlob.size > 0) { - fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration); + 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 screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`; - const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`; 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, }); @@ -861,10 +922,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return true; } if (webcamStream.current) { - nativeWebcamRecorder = createRecorderHandle(webcamStream.current, { - mimeType: selectMimeType(), - videoBitsPerSecond: BITRATE_BASE, - }); + nativeWebcamRecorder = createRecorderHandle( + webcamStream.current, + { mimeType: selectMimeType(), videoBitsPerSecond: BITRATE_BASE }, + activeRecordingId + 1, + `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`, + ); } else { webcamAcquireId.current++; setWebcamEnabledState(false); @@ -1252,13 +1315,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", () => { @@ -1268,10 +1336,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;