All posts
FFmpegEngineering11 Sep 2026 · 3 min read

The compiler that matched the prototype

JPJean Perez

@beemmeup/ffmpeg-graph is a pure graph-to-command compiler: no engine license, no cloud dependency, a TypeScript function that takes a node graph and returns an FFmpeg command line. It shipped on 5 September, and by the 10th it was smarter than the hand-built design prototype it was meant to match. That turned out to be a bug.

The first version, compileStudio(), tried to emit commands the way a person would write them by hand: -vf/-af for a simple chain instead of always reaching for -filter_complex, and dropping -map when it didn't carry any information. Both are reasonable moves if you're optimizing for a command a tutorial would show you.

Dropping -map was the one that broke. FFmpeg's own default stream selection fills in whatever -map doesn't specify, so a video-only filter graph applied to a video-and-audio source quietly produced audio in the output anyway. The canvas said one thing and the file contained another. The graph never routed that audio stream, and the audio stream showed up regardless.

The leak and the two fixes

The first fix suppressed the leak directly: any media kind a source declared but the graph didn't route now got an explicit -vn, -an or -sn. Verification got stricter in the same change. Every output is now ffprobed and its stream kinds checked against what the graph routed. Exit status alone is what let the leak through.

That fix held for about four hours, then got reverted. BMU-104's decision was that where a ticket and the design prototype disagree, the prototype wins, and the prototype always emits -filter_complex and one explicit -map per connected output slot. The next change put that back: -vf/-af is gone, and with it the -vn/-an/-sn suppression that only existed to plug a leak that explicit mapping doesn't have in the first place. Explicit mapping cannot leak. The workaround for the bug and the bug's own cause were the same design choice, and removing the choice removed both.

Two more latent bugs came out of the same pass: filters whose catalog ports are a dynamic set with no count parameter, scale among them, were getting zero inputs, and the auto-inserted split node for a double-consumed stream skipped sources marked generated. Neither showed up until matching the prototype forced a full re-read of the emission path.

Before and after

# first version: no -map, leak suppressed by hand
ffmpeg -i in.mp4 -vf "scale=1280:720" -an -c:v libx264 out.mp4

# after matching the prototype: explicit -filter_complex and -map
ffmpeg -i in.mp4 -filter_complex "[0:v]scale=1280:720[bg]" \
  -map "[bg]" -c:v libx264 out.mp4

Labels changed too. The [v0]/[a0] counters became per-entry tags (scale emits [bg], overlay emits [ov]), which read better in Explain mode and match the prototype's own catalog.

Verification ran three ways: all 16 template and fix commands matched EXPECTED-COMMANDS.json character for character; the prototype's own JavaScript and the TypeScript port ran side by side in one process and produced 16 identical commands; and 20 real cases ran against ffmpeg 7.1.1, 22 outputs ffprobed, every stream kind matching what the graph routed.

Escaping twice

A value inside a filter argument gets unescaped twice inside FFmpeg: once as an option value, once as part of the filtergraph description. The shell adds its own quoting on top. The graph package's README records one deliberate deviation from the written spec because of this. escapeFilterArg needed a backslash in its escape-trigger set, or a value can't round-trip through both levels. A dedicated test file pins it.

What 43 entries actually took

The hand-authored catalog behind the prototype match carries 43 entries: teaching copy, wasm availability, pinned params, port ids. It was produced by executing the prototype's own JavaScript in a sandbox rather than transcribed by hand. The full generated catalog is bigger, 970 process spawns in 25 seconds at concurrency 16, 2.47 MB of JSON, against ffmpeg 6.1.1-3ubuntu5: 555 filters, 226 encoders, 189 muxers, 5,694 parameters, 98.2% with descriptions.

None of that JSON made it into the repo on the first attempt. The blanket .gitignore rule for *.json swallowed the catalog's own generated output, so the generate-in-CI, commit-the-JSON design failed silently until a !packages/ffmpeg-catalog/catalog/*.json line was added to un-ignore it. Found by running the install for real, not by reading the config.

The design spec credits ff-studio, an open-source desktop app, as prior art. It builds its catalog live with roughly 1,900 subprocess spawns and caches it by binary path, so replacing the binary in place keeps serving the old catalog. BeemMeUp's generates once in CI and commits the result, which means the next thing to watch is the FFmpeg version that CI runs.