Skip to content

fix(render): trim AAC packet padding exactly#2531

Open
miguel-heygen wants to merge 10 commits into
mainfrom
fix/aac-packet-padding-1784185114
Open

fix(render): trim AAC packet padding exactly#2531
miguel-heygen wants to merge 10 commits into
mainfrom
fix/aac-packet-padding-1784185114

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

What

Normalize both padded and trimmed render audio on the decoded sample timeline, encode the result as AAC in M4A, and keep the no-op path on stream copy. Final video muxing remains stream-copy only.

Why

AAC packet-copy operations cannot guarantee the requested presentation duration. Trimming could retain a packet-boundary tail, while padding by concatenating a separately encoded raw-ADTS silence segment could create a timestamp/bitrate discontinuity that surfaced as roughly 606 ms of apparent audio drift in the production-style regression.

How

  • Trim with atrim plus asetpts, then AAC-encode into M4A.
  • Pad with apad=whole_dur, cap the output at the target duration, and AAC-encode the continuous sample timeline into M4A.
  • Preserve the M4A encoder-delay edit list when the normalized audio is copied into the final MP4.
  • Use the normalized M4A path in both local and distributed assembly; leave already-correct audio unchanged.
  • Remove the obsolete raw-ADTS concat helpers and the disproven final-mux duration workaround.

Test plan

  • 18 focused pad/trim unit tests
  • Real-media integration regression covering the packet-timeline boundary
  • Producer typecheck and repository commit hooks
  • Faithful Docker style-3-prod regression: video/audio approximately 16.07 s, 1 ms drift, zero visual failures
  • Exact-head required CI was green before the review-cleanup commit; fresh exact-head CI is running
  • Documentation updated (not applicable)

The regression was reproduced before the fix with 0.563416 s of reported audio/video drift. The sample-timeline normalization removes the malformed tail while preserving stream-copy final muxing and preview behavior.

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Pushed b82a21dd1 to address the first CI run. The producer integration failure showed -avoid_negative_ts make_zero shifting copied video to start_time=0.021029 when the new M4A edit list already accounted for AAC priming. The mux now preserves containerized-AAC edit timing while retaining the existing guard for raw AAC and transcoded paths. Added a focused argument regression. Manual FFmpeg verification: video/audio both start at 0.000000, both end at 15.000000, and extracted H.264 SHA-256 remains 031c8f7f8f8d43daffacd7d5ba565a9cfa088a3e8e4b95b3cdbf4a2a69e581c9. oxfmt, oxlint, and git diff --check pass; fresh CI is running.

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed at b82a21dd. Traced the padding-detection → trim-length → mux carrier boundaries end-to-end, checked the -avoid_negative_ts make_zero decoupling for the copy-M4A mux path, and dug into the failing regression-shards (shard-1 … style-3-prod) output to reason about whether this PR's mechanism actually addresses the observed failure.

Blockers

(none on code shape)

Concerns

🟠 The failing style-3-prod shard shows a 606ms audio-over-video drift, which is 30× larger than any single AAC packet (~21ms at 48 kHz / 1024 samples-per-frame). The regression-shards (shard-1 … style-3-prod) job at HEAD b82a21dd reports:

✗ Stream duration parity: FAILED (video: 16.07s, audio: 16.67s, drift: 0.606s > 0.5s)
❌ Test FAILED: style-3-prod

This PR's mechanism trace — "packet-copy -t target snaps to AAC frame boundaries, ~20ms over" → "atrim filter + PTS reset + re-encode to M4A gives exact presentation duration" — cleanly explains the ~21ms class of drift documented in the PR body (15.018667s → 15.000000s in the manual fixture). It does NOT explain a 606ms drift. Whatever is producing 606ms of extra audio for style-3 must be either:

  1. A path that doesn't invoke padOrTrimAudioToVideoFrameCount at all. The failing job's assemble phase log shows durationMs: 399 for a 16.04s composition with hasAudio: true, audioCount: 5, workerCount: 1, forceScreenshot: true. 399ms is likely too short for a full decode / atrim=duration=16.04 / re-encode of 16 seconds of audio at 48 kHz (which typically wall-clocks in the 1–3s range on this class of runner). If the trim branch ran, its wall-clock should be visible.
  2. A path routing mismatch — the PR changes both writer (padOrTrimAudioToVideoFrameCount's outputPath) and every reader I could find (assembleStage.ts:64, distributed/assemble.ts:294, assembleStage.test.ts) from .aac to .m4a. If a third caller (streaming-encode assemble path? cloud renderer? chunkEncoder inline mux?) still reads a .duration-normalized.aac path, or the atrim'd .m4a never actually gets fed into the mux command, the muxed output would carry the pre-normalized (untrimmed) audio — which would explain 5 audio tracks × their concat producing ~600ms of AAC-packet-padding cumulative drift rather than the ~21ms of a single trim boundary.
  3. A pre-existing style-3 issue unrelated to this PR that has been latent-then-tripped by the -avoid_negative_ts change or the M4A edit-list preservation. This is worth ruling out by running style-3-prod against fresh main first — if main is also >0.5s drift on this fixture today, then this PR is not the regression cause and the merge gate can shift to "PSNR / audio-correlation still pass on the failing head, drift is a pre-existing baseline miss." (Recent main regression CI runs are success, so pre-existing is unlikely — but worth the 30-second empirical check before assuming this PR broke it.)

Recommended follow-up: dump ffmpeg's atrim invocation stderr on the style-3-prod render — if the filter didn't actually run (path routing / assemble-branch skip), that log will be silent for the trim step. If the filter DID run and still produced 16.67s output, then atrim=duration=X isn't behaving the way the PR body assumes on this input shape (likely candidate: multi-input concat / mixed sample rates producing a filter graph where atrim receives already-mixed input past the target). Either finding sharpens the fix.

Nits

🟡 buildPadTrimAudioPlan trim branch loses sourceDurationSeconds context in the emitted command. The new args are:

[
  "-i", audioPath,
  "-af", `atrim=duration=${targetSec},asetpts=PTS-STARTPTS`,
  "-c:a", "aac",
  "-b:a", "192k",
  "-y", outputPath,
]

If a caller passes sourceDurationSeconds < targetDurationSeconds by accident (should be caught by the delta check upstream, but defense-in-depth), atrim=duration=X where X > source would silently succeed and produce a track SHORTER than X (only source-duration long). Not a blocker — the upstream delta > tolerance check should prevent this and the pad branch handles source-shorter cases correctly — but a follow-up assertion in the tests (sourceDuration >= targetDuration when operation === "trim") would pin the invariant.

🟡 copiesContainerizedAac gate keys on extension string, not container type. extname(audioPath).toLowerCase() === ".m4a" — if a caller ever produces .mp4 output from padOrTrim (mp4 containers can carry AAC and edit lists just like m4a), the make_zero suppression won't fire. Today no caller emits .mp4 from padOrTrim, so this is theoretical. If the container-choice ever expands (e.g. streaming-encode also containerizes AAC), the gate needs to broaden. Consider [".m4a", ".mp4"].includes(...) or an explicit carriesEditList: boolean field on the padOrTrim result.

Questions

Where does style-3's 5-track audio pipeline concat / mix? Before or after padOrTrimAudioToVideoFrameCount? The renderJob b619fc56's audio_process phase completed with audioCount: 5, durationMs: 989. If those 5 tracks are extracted individually and each padded/trimmed to per-track duration BEFORE mixing, the mix step's -shortest / -longest behavior determines the final duration — and post-mix, padOrTrim on the final track is the last chance to hit the video target. If padOrTrim runs on the mix (as the code path I traced suggests), then the observed 16.67s must come from AFTER padOrTrim ran — either its output wasn't muxed, or the target-duration probe hit a different frame count than 16.04s × 30. Worth a data-dump on targetDurationSeconds / sourceDurationSeconds for style-3 to know which branch fired.

Green notes

🟢 The atrim-then-re-encode shape is the correct fix for the ~21ms packet-copy class. atrim=duration=X operates on decoded PCM samples inside the filter graph — sample-accurate cut at exact PTS, not packet-accurate. asetpts=PTS-STARTPTS resets the timeline so downstream containers record from t=0 with no leading gap. Re-encoding to M4A gives ffmpeg an exact presentation duration in the container's edit list. Every step of that chain contributes to sample-exact duration.

🟢 -avoid_negative_ts make_zero suppression for containerized-AAC copy is well-reasoned. The docstring on the guard captures the mechanism: M4A carries an edit list that hides the AAC priming packet (typically 2112 samples ≈ 44ms at 48 kHz, but often 1024 samples ≈ 21ms). make_zero discards that edit and shifts copied video forward by exactly the priming duration, producing a visible first-frame offset. Suppressing the flag for this specific copy path preserves the edit list intent. The gate is narrow (!isWebm && shouldCopyAudio && ext === ".m4a"), so other mux paths keep the flag.

🟢 Regression test in chunkEncoder.test.ts pins the -avoid_negative_ts invariant. it("preserves M4A priming edit lists instead of shifting copied video", ...) explicitly asserts .not.toContain("-avoid_negative_ts") in the emitted args. Clean guard against a "just always add the flag" refactor. Symmetric with the existing "uses caller-provided AAC codec contract" test at line 424.

🟢 Audio-mux parity harness test at packages/producer/tests/audio-mux-parity/output/output.mp4 passed (321645 bytes recorded). That's the narrow case the PR was designed to fix (a single-track mixed audio going through the trim branch). So the PR does what it says on the tin for the shape captured by that fixture — the style-3 failure is a wider class the fixture didn't cover.

🟢 buildPadTrimAudioPlan docstring / mechanism trace is honest. The updated block documents WHY packet-copy is being replaced ("AAC frames are independently decodable [old]; packet-copy trimming snaps to AAC frame boundaries [new]") and calls out the ~20ms bound. Future maintainers can find the reasoning from the code without digging in git-log for the PR body.

🟢 Path renames in test fixtures are consistent. assembleStage.test.ts updates "/tmp/audio.duration-normalized.aac".m4a in every position (mock resolve value, expect.toHaveBeenCalledWith for both padOrTrim and muxVideoWithAudio, and the fail-path fixture). No stale .aac reference could pass a mock check that the writer/reader have diverged.

What I didn't verify

  • Didn't stand up a full local render of style-3-prod to reproduce the 606ms drift and empirically split the three hypotheses (unrun trim / path routing miss / pre-existing baseline). This is the highest-leverage next step; the CI log gives the drift number but not the atrim-invocation stderr.
  • Didn't grep for a third caller of padOrTrimAudioToVideoFrameCount or the .duration-normalized naming convention across the whole repo. The two callers I updated in the PR (assembleStage.ts, distributed/assemble.ts) match every hit I saw, but I didn't do a byte-exhaustive sweep.
  • Didn't check whether streaming-encode / chunked-encode audio paths go through runAssembleStage OR through a separate assemble routine that bypasses padOrTrimAudioToVideoFrameCount entirely. style-3-prod's log shows streaming-encode gate {enabled:true, ...} + workerCount:1 + chunkedEncode:false — the interplay between streaming-encode-enabled and chunkedEncode-false is worth confirming; if streaming-encode has its own assemble path that doesn't call padOrTrim, the entire fix is unreachable for streaming-encode compositions (which is basically every high-res prod render).
  • Didn't try to reason about whether the atrim filter changes the sample rate / channel-layout of the output — if the input has an unusual layout, atrim + 192k AAC re-encode might not preserve it perfectly, but the audio-correlation check (which passed at 1.000 with lag 0 for the two other jobs in this shard) suggests decode-side is fine.

Merge gate — CI is red

regression-shards (shard-1, hdr-regression style-5-prod style-3-prod mov-prores) is FAILURE at HEAD b82a21dd; approval is correctly held per Magi's own framing. The stream-duration-parity threshold (0.5s) is what fires. The other two jobs in that shard (hdr-regression, style-5-prod) both pass audio correlation and visual quality — only style-3-prod hits the stream-duration check red. Sibling shards (shard-2…shard-8) were cancelled after shard-1's failure; they need to complete on the corrective head to prove no wider regression.

Do NOT merge until regression-shards is green. The code shape is defensible for the class it targets, but the concrete failing counterexample is not addressed. Recommend either (a) a follow-up commit that shows an atrim invocation log for style-3-prod and either narrows the root cause or exposes a third path that also needs the .m4a routing, or (b) landing this PR gated on a second PR that addresses style-3 specifically.

Verdict framing

The mechanism trace and code shape are correct for the ~21ms packet-copy class of drift. The M4A copy-mux -avoid_negative_ts decoupling is a valuable adjacent fix documented clearly. But the empirical failure at HEAD reports drift 30× larger than the class this PR targets — the fix likely doesn't reach style-3's failure mode, and the merge gate correctly enforces that. Ready-from-my-side on the code delta; blocked on the concrete counterexample being resolved either here or via a follow-up.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Adversarial R1 review. Verdict: CHANGES_REQUESTED. No prior reviews on the PR — this is the first pass.

Summary

Root-cause approach is right (filter-trim + re-encode into M4A + preserve edit list at mux) — not a band-aid on a downstream muxer step. On style-5-prod the mechanism visibly works (drift dropped 0.005s → 0.001s across the two commits). But on the same fixture the PR body claims went RED→PASS, style-3-prod is now RED with a much larger drift than base main showed:

video audio drift status
Base main 21cb722 16.07s 16.11s 0.040s PASSED
Head b82a21dd (this) 16.07s 16.67s 0.606s FAILED

Audio_correlation is identical on both runs (0.9539), so it's the same signal — the pipeline is emitting 560ms more audio than before the fix. That is not "the assertion is tightened"; the output is objectively longer. This won't be resolved by CI turning green — the head reproduces 0.606s drift deterministically on both 1ca01819 and b82a21dd, and the second commit only moved style-5 (5ms→1ms) and had no effect on style-3.

Findings

blocker — style-3-prod: 40ms → 606ms drift (audio +560ms)

Run on head b82a21dd, shard-1, packages/producer/tests/style-3-prod:

✗ Stream duration parity: FAILED (video: 16.07s, audio: 16.67s, drift: 0.606s > 0.5s)
(log: actions/runs/29482258384/job/87568690623 line 2239)

Base 21cb722 same fixture:

✓ Stream duration parity: PASSED (video: 16.07s, audio: 16.11s, drift: 0.040s)
(log: actions/runs/29477616277/job/87554113154 line 1818)

packages/producer/src/services/render/audioPadTrim.ts:186-207 (new trim branch) is expected to bound output at targetSec. On style-3 with source ≈16.11s and target ≈16.07s, delta = -40ms → outside the 1ms tolerance at audioPadTrim.ts:132, so the trim plan should fire. Yet the produced audio is 16.67s, i.e. ~600ms past the atrim duration=. Some interaction of atrim=duration=16.070000,asetpts=PTS-STARTPTS + -c:a aac -b:a 192k + M4A container is producing extra audio on this fixture. Manual FFmpeg reproduction against the style-3 audio.aac before merge is needed. Do not resolve by widening the parity threshold.

blocker — PR base is stale w.r.t. #2525

#2525 (merged 2026-07-16T07:35Z, ~19 min after this PR was opened) rewrote concatFileLine to emit bare paths + materialized concat scripts, killing the Windows file:///C:/… failure and the Linux pipe:/tmp/… failure. packages/producer/src/services/render/audioPadTrim.ts at head b82a21dd still carries the pre-#2525 shape:

  • audioPadTrim.ts:24import { pathToFileURL } from "node:url";
  • audioPadTrim.ts:170-178 — pad-concat step still -i pipe:0 with a stdin concat script
  • audioPadTrim.ts:248-250concatFileLine still pathToFileURL(path).href

Git 3-way merge will likely keep main's concatFileLine (no overlap with the trim-branch changes), but CI on this PR was run against pre-#2525 world — style-3's failure is on a base that main no longer looks like. Rebase onto latest main and re-run regression before any verdict-worthy read of the CI signal. If style-3 clears post-rebase, we still need to understand why the pre-rebase run failed — a silent fix by rebase points to a pipeline sensitivity the trim path shouldn't have.

blocker — extension-based muxer gate applies to pad + no-op branches too, without an edit list to preserve

packages/engine/src/services/chunkEncoder.ts:789-793:

const copiesContainerizedAac =
  !isWebm && shouldCopyAudio && extname(audioPath).toLowerCase() === ".m4a";
...
if (!copiesContainerizedAac) args.push("-avoid_negative_ts", "make_zero");

The .m4a extension is now written by all three operations in buildPadTrimAudioPlan:

  • trim (audioPadTrim.ts:186-207) — real M4A edit list, gating make_zero OFF is correct
  • pad (audioPadTrim.ts:139-182) — concat-copies AAC packets into .m4a (no re-encode → no priming edit)
  • no-op copy (audioPadTrim.ts:132-138) — bare -c:a copy from source AAC into .m4a (no edit)

For the pad and no-op paths the gate flips OFF make_zero on the muxer even though there is no edit list to preserve. That's the exact opposite of the safety -avoid_negative_ts make_zero provides against negative-DTS after concat-copy. This is a plausible contributor to the style-3 regression (if style-3 hit pad or copy rather than trim under any code path) and is a live footgun regardless. Options: (a) gate on the trim operation actually having fired (thread operation through, not the extension), or (b) keep pad/no-op writing to .aac and only trim to .m4a.

The comment at chunkEncoder.ts:790-792 claims the edit list "already accounted for AAC priming" — that assumption only holds for the trim branch. extname === ".m4a" is a decorative proxy for "was just re-encoded" that will lie for pad and copy outputs.

important — regression test locks argv shape, not output duration

packages/producer/src/services/render/audioPadTrim.test.ts:69-82 asserts the ffmpeg argv (atrim=duration=15.000000,asetpts=PTS-STARTPTS, -c:a aac, -b:a 192k, .m4a). It doesn't run the plan against a real audio fixture and doesn't assert on the resulting file's ffprobe duration. That's the failure mode the CI regression is surfacing (argv looks correct; output is 600ms too long). Add a fixture-level integration test that pins ffprobe(out.m4a).duration ≤ targetSec + AUDIO_DURATION_TOLERANCE_SECONDS.

important — PR body claim contradicts CI

"Focused regression observed RED before implementation and PASS after implementation."

If the "focused regression" was style-3-prod, it's currently RED at head. If it was something else, that should be named in the PR body — the failing check name is regression-shards (shard-1, hdr-regression style-5-prod style-3-prod mov-prores), and reviewers need to know whether the intended-fix regression is one of those or lives elsewhere.

nit — 5-surface consumer audit is clean

padOrTrimAudioToVideoFrameCount is called from packages/producer/src/services/render/stages/assembleStage.ts:65 (local) and packages/producer/src/services/distributed/assemble.ts:295 (distributed). Both updated to .m4a. Lambda + cloudrun surfaces route through distributed/assemble.ts, so the change reaches all render surfaces via those two entrypoints. No dead half-plumbed surfaces here. The #2525 file://-vs-bare-path concern is orthogonal to this fix's intent and only surfaces because of the stale base (above).

Recommendation

Do not stamp until:

  1. style-3-prod regression is diagnosed and closed on the fresh head — not by threshold-widening.
  2. Base is rebased onto latest main so #2525's concatFileLine reconciliation is explicit, and regression is re-run.
  3. extname === ".m4a" gate at chunkEncoder.ts:789 is either narrowed to "trim operation ran" or the pad/no-op branches keep the .aac extension so the gate stays precise.
  4. Argv-only regression test is complemented by an actual-duration assertion against an ffprobed output.

Miguel — root-cause direction is right, and I'd expect this to converge quickly once style-3 is reproduced locally. The 606ms delta is much too large to be encoder priming; there's a real mechanism to find.

Verdict: REQUEST CHANGES
Reasoning: Head produces a 606ms audio-lengthening regression on style-3-prod (base was 40ms), and the extension-based copiesContainerizedAac gate at chunkEncoder.ts:789 skips negative-DTS repair on pad/no-op branches that have no edit list to preserve. Both blockers need addressing before green CI would authorize a stamp.

— Via

@vanceingalls

Copy link
Copy Markdown
Collaborator

Cross-posted with @james-russo-rames-d-jusso — my review went up 2 minutes after his and I hadn't fetched. Manual peer-lens split so Miguel doesn't have to reconcile:

Where we overlap: 606ms drift on style-3-prod is 30× the packet-copy class this PR targets; the argv-shape regression test doesn't cover the actual failure; the -avoid_negative_ts gate at chunkEncoder.ts:789 needs sharper scoping.

Adds from my pass (not in Rames's review):

  1. Stale-base w.r.t. fix(producer): audioPadTrim FFmpeg-8.x-compatible apad invocation #2525 — this PR's base 21cb722 predates fix(producer): audioPadTrim FFmpeg-8.x-compatible apad invocation #2525 (merged 07:35Z). audioPadTrim.ts at head still has pathToFileURL + pipe:0 shape; main has bare paths + concatListPath. Rebase before re-reading CI.
  2. Pad + no-op branches also emit .m4acopiesContainerizedAac at chunkEncoder.ts:789 flips -avoid_negative_ts make_zero OFF for pad-concat outputs and copy outputs that have no edit list to preserve. That's the opposite of the safety make_zero provides against negative-DTS after concat-copy — and it's a plausible mechanism for the style-3 regression if style-3 hits pad rather than trim. (Rames flagged the ext-string keying as a .mp4 nit; the pad-branch collision is the stronger version of that concern.)

Adds from Rames's pass (not in mine): the 399ms durationMs observation as evidence the trim branch may not have run for style-3; the streaming-encode / chunkedEncode bypass hypothesis; the mixed-sample-rate atrim-graph question. Those are all good hypotheses for the diagnostic Miguel will need before the next push.

Position stays CHANGES_REQUESTED on my side vs. Rames's COMMENTED — divergence axis is the extension-based gate on pad/no-op branches. If pad/no-op stay writing to .m4a, that gate is broken independently of whether style-3 root-causes to trim. If they don't (or the gate is threaded on operation instead), fine.

— Via

@miguel-heygen
miguel-heygen force-pushed the fix/aac-packet-padding-1784185114 branch from b82a21d to d92c236 Compare July 16, 2026 14:53

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Delta review at d92c236e (from R1 head b82a21dd — GC'd, structurally compared against the current diff). Scoped to the two follow-up commits 82537eb0 (edit-timing preservation) and d92c236e (explicit trim-only scoping).

Blockers

(none on code)

Concerns

(none)

Nits

(none)

Green notes

🟢 The preserveAudioPrimingEditList scoping is exactly the right fix and directly resolves my R1 nit about the extension-string gate. R1 flagged that extname(audioPath).toLowerCase() === ".m4a" was a fragile signal for "this is a freshly-encoded AAC file whose priming edit list must be preserved." The old shape swallowed ALL .m4a outputs — including pad-branch concat outputs and copy-branch containerized AAC — regardless of whether they carried a fresh priming edit. The new shape lifts the decision out of the muxer's file-name inspection and into the caller, which knows for certain whether the audio it's handing over is a fresh trim/re-encode or something else. Explicit contract, no extension guessing, type-safe.

🟢 This likely also closes the style-3-prod 606ms drift concern I raised in R1. My R1 hypothesis (b) was "a third caller routes to a path that gets the wrong suppression." The failure mode I was reaching for was subtler than I realized: padOrTrimAudioToVideoFrameCount writes ALL three operation branches (copy / pad / trim) to a caller-provided outputPath, and both callers I traced (assembleStage.ts line 76's normalizeResult.outputPath.m4a, distributed/assemble.ts:294's paddedAudioPath = join(workDir, "audio-padded.m4a")) hand back .m4a regardless of operation. That means the R1 head was suppressing -avoid_negative_ts make_zero on pad-branch outputs too — including the pad branch's concat of a silence tail with the source audio, which does NOT carry a fresh AAC priming edit and needs the DTS repair. Any small PTS discontinuity from the silence-concat then propagates through the mux unrepaired, and on a 5-track composition (style-3-prod's audioCount: 5) can accumulate to hundreds of ms of drift. The d92c236e gate flips exactly this: pad-branch → preserveAudioPrimingEditList: false → mux keeps -avoid_negative_ts make_zero → concat drift suppressed. Mechanism-plausible, but empirical proof waits on regression-shards green.

🟢 Test coverage is a clean RED→GREEN pair. The old single test (preserves M4A priming edit lists instead of shifting copied video) is split into two symmetric tests:

  • keeps negative-timestamp repair for an M4A without a known priming edit list — asserts -avoid_negative_ts IS in args when the caller does not opt in (default false).
  • preserves a known M4A priming edit list instead of shifting copied video — asserts -avoid_negative_ts is NOT in args when the caller sets preserveAudioPrimingEditList: true.

Two-sided coverage locks the invariant on both sides of the branch. A future refactor that always sets or always drops the flag would fail one test or the other.

🟢 Caller-side pairing is correct at both invocation sites.

  • assembleStage.ts:76-82: preserveAudioPrimingEditList: normalizeResult.operation === "trim" — only the trim operation carries the flag.
  • distributed/assemble.ts:293-325: same pattern (preserveAudioPrimingEditList = padTrimResult.operation === "trim"), threaded through to the mux call. Neither caller synthesizes the value from .m4a extension or naming convention; it flows directly from the pad-or-trim result. Correctly decoupled.

🟢 Interface is scoped to what the muxer actually needs to decide. MuxVideoWithAudioOptions.preserveAudioPrimingEditList?: boolean with a clear docstring ("Preserve a priming edit list known to have been created by AAC re-encoding."). Optional with implicit default of false — safe on all existing callers (preserveAudioPrimingEditList !== true picks up the negative-TS repair). No behavioral change for any caller that doesn't opt in.

🟢 Comment on the copy-M4A guard is preserved from R1 through the refactor. The 82537eb0 commit's docblock ("A freshly encoded M4A is the exception: its edit list already hides the AAC priming packet. \make_zero` discards that edit and shifts copied video forward by one AAC frame (~21ms), creating a visible first-frame offset.") survives into d92c236above the gate. Future maintainer looking atif (!copiesContainerizedAac)gets the mechanism trace inline; the newpreserveAudioPrimingEditList` variable name reinforces the intent.

What I didn't verify

  • Didn't re-run regression-shards locally to confirm the 606ms drift on style-3-prod is empirically gone at d92c236e. CI is currently IN_PROGRESS on regression-shards at time of review; that job's outcome is the definitive test of the mechanism trace above.
  • Didn't audit whether the streaming-encode / chunkedEncode assemble path (surfaced as R1 hypothesis (a)) routes through the same padOrTrimAudioToVideoFrameCount and thus benefits from this fix. style-3-prod's log line streaming-encode gate {enabled:true, workerCount:1, chunkedEncode:false} suggests it does share the assemble stage, but I didn't grep exhaustively for a bypass path.

Merge gate — CI still running

Required checks in progress at d92c236e:

  • Test, Typecheck, Producer: unit tests, Producer: integration tests, SDK: unit + contract + smoke, Studio: load smoke, Test: runtime contract, CLI smoke (required), Build, Fallow audit — all IN_PROGRESS on the CI workflow.
  • regression-shards (the shard that failed at R1 head with the 606ms drift) is not yet visible in the rollup, but is expected to be re-triggered by the new head.

reviewDecision: CHANGES_REQUESTED is stale from the R1-era peer review; that will need to be dismissed or re-reviewed on this head before the merge state clears.

Do NOT merge until regression-shards reports green on d92c236e. The mechanism trace is now defensible on both sides (the extension-check-driven over-suppression → pad-branch drift theory closes the loop cleanly), but the concrete counterexample from R1 needs to actually be shown resolved.

Verdict framing

Delta LGTM from my side. d92c236e is a well-targeted scope narrowing that both closes the R1 nit and plausibly reaches the R1 concern's failure mode. Waiting on regression-shards green to confirm the 606ms drift is gone at HEAD.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

R2 verification at d92c236e — re-verified each of my R1 blockers independently against the new head, ran the full adversarial pass on the three follow-up commits, and cross-checked against Rames's delta R1 + delta R2. Two of three blockers are cleanly resolved in code; the third — style-3-prod parity — is still failing at HEAD with byte-identical drift numbers to R1.

Per-blocker verdict

B2 (stale base w.r.t. #2525) — ✅ resolved

  • gh api /compare/main...d92c236e reports behind_by: 0, ahead_by: 3. Rebase caught up cleanly.
  • packages/producer/src/services/render/audioPadTrim.ts at d92c236e: no pathToFileURL, no pipe:0-fed concat. -i concatListPath at audioPadTrim.ts:174-175, -i audioPath at :197-198, concatFileLine(audioPath) writing bare paths at :182. The R1 head's pathToFileURL / pipe:0 shape is absorbed away by the rebase over #2525.

B3 (copiesContainerizedAac .m4a extname footgun) — ✅ resolved

  • packages/engine/src/services/chunkEncoder.ts:794-795: gate is now
    const copiesContainerizedAac =
      !isWebm && shouldCopyAudio && config?.preserveAudioPrimingEditList === true;
    The extname-only signal is gone. Default is false.
  • chunkEncoder.ts:801: if (!copiesContainerizedAac) args.push("-avoid_negative_ts", "make_zero"); — negative-DTS safety fires on every path that doesn't explicitly opt in.
  • Callers thread the flag correctly from the pad-or-trim operation only:
    • packages/producer/src/services/render/stages/assembleStage.ts:81preserveAudioPrimingEditList: normalizeResult.operation === "trim".
    • packages/producer/src/services/distributed/assemble.ts:305,325preserveAudioPrimingEditList = padTrimResult.operation === "trim", threaded through to the mux at :325.
  • Pad and copy branches → false-avoid_negative_ts make_zero still applied. Fresh trim/re-encode → true → priming edit list preserved. Test coverage in chunkEncoder.test.ts is a tight RED→GREEN pair on both sides of the branch (line-435 asserts -avoid_negative_ts IS in args without the flag, line-460 asserts it is NOT in args with the flag). This is exactly the shape I asked for at R1.

B1 (style-3-prod parity regression) — ❌ still open (blocker)

regression-shards (shard-1, hdr-regression style-5-prod style-3-prod mov-prores) completed at 15:16:08Z on d92c236e with conclusion: failure. From the job log:

✗ Stream duration parity: FAILED (video: 16.07s, audio: 16.67s, drift: 0.606s > 0.5s)
{"event":"audio_comparison_complete","suite":"style-3-prod","passed":true,
 "correlation":0.9539530103009182,"lagWindows":0,"residualRmsDb":null}

This is byte-identical to the R1 head b82a21dd signature: same video 16.07s, same audio 16.67s, same 606ms drift, same correlation 0.9539530103. Neither the M4A trim mechanism refactor (82537eb0) nor the extname → explicit-flag scoping (d92c236e) moved the numbers.

That empirically falsifies Rames's plausible mechanism trace — the theory was "extname gate over-suppressed -avoid_negative_ts on the pad-branch concat; scoping it to trim only should recover the DTS repair on pad." The scoping is now correct in code (verified in B3 above), pad-branch on style-3-prod's assemble now flows preserveAudioPrimingEditList: false-avoid_negative_ts make_zero fires — and the drift is unchanged. The 560ms audio surplus is originating somewhere else: probably the pad or trim step itself, or the pre-assembly audio mixer that emits audio.aac, not the final mux flag.

For reference, base main at 21cb722 passed the same shard at 40ms drift with correlation 0.954. So the regression started with something in this PR's diff, but the B3 fix is not the mechanism.

Adversarial notes on what to look at next:

  1. padOrTrimAudioToVideoFrameCount in the new head can now emit .m4a when the pad branch fires (assembleStage.test.ts fixtures moved /tmp/audio.duration-normalized.aac.m4a at every callsite). But the pad branch still does an AAC-copy concat (audioPadTrim.ts:168-183, kind: "pad-concat", -c:a copy), not a re-encode. Copying AAC packets into an .m4a shell without a fresh encoder-priming interval risks the same class of duration-recording drift the atrim-then-re-encode fix was designed to close for the trim branch. Worth probing whether style-3-prod is hitting the pad branch and whether the .m4a container is recording the sum-of-packet duration instead of the concat script's target.
  2. audioPadTrim.ts:139 — the no-op copy branch also now writes into outputPath which the callers made .m4a. Same concern: -c:a copy into .m4a without a priming edit could produce a container whose duration metadata is packet-derived rather than sample-exact.
  3. Rames's R1 hypothesis (a) — streaming-encode / chunkedEncode bypass — is closed by inspection: renderOrchestrator.ts:3157-3170 dispatches Stage 6 unconditionally through runAssembleStage, and the style-3-prod trace shows streaming-encode gate {enabled:true, ...} on the same job that then hits the assemble stage. Both encode strategies funnel through the fixed callers. Not the miss.
  4. Rames's R1 hypothesis on 399ms durationMs evidence trim not running — the R2 fix DID change -t <sec> packet-copy trim to -af atrim=duration=<sec>,asetpts=PTS-STARTPTS with -c:a aac -b:a 192k re-encode into .m4a (audioPadTrim.ts:191-210, verified against the test patch in audioPadTrim.test.ts and assembleStage.test.ts). That's the right structural change for AAC packet-padding elimination. But it didn't move style-3-prod's numbers, so either that path isn't the one exercised by style-3-prod, or the mechanism is downstream.

Rames's concerns roll-up

  • Extension-string gate → ✅ closed by B3.
  • Streaming-encode / chunkedEncode bypass → ✅ closed by inspection (both funnel through the fixed callers).
  • 399ms trim evidence / atrim re-encode → ✅ mechanism correctly refactored (audioPadTrim.ts:191-210), but empirically not the fix for style-3-prod.
  • Mixed-sample-rate atrim → not verified this round; parking as follow-up since the parity failure is the dominant signal.

Non-blocker green notes

  • chunkEncoder.test.ts RED→GREEN pair is exactly the shape B3 asked for. Both value-side (flag presence/absence) and reachability-side (audioCodec: "aac"shouldCopyAcasSidecar returns true → gate active) are locked.
  • assembleStage.test.ts now asserts preserveAudioPrimingEditList: true is passed on the trim path. Good coverage on the invariant.
  • Docstring at chunkEncoder.ts:798-800 preserving the mechanism trace inline is a durable win for the next maintainer.

Verdict

Rebase-resolution correctness: ✅ clean (per rebase-clean-ci-red-new-base discipline — the byte-level file audit at both SHAs shows the resolution introduced no drift beyond the intended fix commits).

Merged-tree CI health: ❌ red on regression-shards shard-1 (style-3-prod). The one shard that mattered to B1 completed with the exact same failure signature as R1.

Verdict: REQUEST CHANGES
Reasoning: B2 and B3 are cleanly resolved in code, but B1's style-3-prod 606ms parity regression reproduced byte-identically at d92c236e. This isn't a mitigation-accepting case per the r2-verdict-mitigation-vs-full-resolution discipline — the blast radius is unchanged, and the mechanism theory driving the code changes was empirically falsified by the CI shard.

— Via

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

New Windows field signal maps directly to this open audioPadTrim/AAC-padding work:

  • non-frame-aligned composition + shorter audio fails in the audioPadTrim concat input with a file:///.../audio.aac error
  • reproduced by reporter with ASCII-only TEMP and both 1 and 2 workers, which rules out Unicode-path and capture-concurrency explanations
  • frame-aligning the composition to 65.9s plus an explicit zero-sample tail succeeds

Source: https://heygen.slack.com/archives/C0BGC335AQY/p1784220443405999

This is additional evidence for probing the pad-concat/no-op branches rather than only the trim re-encode branch. No duplicate PR created; #2531 remains the owning fix and is still blocked by the existing style-3 parity regression.

@miguel-heygen
miguel-heygen force-pushed the fix/aac-packet-padding-1784185114 branch from d92c236 to 0096018 Compare July 18, 2026 18:16
@mintlify

mintlify Bot commented Jul 18, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
hyperframes 🟢 Ready View Preview Jul 18, 2026, 7:14 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Resolved the outstanding style-3-prod blocker at exact head 8aa09a8e289acfe498c59416f4c562b08b124c57.

Root cause was not a trim miss or the final mux threshold. The pad path concat-copied raw ADTS AAC with a tiny generated silence segment. FFmpeg's concat demuxer estimated the raw ADTS duration from bitrate and timestamped the silence segment about 606ms late; the final MP4 therefore carried an oversized terminal AAC packet/timeline gap.

The corrective delta:

  • replaces raw-ADTS silence concat-copy with one decode/filter/re-encode step using apad=whole_dur=<target> and -t <target>;
  • preserves the AAC priming edit list for every re-encoded normalization result;
  • removes the disproven final-mux durationSeconds / -shortest workaround and dead concat machinery;
  • adds a real-media ADTS packet regression that invokes the production normalizer and asserts target duration plus no oversized terminal packet.

Verification:

  • focused render/audio tests: 190 passed;
  • real-media packet regression: passed;
  • Producer + Engine typecheck and diff/format gates: passed;
  • faithful isolated Docker style-3-prod: video/audio 16.07s, 1ms drift, 0 failed visual frames, correlation 0.9537007361;
  • exact-head required CI is fully green, including all 8 regression shards, Windows tests/render, Build, Test, Typecheck, CLI smoke/global install, and JavaScript CodeQL: https://github.com/heygen-com/hyperframes/actions/runs/29660990842

The prior CHANGES_REQUESTED reviews were against b82a21dd / d92c236e; their concrete 606ms counterexample is now resolved without widening the parity threshold. Re-review requested on the exact head above.

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

R2 exact-head verify at 8aa09a8e2. Re-read audioPadTrim.ts, both test files, assembleStage.ts, assemble.ts, and chunkEncoder.ts at this SHA; cross-checked each of the four R2-fix claims against the actual bytes. Verdict: APPROVED — root-cause fix, no band-aid, regression-shards shard-1 (style-3-prod) green at this SHA.

Verified

Root cause identified and fixed. The prior head's pad branch generated a tiny raw-ADTS silence tail via anullsrc and stitched it via concat + -c:a copy. The concat demuxer's terminal-packet PTS is bitrate-estimated on raw ADTS input, not sample-counted — with a ~26ms pad ask and 192 kbps AAC, that estimator can round the last packet's presentation up by ~600ms in the muxed MP4. Verified by reading the old pad-silence + pad-concat step pair in the pre-fix source and confirming both are gone at head.

Fix is on the correct axis. Both branches at head use filter-graph, sample-counted duration + a container-boundary -t cap + AAC re-encode:

  • Pad (audioPadTrim.ts L139-155): -af apad=whole_dur=<targetSec> + -t <targetSec> + -c:a aac -b:a 192k. apad=whole_dur counts samples through the filter graph, and -t bounds the muxer at the container boundary. Belt + suspenders in the right places.
  • Trim (L165-186): -af atrim=duration=<targetSec>,asetpts=PTS-STARTPTS + -t <targetSec> + -c:a aac -b:a 192k. Comment in-source correctly notes that atrim alone doesn't cap muxer timestamps (encoder delay/flush can persist beyond samples), so the -t at the container boundary is the second lock.

Dead concat mechanism removed — the old pad-silence / pad-concat step kinds, writeFileSync for the concat script, sampleRateForFilter / channelLayoutForChannels helpers, and both concatListPath / concatListContent fields are all gone. The PadTrimAudioStepKind union narrowed from four kinds to three (copy | trim | normalize). No compensating post-hoc offset anywhere — this is a root-cause replacement, not a band-aid on the drift.

Edit-list preservation is the right complement, not a workaround. chunkEncoder.ts L693-703 conditionally drops -avoid_negative_ts make_zero when the mux is copy-audio on freshly re-encoded M4A. The comment nails the reason: the M4A edit list already hides AAC priming; make_zero would discard that edit and shift copied video by ~21ms. The preserveAudioPrimingEditList flag is threaded through assembleStage.ts L79 and assemble.ts L306-329 gated on padTrimResult.operation !== "copy". This is a real correctness constraint of the re-encode approach, not a hack.

Real-media regression covers the failure mode. audioPadTrim.integration.test.ts synthesizes a 16.04s raw-ADTS AAC input, drives the pad branch to a 16.0667s target (~26ms delta — the exact shape of the prior style-3-prod failure), and asserts (a) output duration ∈ [16.0, 16.1] s (would catch a 606ms overrun) and (b) packet.duration_time on the terminal packet < 0.1s (would catch a bitrate-PTS-estimated fat tail). CI shows regression-shards (shard-1, hdr-regression style-5-prod style-3-prod mov-prores) PASS at this head.

Non-blocking follow-ups (not gating)

  1. audioPadTrim.ts L217-238 — the concatFileLine function body was retained as a bare /* … */ block comment after the function itself was deleted. Now-orphaned documentation for a mechanism the file no longer has; safe to remove in the next janitor pass.
  2. audioPadTrim.test.ts L28 — the first buildPadTrimAudioPlan(...) call still passes a 5th arg { sampleRate: 48000, channels: 2 }. The function signature at head only takes four params; the extra positional is silently dropped at runtime (Typecheck CI passes, so this file's tsconfig must be lenient enough not to flag it). Dead argument — trim for hygiene.
  3. PR body drift — the ## How section says "Pad and no-op branches continue to stream-copy audio," but the pad branch at head re-encodes AAC. Description reads like it was written before the pad branch was also converted; the code is correct, the copy is stale. Worth fixing for future spelunkers.

CI at head: Build, Test, Typecheck, Lint, Format, all Windows lanes, CodeQL, all 8 regression shards, CLI smoke, and Fallow audit — all green. Ship it.

— Via

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Addressed the three non-blocking review follow-ups at exact head d4c77a8a4b5080fbde38ea36bb1b4eb767921359:

  • removed the orphaned raw-ADTS concat comment
  • removed the stale fifth test argument and updated its cleanup assertion comment
  • rewrote the PR description to accurately describe the sample-timeline pad/trim normalization and no-op stream-copy path

Verification: bun test packages/producer/src/services/render/audioPadTrim.test.ts (18/18), real-media integration test (1/1), Producer typecheck, git diff --check, and commit hooks all pass. Fresh exact-head CI is now required.

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Delta re-review at head d4c77a8a4b5080fbde38ea36bb1b4eb767921359. All 7 required checks pass (Build, Test, Test: runtime contract, Typecheck, Render on windows-latest, Tests on windows-latest, Semantic PR title). Compare vs prior approved head 8aa09a8e28: 1 commit ahead, 0 behind, only 2 files touched (audioPadTrim.ts -23, audioPadTrim.test.ts +2/-5) — matches the hygiene-only scope.

Three hygiene follow-ups verified:

  1. Orphan concatFileLine block commentpackages/producer/src/services/render/audioPadTrim.ts L217-238 in the prior head is gone. New file goes cleanly from formatSeconds at L212-215 straight into the padOrTrimAudioToVideoFrameCount doc block at L217. No new orphan blocks introduced.

  2. Stale 5th arg in testpackages/producer/src/services/render/audioPadTrim.test.ts L28 is now buildPadTrimAudioPlan("/tmp/in.aac", "/tmp/out.aac", 4.0, 5.0) — 4 args, matching the current public signature. The sibling win32-path call at L102-107 is also 4-arg.

  3. PR body ## How stale copy## How now states "Pad with apad=whole_dur, cap the output at the target duration, and AAC-encode the continuous sample timeline into M4A." The old "Pad and no-op branches continue to stream-copy audio" phrasing is gone. Remaining stream-copy mentions in ## What and the closing paragraph refer only to the no-op audio path and final video mux — both accurate.

No regression vs prior R2 approval: pad-silence / pad-concat step kinds remain absent (step kinds are copy / normalize / trim), apad=whole_dur=${targetSec} + -c:a aac -b:a 192k re-encode present at L145-151, sampleRateForFilter / channelLayoutForChannels helpers still deleted, and audioPadTrim.integration.test.ts still present alongside the unit test.

LGTM.

— Via

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.

3 participants