Skip to content

Commit 646a66d

Browse files
fix(linux): anchor recording start to the first real captured frame
MediaRecorder's internal frame-0 timeline starts whenever the track delivers its first real frame, not when recorder.start() is called. On Linux/PipeWire the desktop-capture portal can still be negotiating (DMA-BUF modifier renegotiation) for several seconds after the track reports unmuted, so anchoring duration accounting and cursor telemetry to Date.now() at button-press time let both drift out of sync with the actual video content: declared duration exceeded the real content span (playback jumped to the end early) and the cursor overlay lagged the picture. waitForFirstVideoFrame() now blocks on an actual rendered frame (via requestVideoFrameCallback, capped at 3s) before recorder.start() is ever called, so recordingId, segmentStartedAt, and the cursor-telemetry start time all anchor to the same verified wall-clock instant.
1 parent 342b4f9 commit 646a66d

1 file changed

Lines changed: 61 additions & 26 deletions

File tree

src/hooks/useScreenRecorder.ts

Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,60 @@ const DEFAULT_HEIGHT = 1080;
3838
const CODEC_ALIGNMENT = 2;
3939

4040
const BITS_PER_MEGABIT = 1_000_000;
41+
42+
const FIRST_FRAME_TIMEOUT_MS = 3000;
43+
44+
/**
45+
* Resolve once `track` has produced a real, decodable frame -- not merely once
46+
* `track.muted` reads false. On Linux/PipeWire the desktop-capture portal can still be
47+
* negotiating (DMA-BUF modifier renegotiation, `no_hardware_cursors`, etc.) well after
48+
* the track reports unmuted, so anchoring recording start to "now" makes MediaRecorder's
49+
* own internal frame-0 arrive silently late: the declared recording duration (wall time
50+
* from button-press to stop) ends up longer than the video's real content span (playback
51+
* jumps to the end early), and cursor telemetry -- anchored to the same "now" -- drifts
52+
* out of sync with the picture. Waiting for an actual rendered frame here, before
53+
* `recorder.start()` is ever called, keeps duration accounting, cursor telemetry, and
54+
* MediaRecorder's timeline all pointing at the same wall-clock instant.
55+
*
56+
* Falls back to `Date.now()` after `FIRST_FRAME_TIMEOUT_MS` if no frame arrives (e.g. a
57+
* genuinely stalled capture), so recording start is never blocked indefinitely.
58+
*/
59+
function waitForFirstVideoFrame(track: MediaStreamTrack): Promise<number> {
60+
return new Promise((resolve) => {
61+
const video = document.createElement("video");
62+
video.muted = true;
63+
video.playsInline = true;
64+
video.srcObject = new MediaStream([track]);
65+
66+
let settled = false;
67+
const cleanup = () => {
68+
video.srcObject = null;
69+
video.remove();
70+
};
71+
const finish = (timeMs: number) => {
72+
if (settled) return;
73+
settled = true;
74+
clearTimeout(timeoutId);
75+
cleanup();
76+
resolve(timeMs);
77+
};
78+
79+
const timeoutId = setTimeout(() => finish(Date.now()), FIRST_FRAME_TIMEOUT_MS);
80+
81+
if (typeof video.requestVideoFrameCallback !== "function") {
82+
// API unavailable (older Chromium); fall back to a short settle delay rather
83+
// than blocking on the full timeout every time.
84+
finish(Date.now());
85+
return;
86+
}
87+
88+
video.requestVideoFrameCallback(() => finish(Date.now()));
89+
void video.play().catch(() => {
90+
// Autoplay/play() rejection still lets requestVideoFrameCallback or the
91+
// timeout resolve this promise; nothing else to do here.
92+
});
93+
});
94+
}
4195
const CHROME_MEDIA_SOURCE = "desktop";
4296
const RECORDING_FILE_PREFIX = "recording-";
4397
const VIDEO_FILE_EXTENSION = ".webm";
@@ -1416,27 +1470,10 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
14161470
);
14171471
}
14181472

1419-
// On Linux/PipeWire the capture track can still be muted here (mid-negotiation --
1420-
// see main.ts's DMA-BUF modifier renegotiation comment, and applyConstraints above
1421-
// can itself trigger another round). Cursor telemetry anchored to Date.now() right
1422-
// after acquiring the track, instead of to when frames actually start flowing, makes
1423-
// every sample know the cursor's position slightly before the frame showing it
1424-
// exists -- which plays back as the cursor lagging the recording. Start listening
1425-
// now (cheap, non-blocking) so this is almost always already resolved by the time
1426-
// it's consumed below; only a genuinely slow negotiation waits, capped at 500ms.
1427-
const cursorStartTimeMsPromise: Promise<number> = videoTrack.muted
1428-
? new Promise((resolve) => {
1429-
const onUnmute = () => {
1430-
clearTimeout(timeoutId);
1431-
resolve(Date.now());
1432-
};
1433-
const timeoutId = setTimeout(() => {
1434-
videoTrack.removeEventListener("unmute", onUnmute);
1435-
resolve(Date.now());
1436-
}, 500);
1437-
videoTrack.addEventListener("unmute", onUnmute, { once: true });
1438-
})
1439-
: Promise.resolve(Date.now());
1473+
// See waitForFirstVideoFrame's doc comment: block here until the track is
1474+
// actually producing frames, so duration accounting, cursor telemetry, and
1475+
// MediaRecorder's own timeline all anchor to the same wall-clock instant.
1476+
const firstFrameAtMs = await waitForFirstVideoFrame(videoTrack);
14401477

14411478
if (!isCountdownRunActive(countdownRunToken)) {
14421479
teardownMedia();
@@ -1467,7 +1504,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
14671504
return;
14681505
}
14691506

1470-
recordingId.current = Date.now();
1507+
recordingId.current = firstFrameAtMs;
14711508
const activeRecordingId = recordingId.current;
14721509
screenRecorder.current = createRecorderHandle(
14731510
stream.current,
@@ -1497,14 +1534,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
14971534
}
14981535

14991536
accumulatedDurationMs.current = 0;
1500-
segmentStartedAt.current = Date.now();
1537+
segmentStartedAt.current = firstFrameAtMs;
15011538
allowAutoFinalize.current = true;
15021539
setRecording(true);
15031540
setPaused(false);
15041541
setElapsedSeconds(0);
1505-
void cursorStartTimeMsPromise.then((cursorStartTimeMs) => {
1506-
window.electronAPI?.setRecordingState(true, cursorStartTimeMs, cursorCaptureMode);
1507-
});
1542+
window.electronAPI?.setRecordingState(true, firstFrameAtMs, cursorCaptureMode);
15081543

15091544
const activeScreenRecorder = screenRecorder.current;
15101545
const activeWebcamRecorder = webcamRecorder.current;

0 commit comments

Comments
 (0)