Skip to content
This repository was archived by the owner on Jun 17, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ interface Window {
recordingId: number;
webcam: import("../src/lib/recordingSession").RecordedVideoAssetInput;
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
durationMs?: number;
}) => Promise<{
success: boolean;
path?: string;
Expand Down
49 changes: 42 additions & 7 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,12 @@ type AttachNativeMacWebcamRecordingInput = {
recordingId?: number;
webcam?: RecordedVideoAssetInput;
cursorCaptureMode?: CursorCaptureMode;
/**
* Recording duration in ms. Present when the webcam took the streaming path so
* its on-disk WebM (which lacks a Duration header) can be patched here, exactly
* as store-recorded-session patches streamed screen/webcam files.
*/
durationMs?: number;
};

let selectedSource: SelectedSource | null = null;
Expand Down Expand Up @@ -2134,9 +2140,20 @@ export function registerIpcHandlers(
}
});

// On-disk write streams for in-progress recordings, keyed by output file name.
// Chunks are appended as they arrive from ondataavailable so the renderer
// never buffers the full video in memory (the #616 fix). Declared before the
// handlers that consume it (attach-native-mac-webcam-recording finalizes a
// streamed webcam through this registry).
const recordingStreams = new RecordingStreamRegistry();
registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath);

ipcMain.handle(
"attach-native-mac-webcam-recording",
async (_, payload: AttachNativeMacWebcamRecordingInput) => {
// When a streamed webcam is finalized to disk but a later step throws, this
// holds its path so the catch can remove the orphaned file.
let streamedWebcamRollbackPath: string | undefined;
try {
if (process.platform !== "darwin") {
return { success: false, error: "Native macOS webcam attachment requires macOS." };
Expand All @@ -2152,12 +2169,30 @@ export function registerIpcHandlers(

await fs.access(screenVideoPath, fsConstants.R_OK);

if (!payload.webcam?.fileName || !payload.webcam.videoData) {
if (!payload.webcam?.fileName) {
return { success: false, error: "Native macOS webcam attachment is missing video data." };
}

const webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
// Streamed webcam bytes are already on disk (appended chunk-by-chunk during
// recording, the #616 fix); finalize() closes the stream and keeps the file.
// Otherwise the renderer sent the whole clip in memory and we write it here.
const webcamStreamed = await recordingStreams.finalize(payload.webcam.fileName);
if (webcamStreamed) {
// The file is now kept on disk; mark it so a later failure rolls it back.
streamedWebcamRollbackPath = webcamVideoPath;
if (isValidDurationMs(payload.durationMs)) {
await patchWebmDurationOnDisk(webcamVideoPath, payload.durationMs);
}
} else {
if (!payload.webcam.videoData || payload.webcam.videoData.byteLength === 0) {
return {
success: false,
error: "Native macOS webcam attachment is missing video data.",
};
}
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
}

const createdAt =
typeof payload.recordingId === "number" && Number.isFinite(payload.recordingId)
Expand Down Expand Up @@ -2187,6 +2222,11 @@ export function registerIpcHandlers(
};
} catch (error) {
console.error("Failed to attach native macOS webcam recording:", error);
// A streamed webcam was already finalized to disk before this failure;
// remove the orphan so no stray *-webcam.webm lingers without a session.
if (streamedWebcamRollbackPath) {
await fs.unlink(streamedWebcamRollbackPath).catch(() => undefined);
}
return {
success: false,
error: error instanceof Error ? error.message : String(error),
Expand All @@ -2195,11 +2235,6 @@ export function registerIpcHandlers(
},
);

// On-disk write streams for in-progress recordings, keyed by output file name.
// Chunks append as they arrive so the renderer never buffers the full video (#616).
const recordingStreams = new RecordingStreamRegistry();
registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath);

ipcMain.handle("store-recorded-session", async (_, payload: StoreRecordedSessionInput) => {
try {
return await storeRecordedSessionFiles(payload);
Expand Down
1 change: 1 addition & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ contextBridge.exposeInMainWorld("electronAPI", {
recordingId: number;
webcam: { fileName: string; videoData: ArrayBuffer };
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
durationMs?: number;
}) => {
return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload);
},
Expand Down
106 changes: 93 additions & 13 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,18 @@ type NativeWindowsRecordingHandle = {
finalizing: boolean;
paused: boolean;
webcamRecorder: RecorderHandle | null;
// File name the webcam sidecar streams to on disk, captured at start so finalize
// targets the exact same file regardless of the native recordingId echoed back.
webcamFileName?: string;
};

type NativeMacRecordingHandle = {
recordingId: number;
finalizing: boolean;
paused: boolean;
// File name the webcam sidecar streams to on disk, captured at start so finalize
// targets the exact same file regardless of the native recordingId echoed back.
webcamFileName?: string;
};

export function useScreenRecorder(): UseScreenRecorderReturn {
Expand Down Expand Up @@ -454,43 +460,68 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
try {
const result = await window.electronAPI.stopNativeWindowsRecording(discard);
if (discard || result.discarded) {
// Streamed webcam left an open stream + partial file; drop it.
await activeWebcamRecorder?.discard().catch(() => undefined);
clearNativeRecordingState();
return true;
}
if (!result.success) {
console.error("Failed to stop native Windows recording:", result.error);
toast.error(result.error ?? "Failed to stop native Windows recording");
await activeWebcamRecorder?.discard().catch(() => undefined);
activeNativeRecording.finalizing = false;
return true;
}

const nativeScreenPath = result.session?.screenVideoPath ?? result.path;
let storedSession = result.session;
if (activeWebcamRecorder && nativeScreenPath) {
// Await the blob promise to drain in-flight chunk writes (and surface a
// mid-stream write error) even when streaming, where it resolves empty.
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise.catch(() => null);
const webcamStreamed = activeWebcamRecorder.isStreaming();
const screenRead = await window.electronAPI.readBinaryFile(nativeScreenPath);
if (webcamBlob && webcamBlob.size > 0 && screenRead.success && screenRead.data) {
const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
const hasWebcamData = webcamStreamed || (webcamBlob != null && webcamBlob.size > 0);
const canStore = hasWebcamData && screenRead.success && !!screenRead.data;
// Once store-recorded-session is called it owns the webcam's disk stream
// (it finalizes the file). Until then, any opened stream is ours to drop.
let storeOwnsWebcam = false;
if (canStore && screenRead.data) {
storeOwnsWebcam = true;
const nativeScreenFileName =
nativeScreenPath.split(/[\\/]/).pop() ??
`${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`;
const webcamFileName = `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
const webcamFileName =
activeNativeRecording.webcamFileName ??
`${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
// Streamed webcam bytes are already on disk; send an empty buffer and let
// the main process patch the WebM duration there (mirrors the screen path).
const webcamVideoData = webcamStreamed
? new ArrayBuffer(0)
: await (await fixWebmDuration(webcamBlob as Blob, duration)).arrayBuffer();
const stored = await window.electronAPI.storeRecordedSession({
screen: {
videoData: screenRead.data,
fileName: nativeScreenFileName,
},
webcam: {
videoData: await fixedWebcamBlob.arrayBuffer(),
videoData: webcamVideoData,
fileName: webcamFileName,
},
createdAt: activeNativeRecording.recordingId,
cursorCaptureMode,
durationMs: duration,
});
Comment thread
codigoyi marked this conversation as resolved.
Outdated
if (stored.success && stored.session) {
storedSession = stored.session;
}
}
if (!storeOwnsWebcam) {
// Webcam never reached a store call (no usable data, missing screen file,
// or a mid-stream write error left isStreaming() false). Drop any partial
// file/stream so it isn't orphaned. No-op for an in-memory recorder.
await activeWebcamRecorder.discard().catch(() => undefined);
}
}

clearNativeRecordingState();
Expand Down Expand Up @@ -540,17 +571,30 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (activeWebcamRecorder.recorder.state !== "inactive") {
activeWebcamRecorder.recorder.stop();
}
// Await the blob promise to drain in-flight chunk writes (and surface a
// mid-stream write error) even when streaming, where it resolves empty.
const webcamBlob = await activeWebcamRecorder.recordedBlobPromise;
const webcamFileName =
activeNativeRecording.webcamFileName ??
`${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
if (activeWebcamRecorder.isStreaming()) {
// Streamed webcam bytes are already on disk; hand the attach step an
// empty buffer and let the main process patch the duration there.
return { videoData: new ArrayBuffer(0), fileName: webcamFileName };
}
if (!webcamBlob || webcamBlob.size === 0) {
return undefined;
}
const fixedWebcamBlob = await fixWebmDuration(webcamBlob, duration);
return {
videoData: await fixedWebcamBlob.arrayBuffer(),
fileName: `${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`,
fileName: webcamFileName,
};
} catch (error) {
console.error("Failed to finalize native macOS webcam recording:", error);
// A streamed recorder that errored mid-flight left a partial file and an
// open disk stream; discard both so nothing is orphaned.
await activeWebcamRecorder.discard().catch(() => undefined);
return undefined;
}
})();
Expand All @@ -568,12 +612,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const result = await window.electronAPI.stopNativeMacRecording(discard);
const webcamAsset = await webcamAssetPromise;
if (discard || result.discarded) {
// Streamed webcam left an open stream + partial file; drop it.
await activeWebcamRecorder?.discard().catch(() => undefined);
clearNativeRecordingState();
return true;
}
if (!result.success) {
console.error("Failed to stop native macOS recording:", result.error);
toast.error(result.error ?? "Failed to stop native macOS recording");
await activeWebcamRecorder?.discard().catch(() => undefined);
activeNativeRecording.finalizing = false;
return true;
}
Expand All @@ -584,13 +631,20 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
recordingId: activeNativeRecording.recordingId,
webcam: webcamAsset,
cursorCaptureMode,
// Lets the main process patch the WebM duration of a streamed webcam,
// whose bytes are on disk and so were never duration-fixed in memory.
durationMs: duration,
});
if (attachResult.success) {
result.session = attachResult.session;
} else {
console.error("Failed to attach native macOS webcam recording:", attachResult.error);
toast.error(attachResult.error ?? "Failed to store webcam recording");
}
} else if (webcamAsset && activeWebcamRecorder?.isStreaming()) {
// Streamed webcam with no screen output to attach to: drop the partial
// file and close its stream so it isn't orphaned on disk.
await activeWebcamRecorder.discard().catch(() => undefined);
}

clearNativeRecordingState();
Expand Down Expand Up @@ -815,12 +869,19 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return true;
}
}
const windowsWebcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
const browserWebcamRecorder =
webcamEnabled && webcamStream.current
? createRecorderHandle(webcamStream.current, {
mimeType: selectMimeType(),
videoBitsPerSecond: BITRATE_BASE,
})
? createRecorderHandle(
webcamStream.current,
{
mimeType: selectMimeType(),
videoBitsPerSecond: BITRATE_BASE,
},
// Stream webcam chunks to disk instead of buffering the whole clip in
// renderer memory, so a long recording can't OOM-crash on stop (#616).
windowsWebcamFileName,
)
Comment thread
codigoyi marked this conversation as resolved.
: null;
if (webcamEnabled && !browserWebcamRecorder) {
stopWebcamPreviewStream();
Expand Down Expand Up @@ -869,6 +930,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
) {
browserWebcamRecorder.recorder.stop();
}
// The sidecar may already be streaming to disk; drop the partial file
// and close its stream so a failed start doesn't orphan it.
await browserWebcamRecorder?.discard().catch(() => undefined);
Comment thread
codigoyi marked this conversation as resolved.
throw new Error(result.error ?? "Native Windows capture failed.");
}

Expand All @@ -878,6 +942,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
finalizing: false,
paused: false,
webcamRecorder: browserWebcamRecorder,
webcamFileName: browserWebcamRecorder ? windowsWebcamFileName : undefined,
};
webcamRecorder.current = browserWebcamRecorder;
accumulatedDurationMs.current = 0;
Expand Down Expand Up @@ -926,6 +991,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
Number(selectedSource.display_id) || parseMacDisplayIdFromSourceId(selectedSource.id);
const windowId = parseMacWindowIdFromSourceId(selectedSource.id);
let nativeWebcamRecorder: RecorderHandle | null = null;
let macWebcamFileName: string | undefined;
if (webcamEnabled) {
if (!webcamReady.current) {
await new Promise<void>((resolve) => {
Expand All @@ -945,10 +1011,17 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return true;
}
if (webcamStream.current) {
nativeWebcamRecorder = createRecorderHandle(webcamStream.current, {
mimeType: selectMimeType(),
videoBitsPerSecond: BITRATE_BASE,
});
macWebcamFileName = `${RECORDING_FILE_PREFIX}${activeRecordingId}${WEBCAM_FILE_SUFFIX}${VIDEO_FILE_EXTENSION}`;
nativeWebcamRecorder = createRecorderHandle(
webcamStream.current,
{
mimeType: selectMimeType(),
videoBitsPerSecond: BITRATE_BASE,
},
// Stream webcam chunks to disk instead of buffering the whole clip in
// renderer memory, so a long recording can't OOM-crash on stop (#616).
macWebcamFileName,
);
} else {
webcamAcquireId.current++;
setWebcamEnabledState(false);
Expand All @@ -958,6 +1031,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") {
nativeWebcamRecorder.recorder.stop();
}
// Drop the partial streamed sidecar so a cancelled countdown can't orphan it.
await nativeWebcamRecorder?.discard().catch(() => undefined);
return true;
}
const request: NativeMacRecordingRequest = {
Expand Down Expand Up @@ -1007,12 +1082,16 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") {
nativeWebcamRecorder.recorder.stop();
}
// Drop the partial streamed sidecar so a failed start doesn't orphan it.
await nativeWebcamRecorder?.discard().catch(() => undefined);
throw new Error(result.error ?? "Native macOS capture failed.");
}
if (!isCountdownRunActive(countdownRunToken)) {
if (nativeWebcamRecorder && nativeWebcamRecorder.recorder.state !== "inactive") {
nativeWebcamRecorder.recorder.stop();
}
// Drop the partial streamed sidecar before bailing on the cancelled run.
await nativeWebcamRecorder?.discard().catch(() => undefined);
await window.electronAPI.stopNativeMacRecording(true);
return true;
}
Expand All @@ -1022,6 +1101,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
recordingId: result.recordingId,
finalizing: false,
paused: false,
webcamFileName: macWebcamFileName,
};
webcamRecorder.current = nativeWebcamRecorder;
accumulatedDurationMs.current = 0;
Expand Down
Loading