Skip to content

fix(engine): recover audio duration by decoding the final frame - #3986

Open
miga-heygen wants to merge 4 commits into
mainfrom
fix-heaac-sbr-duration-probe
Open

miga-heygen wants to merge 4 commits into
mainfrom
fix-heaac-sbr-duration-probe

Conversation

@miga-heygen

@miga-heygen miga-heygen commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • 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 underlying audio frames are intact.
  • Replaces the old AAC-LC-only packet-count refinement inside 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 of extractAudioMetadata benefits uniformly, not just one call site.
  • The corrected duration is only used when it exceeds the container's own summary by more than a small margin (absorbing a few milliseconds of edit-list/priming-delay drift that's normal even on an honest file).
  • The decode probe reads CSV output and retains only its tail under a small size cap — a lying container's true duration can be arbitrarily larger than its declared one, so an unbounded scan of a long file could otherwise exceed ffprobe's stdout limit and silently fall back to the wrong duration; CSV (unlike JSON) stays parseable when truncated from the front.
  • Fixes a stale regression-suite golden fixture (style-7-prod) whose expected data-duration value 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.
  • Performance: the decode probe now seeks to ~1 second before the container's already-known claimed end via -read_intervals, decoding only that tail instead of the entire file — every extractAudioMetadata caller 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.ts has a comment-only update describing the new mechanism; no behavior change there.

Test plan

  • Unit tests for the decode-based correction covering: normal correction, staying within the margin (no correction), no decodable frames, zero sample rate (probe skipped), decode-probe failure/malformed output (swallowed, container duration kept), an aborted signal during the decode probe (rethrown), and the tail-seek-then-full-decode fallback (a seeked read that finds nothing falls back to a full scan before giving up).
  • A real-fixture regression test using two small, committed binary fixtures (an honest FLAC-in-MP4 file and a byte-identical copy with its mvhd/tkhd/mdhd duration atoms halved while every real frame stays intact) plus the ffprobe-static package 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).
  • Verified this test's discriminating power directly against the prior code, not just the current one: substituted the actual pre-existing file content and confirmed the test genuinely fails against it (reports the halved duration), then confirmed it passes against the current fix.
  • Manually reproduced and confirmed the fix on a real ~33-minute file with a patched (halved) container duration — the boundary case where an earlier JSON-based decode probe exceeded ffprobe's stdout cap and silently failed to correct; the CSV-based version correctly recovers the true duration.
  • The style-7-prod golden 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.
  • Full producer + engine test suites run clean (only pre-existing, environment-specific failures remain: LFS-pointer-fixture tests and one ffmpeg negative-timestamp MPEG-TS quirk, both unrelated to this change).

miga-heygen and others added 2 commits September 16, 2026 05:56
…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>
@miga-heygen miga-heygen changed the title fix(engine): recover audio duration from packet timestamps before clamping fix(engine): recover audio duration by decoding the final frame Sep 16, 2026
miga-heygen and others added 2 commits September 16, 2026 07:38
… 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 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.

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)

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