fix: stream long recordings to disk and patch WebM duration on save

Recordings longer than ~10 minutes silently fail to save (#616). The
renderer buffers the whole WebM as a Blob[], then on stop makes several
in-memory copies (fixWebmDuration -> arrayBuffer -> Buffer.from) before
writing. A long 1080p recording duplicates hundreds of MB several times
in the renderer, exceeds Electron's memory limit, and the renderer
crashes silently with no file saved.

Two changes:

1. Stream chunks to disk (originally @Amanuel2x's contribution in #617).
   Open an fs.WriteStream in the main process at recording start and send
   each ~1s ondataavailable chunk straight to disk over two new IPC calls
   (open-recording-stream, append-recording-chunk), so the renderer never
   holds more than a single chunk. A full in-memory fallback is preserved
   for environments where the IPC stream cannot open.

2. Patch the WebM Duration header on disk after the stream closes. Browser
   MediaRecorder writes WebM with no Duration element, so streamed files
   save with duration=N/A and the editor's seek bar, timeline, and any
   scrub/trim break. A new electron/recording/webm-duration.ts module
   rewrites the Duration element, writing to a temp file and renaming in
   place so a crash mid-write cannot corrupt the recording.

Streaming is opt-in: the screen recorder and the browser-only webcam
recorder stream to disk; native-capture webcam sidecars (Windows, macOS)
keep buffering in-memory, since their finalize path reads the recorder
blob directly to attach the webcam track.

Verified: tsc --noEmit clean; biome clean; vitest 166/166.

Closes #616
Supersedes #617

Co-Authored-By: Amanuel <amanuel@localboostnetworking.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
neurot1cal
2026-05-25 17:53:22 -07:00
parent 54677960d0
commit 727e395fcf
6 changed files with 322 additions and 37 deletions
+8
View File
@@ -81,6 +81,14 @@ interface Window {
message?: string; message?: string;
error?: 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<{ getRecordedVideoPath: () => Promise<{
success: boolean; success: boolean;
path?: string; path?: string;
+89 -3
View File
@@ -1,6 +1,6 @@
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { EventEmitter } from "node:events"; 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 fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
@@ -40,6 +40,7 @@ import { RECORDINGS_DIR } from "../main";
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory"; import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession"; import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession";
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
import { patchWebmDurationOnDisk } from "../recording/webm-duration";
import { registerNativeBridgeHandlers } from "./nativeBridge"; import { registerNativeBridgeHandlers } from "./nativeBridge";
const PROJECT_FILE_EXTENSION = "openscreen"; 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<number, WriteStream>();
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) => { ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
try { try {
return await storeRecordedSessionFiles(payload); return await storeRecordedSessionFiles(payload);
@@ -2161,12 +2203,56 @@ export function registerIpcHandlers(
: Date.now(); : Date.now();
const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode); const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode);
const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName); 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<void>((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 webcamVideoPath: string | undefined;
let webcamStreamed = false;
if (payload.webcam) { if (payload.webcam) {
webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName); 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<void>((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 const session: RecordingSession = webcamVideoPath
+6
View File
@@ -64,6 +64,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
storeRecordedSession: (payload: StoreRecordedSessionInput) => { storeRecordedSession: (payload: StoreRecordedSessionInput) => {
return ipcRenderer.invoke("store-recorded-session", payload); 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: () => { getRecordedVideoPath: () => {
return ipcRenderer.invoke("get-recorded-video-path"); return ipcRenderer.invoke("get-recorded-video-path");
+97
View File
@@ -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 `<filePath>.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<DurationPatchResult> {
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";
}
+114 -34
View File
@@ -77,6 +77,7 @@ type UseScreenRecorderReturn = {
type RecorderHandle = { type RecorderHandle = {
recorder: MediaRecorder; recorder: MediaRecorder;
recordedBlobPromise: Promise<Blob>; recordedBlobPromise: Promise<Blob>;
streaming: boolean;
}; };
type NativeWindowsRecordingHandle = { type NativeWindowsRecordingHandle = {
@@ -92,26 +93,88 @@ type NativeMacRecordingHandle = {
paused: boolean; 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 recorder = new MediaRecorder(stream, options);
const chunks: Blob[] = [];
const mimeType = options.mimeType || "video/webm"; 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<Blob>((resolve, reject) => { const recordedBlobPromise = new Promise<Blob>((resolve, reject) => {
recorder.ondataavailable = (event: BlobEvent) => { recorder.ondataavailable = (event: BlobEvent) => {
if (event.data && event.data.size > 0) { if (!event.data || event.data.size === 0) return;
chunks.push(event.data);
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 = () => { recorder.onerror = () => {
reject(new Error("Recording failed")); reject(new Error("Recording failed"));
}; };
recorder.onstop = () => { 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); recorder.start(RECORDER_TIMESLICE_MS);
return { recorder, recordedBlobPromise }; return { recorder, recordedBlobPromise, streaming: !streamFailed };
} }
export function useScreenRecorder(): UseScreenRecorderReturn { export function useScreenRecorder(): UseScreenRecorderReturn {
@@ -361,34 +424,44 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
window.electronAPI?.discardCursorTelemetry(activeRecordingId); window.electronAPI?.discardCursorTelemetry(activeRecordingId);
return; 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; 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 screenFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`;
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${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({ const result = await window.electronAPI.storeRecordedSession({
screen: { screen: {
videoData: await fixedScreenBlob.arrayBuffer(), videoData: screenVideoData,
fileName: screenFileName, fileName: screenFileName,
}, },
webcam: fixedWebcamBlob webcam:
? { webcamVideoData !== undefined
videoData: await fixedWebcamBlob.arrayBuffer(), ? { videoData: webcamVideoData, fileName: webcamFileName }
fileName: webcamFileName, : undefined,
}
: undefined,
createdAt: activeRecordingId, createdAt: activeRecordingId,
cursorCaptureMode, cursorCaptureMode,
durationMs: duration,
}); });
if (!result.success) { if (!result.success) {
@@ -1336,13 +1409,18 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
recordingId.current = Date.now(); recordingId.current = Date.now();
const activeRecordingId = recordingId.current; const activeRecordingId = recordingId.current;
screenRecorder.current = createRecorderHandle(stream.current, { screenRecorder.current = createRecorderHandle(
mimeType, stream.current,
videoBitsPerSecond, {
...(hasAudio mimeType,
? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE } videoBitsPerSecond,
: {}), ...(hasAudio
}); ? { audioBitsPerSecond: systemAudioTrack ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
: {}),
},
activeRecordingId,
`${RECORDING_FILE_PREFIX}${activeRecordingId}${VIDEO_FILE_EXTENSION}`,
);
screenRecorder.current.recorder.addEventListener( screenRecorder.current.recorder.addEventListener(
"error", "error",
() => { () => {
@@ -1352,10 +1430,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
); );
if (webcamStream.current) { if (webcamStream.current) {
webcamRecorder.current = createRecorderHandle(webcamStream.current, { webcamRecorder.current = createRecorderHandle(
mimeType, webcamStream.current,
videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE), { mimeType, videoBitsPerSecond: Math.min(videoBitsPerSecond, BITRATE_BASE) },
}); activeRecordingId + 1,
`${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`,
);
} }
accumulatedDurationMs.current = 0; accumulatedDurationMs.current = 0;
+8
View File
@@ -20,6 +20,14 @@ export interface StoreRecordedSessionInput {
webcam?: RecordedVideoAssetInput; webcam?: RecordedVideoAssetInput;
createdAt?: number; createdAt?: number;
cursorCaptureMode?: CursorCaptureMode; 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 { export function normalizeCursorCaptureMode(value: unknown): CursorCaptureMode | undefined {