Skip to content

fix(engine): keep a/v in sync when ffprobe misses a late stream's start - #5462

Closed
miguel-heygen wants to merge 7 commits into
mainfrom
fix/vspeed-av-start-offset-webm
Closed

miguel-heygen wants to merge 7 commits into
mainfrom
fix/vspeed-av-start-offset-webm

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

What

Exports of sources whose audio and video start at different times now keep sound and picture in sync even when the gap is longer than ffprobe's probe window. Before, such a file came out exactly as on main. The containers affected are WebM/MKV (what browser recorders and OBS write), MPEG-TS, NUT and others. This finishes the MP4/MOV/MPEG-TS start-offset fix (#5455).

Why

The shared stream-start helper reads each stream's start_time and duration from ffprobe. When ffprobe sees no packet of a stream inside its probe window, libavformat gives that stream the container's start and duration. A video that starts long after its audio therefore looks aligned.

The fingerprint is a stream whose start and duration equal the container's. It is not specific to Matroska:

  • A WebM whose video is 2 s late reports start_time 2.0 correctly.
  • With a 7 s lead, the same file reports the container's -0.007 / 12.008.
  • An MPEG-TS whose video is 20 s late reports the container's 1.4 s for the video.

Measured on a 12 s WebM (VP9 + Opus) with the video 7 s late, <video> from 0, output second to source second heard:

output
main 0:src7 1:src8 2:src9 3:src10 4:src11, then silent
this branch 0:src0 ... 11:src11; nothing is shown before the video starts, as in the browser

Related work

Follows #5455, which named this as a known gap.

How

All in packages/engine/src/utils/ffprobe.ts:

  • When a stream is a suspect. Only when it carries that fingerprint, in a file with at least two audio/video streams. The fingerprint is either of two cases: its start and duration both equal the container's, or it has no start while another audio/video stream has one. The second case is how a WebM written as a stream (the way browser recorders write, with no duration or cues) reports a late track. Indexed MP4/MOV are excluded: they never fill, and their packet times include the AAC priming delay (about 0.021 s), so measuring packets there would move aligned files.
  • Reading the packet list. The end comes from the last 64 KiB of ffprobe's packet list. When that output was cut, its first line is dropped, because a cut like 7.008333,… → 8333,… would read as hours.
  • When a suspect is re-measured. The fingerprint only says ffprobe may have missed the stream; its first packet is the evidence. Only when that packet starts more than 0.05 s after the container's start are the stream's start and end replaced. 0.05 s is the compiler's existing media tolerance (MEDIA_DURATION_CLAMP_EPSILON_SECONDS), and a probe window ffprobe misses is seconds long. Otherwise every value stays exactly as ffprobe reported it and no end probe runs. This keeps an aligned AVI's declared duration, which includes trailing empty frame slots (10 s declared, last real packet ending at 9.66 s), and its loop phase.
  • What is measured. Its first packet gives the start. The end comes from its last packet: first the last 30 s of the container, read with an explicit far end (an open-ended interval reads uninitialised memory in ffprobe, as fix(engine): audio duration probe reads to the end on Windows; cli tests skip bun's disk cache #5395 found), else a full packet scan with bounded output.
  • Cost. Files without the fingerprint spawn nothing extra. Aligned WebM, MKV and TS fixtures probed with 0 packet probes out of 6 ffprobe calls. A suspect stream that starts on time costs one first-packet probe. A late stream costs 2-3 sequential ffprobe calls, one stream at a time.
  • Failure. If the packet probe fails, the stream keeps the container's values and one warning names the file and the reason. That result is cached like a success, so the picture and the sound in a process always see the same timing; a per-call fallback could give them leads 7 s apart. An abort rethrows, starts no further stream's read, and is not cached.
  • One timing per file. Measured timings are cached per file identity (path, size, modification time), apart from the 128-entry probe-output cache. Video and audio metadata both read that one entry, so evicting a probe output can no longer give them two different timings for the same file. One small entry per skewed file, capped at 4096.
  • Duration with no stream duration. When a video stream has a start but no duration (e.g. FLV), the container-duration fallback already spans the lead. It is now stream-relative, so the lead is not counted twice (a 27 s clip read as 34 s).

Known limit, not in this PR: an MPEG-TS whose video starts after ffprobe's probe window also has no codec parameters (size 0x0). FFmpeg cannot open that video, on main or here, so its picture extraction fails with "Video source could not be decoded". This PR fixes its timing and sound placement. Opening the video needs a larger analyzeduration/probesize, which is a separate change.

Test plan

  • Unit tests added/updated in streamStartOffset.test.ts.
    • Audio maps: video-late WebM (seekable and streamed) and MKV, audio-late WebM and MKV, an aligned VP9 + Opus WebM, and a 20 s video lead in TS and NUT.
    • Leads and playable durations: WebM, MKV and FLV (7 s); TS and NUT (20 s, audio 45 s).
    • Frames: nothing shown before the lead, frames after it (WebM, MKV, FLV, NUT).
    • An aligned MP4 keeps its exact ffprobe values (lead 0, duration 20).
    • Aligned AVI (with trailing empty frame slots), Ogg (Theora + Vorbis), MXF and TS with the audio longest: exact ffprobe values and no end probe. The AVI keeps its declared 10 s.
    • A failed measure, then 130 other files probed: a later audio read still agrees with the cached video timing.
    • A packet read that fails once: video and audio metadata both keep the container's timing for that process, one warning names the file (base name only).
  • Red without each fix (Linux, each removed alone, then restored):
change removed result
whole refinement (ffprobe.ts as on main) 12 failed / 19 passed (at the first review round)
fingerprint narrowed to Matroska TS and NUT leads, NUT audio and frames fail
MP4/MOV exclusion "keeps an aligned MP4's timing exactly as ffprobe reports it" fails
audio streams not re-measured audio-late WebM and MKV audio maps fail
stream end taken from the container TS and NUT lead/duration tests fail
first line of a 64 KiB-cut packet list kept "ignores the partial first line of a packet list cut at 64 KiB" and "ends a late 120 fps stream where its packets end" fail
fingerprint checks only the start "does not re-measure an aligned stream that only shares the container's start" fails
open-ended tail interval "reads each late stream's tail in one pass when ffprobe's heap is dirty" (MALLOC_PERTURB_=1, 3 packet reads, not 4) fails
per-call fallback, never cached (previous head) "keeps one timing per file in a process when a packet read fails, so picture and sound agree" fails
first-packet late check removed (previous head) the AVI declared-10 s test and the 4 aligned AVI/Ogg/MXF/TS tests fail
measured-timing cache read removed "keeps a file's measured timing past 128 other probes, so a later audio read agrees" fails
abort not rethrown "starts no further stream's packet read once the probe is aborted" fails (spawn spy: 2 reads, not 1)
no full-scan fallback "scans the whole stream when its tail read finds no packet" and the streamed-WebM test fail
no-start signal removed the streamed-WebM audio map and lead tests fail ([7, 8, 9, 10, 11, null, …])
  • Green: 45/45, three runs in a row. Engine audioMixer, videoFrameExtractor, ffprobe, audioFxRender: 508 pass. The only failures are the 4 ffprobe.test.ts cases that need LFS fixtures, which fail on main too.
  • The first-packet probe reads one packet, so the earlier "earliest of the first 8 packets" rule is gone, along with its untested B-frame claim. In encoder output, a stream's first packet is its first keyframe; a capture cut mid-GOP may start up to one GOP earlier than its first decodable frame.
  • Manual testing performed: not with a real browser-recorder or OBS file; fixtures are FFmpeg encodes and remuxes.
  • Documentation updated (if applicable)
  • Comments follow CONTRIBUTING.md "Comments"

Not verified: a full CLI render of a WebM end to end; an abort during the packet probe (inspected, not run); Windows spawn cost.

@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 119 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (0)

@miguel-heygen miguel-heygen changed the title fix(engine): webm and mkv exports keep sound and picture in sync when streams start apart fix(engine): exports keep sound and picture in sync when ffprobe misses a late stream's start Oct 11, 2026

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at d8d5e84a2b0abccfa089d2f0dacf538ba9076f57, against effective merge-base/API base b211fc7cb845c6838cbdb1860506c2fda2804787: both changed files read end-to-end, two modifications, +409/−21, no additions/deletions/renames. The submitted review and inline histories were empty when checked; private review claims are not GitHub receipts.

The explicit far-end interval, partial-row discard and abort propagation are good safeguards (packages/engine/src/utils/ffprobe.ts:75,105–135). The shared raw probe also avoids independently choosing fallback timing while its entry survives (:460–493).

Blocker — the equality fingerprint can shorten a valid AVI loop period

packages/engine/src/utils/ffprobe.ts:60–72,87–96 treats an aligned stream whose start/duration match its container as unobserved and overwrites its duration when packet measurement succeeds. Equality is not sufficient evidence that the summary was filled: the inspected FFmpeg AVI demuxer takes video duration from the declared frame count, skips empty chunks while advancing the frame offset, and does not extend the preceding real packet to include those slots (n7.1.1 avidec, empty chunks). The muxer's final skip-frame writing supports this representation (avienc). For an AVI exposing that declared-duration/packet-span distinction, this fingerprint discards the declared cycle length even though it was not inferred from a missing packet summary. This is a source-derived contract regression, not a claim that I decoded this representation with the bundled runtime.

Concrete source-derived control: aligned 10-second MJPEG/PCM AVI metadata, 25 fps, 250 declared video slots, with synthetic packet replies representing 242 nonempty frames followed by eight null slots. Declared duration is 10 seconds and last real packet end is 9.68. Executing the extracted refinement preserves 10 on base and substitutes 9.68 on HEAD. The positive substituted duration wins over declared nb_frames (ffprobe.ts:886–898). The actual extraction-window source keeps a loop cycle using that playable duration (packages/engine/src/services/videoFrameExtractor.ts:1419–1434), and resolvePlayableVideoDuration / getFrameIndexAtTime use it for modulo (:1303–1308,2643–2675); the final-frame-only repair is not selected by that loop branch. Given 242 delivered paths, a 20-second looping slot at time 9.8 holds frame 241 on base but wraps to frame 3 on HEAD in the modeled production lookup. That is a 320 ms cycle change for these inputs, not the disclosed sub-frame rounding cost; the actual native delivered-path count remains unverified.

I checked the default producer coverage gate against the same modeled delivery count, rather than claiming a completed render. With 242 synthetic delivered paths the extracted report/assert passes: 242/250 = 96.8% on base and 242/242 = 100% on HEAD, both above default 95% (packages/producer/src/services/render/videoFrameCoverage.ts:125–133,169–220). This shows the gate does not necessarily exclude the source-derived contract regression; it is not a native passing-render receipt. The ≤60% extraction reconciliation does not restore the duration for these inputs. An earlier 200/250 control does not establish default producer rendering because base fails that gate; that larger-tail example is not the reachability basis for this finding.

Please preserve trustworthy hold-bearing stream duration, or narrow/corroborate the filled-metadata inference before replacing it. Add an AVI trailing-null-slot regression that pins the declared playable duration and loop phase, alongside the late-stream tests.

Important follow-up — the disclosed cache-eviction boundary is real

As the PR body already notes, raw eviction can separate video/audio timing generations. Raw is capped at 128, but normalized video/audio caches outlive it (ffprobe.ts:233–247,432–493,794–797,1049–1086). A failed refinement can remain in normalized video while a later first audio read measures successfully, or vice versa; source-extracted controls show leads 0/7 or 7/0 and the corresponding input-seek versus timestamp-trim argument split (packages/engine/src/services/audioMixer.ts:624–657). Producer awaits video probes, then image probes, extraction, coverage and audio, so intervening images can supply eviction pressure. I am treating this narrow, explicitly disclosed boundary as follow-up, not as an additional blocker or a measured PCM desync. Tie timing decisions to a common generation/lifetime and add eviction coverage.

Evidence limits: two independent full source audits and subsequent cross-falsification completed. I read their controls and the finding-bearing production/upstream paths; my additional offline control executes extracted refinement, lookup and coverage bodies with synthetic metadata, packet replies, delivered frame counts and constant-rate collaborators. It is not a decoded AVI, native FFmpeg/bundled-version verification or a completed CLI render. Native repository/media/browser tests and CI were not run; the author's measurements remain separate evidence claims.

— tai

Verdict: REQUEST CHANGES
Reasoning: For the AVI declared-duration/packet-span representation supported by the inspected demuxer source, this fingerprint replaces the declared cycle with packet extent and changes the loop period; the modeled narrower case passes the extracted coverage gate. Narrow/corroborate the inference and preserve that duration contract before merging; this verdict rests on the source-derived regression, not a native rendered-output claim.

@miguel-heygen miguel-heygen changed the title fix(engine): exports keep sound and picture in sync when ffprobe misses a late stream's start fix(engine): keep a/v in sync when ffprobe misses a late stream's start Oct 11, 2026

@terencecho terencecho left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 10a544653515152268dea5e77d9a39a72867eb3c, against effective merge-base b211fc7cb845c6838cbdb1860506c2fda2804787. Two modified files, +535/−22; both changed HEAD files and base originals were read, with extraction/audio/cache callers inspected.

Resolved from my previous review:

  • The exact AVI witness with first packet at zero now retains its declared 10 s: ffprobe.ts:168–171; the new assertion is at streamStartOffset.test.ts:591–595. I am not carrying that resolved example forward as an active blocker.
  • The separate measured-timing cache fixes the previously disclosed ordinary 128-entry raw-LRU eviction witness (ffprobe.ts:79–82): the same simulated fallback remains shared after 130 intervening probes. The missing-duration fallback also avoids double-counting stream lead (ffprobe.ts:926–955).

Blocker [P2] — Bind shared measured streams to the raw probe's generation. packages/engine/src/utils/ffprobe.ts:65–72.

The raw probe fingerprints A before asynchronous ffprobe (:509), but refinement fingerprints the path again after its response (:65) and caches the entire A stream array under that later identity. If an external writer replaces A with B in that window, a subsequent call correctly performs a fresh raw probe of B, then :67 discards B's streams and substitutes cached A streams. The normalized cache now retains A metadata under B: stale metadata is reused across subsequent calls while B's fingerprint remains unchanged, not merely during the pre-existing transient observation of a concurrent replacement.

An independently executed, source-extracted public extractMediaMetadata control simulated width64 A → width128 B during the first raw response. Base returned [64,128,128]; HEAD returned [64,64,64], with two raw probes in each. Without replacement both returned [64,64,64] with one probe; ordinary replacement between completed calls invalidates correctly. Width is a production-consumed field (videoFrameExtractor.ts:1006), and caching the whole array can also preserve stale codec/fps/color/timing fields.

Carry the raw probe's immutable generation into refinement and prevent publishing/reusing results under a different generation. Add a replacement-during-probe regression through the public metadata reader; reducing the cached fields alone does not establish generation correctness.

Blocker [P2] — A late first packet does not justify truncating AVI's declared end. packages/engine/src/utils/ffprobe.ts:103–108,168–177.

The first-packet guard fixes the zero-start example, but the same valid null-slot representation with leading empty slots still passes it. A 25fps AVI can declare 250 slots / 10 s, contain two leading null slots, 240 real packets from .08 through 9.64 (last packet end9.68), and eight trailing null slots. .08 > .05 triggers replacement with start .08 and duration 9.6; playable duration becomes 9.68, losing the declared trailing320ms hold. Base keeps the 10 s cycle. The declared 10 s already includes the leading80ms; it is not 10 s plus an additional lead.

This is grounded in the inspected FFmpeg n7.1.1 AVI contract: the demuxer derives duration from declared frame count and skips empty chunks while advancing frame offset; the muxer accepts explicitly supplied empty packets. Its automatic skipped-frame insertion does not establish initial null slots. This is an explicitly leading-null representation, not a claim that the new test fixture automatically generates it, nor confirmation of the bundled binary version.

Extracted production window/final-frame/reconciliation/coverage/lookup controls with 240 modeled delivered paths preserve a 10 s loop on base versus9.68 on HEAD. At9.8 s lookup selects final frame239 versus wrapped frame3. Both modeled default coverage checks pass:240/250=.96 versus240/242≈.9917; the ≤60% correction does not run, and the loop window does not request final-frame repair. Thus those source guards do not restore the declared cycle in this control. Do not shorten a valid declared AVI cycle merely because its first/last nonempty packets exclude null slots: preserve its declared media end (the 10 s playable cycle here) when correcting start, or require evidence that the declaration is invalid. Add the leading-plus-trailing-null case alongside the existing test.

Evidence limits: controls used extracted source bodies with stat/process/media collaborators simulated. No native ffprobe/FFmpeg, real file-replacement experiment, repository suite, browser/CLI render, decoded audio or CI verification was performed. Modeled packet/frame outcomes are not native delivery receipts; full-render success and occurrence frequency remain unmeasured. The verdict is based on the reproduced metadata-generation defect and the source-derived duration-contract regression, not missing native evidence or CI status.

— tai

Verdict: REQUEST CHANGES
Reasoning: The original AVI example and ordinary raw-LRU follow-up are resolved, but the new cache can persist a replaced file's old metadata, and the first-packet guard still permits truncating a declared AVI cycle with leading null slots. Correct these generation and duration contracts before approval.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: REQUEST CHANGES · 1 blocker · 1 important · 0 nits — head 7b93d64e

Both earlier change requests are resolved: AVI is no longer measured (only mpegts|nut|matroska|webm), and measured timings are now cached under the raw probe's pre-probe identity, so a file replaced mid-probe is re-probed on the next call. What's left is CI and one cost regression.

Blocker: the required Tests on windows-latest check fails because of the new test. In streamStartOffset.test.ts:597-618 ("does not keep a replaced file's measured timing…"), the fake ffprobe is a #!/bin/sh script. Windows can't spawn it: ffprobe not found at HYPERFRAMES_FFPROBE_PATH="…\fake-ffprobe-swap" (engine-cli job, 1 failed / 2553 passed). Either write the wrapper as a Node script launched through process.execPath, or it.skipIf(process.platform === "win32").

Important: a URL with stored probe facts now downloads the media when a stream is late. buildProbeFactsText stores the raw, unmeasured ffprobe JSON. readProbeJson returns that JSON, and withMeasuredFilledStreams (ffprobe.ts:66, called at :669-671) still sees the fingerprint, so it runs packet probes against the remote URL. Repro: a TS whose video is 20 s late, served with declareContentAddressedUrl + declareProbeFactsUrl. extractMediaMetadata(url) made 15 range requests (~7 MB for a 0.73 MB file). An aligned TS made none. Possible fixes: store the measured streams in the facts and bump PROBE_FACTS_VERSION, or skip measuring when the JSON came from stored facts. If the fetch fails, the container fallback is also cached under the sha256: identity for the rest of the process.

Scope

I read both changed files end to end and checked them against the two prior reviews. I ran the PR's test file locally with ffmpeg 5.1: 46/47 pass. The one failure is the AVI declared-10 s fixture, which gets 9.68. That fixture depends on how the setts muxer behaves, and AVI is no longer measured on this head, so I treat it as a quirk of the local ffmpeg version, not a regression. I checked B-frame/open-GOP TS cuts, PTS wrap, empty PIDs, Opus negative start, the removed promise.catch (writeMetadataCache covers it), null-identity sharing, and abort paths: all fine. Linux CI CI is green.

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Closing: this conflicts with main, has had no update for over 5 days, and no open issue or report names who it hurts. Reopen if someone hits it; the branch is kept.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants