Featured image of post One input, many outputs

One input, many outputs

afmpeg shipped single input to single output. Adding multi-pad filter graphs and multi-output muxing: one decode, two files, one pass.

When I introduced afmpeg and ffmpeg-wasi, the post carried a deliberate caveat: “single input to single output and Probe work now; the full multi-pad filter_complex and multi-output muxing are the next thing on the bench.”

That was the true state of the engine on announcement day. So this is the follow-up: the thing on the bench is finished now, and how it works turned out to be the tidiest part of the whole project.

Why one output was never going to be enough

The whole reason ffmpeg-wasi links the libav* libraries directly instead of wrapping the ffmpeg CLI is control over exactly this sort of thing. A media pipeline in the real world rarely wants one file out. My reel pipeline wants the full-quality master and a lightweight preview; an audio workflow wants the loud mix alongside the quiet one. With the CLI you’d shell out twice and decode the input twice over… but with the graph in your own hands, one decode should be able to feed the lot.

FFmpeg’s filter-graph machinery has supported this forever: split (video) and asplit (audio) are filters whose entire job is duplicating one stream into several identical ones, so different processing chains can each take a copy. The engine just had to grow up enough to use them.

Two commits, two halves of the problem

It landed in two distinct steps, and the split between them tells you where the real work was.

The first rebuilt the processing core around the real filter-graph parser: N inputs feed one graph via avfilter_graph_parse2, and every labelled output pad gets its own encoder. That’s the “multi-pad” half, and validating it flushed out genuine bugs (an undeclared function that trapped the wasm outright, and a pts discontinuity that broke xfade). But every encoded pad still funnelled into a single output file. Half the promise.

The second is where the fan-out happens: one AVFormatContext, FFmpeg’s per-file muxing context, for each output, and a router that decides which file each graph pad belongs to:

static int find_output_for_pad(Ctx *c, const char *label) {
    for (int i = 0; i < c->n_out; i++) {
        const cJSON *m = NULL;
        cJSON_ArrayForEach(m, c->out[i].map) {
            if (cJSON_IsString(m) && label_matches(m->valuestring, label)) return i;
        }
    }
    if (c->n_out == 1 && (!c->out[0].map || cJSON_GetArraySize(c->out[0].map) == 0)) return 0;
    return -1;
}

Each output in the job declares a map, the list of pad labels it wants, and every pad is matched to its home. The fallback line keeps the old behaviour intact: a single output with no map takes everything, so every existing single-output job carries on untouched. From there, each encoder drains into its muxer rather than the muxer:

static int drain_encoder(Ctx *c, GOut *go, AVFrame *frame) {
    AVFormatContext *ofmt = c->out[go->out_idx].ofmt;

Headers are written per output, trailers are written per output, and split/asplit joined the enabled filter set in the same commit. Shipped as n8.1.2-5.

One decode, two files, one pass

The proof lives in afmpeg’s integration suite as TestIntegration_RunJob_MultiOutput. One WAV goes in. The graph is:

[0:a]asplit=2[a1][a2];[a1]volume=0.9[loud];[a2]volume=0.1[quiet]

asplit doubles the decoded audio, one copy gets turned up, one turned down, and two mp4 files come out of a single RunJob, each output’s map claiming its pad. The input was decoded exactly once. The test cracks both files open and checks they’re real mp4s, not empty files that merely exist.

Worth being precise about the shape of this: the fan-out is explicit. Every output names the pads it wants, and a pad nobody claims is an error rather than a guess. I’ve spent enough time debugging tools that helpfully infer things (the ffmpeg CLI’s own stream-mapping heuristics among them) to want the boring, spelled-out version in an engine that runs unattended.

The bench is clear

There’s a certain neatness in closing a gap your own announcement admitted to. Funnier still, the first half had already landed the morning the announcement went out, and the second followed two days later… cleared almost before the ink dried on the caveat. As a way of doing release notes, “under-promise, then ship anyway” beats the usual arrangement.

One decode in, as many outputs as the job asks for. The reel pipeline gets its master and its preview for the price of a single pass, and the engine’s job-spec grew the one word (map) it needed to say so.

Built with Hugo · Theme Stack designed by Jimmy