fix(engine): recover audio duration by decoding the final frame - #3986
miga-heygen wants to merge 4 commits into
Conversation
…mping An authored `data-duration` on an <audio> element could be silently shortened when a source file's container-summary duration (mvhd/tkhd/mdhd) undercounts the real audio length while the packet stream itself is intact. Add `probeAudioDurationFromPackets()`, which re-derives the true duration from the last packet's own pts_time + duration_time — figures ffprobe computes from the stream's own timestamps, not the container's summary header — and wire it into the compiler's existing clamp check as a rescue attempt before accepting a shorter value. Works regardless of codec/profile; verified against real HE-AAC/SBR fixtures and a binary-patched "lying container" MP4 built for the regression test. The rescue only fires for candidates the existing clamp predicate already flagged (no added cost on the common path), runs in parallel across candidates rather than serially, and requires the recovered duration to exceed the existing clamp epsilon (absorbing the few milliseconds of priming-delay drift raw packet timestamps carry relative to an edit-list-corrected container duration) before overriding the shorter value. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
The previous approach compared the compiler's own packet-scan probe against the already-corrected duration returned by extractAudioMetadata, which meant the correction only applied to one caller and the regression fixture happened to exercise a pre-existing AAC-LC-specific refinement instead of the new code path. Move the fix to the single canonical place duration is derived: replace the old AAC-LC-only packet-count refinement (a profile allowlist assuming a fixed 1024-samples-per-frame constant) with a codec-agnostic mechanism that decodes the final audio frame and reads its own timestamp plus real sample count. This has no per-codec assumption, so it applies uniformly regardless of codec or profile, and every caller of extractAudioMetadata benefits automatically. The compiler's own clamp logic is now unchanged from its pre-existing form. Verified against a real, previously-untouched case: a FLAC-in-MP4 file (a codec the old AAC-LC-only path never corrected) with its mvhd/tkhd/mdhd duration atoms binary-patched to half their true value while every real frame stays intact. Confirmed this reproduces on the prior code (by literally substituting its actual content) and is fixed by the new code. The decode probe reads CSV output (each line stands alone) rather than JSON (a single top-level array), and retains only the output tail under a small cap — a lying container's true duration can be arbitrarily larger than its declared one, and JSON truncated from the front isn't valid JSON, so an unbounded scan of a long file silently fell back to the wrong duration once ffprobe's stdout cap was hit. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
… probe The deleted AAC-LC packet-count refinement assumed every demuxed packet decodes to exactly 1024 samples. For this suite's real audio fixture (a 48kHz stereo AAC-LC MP4), that assumption overcounts: ffprobe reports 753 demuxed packets, but only 752 of them actually decode into a real audio frame (verified via raw PCM sample count: 770048 samples / 48000Hz = 16.0426666...s). The old mechanism's 753 * 1024 / 48000 = 16.064s was therefore wrong; the new decode-based probe correctly derives ~16.043s from the real final frame. Regression-shards caught this as a compilation mismatch on the previous commit. Verified the corrected golden against the real source: full compilation, visual (0 failed frames), and audio (correlation 0.966) checks all pass locally with the actual LFS-hosted golden video, and a standalone compileForRender() check confirms all other audio-bearing fixture suites in the repo are unaffected. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
…le file probeDecodedAudioDuration ran a full, single-threaded decode of the entire audio stream on every extractAudioMetadata call (once per audio element in the HTML compiler, plus audio mixing/padding/encoding steps). On real 30-minute files this took over a second, and could approach ffprobe's 30s deadline on multi-hour files while holding a shared probe slot the whole time. Add -read_intervals so the probe seeks to ~1 second before the container's already-known claimed duration and decodes only that tail. This is safe because the correction only ever raises the duration: an honest or over-claiming container's seek lands at or past the real end and finds nothing extra, while an under-claiming container (the only case this exists to fix) still has real frames past the seek point. If the tail read finds nothing at all, fall back to a full unseeked decode before giving up, protecting files whose seek index is unreliable (an index-less or non-seekably-sourced container) from silently missing a real correction — mirroring the existing seek-then-fallback pattern already used by the sibling final-video-frame probe in this file. Also replaces the real-file regression test's runtime ffmpeg-spawning fixture generation with two small, committed binary fixtures (an honest FLAC-in-MP4 file and a copy with its container duration atoms halved) plus the ffprobe-static package, so this test's real-file proof actually runs in CI instead of being silently skipped when no system ffmpeg is on PATH. Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>
terencecho
left a comment
There was a problem hiding this comment.
APPROVE at f2303af3 — bounded tail-decode with a correct-direction guard; only raises a lying container's duration
Head verified: f2303af38a1cb4233f3fa932af0cc8ab31a28c10. Commit co-authors: miga-heygen (bot carrier) + miguel-heygen (trust-listed).
The fix is bounded, not an unbounded scan
probeDecodedAudioDuration() decodes only the tail (-read_intervals ${tailStart}%, ~1s before the container's claimed end), with runFfprobe(retainTail: true, maxChars: 64 * 1024) and the existing 30s deadline still governing. CSV output format is chosen specifically so a front-truncated ffprobe stream stays parseable — this is exactly the failure mode of the earlier JSON-based revision on multi-hour files. Malformed-header worst case is a single tail read + one full unseeked decode fallback, each bounded.
Correct-direction guard — recovery only raises duration
if (decoded !== null && decoded > durationSeconds + AUDIO_DURATION_PROBE_MARGIN_SECONDS) — the correction only fires when tail-decode discovers samples PAST the container's claimed end. An honest container's tail read finds nothing later → no correction → previous behavior preserved. The 0.05s margin absorbs edit-list / priming-delay drift. A lying container is the only case that changes.
Fallback path is safe and mirrors extractFinalVideoFrameTimestamp
Tail read empty → full unseeked decode → still empty → return null (not a throw). Index-less / non-seekably-sourced files (MediaRecorder WebM class) don't silently miss a real correction. if (signal?.aborted) throw signal.reason ?? error; inside the inner probe correctly surfaces caller cancellation while swallowing genuine probe failures.
Applied at the single canonical site (extractAudioMetadata)
Every consumer of audio metadata benefits uniformly — the previous PR-body-cited flaw of an earlier attempt (compiler-only) is directly addressed. audioPadTrim.ts change is comment-only and correctly describes the new mechanism.
Regression pins are strong
Unit rewrite covers: within-margin no-op, no-frames, zero-sample-rate skip, decode failure, junk output, abort propagation mid-decode, and the tail-seek-then-full-decode fallback (asserting -read_intervals is in call 1 args and NOT in call 2). AAC-profile loop proves allowlist deletion is safe. Real-file integration test with committed honest.mp4 + binary-patched lying.mp4 under __fixtures__/lying-container/ using ffprobe-static — genuinely runs in CI (per PR body, prior version silently skipped when system ffmpeg was absent).
Golden style-7-prod update (16.064 → 16.043) is correctness, not masking
Old value counted 753 demuxed AAC-LC packets × 1024 samples/packet, but only 752 actually decode — the phantom-packet bug the old refinement itself introduced. New value matches raw-PCM ground truth (770048 samples / 48000 Hz = 16.0427s). PR-body sweep confirms no other audio-bearing fixture required updating.
CI
All required checks SUCCESS: Format, Lint, Typecheck, Build, Producer unit + integration, Test, Tests-on-windows, 9 regression shards, Preview parity, CodeQL, CLI smoke, Studio load/viewport, SDK, GCP BeginFrame image contract, all Preflight + File-size on 4 workflows. Zero failing, zero pending. Only SKIPPED is the path-filtered "Catalog: search index covers the registry".
— Review by tai (pr-review)
Summary
data-durationon an<audio>element could be silently shortened when a source file's container-summary duration (mvhd/tkhd/mdhd) undercounts the real audio length while the underlying audio frames are intact.extractAudioMetadata(a profile allowlist assuming a fixed 1024-samples-per-frame constant) with a single, codec-agnostic mechanism: decode the final audio frame via ffprobe and read its own timestamp plus real sample count. This has no per-codec assumption, so every caller ofextractAudioMetadatabenefits uniformly, not just one call site.style-7-prod) whose expecteddata-durationvalue came from the OLD, now-deleted AAC-LC packet-count mechanism's own bug: it counted one demuxed packet that never actually decodes into a real audio frame, overcounting duration by that phantom frame's worth of samples. Verified against the real source file via raw PCM sample count, and confirmed via a standalone compile-and-validate sweep that no other audio-bearing fixture in the repo needed the same correction.-read_intervals, decoding only that tail instead of the entire file — everyextractAudioMetadatacaller pays this cost (once per audio element during HTML compilation, plus audio mixing/padding/encoding steps), and a full decode of a long file could itself take several seconds. Falls back to a full unseeked decode if the tail read finds no frames, which protects files whose seek index is unreliable (e.g. an index-less or non-seekably-sourced container) from silently missing a real correction.packages/producer/src/services/render/audioPadTrim.tshas a comment-only update describing the new mechanism; no behavior change there.Test plan
mvhd/tkhd/mdhdduration atoms halved while every real frame stays intact) plus theffprobe-staticpackage for a guaranteed-present real ffprobe binary, so this real-file proof actually runs in CI (the CI job running this suite has no real ffmpeg/ffprobe on PATH otherwise).style-7-prodgolden fix was verified three ways: raw PCM ground-truth arithmetic against the real source file, a full local compilation + visual + audio comparison against the real (LFS-hosted) golden video, and a standalone compile-and-validate sweep across every audio-bearing fixture suite in the repo confirming no other golden needed the same correction.