diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 81e706c08..d17e51290 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1682,8 +1682,12 @@ function attachNativeMacCaptureOutputDrain( }); } +/** + * Resolves with the helper's own stamp of its first appended frame: the file's t=0, and + * so the instant the HUD timer counts from (#901). Null from a helper that sends none. + */ function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) { - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const timer = setTimeout(() => { cleanup(); reject(new Error("Timed out waiting for native macOS capture to start")); @@ -1692,7 +1696,11 @@ function waitForNativeMacCaptureStart(proc: ChildProcessWithoutNullStreams) { const inspect = (event: Record) => { if (event.event === "recording-started") { cleanup(); - resolve(); + resolve( + typeof event.timestampMs === "number" && Number.isFinite(event.timestampMs) + ? event.timestampMs + : null, + ); return; } if (event.event === "error") { @@ -3165,8 +3173,7 @@ export function registerIpcHandlers( }, ); - await waitForNativeMacCaptureStart(proc); - const captureStartedAtMs = Date.now(); + const captureStartedAtMs = (await waitForNativeMacCaptureStart(proc)) ?? Date.now(); const microphoneDefaulted = request.audio.microphone.enabled && readMicrophoneDefaulted(nativeMacCaptureOutput); if (microphoneDefaulted) { @@ -3201,6 +3208,7 @@ export function registerIpcHandlers( recordingId, path: outputPath, helperPath, + startedAtMs: captureStartedAtMs, microphoneDefaulted, microphoneUnavailable, }; diff --git a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx index 2fc860d77..a56c7bcc4 100644 --- a/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx +++ b/src/hooks/useScreenRecorder.nativeMacStartWarning.test.tsx @@ -287,6 +287,25 @@ describe("useScreenRecorder native macOS start warnings", () => { expect(view.result.current.recording).toBe(true); }); + // The file starts at the helper's first frame, however late the start reply lands (#901). + it("counts the HUD timer from the helper's first frame, not from the start reply", async () => { + api.startNativeMacRecording.mockImplementation(async () => ({ + success: true, + recordingId: 9, + startedAtMs: Date.now() - 20_000, + })); + const view = renderHook(() => useScreenRecorder()); + await settle(); + + await act(async () => { + view.result.current.toggleRecording(); + }); + await settle(3_500); + + expect(view.result.current.recording).toBe(true); + expect(view.result.current.elapsedSeconds).toBe(20); + }); + it("does not warn after the recording start is cancelled", async () => { let resolveStart: | ((result: Awaited>) => void) diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 5feea0ace..33a6039aa 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -1465,7 +1465,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { }; webcamRecorder.current = nativeWebcamRecorder; accumulatedDurationMs.current = 0; - segmentStartedAt.current = Date.now(); + // From the file's first frame, not from this reply, so the timer reads what the + // file holds however late the reply arrives (#901). + segmentStartedAt.current = result.startedAtMs ?? Date.now(); allowAutoFinalize.current = true; setRecording(true); setPaused(false); diff --git a/src/lib/nativeMacRecording.ts b/src/lib/nativeMacRecording.ts index 971b36112..83c9509dc 100644 --- a/src/lib/nativeMacRecording.ts +++ b/src/lib/nativeMacRecording.ts @@ -94,6 +94,8 @@ export type NativeMacRecordingStartResult = { recordingId?: number; path?: string; helperPath?: string; + /** Wall-clock ms of the take's first appended frame: the file's t=0. */ + startedAtMs?: number; /** The helper could not resolve the selected device and is using the system default. */ microphoneDefaulted?: boolean; /** The microphone was asked for, but this macOS cannot capture it: the take has no voice. */