Skip to content

feat(producer): a cloud plan can leave its audio to be mixed beside its chunks - #5988

Merged
miguel-heygen merged 3 commits into
mainfrom
cloudrender/audio-parallel
Oct 21, 2026
Merged

miguel-heygen merged 3 commits into
mainfrom
cloudrender/audio-parallel

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What changes

Nothing a render does today. This adds the producer pieces that let a cloud render mix its audio beside its chunks instead of before them; the Lambda wiring follows in the next PR.

  • A plan can leave its audio out (deferAudio): mp4 chunks then render video only.
  • buildPlanAudio makes that audio track on its own: the same mix and true-peak hold the plan makes today, from the same code, at exactly the video's length.
  • The mp4 stitch takes the audio track from that one file and puts its samples right after the header, ahead of the video.

Why

On a long 4K render the plan spends most of its time on audio (about 48 of 57 s on a 20-minute film) while no chunk can start. With audio made beside the chunks, the plan shrinks to the rest and the audio finishes long before the chunks do.

How

  • The plan's copy, compile and probe move into one helper shared by the plan and buildPlanAudio, so both see the same composition. Video extraction, which adds the sound of audible videos the static parse misses (a src the scene script sets), is one helper too; buildPlanAudio runs it deferred, so it probes without extracting long windows. The mix and the true-peak hold are likewise one helper each.
  • plansSeparateAudio decides when audio is left out: mp4 without cfr, the cases the stitch can join. Any other plan mixes as before.
  • The locked config records separateAudio, so chunks know to encode video only and the assembler knows to stitch the audio file in.
  • buildStitchedHeader(layouts, audio?) appends the audio file's track to chunk 0's moov under a new track id and indexes its samples first. It refuses any chunk, chunk 0 included, that carries audio of its own when a separate file is given.

Tests

  • plan.test.ts: a plan with deferAudio has no audio and records separateAudio; buildPlanAudio writes a byte-identical file to the audio a carrying plan makes (a hot tone, so the true-peak hold re-encodes too), and the same for a <video data-has-audio> whose src the scene script sets (red before the shared extraction: no audio); cfr still mixes in the plan.
  • mp4Stitch.test.ts: the separate audio's samples sit before every chunk's, with its edit list, sizes and a new track id; chunk 0 or chunk 1 carrying audio is refused (chunk 0 was red before); the local join writes the same bytes as the in-memory stitch.
  • chunkAudio.test.ts: video-only chunks stitched with the whole plan audio give the same packets in every stream as chunks that carry their slices.
  • Each new behaviour was broken on purpose and its test went red: no new track id, no peak hold, cfr deferring audio, audio payload placed last.
  • Heavy-4K composition (20.6 min of two 50-min 4K takes, read by URL as a cloud plan reads them, ffmpeg 8.0.1): buildPlanAudio and today's plan write byte-identical audio (same file, same audio stream md5, 59,376,000 samples = 30,925 frames at 25 fps exactly).

Test counts: plan.test.ts 63 pass (the script-src test 3 runs in a row), mp4Stitch.test.ts 13, chunkAudio.test.ts 9, assemble.test.ts 18, each 0 fail; producer typecheck clean.

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

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

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

Quarantined, measured but not gated (0)

@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.

Reviewed at 4c142905. Required CI is green and there are no other reviews yet.

What holds

  • The stitch is sound. The audio track gets a new id, mvhd next_track_ID moves past it (TRACK_ID_AT offsets match v0 and v1 boxes), and the audio's samples sit ahead of every chunk's. withId only ever writes into the copy withDuration makes, so the template moov is never mutated.
  • Nothing a render does today changes. No caller passes deferAudio. s3Stitch.ts:141 still calls buildStitchedHeader(layouts) with no audio, and cfr or a non-mp4 format keeps the plan's own mix.
  • Byte-identity holds for <audio> and static audible video. Locally (ffmpeg 6.0), the PR's buildPlanAudio test passes. For a <video src="clip.mp4" data-has-audio="true"> composition, buildPlanAudio also writes the same bytes as the plan's audio.m4a.

Blocker: buildPlanAudio drops the sound of a video the static parse can't see
The plan's mix includes audio that runExtractVideosStage (plan.ts:1428) appends to composition.audios through appendAutoDetectedVideoAudio (extractVideosStage.ts:544). That append is the only way a <video data-has-audio="true"> gets into the mix when the static parse misses it. The common case is a src assigned from the scene script, a pattern the compiler explicitly supports (core/src/compiler/mediaRenderIds.ts:48). The browser probe then adds the video (probeStage.ts:610), but parseAudioElements never saw a src. buildPlanAudio (plan.ts:1741) runs compile and probe, then mixes at :1756 without that step, so those tracks are missing.

Repro at this head. The composition is <video id="v" data-has-audio="true" data-start="0" data-duration="1"> plus <script>document.getElementById("v").src = "clip.mp4"</script>, where clip.mp4 has an AAC tone:

  • buildLocalExecutionPlan(…, { chunksCarryAudio: true }) writes audio.m4a.
  • buildPlanAudio(…) returns { hasAudio: false } and writes nothing.

Once the next PR wires deferAudio, that render ships silent where today's plan has sound. The fix belongs here, because the function's contract is "the mix … that plan would have made". A probe-only pass would do it: ffprobe the audible, non-hidden composition.videos sources and feed appendAutoDetectedVideoAudio, without extracting frames. Alternatively, share the plan's post-extraction audio list. A buildPlanAudio-equals-plan test on a script-assigned src would pin the fix.

Nit: the "carries its own audio" refusal misses chunk 0
sourceTracks (mp4Stitch.ts:359) checks tracks.at(-1), the match for the separate audio's plan slot. When chunk 0 carries audio, its soun joins the plan, and every chunk's audio matches that slot instead, so nothing throws. With a separate file, buildStitchedHeader([chunk0(vide+soun), chunk1(vide+soun)], mix) returns a moov with tracks vide, soun, soun. This is unreachable from the plan, since separateAudio and chunksCarryAudio are exclusive, but the guard and its test only cover chunk ≥1. Checking chunkPlan.some(t => t.handler === "soun") up front closes it.

Verdict: REQUEST CHANGES
Reasoning: buildPlanAudio doesn't make the mix the plan makes for audible videos with a script-assigned src, which would silently drop their sound once it is wired. The rest is sound.

— Via

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Fixed at 83e86e8, thanks.

  • Blocker: the plan's video extraction is now one helper (extractPlanVideos), and buildPlanAudio runs it deferred before the mix, so appendAutoDetectedVideoAudio adds the same tracks in both. The new plan.test.ts case (your repro: script-assigned src, AAC tone) failed before (hasAudio: false) and now writes bytes identical to the plan's audio.m4a, 3 runs in a row.
  • Nit: sourceTracks now refuses any chunk with a soun track when a separate file is given, chunk 0 included; the refusal test runs for chunk 0 (red before) and chunk 1.

@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.

Re-reviewed at 73801e70, covering the delta from 4c142905 (plan.ts, mp4Stitch.ts and their tests).

The blocker is closed

  • buildPlanAudio now runs extractPlanVideos before mixing, the same stage the plan runs. So appendAutoDetectedVideoAudio adds the sound of a <video data-has-audio> whose src a script sets. The plan's own call is now the shared helper, with the same arguments and the same failureToEnforce and assertVideoExtractionSucceeded checks.
  • deferRangeExtraction: true is right for an audio-only pass. A deferred long CFR window still carries the probed metadata (videoFrameExtractor.ts:2259-2305), so its hasAudio reaches the append without extracting frames.
  • Locally at this head (ffmpeg 6.0), the two buildPlanAudio tests pass and the script-src one is byte-identical to the plan's audio.m4a. Removing the extractPlanVideos call turns the script-src test red.

The nit is closed. The refusal now checks each chunk's own tracks for soun before matching, so chunk 0 is covered too. mp4Stitch.test.ts passes 13/13. Exempting chunk 0 from the check turns the new chunk 0 case red.

Nit (cost, not correctness): the windows extraction won't defer (VFR, HDR transforms, final-frame-only, or a clip of about a second or less) are fully extracted again in buildPlanAudio, only to be discarded with workDir. This doesn't touch the 4K takes this targets (long CFR). A VFR-heavy film would pay the frame extraction twice, once on the plan's critical path and once beside the chunks. A metadata-only pass would avoid that if it ever shows up.

CI: every required check is green at 73801e70.

Verdict: APPROVE
Reasoning: buildPlanAudio now goes through the same extraction the plan does, so a deferred plan's audio matches today's for script-set video sources. The stitch refuses audio-carrying chunks at every index.

— Via

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 21, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 21, 2026
@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit 8b410f1 Oct 21, 2026
75 checks passed
@miguel-heygen
miguel-heygen deleted the cloudrender/audio-parallel branch October 21, 2026 03:10
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