<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Video on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/video/</link><description>Recent content in Video on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Thu, 20 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/video/index.xml" rel="self" type="application/rss+xml"/><item><title>One input, many outputs</title><link>https://phpboyscout.uk/one-input-many-outputs/</link><pubDate>Thu, 20 Aug 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/one-input-many-outputs/</guid><description>&lt;img src="https://phpboyscout.uk/one-input-many-outputs/cover-one-input-many-outputs.png" alt="Featured image of post One input, many outputs" /&gt;&lt;p&gt;When I &lt;a class="link" href="https://phpboyscout.uk/introducing-afmpeg-and-ffmpeg-wasi/" &gt;introduced afmpeg and ffmpeg-wasi&lt;/a&gt;, the post carried a deliberate caveat: &amp;ldquo;single input to single output and &lt;code&gt;Probe&lt;/code&gt; work now; the full multi-pad &lt;code&gt;filter_complex&lt;/code&gt; and multi-output muxing are the next thing on the bench.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id="why-one-output-was-never-going-to-be-enough"&gt;Why one output was never going to be enough
&lt;/h2&gt;&lt;p&gt;The whole reason ffmpeg-wasi links the &lt;code&gt;libav*&lt;/code&gt; 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 &lt;em&gt;and&lt;/em&gt; a lightweight preview; an audio workflow wants the loud mix alongside the quiet one. With the CLI you&amp;rsquo;d shell out twice and decode the input twice over&amp;hellip; but with the graph in your own hands, one decode should be able to feed the lot.&lt;/p&gt;
&lt;p&gt;FFmpeg&amp;rsquo;s filter-graph machinery has supported this forever: &lt;code&gt;split&lt;/code&gt; (video) and &lt;code&gt;asplit&lt;/code&gt; (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.&lt;/p&gt;
&lt;h2 id="two-commits-two-halves-of-the-problem"&gt;Two commits, two halves of the problem
&lt;/h2&gt;&lt;p&gt;It landed in two distinct steps, and the split between them tells you where the real work was.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/ffmpeg-wasi/-/commit/faa5f3c92d66914e0a5daf65233dc42e0ec71a74" target="_blank" rel="noopener"
 &gt;The first&lt;/a&gt; rebuilt the processing core around the real filter-graph parser: N inputs feed one graph via &lt;code&gt;avfilter_graph_parse2&lt;/code&gt;, and every labelled output pad gets its own encoder. That&amp;rsquo;s the &amp;ldquo;multi-pad&amp;rdquo; half, and validating it flushed out genuine bugs (an undeclared function that trapped the wasm outright, and a pts discontinuity that broke &lt;code&gt;xfade&lt;/code&gt;). But every encoded pad still funnelled into a single output file. Half the promise.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/ffmpeg-wasi/-/blob/267918e/src/process.c#L97-106" target="_blank" rel="noopener"
 &gt;The second&lt;/a&gt; is where the fan-out happens: one &lt;code&gt;AVFormatContext&lt;/code&gt;, FFmpeg&amp;rsquo;s per-file muxing context, for &lt;em&gt;each&lt;/em&gt; output, and a router that decides which file each graph pad belongs to:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-c" data-lang="c"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;find_output_for_pad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Ctx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;char&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;n_out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="n"&gt;cJSON&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nf"&gt;cJSON_ArrayForEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;cJSON_IsString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;label_matches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;valuestring&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;n_out&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;cJSON_GetArraySize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;map&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Each output in the job declares a &lt;code&gt;map&lt;/code&gt;, 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 &lt;code&gt;map&lt;/code&gt; takes everything, so every existing single-output job carries on untouched. From there, each encoder drains into &lt;em&gt;its&lt;/em&gt; muxer rather than &lt;em&gt;the&lt;/em&gt; muxer:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-c" data-lang="c"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;drain_encoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Ctx&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GOut&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;go&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AVFrame&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;AVFormatContext&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ofmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;go&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;out_idx&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;ofmt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Headers are written per output, trailers are written per output, and &lt;code&gt;split&lt;/code&gt;/&lt;code&gt;asplit&lt;/code&gt; joined the enabled filter set in the same commit. Shipped as &lt;a class="link" href="https://gitlab.com/phpboyscout/ffmpeg-wasi/-/releases/n8.1.2-5" target="_blank" rel="noopener"
 &gt;n8.1.2-5&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="one-decode-two-files-one-pass"&gt;One decode, two files, one pass
&lt;/h2&gt;&lt;p&gt;The proof lives in afmpeg&amp;rsquo;s integration suite as &lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/blob/264355b/pkg/afmpeg/integration_test.go#L277-326" target="_blank" rel="noopener"
 &gt;&lt;code&gt;TestIntegration_RunJob_MultiOutput&lt;/code&gt;&lt;/a&gt;. One WAV goes in. The graph is:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[0:a]asplit=2[a1][a2];[a1]volume=0.9[loud];[a2]volume=0.1[quiet]
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;asplit&lt;/code&gt; doubles the decoded audio, one copy gets turned up, one turned down, and two mp4 files come out of a single &lt;code&gt;RunJob&lt;/code&gt;, each output&amp;rsquo;s &lt;code&gt;map&lt;/code&gt; claiming its pad. The input was decoded exactly once. The test cracks both files open and checks they&amp;rsquo;re real mp4s, not empty files that merely exist.&lt;/p&gt;
&lt;p&gt;Worth being precise about the shape of this: the fan-out is &lt;em&gt;explicit&lt;/em&gt;. Every output names the pads it wants, and a pad nobody claims is an error rather than a guess. I&amp;rsquo;ve spent enough time debugging tools that helpfully infer things (the ffmpeg CLI&amp;rsquo;s own stream-mapping heuristics among them) to want the boring, spelled-out version in an engine that runs unattended.&lt;/p&gt;
&lt;h2 id="the-bench-is-clear"&gt;The bench is clear
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;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&amp;hellip; cleared almost before the ink dried on the caveat. As a way of doing release notes, &amp;ldquo;under-promise, then ship anyway&amp;rdquo; beats the usual arrangement.&lt;/p&gt;
&lt;p&gt;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&amp;rsquo;s job-spec grew the one word (&lt;code&gt;map&lt;/code&gt;) it needed to say so.&lt;/p&gt;</description></item></channel></rss>