Skip to content
This repository was archived by the owner on Jun 17, 2026. It is now read-only.

Commit 727e395

Browse files
neurot1calAmanuel2xclaude
committed
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>
1 parent 5467796 commit 727e395

6 files changed

Lines changed: 319 additions & 34 deletions

File tree

electron/electron-env.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ interface Window {
8181
message?: string;
8282
error?: string;
8383
}>;
84+
openRecordingStream: (
85+
recordingId: number,
86+
fileName: string,
87+
) => Promise<{ success: boolean; error?: string }>;
88+
appendRecordingChunk: (
89+
recordingId: number,
90+
chunk: ArrayBuffer,
91+
) => Promise<{ success: boolean; error?: string }>;
8492
getRecordedVideoPath: () => Promise<{
8593
success: boolean;
8694
path?: string;

electron/ipc/handlers.ts

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
22
import { EventEmitter } from "node:events";
3-
import { constants as fsConstants } from "node:fs";
3+
import { createWriteStream, constants as fsConstants, type WriteStream } from "node:fs";
44
import fs from "node:fs/promises";
55
import os from "node:os";
66
import path from "node:path";
@@ -40,6 +40,7 @@ import { RECORDINGS_DIR } from "../main";
4040
import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory";
4141
import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession";
4242
import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session";
43+
import { patchWebmDurationOnDisk } from "../recording/webm-duration";
4344
import { registerNativeBridgeHandlers } from "./nativeBridge";
4445

4546
const PROJECT_FILE_EXTENSION = "openscreen";
@@ -2141,6 +2142,47 @@ export function registerIpcHandlers(
21412142
},
21422143
);
21432144

2145+
// Streaming chunk writers — keyed by recordingId. Chunks are appended directly
2146+
// to disk as they arrive from ondataavailable so the renderer never holds the
2147+
// full video in memory.
2148+
const activeWriteStreams = new Map<number, WriteStream>();
2149+
2150+
ipcMain.handle(
2151+
"open-recording-stream",
2152+
async (
2153+
_,
2154+
recordingId: number,
2155+
fileName: string,
2156+
): Promise<{ success: boolean; error?: string }> => {
2157+
try {
2158+
const filePath = resolveRecordingOutputPath(fileName);
2159+
const ws = createWriteStream(filePath, { flags: "w" });
2160+
activeWriteStreams.set(recordingId, ws);
2161+
return { success: true };
2162+
} catch (error) {
2163+
return { success: false, error: String(error) };
2164+
}
2165+
},
2166+
);
2167+
2168+
ipcMain.handle(
2169+
"append-recording-chunk",
2170+
async (
2171+
_,
2172+
recordingId: number,
2173+
chunk: ArrayBuffer,
2174+
): Promise<{ success: boolean; error?: string }> => {
2175+
const ws = activeWriteStreams.get(recordingId);
2176+
if (!ws) return { success: false, error: "No active stream for recordingId " + recordingId };
2177+
return new Promise((resolve) => {
2178+
ws.write(Buffer.from(chunk), (err) => {
2179+
if (err) resolve({ success: false, error: err.message });
2180+
else resolve({ success: true });
2181+
});
2182+
});
2183+
},
2184+
);
2185+
21442186
ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
21452187
try {
21462188
return await storeRecordedSessionFiles(payload);
@@ -2161,12 +2203,56 @@ export function registerIpcHandlers(
21612203
: Date.now();
21622204
const cursorCaptureMode = normalizeCursorCaptureMode(payload.cursorCaptureMode);
21632205
const screenVideoPath = resolveRecordingOutputPath(payload.screen.fileName);
2164-
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
2206+
2207+
// Close the streaming write stream if one was used; otherwise fall back to
2208+
// writing the full buffer (short recordings that never opened a stream).
2209+
const screenWs = activeWriteStreams.get(createdAt);
2210+
let screenStreamed = false;
2211+
if (screenWs) {
2212+
await new Promise<void>((resolve, reject) =>
2213+
screenWs.end((err?: Error | null) => (err ? reject(err) : resolve())),
2214+
);
2215+
activeWriteStreams.delete(createdAt);
2216+
screenStreamed = true;
2217+
} else if (payload.screen.videoData && payload.screen.videoData.byteLength > 0) {
2218+
await fs.writeFile(screenVideoPath, Buffer.from(payload.screen.videoData));
2219+
}
21652220

21662221
let webcamVideoPath: string | undefined;
2222+
let webcamStreamed = false;
21672223
if (payload.webcam) {
21682224
webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
2169-
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
2225+
const webcamWs = activeWriteStreams.get(createdAt + 1); // webcam stream keyed as recordingId+1
2226+
if (webcamWs) {
2227+
await new Promise<void>((resolve, reject) =>
2228+
webcamWs.end((err?: Error | null) => (err ? reject(err) : resolve())),
2229+
);
2230+
activeWriteStreams.delete(createdAt + 1);
2231+
webcamStreamed = true;
2232+
} else if (payload.webcam.videoData && payload.webcam.videoData.byteLength > 0) {
2233+
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
2234+
}
2235+
}
2236+
2237+
// Streamed files lack the WebM Duration header (renderer no longer holds the
2238+
// blob to patch). Patch on disk so the editor's seek bar and timeline work.
2239+
// Best-effort: log on failure but don't block, since the file is still playable.
2240+
if (
2241+
screenStreamed &&
2242+
typeof payload.durationMs === "number" &&
2243+
Number.isFinite(payload.durationMs) &&
2244+
payload.durationMs > 0
2245+
) {
2246+
await patchWebmDurationOnDisk(screenVideoPath, payload.durationMs);
2247+
}
2248+
if (
2249+
webcamStreamed &&
2250+
webcamVideoPath &&
2251+
typeof payload.durationMs === "number" &&
2252+
Number.isFinite(payload.durationMs) &&
2253+
payload.durationMs > 0
2254+
) {
2255+
await patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs);
21702256
}
21712257

21722258
const session: RecordingSession = webcamVideoPath

electron/preload.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
6464
storeRecordedSession: (payload: StoreRecordedSessionInput) => {
6565
return ipcRenderer.invoke("store-recorded-session", payload);
6666
},
67+
openRecordingStream: (recordingId: number, fileName: string) => {
68+
return ipcRenderer.invoke("open-recording-stream", recordingId, fileName);
69+
},
70+
appendRecordingChunk: (recordingId: number, chunk: ArrayBuffer) => {
71+
return ipcRenderer.invoke("append-recording-chunk", recordingId, chunk);
72+
},
6773

6874
getRecordedVideoPath: () => {
6975
return ipcRenderer.invoke("get-recorded-video-path");
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import fs from "node:fs/promises";
2+
import { fixParsedWebmDuration } from "@fix-webm-duration/fix";
3+
import { WebmFile } from "@fix-webm-duration/parser";
4+
5+
export type DurationPatchResult =
6+
| { patched: true }
7+
| { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" };
8+
9+
/**
10+
* Patch the WebM Duration header on a finalized recording file.
11+
*
12+
* Browser MediaRecorder writes WebM with no Duration EBML element. With the
13+
* streaming-to-disk path the renderer never holds the blob, so the historical
14+
* `fixWebmDuration(blob, durationMs)` call can't run. Patching on disk after
15+
* `WriteStream.end()` produces an equivalent result: the editor's seek bar and
16+
* timeline read a real duration instead of `N/A`.
17+
*
18+
* Atomic by design: writes the patched bytes to `<filePath>.duration-patch.tmp`
19+
* and renames in place. If the process crashes mid-rewrite, the original file
20+
* survives intact, so the user never loses their recording to a partial write.
21+
*
22+
* Best-effort by intent: any failure (read, parse, write) logs and returns a
23+
* non-`patched` result rather than throwing. The file is still playable without
24+
* the patch (decoders walk frames sequentially); the only cost is that the
25+
* editor's seek bar and timeline break until it is patched.
26+
*
27+
* Memory: reads the whole file into a main-process Buffer, the same footprint
28+
* as the pre-streaming renderer path, just on the side without V8's heap cap.
29+
*/
30+
export async function patchWebmDurationOnDisk(
31+
filePath: string,
32+
durationMs: number,
33+
): Promise<DurationPatchResult> {
34+
try {
35+
const fileBytes = await fs.readFile(filePath);
36+
const webm = new WebmFile(new Uint8Array(fileBytes));
37+
38+
const patched = fixParsedWebmDuration(webm, durationMs, { logger: false });
39+
if (!patched) {
40+
// fixParsedWebmDuration returns false for: missing Segment, missing
41+
// Info, or a Duration that is already valid. The first two mean a
42+
// malformed (most likely truncated) file; the third is a no-op.
43+
const reason = inferUnpatchedReason(webm);
44+
if (reason === "no-section") {
45+
console.warn(
46+
`[webm-duration] no Segment/Info section in ${filePath}; file may be truncated`,
47+
);
48+
}
49+
return { patched: false, reason };
50+
}
51+
52+
if (!webm.source) {
53+
console.error(`[webm-duration] patched but source missing for ${filePath}`);
54+
return { patched: false, reason: "internal" };
55+
}
56+
57+
const tmpPath = `${filePath}.duration-patch.tmp`;
58+
const patchedBytes = Buffer.from(
59+
webm.source.buffer,
60+
webm.source.byteOffset,
61+
webm.source.byteLength,
62+
);
63+
try {
64+
await fs.writeFile(tmpPath, patchedBytes);
65+
await fs.rename(tmpPath, filePath);
66+
return { patched: true };
67+
} catch (writeError) {
68+
console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError);
69+
// Best-effort cleanup of the temp file; if unlink also fails, leave it.
70+
// The original recording is untouched because the rename never ran.
71+
await fs.unlink(tmpPath).catch(() => undefined);
72+
return { patched: false, reason: "io-error" };
73+
}
74+
} catch (error) {
75+
console.error(`[webm-duration] failed to patch ${filePath}:`, error);
76+
return { patched: false, reason: "io-error" };
77+
}
78+
}
79+
80+
/**
81+
* Distinguish "no Segment/Info section" (malformed/truncated file) from "Info
82+
* present but Duration already valid" (patch unnecessary).
83+
*
84+
* The IDs are the length-descriptor-stripped form that @fix-webm-duration/parser
85+
* uses as its lookup keys (Segment `0x8538067`, Info `0x549a966`), verified
86+
* against the parser's `src/lib/sections.js` — not the canonical 4-byte EBML
87+
* IDs (`0x18538067` / `0x1549A966`), which this parser's `getSectionById` would
88+
* never match.
89+
*/
90+
function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" {
91+
const segment = webm.getSectionById?.(0x8538067);
92+
if (!segment) return "no-section";
93+
const info = (
94+
segment as unknown as { getSectionById?: (id: number) => unknown }
95+
).getSectionById?.(0x549a966);
96+
return info ? "already-valid" : "no-section";
97+
}

0 commit comments

Comments
 (0)