Repository navigation
perf(producer): each distributed chunk downloads only its own slice of the plan audio - #5881
Conversation
…f the plan audio A v2 plan whose mp4 chunks carry their audio now cuts the mixed track into one file per chunk (stream copy, the same cut the chunk made itself), and the manifest sends each chunk only its own file. A chunk muxes its slice with the timestamps the cut gave it, so its packets match the old in-chunk cut.
Edit accuracy: accurate 271 (base branch 271), smooth 166 of thoseThe gate passes. Quarantined, measured but not gated (0) |
…fmpeg version Re-muxing a stream-copied AAC slice changed each chunk's last packet duration on ffmpeg 6 and 7 (the slice's own edit list trims it), which shortened the stitched audio timeline by about 2 s on a 30-minute render. The plan now ships the audio's moov once plus each chunk's packet bytes, and a chunk rebuilds a sparse copy of the whole file and runs the unchanged cut on it.
somanshreddy
left a comment
There was a problem hiding this comment.
Reviewed at 8ff43a6d. No blocker, so I'm approving. There is one should-fix for a deploy that happens mid-render.
What I checked
- Slice writer: the packet offsets are walked from
stco/stsc/stsz, and eachNNNN.binis cut with the sameaudioCutForChunkthat mux uses.frame.m4ais the file minus[payloadStart, payloadEnd).chunkAudioSourcewrites the head, the tail atpayloadEndand the chunk's part at its original offset, thenftruncates tosizeBytes. So the moov offsets stay valid, and any packet the chunk doesn't own reads as zeros. The cut never touches those packets, and the payload-sha256 test proves that. - Targeting:
frame.m4aandlayout.jsongo to every chunk,NNNN.bingoes only to chunk N, andaudio.m4anow goes to no chunk and no assembler when the plan is sliced. A v1 plan, or a v2 plan with noaudio-chunks/, falls back to the whole file. - Tests: I ran
bun test src/services/distributed/with ffmpeg 8.0.1: 268 pass and 1 fail. The failure isplan.masterBus.test.ts, a vitest-only file (vi.hoisted) that this PR doesn't touch.chunkAudio.test.tsandplanV2.test.tspass 71 of 71. - Mutation checks:
- Writing the chunk's part at
start + 1fails 3 of 4 tests inchunkAudio.test.ts. - Sending every
NNNN.binto all chunks fails 2 tests inplanV2.test.ts.
- Writing the chunk's part at
- API surface: the plan format is internal. No public SDK or CLI export changed.
1. Old chunk worker with a new plan: silent audio gaps (should-fix)
Nothing stops a worker from before this PR from running a chunk of a sliced plan. renderChunk only gates on the ffmpeg version, and the Lambda target is unqualified ($LATEST). Cloud Run can also split traffic across revisions. Such a worker calls resolvePlanAudioPath(planDir), gets null because audio.m4a is no longer materialised for chunks, and muxes a video-only chunk. Its probe then only expects video, so it passes.
planTracksOfaccepts a chunk with nosountrack as "owns no audio packets", so the stitched video gets silent stretches with no error.- It only fails loudly if chunk 0 itself is the old one.
This needs a deploy or rollback while a render is running, so it's rare, and I didn't reproduce it. I inferred it from the code. Two cheap fixes:
- Bump something an old chunk refuses: a plan v2 manifest or protocol version, or a required capability.
- Or keep shipping
audio.m4ato chunks for one release, so the fallback still works.
Nits
rmSync(audioScratchPath)only runs on the success path. On a mux failure the sparse scratch file stays next tooutputChunkPath. It's small on disk but has the full apparent size. Moving it into thefinallywould fix that.track!is used in three places inwriteChunkAudioSlices. An audio file with no track would throw a bareTypeError. A one-lineif (!track) throw new Error("[chunkAudio] plan audio has no track")would give a clearer error.
I didn't measure this on Lambda or Cloud Run. CI was 13 pass, 47 pending and 0 failing when I approved.
— Somu
somanshreddy
left a comment
There was a problem hiding this comment.
Re-approving at aeebedfc. It adds one commit on top of 8ff43a6d, which I approved in review 5574472941. The only change is in scripts/package-size-budgets.json: the packed budget for @hyperframes/producer goes from 729,380 to 767,334 bytes (+5.2%). The unpacked budget is unchanged. No source changed, so my earlier findings stand, including the should-fix for an old chunk worker running a new plan. CI was 21 pass, 28 pending and 0 failing.
— Somu
somanshreddy
left a comment
There was a problem hiding this comment.
Re-approving at 91fc255c. It's a merge of main (through #5903) into aeebedfc, which I approved in review 5574981230. git range-diff shows all four of the PR's commits as =.
The merge resolution (git show --remerge-diff) has two hunks:
planV2.ts: #5772's new remote-artifact loop now passeschunkAudioinstead ofchunksCarryAudio, to match this PR's newartifactTargetssignature. The remote paths are project assets from shared storage, not plan audio oraudio-chunks/.chunkAudiois still derived from the localfiles, where the slices live.scripts/package-size-budgets.json: takesmain's@hyperframes/producerbudget (768,210 packed / 2,807,938 unpacked).
Tests: bun test src/services/distributed/ passes 282 and fails 1. The failure is the same vitest-only plan.masterBus.test.ts (vi.hoisted) as before, unrelated to this PR. CI was 30 pass, 25 pending and 0 failing.
My earlier should-fix still applies: an old chunk worker given a new sliced plan finds no audio.m4a and leaves silent gaps.
— Somu
Merged with main after #5772
#5772 added a loop that sets download targets for files chunks read by URL; it still passed the old
chunksCarryAudioflag this PR replaced with thechunkAudiomode. The merge passeschunkAudiothere (planV2.ts); git flagged no conflict, the typecheck did. The size budget needs no change now: main's producer budget (768,210 B packed) covers this branch, which packs to 733,045 B.What changes for users
A distributed render whose mp4 chunks carry their own audio no longer makes every chunk download the whole mixed audio track. Each chunk downloads the track's index (its moov) and only the AAC packet bytes it encodes. On a 30-minute render that is about 0.5 MB per chunk instead of about 43 MB. The output is unchanged: every chunk carries the same audio packets as before, and the finished video's audio is the same.
How
audio-chunks/:frame.m4a: the audio file without its mdat payload (ftyp, moov and the mdat header). It goes to every chunk.layout.json: the file's size, where the payload sits, and where each chunk's packets start. It goes to every chunk.NNNN.bin: chunk N's packet bytes, cut with the sameaudioCutForChunkthe chunk uses. It goes to chunk N only.audio.m4astill ships in the plan, soHasAudiois unchanged, but no role downloads it.audio-chunks/(a v1 plan, or one written before this change) reads the whole track as before.Why not cut each slice into its own m4a
That was the first version of this PR. Decoded audio stayed identical, but on ffmpeg 6.0 (the Lambda's ffmpeg-static) and 7.0.2, re-reading a stream-copied slice changes its last packet's duration (the slice's own edit list trims it). The stitch sums sample durations, so a 30-minute render's audio track came out about 2.2 s short of the video. ffmpeg 8.x does not show it, which is why it passed CI. The sparse rebuild does not depend on how any ffmpeg version writes or reads edit lists.
Tests
chunkAudio.test.ts: for three layouts (three uneven chunks, one chunk, and a 60 fps one-frame tail that owns no packets), a chunk muxed from a sliced plan has the same packets per stream (timestamps, durations, sizes and payload sha256) as one muxed from the whole file. Durations are 91 frames at 30 fps and 51 frames at 60 fps, which are not whole 1024-sample frames. Passes with ffmpeg 6.0, 7.0.2 and 8.1.1. The first version of this PR fails it on 6.0.planV2.test.ts: a sliced plan sends each chunk its own packet file plus the shared frame and layout, and nothing getsaudio.m4a. A realplanV2WithPublisherrun (2.1 s tone, three chunks) writes and targets them.Measured
Local v2 distributed render on a Linux dev box (8 cores, shared, ffmpeg 7.0.2): a 30-minute composition (1800.5 s at 30 fps, 54,015 frames, so not a whole number of AAC frames), 226 chunks of 240 frames. Each run goes through the producer's own plan, per-chunk materialization,
renderChunkand assemble, with the same fixture, once onmainand once on this branch. Chunks read from local disk, so the download time does not show here; the bytes are what a Lambda chunk fetches from S3.pan=mono|c0=FLand stereo f32 hashes<audio>source (tone.m4a, 29.6 MB) that chunks never play: capture does not wait on<audio>and the plan mixes the audio. That is a separate change to how compiled assets are targeted.mainon this loaded box have differed the same way before. Not re-proven with a secondmainrun here.