The first render of my podcast's long-form edit ran for about three minutes and left 626,524,208 bytes on disk. ffprobe would not open the file. It said "moov atom not found". The Python wrapper that had launched ffmpeg printed an empty stderr.

The episode was recorded on 13 September, about 34 minutes of two people talking football. The edit put 38 graphics over it, name straps, stat panels and context notes, plus five 10 per cent punch-ins on the lines that land. Twelve licence-clean photos were added later. Each one's start time and duration sat in one Python spec.

The obvious build

The obvious way to build that is one ffmpeg command. Each graphic is a PNG added as its own input with -loop 1 -t <duration>, and a chain of overlay filters stacks them on the base video at their times. One graph, one encode. The filter saved from that first attempt has 39 overlay steps, one per graphic and one for the punch-in layer.

A looped PNG input is a stream of full frames for as long as -t says. At 1920 by 1080 with an alpha channel, one frame is 8.3 MB. The longest panel in the edit, a match result card, stays on screen for 40 seconds. At 30 frames a second that is 1,200 frames and about 10 GB for one card. The 38 stills add up to 376.5 seconds of screen time.

My working explanation was that ffmpeg produced those frames as fast as it could read them, whether or not the overlay chain was ready for them, and they queued in memory while the main video was still near the start. I did not confirm that in ffmpeg's source, and the only measurements I have come from the next attempt.

The change that did nothing

The first change put -itsoffset on each graphic input, so each still would begin at its own time. ffmpeg's documentation says the offset is added to the input's timestamps, and a 2013 guide to fixing audio sync calls it a nudge to a stream's start time. Neither says it stops ffmpeg reading an input early, and here it did not. Two seconds into the rerun the ffmpeg process held 2,665 MB. At four seconds it held 4,551 MB, and the render was stopped by hand.

What went in

Both of the changes that worked are still in the build script. The first reads each still as one frame and repeats it inside the graph. The loop filter takes a loop count, a size in frames and a start frame, and John Riselvato's 2020 walkthrough describes size as the number of frames that get repeated. A five-second graphic becomes loop=loop=149:size=1:start=0, and setpts moves it to its place on the timeline. The input is one decoded image instead of 150.

The second cuts the timeline up. The script merges overlay windows that overlap into groups, cuts the body at the edges of each group, splits any plain stretch into pieces of 90 seconds or less, and renders each piece with only the graphics that fall inside it. The body came out as 85 chunks of ProRes LT, from a tenth of a second to 90 seconds long, and no chunk carried more than five graphics. The concat demuxer joins them, and the audio comes from the uncut edit rather than from the chunks, which carry picture only.

With both in place the process memory held steady, and the body rendered through to the end.

Checking the joins

With 85 joins against an audio track that was never cut, picture and sound could slip apart by a frame without a quick watch showing it. The check grabs the same moment from the source edit and from the finished file, shrinks both to 192 by 108 in greyscale, correlates them across three frames either side, and reports where the peak falls. At 10:00 in the source edit it fell at zero offset with a score of 0.9998. A sample at 33:10 scored below zero at every offset, because a full-screen card covers the picture there, so the check moved to 23:20 and 33:35. Those scored 0.9999 and 0.9998, both at zero.

What the chunks were for

An environment variable names the chunks to rebuild, and the rest are reused from disk. When a name strap's wording changed, one chunk was rendered again.

Later on 17 September my co-host asked for two passages to come out of the finished episode. The cuts removed 11.85 and 8.98 seconds, and the recut runs 34:12 against 34:33 before it. Every graphic after the first cut had to move, so all 85 chunks rendered again. The spec kept its times in the original timeline, and a two-line function subtracts whatever was removed before each one.

The cold open needed the same treatment, in a place the first pass missed. Its clips are found by searching the transcript for a phrase after a given time, and those times were still in the old timeline. The search failed with "not found" until they went through the same function.