@@ -38,6 +38,60 @@ const DEFAULT_HEIGHT = 1080;
3838const CODEC_ALIGNMENT = 2 ;
3939
4040const 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+ }
4195const CHROME_MEDIA_SOURCE = "desktop" ;
4296const RECORDING_FILE_PREFIX = "recording-" ;
4397const 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