fix(render): normalize local AAC duration before mux#2472
Conversation
Older FFmpeg versions estimate raw ADTS duration from bitrate and can undercount variable-bitrate audio, causing the normalizer to append a false silence tail. Derive the mixed AAC duration from packet count and sample rate instead.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at c2c4e6eb1a57484cd4c4b007d8ee2d0720be85b1.
What this does
Two coordinated changes to stop mixed-source AAC audio from drifting the muxed output:
1. Duration derivation switches from ffprobe metadata to packet-count math for AAC-LC.
Pre-PR, defaultProbeAudioInfo in audioPadTrim.ts:420-424 trusted metadata.durationSeconds from extractAudioMetadata (ffprobe format tag). For raw ADTS AAC, FFmpeg 5.1 estimates that field from bitrate, and a 16.704s synthetic track probed as 16.955s (~250-708ms over-estimate depending on bitrate stats). That fed a false pad decision downstream. Post-PR at audioPadTrim.ts:423-446, if audioCodec === "aac" and sampleRate > 0, a second ffprobe call with -count_packets -show_entries stream=nb_read_packets returns the exact ADTS frame count; durationSecondsFromAacPacketCount(packetCount, sampleRate) computes packetCount * 1024 / sampleRate (AAC-LC's fixed 1024-sample frame) as the authoritative duration.
2. runAssembleStage now runs padOrTrimAudioToVideoFrameCount unconditionally before mux at assembleStage.ts:58-68, writes to <name>.duration-normalized.aac, and hard-fails the render if normalization fails. Was previously an optional/conditional step on the mux path; now always-on so any residual drift after the fixed probe is closed by an explicit trim/pad rather than surviving into the muxed MP4.
Test coverage: pure math pinned at audioPadTrim.test.ts:26-30 (durationSecondsFromAacPacketCount(783, 48_000) === 16.704); orchestration pinned at assembleStage.test.ts:56-94 (happy path passes the normalized path through to mux; failure path throws before the mux call).
Verification
- Packet-math arithmetic. AAC-LC: 1024 samples per raw-frame packet.
783 * 1024 / 48_000 = 802,032 / 48,000 = 16.704. ✓ Also holds for the CI-reproducer numbers Miguel cited: 501 video frames at 30fps → 16.700s video; audio at 16.704s → 4ms drift → within the existingAUDIO_DURATION_TOLERANCE_SECONDS = 0.001s trim envelope. ✓ - Codec gating. The
-count_packetsbranch fires only whenmetadata.audioCodec === "aac" && metadata.sampleRate > 0. Non-AAC codecs preserve pre-PR behavior (metadata.durationSeconds). ✓ - Assemble-stage failure mode is fail-closed.
if (!normalizeResult.success) throwbefore the mux call — no silent fallback to unnormalized audio. Test atassembleStage.test.ts:79-93pins this: mux not called on normalization failure. ✓ extractAudioMetadatacaching + fresh packet probe — the packet-count call goes throughrunFfprobeJson(not the cached extractor), so the packet number reflects the current file even if metadata was cached from an earlier read. ✓- CI green on the current head after Miguel's dockerized reproducer confirmed the root cause. ✓
Concerns — most-severe first
-
🟠
audioOutputPath.replace(/\.aac$/i, ".duration-normalized.aac")silently no-ops when the input path doesn't end in.aac.String.prototype.replaceon a regex that doesn't match returns the original string. So a non-.aacaudioOutputPath (e.g. an intermediate wav/m4a fallback path) producesnormalizedAudioPath === audioOutputPath, and the subsequentpadOrTrimAudioToVideoFrameCount({audioPath, outputPath: <same path>})writes to its own input — undefined behavior at the FFmpeg layer (can succeed with a copy, can truncate mid-write depending on backend). Two shapes fix this cleanly: (a) assertaudioOutputPath.endsWith(".aac")before the replace (fail fast if the assumption breaks in a refactor), or (b) derive the tempfile as${dirname}/${basename-without-ext}.duration-normalized${ext}so any extension is handled. Given the AAC-gate on the whole path, (a) is the smaller change. Worth closing before merge if the assumption is load-bearing. -
🟡
-count_packetswalks every packet. For a 16-second AAC (~800 packets) the cost is milliseconds — negligible. But this now runs on every render's audio probe path. Long recordings (podcast-length inputs, multi-hour audio) will pay proportionally. Correctness-over-speed is the right trade for the drift bug, but worth documenting so a future perf regression on long inputs traces back to this deliberate choice. Non-blocking. -
🟡 Assemble-stage normalization is now always-on. Every render with audio now writes a second
.duration-normalized.aacintermediate plus the mux input. Extra ffmpeg pass per render (typically fast — trim/pad without re-encode), extra disk write on each job. For batch renders in throughput-sensitive environments this compounds. If the normalization is a no-op (source already matches video frame count), an early return on "no work to do" would keep the tempfile write off the disk. Non-blocking; small optimization worth as a follow-up if profiling flags it. -
🟢
AAC_SAMPLES_PER_PACKET = 1024is correct for AAC-LC (the codec the audio path emits at ffmpeg default settings). AAC-HEv1/v2 (SBR) uses 2048 samples/packet at the output rate. If the codec detection ever widens beyondmetadata.audioCodec === "aac"to include HE-AAC variants, the constant would need to become codec-profile-aware. Note-only. -
🟢
assembleStage.test.tsuses vitest but the siblingaudioPadTrim.test.tsusesbun:test. Different runners in the same directory tree — probably intentional (assemble uses vi.hoisted mocks) but worth confirming the CI runs both. Both suites appear to pass in Magi's summary.
Verdict framing
Two-part fix on a real, reproduced root cause; math checks out; failure mode is fail-closed. The replace(/\.aac$/, ...) fallback is the one thing I'd like to see hardened before merge. Otherwise LGTM from my side once the AAC-path assumption is either asserted or made extension-agnostic.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at d8e8477eb9a4f616100ead178ca04190d6b2a39d — R1 (c2c4e6eb) → R2 delta is +16 / -1 across two files.
R2 delta
Closes the R1 🟠 on the extension-agnostic path derivation. assembleStage.ts:59-64:
const audioExtension = extname(audioOutputPath);
const audioStem = audioExtension
? audioOutputPath.slice(0, -audioExtension.length)
: audioOutputPath;
const normalizedAudioPath = `${audioStem}.duration-normalized.aac`;The pad-trim output is now derived from the stem regardless of input extension, so a non-.aac input (e.g. mixed-audio .m4a intermediates that would have silently no-op'd through the old .replace(/\.aac$/i, …)) gets a distinct output path.
Regression at assembleStage.test.ts:77-85 pins the .m4a case: audioOutputPath: "/tmp/audio.m4a" → outputPath: "/tmp/audio.duration-normalized.aac". The existing default-.aac test at :56-74 continues to assert the same /tmp/audio.duration-normalized.aac output — backwards-compat with the R1 behavior for the AAC path is preserved.
Verification
- Node's
extnamesemantics."/tmp/audio.m4a"→.m4a, stem/tmp/audio, normalized/tmp/audio.duration-normalized.aac. ✓"/tmp/audio.aac"→.aac, stem/tmp/audio, normalized/tmp/audio.duration-normalized.aac(identical to R1 output). ✓- No extension (
"/tmp/audio") →"", stem/tmp/audio, normalized/tmp/audio.duration-normalized.aac. ✓ - Leading-dot file (
"/tmp/.audio") →extnamereturns"", so stem is the whole path →/tmp/.audio.duration-normalized.aac. Edge case but sensible. ✓
- No input/output path collision. For any input extension, the derived normalized path differs from the input path (different suffix). Fixes the R1 silent no-op. ✓
- Repeated normalization is idempotent-safe. If a caller feeds a
.duration-normalized.aacinput (e.g. a re-run against the tempfile), the derived output is<...>.duration-normalized.duration-normalized.aac— distinct, so no self-overwrite. ✓ - CI: 9 SUCCESS + 22 pending + 4 skipped at read time; fresh restack still settling, no FAILURES on completed jobs. ✓
R1 concerns status
- 🟠 Path collision on non-
.aacinputs → CLOSED via theextname/stem derivation. ✓ - 🟡
-count_packetswalking every packet on long inputs — still unchanged (was note-only, not blocking). - 🟡 Always-on assemble-stage pad-trim on every render — still unchanged (was note-only, not blocking).
- 🟢
AAC_SAMPLES_PER_PACKET = 1024is AAC-LC only, HE-AAC would need 2048 — still unchanged (was note-only, only load-bearing if codec detection widens).
Verdict framing
Small, targeted follow-up that closes the one blocker cleanly, with a matching regression that pins the previously-broken .m4a case. LGTM from my side once CI settles.
vanceingalls
left a comment
There was a problem hiding this comment.
Reviewed at d8e8477eb9a4f616100ead178ca04190d6b2a39d. Additive to @james-russo-rames-d-jusso's review — filling gaps; one explicit divergence on verdict.
Strengths
- Packet-count math is authoritative for AAC-LC.
packetCount * 1024 / sampleRate(audioPadTrim.ts:40-42, :445) bypasses the FFmpeg bitrate estimator that undercounts VBR ADTS containers. Right primitive at the right layer. - Fail-closed on normalization error (
assembleStage.ts:68-71atd8e8477eb) — throws before the mux call rather than muxing an unnormalized tail. Failure test atassembleStage.test.ts:79-93pins this behavior. - Codec + sampleRate gate (
audioPadTrim.ts:425) — non-AAC and zero-sampleRate paths preserve pre-PR behavior. Narrow blast radius on the new branch. - Push at
d8e8477ebresolves the.replace(/\.aac$/i, …)self-write hazard Rames flagged —extname()+ stem derivation is extension-agnostic, and the new.m4aregression test (assembleStage.test.ts:77-86) pins the behavior. Nothing to add there.
Additive concerns (gaps in prior review)
🔴 Field-report attribution unproven; PR body still empty (important). This PR was tagged as targeting the 615s field render that failed with Audio muxing failed: FFmpeg exited with code 196; Error opening input file audio.aac: Operation timed out, with the workaround HF_DE_PARALLEL_ROUTER=false HF_STATIC_DEDUP=false. That failure signature reads as an OS-level open timeout under concurrency contention, and the workaround touches worker parallelism — not obviously the same failure class as "container VBR duration undercount → false silence tail." The causal chain from the fixed root cause to the specific errno=110 on file open is not established anywhere: PR body is blank at d8e8477eb, no repro in the PR, no attribution comment. The fix is defensible on its own merits as parity with distributed/assemble.ts:296-311, but the "closes the field report" thesis is unsupported. Please either (a) explain the causal chain in the body (why ADTS drift produced the observed Operation timed out, not just why it produced a wrong duration), or (b) reframe as "adjacent — mux-drift parity with distributed" and file the observed field failure as a separate open ticket.
🔴 Sibling site with the same VBR-drift precondition — fix locality is one layer too deep (important / Rule 2 extrapolation). The fix patched the leaf (defaultProbeAudioInfo), not the shared root (packages/engine/src/utils/ffprobe.ts:351-390 extractAudioMetadata). packages/producer/src/services/htmlCompiler.ts:434,443 calls extractAudioMetadata(filePath) directly on user-provided AAC composition media, then uses metadata.durationSeconds as the audio element's effective composition duration. Same precondition (VBR ADTS container duration under-reports), different consumer (composition timeline duration, not mux drift). A user-uploaded VBR AAC will still have its composition length undercounted. Two shapes: (a) lift the packet-count fallback into extractAudioMetadata itself so callers can't miss it, or (b) file a follow-up ticket for htmlCompiler + document that the fix is intentionally leaf-only. Extrapolation of the fix pattern is what Rule 2 wants — one site cured, one sibling inheriting the same bug is the classic manifestation.
🔴 Regression test doesn't parameterize the new branch (important — Miguel R1 directive #4). The only new unit test at audioPadTrim.test.ts:26-30 is durationSecondsFromAacPacketCount(783, 48_000) === 16.704 — a math tautology of the exported helper. The real added logic is the four-branch decision inside defaultProbeAudioInfo (audioPadTrim.ts:423-446):
audioCodec !== "aac"→ skip packet probe, keepmetadata.durationSecondsaudioCodec === "aac", sampleRate === 0→ skip packet probe, keepmetadata.durationSecondsaudioCodec === "aac", nb_read_packets NaN/0/missing→ keepmetadata.durationSecondsaudioCodec === "aac", valid packet count→ override with derived
Zero of those branches are covered. The d8e8477eb push added an outer-wrapper .m4a regression test (correct fix for Rames's finding) but did not cover the packet-count branch itself. assembleStage.test.ts exercises the outer wrapping (padOrTrim wired before mux) — it never touches the packet-count branch. Options: export defaultProbeAudioInfo (or a small internal helper) for direct testing, or mock runFfprobeJson at the module level and parameterize with .each across the four cases. Miguel's bar on parameterizing test across every branch reads as blocking on its own.
Notes
- 🟢 CI runs both test runners. Rames flagged bun:test vs vitest as needing confirmation; verified
packages/producer/scripts/run-test-lane.mjs:28-33explicitly spawnsbunx vitest runon vitest-classified files before running bun tests. Both suites execute inProducer: unit tests. - 🟢
AAC_SAMPLES_PER_PACKET = 1024is AAC-LC-only, and the mixer emits AAC-LC (packages/engine/src/services/audioMixer.ts:440 -acodec aac= ffmpeg's built-in AAC-LC encoder). Today the constant is safe. If future work introduces HE-AAC v1/v2 (2048 output samples/frame after SBR) or AAC-LD (480/960), the derivation silently produces wrong durations. Inline comment declaring the invariant would prevent silent breakage; not blocking at current codec scope. - CI state at review time.
mergeStateStatus=BLOCKED, checks in-flight ond8e8477ebafter the extension-agnostic push; the earlierc2c4e6ehead was green. Assuming re-run stays green.
Position
RIGHT direction, CLOSED thesis rejected. The packet-count derivation is the correct primitive, the fail-closed shape is right, and parity with distributed/assemble.ts is a solid architectural argument for the always-on normalization. The extension-agnostic push at d8e8477eb was the right response to Rames's finding. But the fix is one layer too deep (leaf, not shared root), the new branch is untested at parameterization discipline, and the field-report closure claim has no attribution evidence.
Explicit divergence from Rames: he framed this as LGTM-once-.replace-hardened (now done). I'd hold on merge until (a) PR body explains the causal chain or reframes as adjacent, (b) defaultProbeAudioInfo branches are parameterized, and (c) htmlCompiler.ts sibling is either fixed or ticketed.
Verdict: REQUEST CHANGES
Reasoning: Right fix direction, but Rule 2 extrapolation to htmlCompiler.ts sibling site + missing branch parameterization on defaultProbeAudioInfo + no field-report causal chain in the body add up to should-not-merge-as-is.
— Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 9fb6f8b3f — R2 (d8e8477e) → R3 delta is +82 / -36 across 4 files.
R3 delta
Restructures the AAC duration derivation from producer-local to engine-shared, so every consumer of extractAudioMetadata sees the VBR-safe duration transparently.
- AAC packet-duration logic moved into
packages/engine/src/utils/ffprobe.ts:372-390—extractAudioMetadatanow runs the-count_packets -show_entries stream=nb_read_packetsprobe internally foraudioCodec === "aac" && sampleRate > 0, and overridesformat.durationwith(packetCount * AAC_LC_SAMPLES_PER_PACKET) / sampleRatewhen the packet count is a positive finite number. AAC_LC_SAMPLES_PER_PACKET = 1024with an explicit comment ("FFmpeg's built-in AAC encoder emits AAC-LC, which has 1024 samples per packet.") — closes my original 🟢 about codec-profile specificity by documenting the AAC-LC assumption at the constant.FFProbeStream.nb_read_packets?: stringadded to the type schema — properly typed, not stringly-typed.durationSecondsFromAacPacketCountremoved as an exported helper fromaudioPadTrim.ts.defaultProbeAudioInfonow trustsmetadata.durationSecondsdirectly (the packet-derived value is baked in at the extractor).- 5-branch parametrized ffprobe test at
ffprobe.test.ts:206-255:non-AAC metadata(mp3) → usesformat.duration = 1.25, only 1 ffprobe spawn.valid AAC packet count(783 @ 48kHz) →16.704, 2 spawns.missing AAC packet count→ falls back toformat.duration = 1.25, 2 spawns.zero AAC packet count→ fallback, 2 spawns.invalid AAC packet count(non-numeric) → fallback, 2 spawns.
Verification
- Move-into-engine is the right refactor. Every caller of
extractAudioMetadata(there are several acrossproducer,cli, and analysis paths) inherits the fix — no more risk of a sibling caller wired to rawformat.durationregressing to the ADTS bitrate-estimate. ✓ - Cache reuse.
extractAudioMetadatausesaudioMetadataCache— first probe pays for both the metadata and packet-count calls; subsequent probes on the same path return the promise from cache. So the amortized cost of the-count_packetswidening is one packet-walk per file per process lifetime. Addresses my R1 🟡 about the cost concern as a widening trade. ✓ - Fallback contract is fail-safe on all four failure modes — missing / zero / non-numeric / non-AAC — every non-happy path preserves the pre-PR value of
output.format.duration. Test matrix pins each. ✓ sampleRatefallback of 44100 when the stream reports no sample rate matches the pre-PR default, so the packet-count division has a defined divisor in the (unlikely) missing-rate case. ✓- R2 🟠 stays closed. The
extname/stem tempfile derivation from R2 is unchanged in R3. ✓ - CI: 25 SUCCESS + Preflight complete at head per read; still settling. No FAILURES. ✓
R1/R2 concerns status
- 🟠 (R1) Silent no-op on non-
.aacinputs → CLOSED at R2 (unchanged). - 🟡 (R1)
-count_packetswalking every packet on long inputs → NOW WIDENED to all AAC probes but each file pays only once via the audioMetadataCache. Amortized cost is bounded per file. - 🟡 (R1) Always-on assemble-stage pad-trim → unchanged (still note-only, not blocking).
- 🟢 (R1)
AAC_SAMPLES_PER_PACKET = 1024is AAC-LC-only → DOCUMENTED via the rename toAAC_LC_SAMPLES_PER_PACKETand the inline comment. ✓
Verdict framing
R3 is the right level for this fix — the packet-count derivation now lives where the metadata invariant is enforced. Test matrix covers the fallback surface. LGTM from my side.
vanceingalls
left a comment
There was a problem hiding this comment.
Reviewed at 9fb6f8b3f30a40cb19470fe98f4fc2b37517c52b — R1 (d8e8477e) → R2 delta is +82 / -36 across 4 files. Additive to @james-russo-rames-d-jusso's R3.
R1 findings status
-
✅ F1 (blocker, Rule 2 extrapolation) — RESOLVED. Packet-count derivation moved from
defaultProbeAudioInfo(producer leaf) into the shared engine root atpackages/engine/src/utils/ffprobe.ts:376-394.extractAudioMetadatanow runs the-count_packets -show_entries stream=nb_read_packetsprobe internally whenaudioCodec === "aac" && sampleRate > 0, and overridesoutput.format.durationwith(packetCount * AAC_LC_SAMPLES_PER_PACKET) / sampleRate. Verified thehtmlCompiler.tssibling I called out —htmlCompiler.ts:434(await extractAudioMetadata(filePath)) →htmlCompiler.ts:443(const fileDuration = metadata.durationSeconds) — now inherits the VBR-safe duration transparently. Cross-checked every otherextractAudioMetadataconsumer:packages/engine/src/services/audioMixer.ts:557-560(composition mixdown),packages/engine/src/services/chunkEncoder.ts:95(codec-only read, unaffected by duration),packages/producer/src/regression-harness.ts:840,packages/producer/src/services/render/audioPadTrim.ts:419-423(now trustsmetadata.durationSecondsdirectly, no duplicate probe). No sibling bypasses remain. TheAAC_SAMPLES_PER_PACKETexport was correctly removed fromaudioPadTrim.ts:39-43and its tautological helper test ataudioPadTrim.test.ts:26-30was removed with it. ✓ -
✅ F2 (important, regression parameterization) — RESOLVED.
packages/engine/src/utils/ffprobe.test.ts:206-257adds anit.eachmatrix pinning all 5 branches of the new decision:non-AAC metadata(mp3) → keepsformat.duration = 1.25, 1 ffprobe spawn ✓valid AAC packet count(783 @ 48kHz) →16.704, 2 spawns ✓ (math checks:783 * 1024 / 48000 = 16.704)missing AAC packet count→ fallback toformat.duration, 2 spawns ✓zero AAC packet count→ fallback, 2 spawns ✓invalid AAC packet count(non-numeric string) → fallback, 2 spawns ✓
Each row asserts both the derived duration AND the expected ffprobe call count, so the codec-gate is pinned in both directions (non-AAC skips the packet probe, AAC always makes it). This is stricter than my R1 ask — I only requested the 4 defaultProbeAudioInfo branches; the R2 matrix additionally distinguishes missing/zero/invalid fallback shapes. ✓
-
✅ F3 (important, PR body causal-chain) — RESOLVED. PR body now opens with explicit adjacent-not-closing framing: "This is an adjacent parity fix, not a claimed root-cause closure for the reported 615-second render that failed while opening audio.aac with an OS timeout. That field failure remains a separate concurrency or filesystem symptom; its workaround changed drawElement parallelism." and closes with a
## Scopesection: "The original field timeout is intentionally not marked closed by this PR. This change fixes the duration-parity sibling class...". My R1 offered (a) explain the causal chain or (b) reframe as adjacent — option (b) taken cleanly. ✓
Additional verification
- Cache amortization.
audioMetadataCache(ffprobe.ts:406-410) holds the promise so a single probe pays both the base metadata and packet-count calls; subsequent same-path lookups return the memoized value. My R1 didn't flag this but Rames R1 did — good that R2 keeps the cache boundary intact rather than duplicating the packet probe outside the extractor. ✓ - Fallback contract is fail-safe on every non-happy branch. Missing / zero / non-numeric / non-AAC all preserve pre-PR
output.format.duration. Test matrix pins each. No new terminal failure paths introduced. ✓ - Type schema.
FFProbeStream.nb_read_packets?: stringadded atffprobe.ts:103— properly typed at the schema, not stringly-typed at the read site. ✓ - Constant rename to
AAC_LC_SAMPLES_PER_PACKETwith inline comment (ffprobe.ts:56-57) documents the AAC-LC 1024-sample invariant at the definition. Codec-profile widening (HE-AACv1/v2 → 2048, AAC-LD → 480/960) would now naturally scope into the same block. Closes my R1 🟢 note on the constant. ✓ - CI at
9fb6f8b3f.mergeStateStatus=BLOCKEDon required-reviewer gate; all required check-runs green (Test, Producer: unit + integration tests, Typecheck, Lint, Format, Preflight, Windows Render + Tests, Preview parity, SDK unit/contract/smoke, CLI smoke, CodeQL, Analyze × 3). Only pending: 3 regression-shards (non-blocking optionals still running). No FAILUREs. ✓ - Adversarial pass for other duration-drift consumers.
packages/producer/src/utils/audioRegression.ts:302 probeAudioDurationreadsstream=durationdirectly via a rawspawnSync ffprobe— but this is the regression comparison harness (compares two rendered outputs post-hoc), not a production render path. Out of scope for the drift fix. Not a bypass.
Position
RIGHT, thesis CLOSED. All three R1 blockers/importants land cleanly:
- Fix locality lifted to the shared engine root — sibling
htmlCompiler.ts(and every otherextractAudioMetadatacaller) transparently inherits the VBR-safe duration. - New decision surface is fully parameterized at 5 branches with call-count assertions.
- PR body reframes as adjacent-parity, not field-report closure.
Explicit alignment with @james-russo-rames-d-jusso's R3 verdict at the same commit. Nothing to hold on my side.
Verdict: APPROVE
— Via
vanceingalls
left a comment
There was a problem hiding this comment.
R3 verification (head d26f3e889)
Narrow re-stamp on the golden refresh only.
Delta scope. gh api compare/9fb6f8b3f...d26f3e889 returns exactly one file, one line:
packages/producer/tests/style-7-prod/output/compiled.html- <audio id="aroll-audio" ... data-duration="16.043" ... data-end="16.043"></audio> + <audio id="aroll-audio" ... data-duration="16.064" ... data-end="16.064"></audio>
Checks against the claim.
- Confined to test golden — ✅. Path lives under
packages/producer/tests/style-7-prod/output/, a snapshot artifact. No production source, no config, no schema. - Numeric delta 16.043 → 16.064 (+21ms) — ✅. Consistent with the AAC packet-count / 1024-sample-frame normalization derivation introduced upstream in the PR body (
ceil(samples / 1024) * 1024 / sample_raterounds up to the nearest full AAC frame, and MP4 containermdhd.durationis quantized on the same boundary; sub-frame residues manifest as low-tens-of-ms drift). - No test deletion / skip — ✅. Golden value updated in place; no
.skip, no removed assertion, no test file removed.additions:1 deletions:1 changes:2on one file. - CI health at this head — Format ✅, Lint ✅, Fallow ✅, CodeQL analyze (python) ✅, semantic-title ✅, CLI shims (ubuntu/macos) ✅, SDK unit+contract+smoke ✅, player-perf ✅. Producer unit/integration/runtime-contract, regression shards (shard-3 covers style-7-prod), Windows render, and CodeQL (javascript-typescript) still in-flight — none failing yet.
R3 verdict: APPROVE (re-stamp). Prior R2 logic clearance at 9fb6f8b3f (pullrequestreview-4703663981) carries; this head only updates the numeric golden to match the normalized derivation, no re-verification of runtime behavior required beyond the still-running style-7-prod regression shard.
— Via
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at d26f3e88 — R3 (9fb6f8b3) → R4 delta is +1 / -1 across 1 file (golden refresh scoped to a single test).
R4 delta
Single-line golden update at packages/producer/tests/style-7-prod/output/compiled.html:1051:
- <audio id="aroll-audio" src="_remote_media/download_054a544691a0.mp4" data-start="0" data-duration="16.043" data-track-index="1" data-end="16.043"></audio>
+ <audio id="aroll-audio" src="_remote_media/download_054a544691a0.mp4" data-start="0" data-duration="16.064" data-track-index="1" data-end="16.064"></audio>Verification
- Math is consistent with the R3 packet-count formula.
16.064s * 48000 Hz / 1024 samples/packet = 753 packets(exact) — so this audio stream has 753 AAC packets @ 48kHz, and R3'sextractAudioMetadatanow derives the true753 * 1024 / 48000 = 16.064sinstead of the ADTS bitrate estimate (16.043s). The 0.021s = 21ms discrepancy is exactly what the R3 normalization was meant to close. ✓ - Scope is correct — no other stale goldens. Grepped
packages/producer/tests/*/output/*.htmlfor separate<audio>tags with matching pattern:- style-7-prod is the ONLY test with a separate
<audio id="aroll-audio">whose src is the ~16s AAC-in-MP4 file that hits the packet-count code path. - style-1/2/4-prod each expose their a-roll as
<video ... data-has-audio="true" data-duration="16.043">— thedata-durationthere reflects the video-stream duration (unaffected byextractAudioMetadata), and audio-vs-video reconciliation happens later at mux time viapadOrTrimAudioToVideoFrameCount. Correctly not touched. ✓ - Style-7-prod's own MUTED
<video>sibling ondownload_054a544691a0.mp4still showsdata-duration="16.043"— same file, video-stream duration ≠ audio-stream duration by 21ms, both reflected correctly (video track = 16.043s frame-count derivation, audio track = 16.064s packet-count derivation). Container-level asymmetry is normal for MP4/M4A. ✓
- style-7-prod is the ONLY test with a separate
- CI evidence. Per the message: full style-7 regression passed compilation + 100 visual checkpoints + 0.950 audio correlation + 4ms stream drift; audioPadTrim 16/16 and assembleStage 3/3 passed. New CI queued but not yet green — worth watching before merge.
R3 concerns status
- All R3 findings remained closed; R4 only touches the golden. ✓
Verdict framing
Scoped golden refresh reflecting the R3 code change, mathematically consistent with the packet-count derivation, and correctly limited to the one test where the separate <audio> track exposes AAC-packet duration. No other producer-test goldens are stale by this metric. Ready from where I sit once the new CI settles green.
Summary\n\nNormalize local AAC audio to the encoded video frame duration before final muxing, and make AAC duration metadata reliable for VBR ADTS inputs.\n\nThis is an adjacent parity fix, not a claimed root-cause closure for the reported 615-second render that failed while opening audio.aac with an OS timeout. That field failure remains a separate concurrency or filesystem symptom; its workaround changed drawElement parallelism. This PR closes the independently verified AAC duration-drift class shared by local assembly, distributed assembly, and composition metadata consumers.\n\n## Changes\n\n- Run fail-closed pad or trim normalization before local final mux.\n- Derive AAC-LC duration from packet count and sample rate in the shared engine extractAudioMetadata path, so htmlCompiler and all other consumers receive the same VBR-safe duration.\n- Keep non-AAC and invalid or missing packet-count inputs on container metadata.\n- Always create a distinct extension-agnostic duration-normalized AAC tempfile.\n- Document the AAC-LC 1024-sample packet invariant.\n\n## Tests\n\n- Shared engine metadata: table-driven coverage for non-AAC, valid AAC packets, missing packets, zero packets, and invalid packets.\n- Assemble stage: normalization before mux, fail-closed behavior, and non-.aac input path isolation.\n- Full ffprobe utility suite: 19 passing.\n- Focused assemble suite: 3 passing.\n- Producer audioPadTrim execution is blocked locally by a missing sparse-worktree dependency; current-head CI provides the full package run.\n\n## Scope\n\nThe original field timeout is intentionally not marked closed by this PR. This change fixes the duration-parity sibling class and prevents composition metadata from retaining the same VBR AAC undercount.