Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ interface Window {
discarded?: boolean;
error?: string;
}>;
attachNativeMacWebcamRecording: (payload: {
attachWebcamToScreenRecording: (payload: {
screenVideoPath: string;
recordingId: number;
webcam: import("../src/lib/recordingSession").RecordedVideoAssetInput;
Expand Down
27 changes: 14 additions & 13 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ type SelectedSource = {
[key: string]: unknown;
};

type AttachNativeMacWebcamRecordingInput = {
type AttachWebcamToScreenRecordingInput = {
screenVideoPath?: string;
recordingId?: number;
webcam?: RecordedVideoAssetInput;
Expand Down Expand Up @@ -2143,34 +2143,35 @@ 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
// handlers that consume it (attach-webcam-to-screen-recording finalizes a
// streamed webcam through this registry).
const recordingStreams = new RecordingStreamRegistry();
registerRecordingStreamHandlers(ipcMain, recordingStreams, resolveRecordingOutputPath);

// Attaches a webcam sidecar to a native screen recording that is already on
// disk. The renderer only marshals the (small) in-memory webcam bytes — the
// multi-GB screen file never round-trips over IPC (issue #11; the renderer
// previously readBinaryFile'd the whole screen recording back just to re-send
// it to store-recorded-session, which rewrote the same bytes to the same path).
ipcMain.handle(
"attach-native-mac-webcam-recording",
async (_, payload: AttachNativeMacWebcamRecordingInput) => {
"attach-webcam-to-screen-recording",
async (_, payload: AttachWebcamToScreenRecordingInput) => {
// 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." };
}

const screenVideoPath = normalizeVideoSourcePath(payload.screenVideoPath);
if (!screenVideoPath || !isPathWithinDir(screenVideoPath, RECORDINGS_DIR)) {
return {
success: false,
error: "Native macOS webcam attachment requires a recording output path.",
error: "Webcam attachment requires a recording output path.",
};
}

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

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

const webcamVideoPath = resolveRecordingOutputPath(payload.webcam.fileName);
Expand All @@ -2188,7 +2189,7 @@ export function registerIpcHandlers(
if (!payload.webcam.videoData || payload.webcam.videoData.byteLength === 0) {
return {
success: false,
error: "Native macOS webcam attachment is missing video data.",
error: "Webcam attachment is missing video data.",
};
}
await fs.writeFile(webcamVideoPath, Buffer.from(payload.webcam.videoData));
Expand Down Expand Up @@ -2218,10 +2219,10 @@ export function registerIpcHandlers(
success: true,
path: screenVideoPath,
session,
message: "Native macOS webcam recording attached successfully",
message: "Webcam recording attached successfully",
};
} catch (error) {
console.error("Failed to attach native macOS webcam recording:", error);
console.error("Failed to attach 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) {
Expand Down
4 changes: 2 additions & 2 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
stopNativeMacRecording: (discard?: boolean) => {
return ipcRenderer.invoke("stop-native-mac-recording", discard);
},
attachNativeMacWebcamRecording: (payload: {
attachWebcamToScreenRecording: (payload: {
screenVideoPath: string;
recordingId: number;
webcam: { fileName: string; videoData: ArrayBuffer };
cursorCaptureMode?: import("../src/lib/recordingSession").CursorCaptureMode;
durationMs?: number;
}) => {
return ipcRenderer.invoke("attach-native-mac-webcam-recording", payload);
return ipcRenderer.invoke("attach-webcam-to-screen-recording", payload);
},
getCursorTelemetry: (videoPath?: string) => {
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
Expand Down
51 changes: 24 additions & 27 deletions src/hooks/useScreenRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,52 +480,49 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
// 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);
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.
// store-recorded-session finalizes (and thus owns) the webcam disk stream
// only once it returns success. Mark ownership after that resolves, and
// discard in `finally` so a throw in fixWebmDuration/storeRecordedSession
// attach-webcam-to-screen-recording finalizes (and thus owns) the webcam
// disk stream only once it returns success. Mark ownership after that
// resolves, and discard in `finally` so a throw in fixWebmDuration/attach
// still drops the partial sidecar instead of leaking it.
let storeOwnsWebcam = false;
let attachOwnsWebcam = false;
try {
if (canStore && screenRead.data) {
const nativeScreenFileName =
nativeScreenPath.split(/[\\/]/).pop() ??
`${RECORDING_FILE_PREFIX}${activeNativeRecording.recordingId}.mp4`;
if (hasWebcamData) {
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).
// the main process patch the WebM duration there. The screen recording is
// already on disk and never round-trips through the renderer — the main
// process attaches the webcam sidecar to it directly.
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,
},
const attached = await window.electronAPI.attachWebcamToScreenRecording({
screenVideoPath: nativeScreenPath,
recordingId: activeNativeRecording.recordingId,
webcam: {
videoData: webcamVideoData,
fileName: webcamFileName,
},
createdAt: activeNativeRecording.recordingId,
cursorCaptureMode,
durationMs: duration,
});
storeOwnsWebcam = stored.success;
if (stored.success && stored.session) {
storedSession = stored.session;
attachOwnsWebcam = attached.success;
if (attached.success && attached.session) {
storedSession = attached.session;
} else if (!attached.success) {
// Screen-only session from the stop handler still stands; surface
// the webcam loss instead of failing the whole recording.
console.error("Failed to attach webcam recording:", attached.error);
toast.error(attached.error ?? "Failed to store webcam recording");
}
}
} finally {
if (!storeOwnsWebcam) {
// Webcam never reached a successful store (no usable data, missing screen
// file, a mid-stream write error, or store threw/returned failure). Drop
// any partial file/stream. No-op for an in-memory recorder.
if (!attachOwnsWebcam) {
// Webcam never reached a successful attach (no usable data, a mid-stream
// write error, or attach threw/returned failure). Drop any partial
// file/stream. No-op for an in-memory recorder.
await activeWebcamRecorder.discard().catch(() => undefined);
}
}
Expand Down Expand Up @@ -633,7 +630,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}

if (webcamAsset && result.path) {
const attachResult = await window.electronAPI.attachNativeMacWebcamRecording({
const attachResult = await window.electronAPI.attachWebcamToScreenRecording({
screenVideoPath: result.path,
recordingId: activeNativeRecording.recordingId,
webcam: webcamAsset,
Expand Down
Loading