Repository navigation
fix(engine): MP4, MOV and TS exports keep sound and picture in sync when streams start apart - #5455
Conversation
…dio and video start apart
Edit accuracy: accurate 2061 (base branch 2061), smooth 1560 of thoseThe gate passes. Quarantined, measured but not gated (0) Unstable (1)
|
… HDR renders hide its lead
jrusso1020
left a comment
There was a problem hiding this comment.
Reviewed at a73d397a. Approving, no blockers.
I checked the PR body's sync claim against the code and it holds. Media time 0 is the earliest audio/video start_time (ffprobe.ts, audioVideoStreamStarts). That matches FFmpeg's own normalization when there's no input seek, because without -copyts it subtracts the file's start time. So atrim=start=mediaStart is on the same clock as the browser. aresample=async=1:first_pts=0 then pads a late stream's leading gap with silence, instead of playing it early from sample 0. On the picture side, resolvePlayableVideoDuration now returns lead + stream length. The extraction window, held tail, loop cycle (getFrameIndexAtTime) and videoFrameCoverage.ts:189 all read that same value. isVideoHiddenBeforeStreamStart is the one rule for both the SDR/distributed lookup and the HDR blit.
Reuse and simplicity: merging extractAudioFromVideo and prepareAudioTrack into extractAudioSegment removes about 80 lines of near-duplicate code from audioMixer.ts. It also puts both audio paths on the same timestamp rule, so the fix covers the whole class of bug, not one call site. The fast input seek is kept whenever mediaStart is at or past every stream's start, so files with both streams at 0 take the same argv as before.
Run at this head (FFmpeg n8.0.1)
streamStartOffset.test.ts: all 16 ran against real FFmpeg, none skipped. Along withaudioMixer*andvideoFrameExtractor*, 330 engine tests pass.- Producer:
hdrCompositorandvideoFrameCoveragepass (44), anddistributed/videoMetadata.test.tspasses underbun test(15). Enginetsc --noEmitis clean. - My own mutants, beyond the PR's table:
- Removing
aresample=async=1:first_pts=0fails 3 tests. - Hiding the picture for any lead (threshold 0.001 instead of 1 s) fails 3 tests.
- Changing
>=to>in theinputSeekcheck passes. That's expected: at the boundary both paths produce the same audio.
- Removing
Optional
- The cover-art exclusion is untested. If you drop
s.disposition?.attached_pic !== 1fromaudioVideoStreamStarts, all 375 tests still pass. It matters for files like an MP3 or M4A with embedded art, where the art stream'sstart_timesits before the audio's (LAME's ~25 ms delay, for example). One lavfi fixture with an attached picture would pin it. audioFxRender.ts(thedeinterleavedoc comment) is outside this fix. Trimming it loses the rationale for why the graph stopped folding to mono. If the comment ratchet forced the cut, fine. If not, I'd leave that file out of this PR.
— Rames
What
Exports of a source whose audio and video streams start at different times (MP4, MOV, MPEG-TS) now place picture and sound where the browser preview shows them. That covers the whole clip, every render path (in-process, distributed, HDR layers) and loops.
Scope: MP4, MOV and MPEG-TS sources. WebM and MKV are a known gap that a follow-up fixes: the Matroska demuxer reports
start_time0 for every stream even when the first video packet is 7 s in, so this change cannot see the offset there and those files export exactly as on main.A user reported an export that lost its first ~7 s of picture and whose sound was wrong. One source property reproduces both symptoms: per-stream
start_timevalues that differ, e.g. a video stream that starts 7 s after its audio. Keyframe-cut screen recordings have this shape, and so do recorders that start audio before the first video frame.Why
On main, for a 12 s clip with the source played from 0:
The picture of the video-late source has two problems on main:
Root cause, in
packages/engine:audioMixer.ts, both the<video>and the<audio>path):-ss <mediaStart> -t <dur>went before-i. Even-ss 0makes the mov demuxer seek the default stream (video) and move every other stream to the keyframe it found, so audio before the video's first keyframe was cut.fps=...:start_time=0, which fills the slots before the first frame with that frame.resolvePlayableVideoDurationreturned the video stream's own length. Its callers treat it as an end time on the media timeline (the extraction window, the held tail, the loop cycle), so a late stream was cut, or wrapped,leadseconds early.Related work
None open. The sibling fix for a split scene's in-point lives in the producer's media collector and does not touch these files.
How
ffprobe.ts: one helper measures how far after the file's media time 0 a stream starts. Media time 0 is the earliest audio/video stream start, which is the browser's origin. It feedsAudioMetadata.latestStreamLeadSecondsandVideoMetadata.videoStreamLeadSeconds, and both come from the probe each path already makes.audioMixer.ts: oneextractAudioSegmentserves both audio paths.mediaStartis at or past every stream's start, it keeps today's fast input seek, with the same argv and output.atrim=start=S:end=S+D,asetpts=PTS-S/TB,aresample=async=1:first_pts=0. This trims by timestamp first and only then pads the leading gap, so memory stays bounded by the clip, not by the stream offset. The trim also goes in front of the rate-lane graph.videoFrameExtractor.ts:resolvePlayableVideoDurationnow returns the video's end on the media timeline (lead + stream length). The extraction window, the held tail, the loop cycle and frame coverage all read it from there.isVideoHiddenBeforeStreamStartis the one rule for "nothing on screen yet". BothgetFrameIndexAtTime(the SDR and distributed lookup) and the HDR layer blit use it.distributed/shared.ts) carryvideoStreamLeadSecondsthe same way they carryvideoStreamStartSeconds. Older plans read it as 0.Decisions (reversible):
hyperframes snapshoton sources with a 0.1, 0.5, 1, 2, 3 and 7 s video lead. Chrome paints the upcoming first frame when it is under 1 s away and nothing when it is 1 s or more away (3 s lead: black at 2.00 s, first frame at 2.03 s).Test plan
New
packages/engine/src/services/streamStartOffset.test.ts(real FFmpeg, skipped without it). It builds the sources from lavfi; a tone of 200 + 100·s Hz names each source second.Sound, checked as a per-second source map:
<video>and<audio>elements, from media start 0 and 3;Picture:
Other tests:
hdrCompositor.test.ts: the HDR blit paints nothing for a late stream before it starts.videoMetadata.test.ts: plans round-trip the lead.Red without each fix (each mutant applied alone, then restored):
videoStreamLeadSeconds<video>from 3 s (rate lane)"mediaStartRuns:
audioMixer*,videoFrameExtractor*,audioFxRender,audioVolumeEnvelope,videoFrameInjector: green.ffprobe.test.ts: the only failures are 4 tests that need LFS fixtures my checkout skipped, the same as on main.hdrCompositor,captureHdr*andvideoFrameCoveragegreen (93);distributed/green (232, bun).Memory, reading a 12 s window from a file whose audio starts 1 h late:
End to end,
hyperframes render --format mp4 --fps 30 --quality standard --workers autoon Linux:Not verified: