-
Notifications
You must be signed in to change notification settings - Fork 2.6k
fix: stream recording chunks to disk to support recordings longer than 10 minutes #617
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<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) }; | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+2081
to
+2089
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing directory creation before opening write stream.
🐛 Proposed fix ipcMain.handle(
"open-recording-stream",
async (
_,
recordingId: number,
fileName: string,
): Promise<{ success: boolean; error?: string }> => {
try {
+ await fs.mkdir(RECORDINGS_DIR, { recursive: true });
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) };
}
},
);📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| 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) => { | ||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Disk-write failures are swallowed → silent truncation. This returns Fix: propagate the first append failure back to the recorder (stop + user-visible error, or fall back to in-memory if still viable). Also consider backpressure — |
||||||||||||||||||||||||||||||||||||||||
| 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); | ||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [blocking] Open/close keys can diverge → empty file + leaked stream. Streams are opened keyed by Fix: key open and close by the same single id and drop the |
||||||||||||||||||||||||||||||||||||||||
| if (screenWs) { | ||||||||||||||||||||||||||||||||||||||||
| await new Promise<void>((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)); | ||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+2130
to
+2141
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check if discardCursorTelemetry is the only cleanup called on discard
rg -n "discardCursorTelemetry" --type ts -C 3Repository: siddharthvaddem/openscreen Length of output: 1541 🏁 Script executed: #!/bin/bash
# Find the discard-cursor-telemetry handler in electron handlers
rg -n "discard-cursor-telemetry" --type ts -A 15 electron/Repository: siddharthvaddem/openscreen Length of output: 1152 🏁 Script executed: #!/bin/bash
# Also check what happens with activeWriteStreams when recording is discarded
rg -n "activeWriteStreams" --type ts -B 3 -A 3Repository: siddharthvaddem/openscreen Length of output: 3504 🏁 Script executed: #!/bin/bash
# Check if there's a cancel/cleanup handler for recordings
rg -n "cancelRecording\|abort.*[Rr]ecord" --type ts -A 5Repository: siddharthvaddem/openscreen Length of output: 52 🏁 Script executed: #!/bin/bash
# Search for the discard-cursor-telemetry handler definition
rg -n '"discard-cursor-telemetry"' electron/ipc/handlers.ts -A 20Repository: siddharthvaddem/openscreen Length of output: 52 🏁 Script executed: #!/bin/bash
# Also check the structure around the storeRecordedSessionFiles handler to understand the flow better
rg -n '"store-recorded-session"' electron/ipc/handlers.ts -B 5 -A 3Repository: siddharthvaddem/openscreen Length of output: 313 🏁 Script executed: #!/bin/bash
# Search for discard-cursor-telemetry in all files
rg -n '"discard-cursor-telemetry"' . --type tsRepository: siddharthvaddem/openscreen Length of output: 166 🏁 Script executed: #!/bin/bash
# Check the useScreenRecorder hook to see full flow on discard
rg -n "discardRecordingId.current" src/hooks/useScreenRecorder.ts -B 5 -A 10Repository: siddharthvaddem/openscreen Length of output: 5815 Streams definitely leak when recording is cancelled/discarded. When The You'll need either:
Without this, cancelled recordings will accumulate open file handles and disk clutter. 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This |
||||||||||||||||||||||||||||||||||||||||
| if (webcamWs) { | ||||||||||||||||||||||||||||||||||||||||
| await new Promise<void>((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 | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -79,6 +79,7 @@ type UseScreenRecorderReturn = { | |
| type RecorderHandle = { | ||
| recorder: MediaRecorder; | ||
| recordedBlobPromise: Promise<Blob>; | ||
| 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<Blob>((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) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [blocking] Chunk write order isn't guaranteed → intermittent corruption.
Fix: make ordering explicit — serialize through one queue (await chunk K's Related: the three-state handling here ( |
||
| 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 }; | ||
|
Comment on lines
+104
to
+166
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Race condition: The If the IPC stream fails to open (permissions, disk error, etc.), the fallback logic in if (!activeScreenRecorder.streaming && screenBlob.size > 0) {...is false because kinda cursed timing issue — maybe make 🐛 One possible fix: defer flag evaluation to onstop- const fallbackChunks: Blob[] = [];
+ let usedFallback = false;
+ const fallbackChunks: Blob[] = [];
const recordedBlobPromise = new Promise<Blob>((resolve, reject) => {
recorder.ondataavailable = (event: BlobEvent) => {
if (!event.data || event.data.size === 0) return;
if (streamFailed) {
fallbackChunks.push(event.data);
+ usedFallback = true;
return;
}
void event.data.arrayBuffer().then((buf) => {
if (streamFailed) {
fallbackChunks.push(new Blob([buf], { type: mimeType }));
+ usedFallback = true;
return;
}
// ... rest unchanged
});
};
recorder.onstop = () => {
- if (streamFailed) {
+ if (usedFallback) {
resolve(new Blob(fallbackChunks, { type: mimeType }));
} else {
resolve(new Blob([], { type: mimeType }));
}
};
});
recorder.start(RECORDER_TIMESLICE_MS);
- return { recorder, recordedBlobPromise, streaming: !streamFailed };
+ // streaming is determined by whether we fell back to in-memory
+ // We need to expose this info; simplest: check blob size in finalizeRecording
+ // OR return a promise/getter. For now, returning true and relying on blob.size check.
+ return { recorder, recordedBlobPromise, streaming: true };Then update -if (!activeScreenRecorder.streaming && screenBlob.size === 0) {
+if (screenBlob.size === 0 && !activeScreenRecorder.streaming) {
return;
}
+// If blob has data, always process it (fallback case)
+let screenVideoData: ArrayBuffer = new ArrayBuffer(0);
+if (screenBlob.size > 0) {
+ const fixedScreenBlob = await fixWebmDuration(screenBlob, duration);
+ screenVideoData = await fixedScreenBlob.arrayBuffer();
+}🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [blocking] Raw Fix: repair duration on the file after the stream closes, in the main process where the bytes now live. |
||
| 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; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[blocking] WriteStream lifecycle leaks.
activeWriteStreamsis only.deleted insidestoreRecordedSessionFiles. Any abandon path —onerror, user discard, early return, crash before stop — leaves the stream open (leaked fd) with a half-written file on disk. Opening a second stream for the same id would also orphan the first.Fix: wrap this in one owner (
RecordingStreamswithopen / append / close / abort) that closes on every terminal path, and guard against double-open.