Skip to content

perf(producer): assemble in one pass and keep its disk use under one output copy - #5768

Merged
miguel-heygen merged 2 commits into
mainfrom
cloudrender/assemble-one-pass
Oct 17, 2026
Merged

miguel-heygen merged 2 commits into
mainfrom
cloudrender/assemble-one-pass

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

What changed

The distributed assembler used to write the video three times on local disk: concat the chunks into a full-size file, mux the audio into a second one, then rewrite that again for faststart (a no-op rewrite, since the mux already wrote +faststart). While the last pass ran, disk held the chunks plus three full copies of the output. On Lambda's 10 GB /tmp that caps a 1080p render at roughly 35-40 minutes at typical bitrates.

Now the join, the audio mux and faststart run as one ffmpeg pass that reads the chunks through the concat demuxer, writes into the private work dir and renames into place. Disk peaks at the chunks plus one output copy.

  • assemble.ts: one pass for mp4/mov/webm, with and without audio. A single chunk is still read directly (not through a one-entry concat list), and every write still sets the output -r.
  • Audio pad/trim needs the video's frame count before the joined file exists. It now sums the chunks' frame counts at the plan's fps, which is what probing the joined file returned.
  • muxVideoWithAudio gains a videoInputArgs option so the mux can take a concat-demuxer input. It is the only engine change.
  • The opt-in cfr re-encode reads the chunks directly instead of a joined intermediate. Its output is the same.
  • Stage timers: assemble logs [assemble] stage timings (cfrMs, audioMs, joinMs, totalMs). The v2 Lambda assemble handler logs an assemble_stages event (planMs, chunksMs, assembleMs, uploadMs).

What I measured

Fixture: a 30-minute 1080p30 H.264 video cut into 16 chunks (0.90 GB) plus 30 minutes of AAC audio. Real assemble() from main and from this branch, alternated, page cache dropped before each run. Ran on a Linux x86 machine with the same ffmpeg 7.0.2 static build the Lambda image ships. A wrapper ffmpeg logged each pass's wall time and the disk total at its exit.

main this branch
Peak disk (chunks + audio = 0.87 GB) 3.47 GB 1.76 GB
Disk above the inputs 2.60 GB (about 3 output copies) 0.89 GB (one output copy)
Assemble, run 1 / run 2 33.0 s / 16.3 s 16.8 s / 13.2 s
ffmpeg passes concat 6.7-7.2 s, pad/trim copy, true-peak scan 5.2-7.7 s, mux 1.5-5.0 s, faststart 1.7-12.9 s pad/trim copy, true-peak scan 5.1-5.3 s, one join 7.5-10.6 s
  • Streams identical: video and audio packets match across all 54,000 video frames and the whole audio track, including pts, dts, duration, size and payload hash (ffmpeg -c copy -f framemd5 per stream).
  • What differs in the container: only moov/udta/meta, which is 160 bytes smaller. main's file also carried major_brand, minor_version and compatible_brands as mdta keys, copied from the joined intermediate. This branch writes only hf_renderer, hf_version and encoder. Atom layout is otherwise the same, and ffprobe format and stream fields match apart from size and bit rate.
  • Tests:
    • New: keeps local disk under the chunks plus one copy of the output. It points HYPERFRAMES_FFMPEG_PATH at a wrapper that records disk use after every ffmpeg exit. On main it fails: 284 KB above the inputs for an 88 KB output. With this change it uses 100 KB.
    • New: writes the same video and audio packets as separate concat, mux and faststart passes. It rebuilds the old path from the engine's own muxVideoWithAudio + applyFaststart + padOrTrimAudioToVideoFrameCount and compares per-packet framemd5. Counting one frame short in the chunk sum makes it fail on the audio packets.
    • assemble.test.ts: 18/18, three runs in a row. Also green: audioPadTrim (38 + 2 integration), planV2 (62), aws-lambda handler.test.ts (16), engine chunkEncoder.test.ts, and tsc for producer, engine and aws-lambda. oxlint clean.

What I did NOT exercise

  • No real Lambda run. The numbers above come from the dev machine, where the page cache can hold everything. On Lambda the saving should be larger, because it drops about two full-size writes and reads of the output, but I have not measured it there.
  • Chunks are not deleted mid-join. A single ffmpeg process owns the concat, so there is no safe moment to drop a chunk it has finished reading. Peak disk is chunks plus output.
  • cfr: true still writes the re-encode and then the muxed output (two copies). It is opt-in and off by default.
  • The v1 Lambda assemble handler has no stage timer; v2 is the default.
  • Windows: the disk-ceiling test skips there (it uses a shell wrapper).

…output copy

The distributed assembler joined the chunks into a full-size file, muxed the
audio into a second one and rewrote that again for faststart, so local disk
peaked near four copies of the video and a 30-minute 1080p render came close
to Lambda's 10 GB /tmp. The join, audio mux and faststart now run as one
ffmpeg pass over the concat demuxer; the audio pad/trim takes its frame count
from the chunks. Video and audio packets are unchanged. Assemble and the
Lambda assemble handler log per-stage timings.
… final write

Keeps assemble under the complexity gate and drops an orphaned doc comment in chunkEncoder. No behaviour change.
@github-actions

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 271 (base branch 271), smooth 187 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)

Unstable (2)

  • seqplainkeys-none-px-r0-root-z100: tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo false, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true / tracking 0.06, pressJump 0, drop 0, reload 0, render 0.03, renderKey -, undo true, teleport true
  • crop-tween-xpercent-r0-nested-z100: tracking 0.04, pressJump 0, drop 40.03, reload 40.03, render 39.99, renderKey -, undo true, teleport true / tracking 0.04, pressJump 0, drop 0.03, reload 0.03, render 0.03, renderKey -, undo true, teleport true / tracking 0.04, pressJump 0, drop 0.03, reload 0.03, render 0.03, renderKey -, undo true, teleport true

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

Approving at be87872b. The second commit splits assemble into readAssemblePlan, reencodeCfr and writeJoined with the same arguments in the same order (plan check, then the cfr and h265 guards, before any ffmpeg runs).

Same join, one pass. main already joined the chunks with -r fps -f concat -safe 0 -i list -c copy and '\'' quote escaping. concatDemuxerInput produces the same arguments, and a single chunk is still read directly. The mux pass sets the output -r, provenance tags and +faststart for mp4. The no-audio join does the same, and the old applyFaststart pass was just a copy for mov/webm, so dropping it changes nothing there. "Every write sets the output -r" holds on all three write paths: the cfr re-encode, the join and the mux. I also checked by hand with ffmpeg: 3-chunk VP9 yuva420p webm and ProRes 4444 mov chunks, in a directory whose name contains a space and a single quote. The old two-step path and this single pass produced identical per-stream framemd5 for video and audio. The webm kept ALPHA_MODE=1, and the frame rates stayed 30/1.

The frame-count sum equals the old probe. The old code probed the joined file, whose rate the input -r pins to the plan fps, and a -c copy concat neither drops nor duplicates packets. For example, 7 + 13 + 11 frames of webm at 30000/1001 join into 31 packets.

The rename can't cross filesystems. workDir is mkdtempSync(\${outputPath}.assemble-work-`), right beside the output, and the finallyremoves the temp file when ffmpeg fails. That's an improvement onmain, where faststart wrote straight to outputPath. The only other caller of muxVideoWithAudio, assembleStage.ts, doesn't pass videoInputArgs, so it keeps -i videoPath`.

Non-blocking

  • padOrTrimAudioToVideoFrameCount is given videoPath: videoSources[0] but probes every chunk. sanitizeProbeFailure only hides the paths it was given, so a corrupt chunk 3 shows its full path in the error.
  • The per-chunk probes run through an uncapped Promise.all. On webm, nb_frames is N/A, so each probe falls back to counting packets across the whole chunk. With a large maxParallelChunks, that's N full reads at once on a memory-limited Lambda. A small concurrency cap would avoid it.
  • The packet-identity test covers mp4 with audio only. There's no equivalent test for webm, mov or single chunk plus audio.

CI is green. No live run, and no Lambda run.

— Rames

@miguel-heygen
miguel-heygen added this pull request to the merge queue Oct 17, 2026
Merged via the queue into main with commit a498cf2 Oct 17, 2026
75 checks passed
@miguel-heygen
miguel-heygen deleted the cloudrender/assemble-one-pass branch October 17, 2026 09:04
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