<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>PHP Boy Scout — Media in pure Go</title><link>https://phpboyscout.uk/topics/media-in-pure-go/</link><description>Current FFmpeg driven from a Go program with no CGO and nothing to install: compiled to WebAssembly, run on a virtual filesystem, and sandboxed against more than code execution.</description><generator>Hugo</generator><language>en-GB</language><copyright>Matt Cockayne</copyright><lastBuildDate>Sat, 26 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/topics/media-in-pure-go/index.xml" rel="self" type="application/rss+xml"/><item><title>Introducing afmpeg and ffmpeg-wasi: FFmpeg with no install, no CGO, no disk</title><link>https://phpboyscout.uk/introducing-afmpeg-and-ffmpeg-wasi/</link><pubDate>Sun, 28 Jun 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/introducing-afmpeg-and-ffmpeg-wasi/</guid><category>afmpeg</category><category>First Light</category><description>FFmpeg in pure Go with no install, no CGO and no disk: a WebAssembly build running over an in-memory filesystem, and why it exists.</description><content:encoded>&lt;p&gt;&lt;a class="link" href="https://keryx.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;keryx&lt;/a&gt; renders short promo reels, and the way it does that, today, is the way nearly everything does: it shells out to the &lt;code&gt;ffmpeg&lt;/code&gt; binary. Which is fine, until you ask it to render a project that doesn&amp;rsquo;t exist on disk. keryx can work on an in-memory project, a repo cloned straight into RAM with no local checkout, and the moment it tries to hand that to &lt;code&gt;ffmpeg&lt;/code&gt;, the whole thing falls over. The binary wants real files in a real directory. There aren&amp;rsquo;t any.&lt;/p&gt;&#10;&lt;p&gt;I went looking for a way out and didn&amp;rsquo;t find one I could live with. The bindings that use &lt;code&gt;purego&lt;/code&gt;/&lt;code&gt;dlopen&lt;/code&gt; are immature and still need the host&amp;rsquo;s &lt;code&gt;libav&lt;/code&gt; libraries installed. The CGO bindings to &lt;code&gt;libav&lt;/code&gt; are mature and can absolutely work in memory, but they&amp;rsquo;re CGO, and CGO takes away the thing I most want from a Go program: a clean static cross-compile to a single binary that runs anywhere. The famous &lt;code&gt;ffmpeg.wasm&lt;/code&gt; is an &lt;em&gt;emscripten&lt;/em&gt; build aimed at the browser, which is the opposite target from a server-side Go tool. And the one existing WASI-capable build pins FFmpeg 5.1, which is end-of-life, and I&amp;rsquo;m not shipping an out-of-support media decoder whose entire job is parsing untrusted files. Every road had a tollbooth I wasn&amp;rsquo;t willing to pay.&lt;/p&gt;&#10;&lt;p&gt;So I built two new things instead, and because they&amp;rsquo;re so closely tied I&amp;rsquo;m introducing them together.&lt;/p&gt;&#10;&lt;h2 id="ffmpeg-wasi-current-ffmpeg-sandboxed-cgo-free"&gt;ffmpeg-wasi: current FFmpeg, sandboxed, CGO-free&#10;&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://ffmpeg-wasi.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;ffmpeg-wasi&lt;/a&gt; (&lt;a class="link" href="https://gitlab.com/phpboyscout/ffmpeg-wasi" target="_blank" rel="noopener"&#10; &gt;repo&lt;/a&gt;) is the foundation, and it&amp;rsquo;s the harder of the two to build. It takes FFmpeg&amp;rsquo;s media libraries, the &lt;code&gt;libav*&lt;/code&gt; family, and builds them to &lt;code&gt;wasm32-wasi&lt;/code&gt;, then drives them with a small purpose-built engine, producing a single &lt;code&gt;.wasm&lt;/code&gt; artifact that runs anywhere a WASI runtime does. No native FFmpeg install, no C toolchain at deploy time, no shelling out to a binary. It&amp;rsquo;s built to run under &lt;a class="link" href="https://wazero.io/" target="_blank" rel="noopener"&#10; &gt;wazero&lt;/a&gt;, the zero-dependency pure-Go WebAssembly runtime, so a Go program can transcode, filter and mux media embedded, sandboxed, and CGO-free, still cross-compiling to one static binary.&lt;/p&gt;&#10;&lt;p&gt;The interesting bit, the reason this didn&amp;rsquo;t already exist, is a wall that FFmpeg 7.0 put up. The 7.x series rewrote the &lt;em&gt;command-line tool&lt;/em&gt; to be mandatorily multithreaded, and a pure-Go WASI runtime can&amp;rsquo;t run that, because the threading model it needs (&lt;code&gt;wasi-threads&lt;/code&gt;, spawning real threads) isn&amp;rsquo;t something wazero does. Every project trying to get &lt;em&gt;current&lt;/em&gt; FFmpeg into WASI hits that wall, which is exactly why the existing build froze at the last single-threaded CLI, 5.1, and went EOL there. ffmpeg-wasi goes under the wall instead of over it: it doesn&amp;rsquo;t compile the CLI at all. It links the &lt;code&gt;libav*&lt;/code&gt; &lt;em&gt;libraries&lt;/em&gt; directly, which build single-threaded without complaint, and drives them with its own engine. That&amp;rsquo;s the move nobody else has made, and it&amp;rsquo;s the whole reason this can track current, maintained FFmpeg rather than a frozen one.&lt;/p&gt;&#10;&lt;h2 id="afmpeg-the-pure-go-binding-that-lives-in-memory"&gt;afmpeg: the pure-Go binding that lives in memory&#10;&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://afmpeg.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;afmpeg&lt;/a&gt; (&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg" target="_blank" rel="noopener"&#10; &gt;repo&lt;/a&gt;) was the catalyst for all this, and it&amp;rsquo;s the part a Go developer actually touches. It&amp;rsquo;s a small, idiomatic Go API (&lt;code&gt;New&lt;/code&gt;, &lt;code&gt;Run&lt;/code&gt;, &lt;code&gt;Probe&lt;/code&gt;, &lt;code&gt;Close&lt;/code&gt;) sitting on top of the ffmpeg-wasi artifact, with one important twist: its I/O is bridged to an &lt;a class="link" href="https://github.com/spf13/afero" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;afero.Fs&lt;/code&gt;&lt;/a&gt;. afero isn&amp;rsquo;t in the standard library, but it&amp;rsquo;s the filesystem abstraction a great deal of Go already reaches for when it wants to swap a real disk for something else, and that &amp;ldquo;something else&amp;rdquo; is exactly the point here. The inputs and outputs of a media job can live entirely in memory, or in any afero backend you like, and &lt;code&gt;ffmpeg&lt;/code&gt; is none the wiser. keryx gets to render its in-memory project without ever touching the disk, which was the whole reason I started.&lt;/p&gt;&#10;&lt;p&gt;This isn&amp;rsquo;t a roadmap post. Both projects are released and run today: afmpeg is at &lt;code&gt;v0.4.0&lt;/code&gt;, ffmpeg-wasi at &lt;code&gt;n8.1.2-1&lt;/code&gt; (current FFmpeg, not the EOL 5.1), and between them they do real in-memory transcodes, verified end to end, WAV to AAC and H.264 rescaled and re-encoded with x264. Stripped of keryx&amp;rsquo;s reel-specific filtergraph, the shape a caller actually deals with is about this small:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Compile the ffmpeg-wasi module once, then reuse the runtime.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;afmpeg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;afmpeg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithModuleFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;ffmpeg-wasi-lgpl.wasm&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;&#10;&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="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt;&#10;&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 class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;defer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// The whole job lives in memory: no temp dir, nothing on disk.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;afero&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewMemMapFs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;afero&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;in.wav&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="nx"&gt;o644&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Drive it with the ffmpeg arguments you already know...&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;-i&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;in.wav&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;-c:a&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;aac&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;out.m4a&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&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="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ExitCode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;#34;render failed: %w (%s)&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&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 class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// ...then read the finished file straight back out of memory.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;afero&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;out.m4a&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The &lt;code&gt;.wasm&lt;/code&gt; itself is a published release artifact you pin by SHA-256 (or let afmpeg fetch and verify for you with &lt;code&gt;WithModuleURL&lt;/code&gt;), so the licence boundary stays explicit and nothing surprising ends up in your binary.&lt;/p&gt;&#10;&lt;p&gt;It doesn&amp;rsquo;t do everything yet, and I&amp;rsquo;d rather say so than let you find out the hard way. 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. But the part that nobody had cracked, getting current FFmpeg to run sandboxed, pure-Go, over a virtual filesystem with nothing on disk, is done, and you can pull it today.&lt;/p&gt;&#10;&lt;h2 id="why-its-two-repos"&gt;Why it&amp;rsquo;s two repos&#10;&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a reason these are separate projects rather than one, and it&amp;rsquo;s about licences, not tidiness. The moment you compile FFmpeg you&amp;rsquo;re handling LGPL and GPL code, and I wanted that boundary to be obvious rather than smeared across one repo where nobody&amp;rsquo;s quite sure what&amp;rsquo;s covered by what. So the build tooling and the &lt;code&gt;(L)GPL&lt;/code&gt; &lt;code&gt;.wasm&lt;/code&gt; artifacts live in ffmpeg-wasi, with no grey areas, and afmpeg stays a clean permissive layer on top of the published artifact. I&amp;rsquo;m also shipping both LGPL and GPL builds of the artifact, so anyone who just wants the output and doesn&amp;rsquo;t fancy doing their own FFmpeg build can pick the licence that suits them and get on with it.&lt;/p&gt;&#10;&lt;p&gt;Both repos are public, so you can rebuild or relink either one yourself, and the doc sites are now linked in the nav up top.&lt;/p&gt;&#10;&lt;p&gt;There&amp;rsquo;s a stack of stories behind these two already, the threading wall and the spike that found the way under it, the afero-to-WASI filesystem bridge, the day I built a reel-shaped API and then reverted it before merge for being too narrow, the licence decision in full. I&amp;rsquo;ll be writing those up as the work lands. For now this is the headline: I needed an FFmpeg I could embed in a Go program with no install, no CGO, and no disk, couldn&amp;rsquo;t buy one off the shelf, so I built it in the open, and as of today it&amp;rsquo;s there to pull.&lt;/p&gt;</content:encoded></item><item><title>The escape hatch that turned out to be french doors</title><link>https://phpboyscout.uk/the-escape-hatch-that-turned-out-to-be-french-doors/</link><pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-escape-hatch-that-turned-out-to-be-french-doors/</guid><category>afmpeg</category><category>Pioneering</category><description>I wanted an FFmpeg that couldn't touch my disk. Building the interface to get one accidentally produced something considerably more useful.</description><content:encoded>&lt;p&gt;This started because &lt;a class="link" href="https://keryx.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;keryx&lt;/a&gt; needed to make videos, and I didn&amp;rsquo;t want to give it a disk.&lt;/p&gt;&#10;&lt;p&gt;That is honestly the whole of it. keryx builds reels, reels need FFmpeg, and I wanted an FFmpeg I could point at a virtual filesystem instead of the real one, something that reads and writes through an &lt;a class="link" href="https://github.com/spf13/afero" target="_blank" rel="noopener"&#10; &gt;afero&lt;/a&gt; filesystem I handed it and never learns that a hard drive exists. A small ask, I thought, late one night at my desk&amp;hellip; with a rather large problem hiding behind it.&lt;/p&gt;&#10;&lt;h2 id="ffmpeg-is-a-cli-and-thats-the-whole-trouble"&gt;FFmpeg is a CLI, and that&amp;rsquo;s the whole trouble&#10;&lt;/h2&gt;&lt;p&gt;FFmpeg is magnificent and it is a command-line tool. So when an engineer needs it, they shell out to it, and two things follow from that (neither of them anyone&amp;rsquo;s fault).&lt;/p&gt;&#10;&lt;p&gt;The first is that you inherit FFmpeg&amp;rsquo;s arguments, which are a language of their own and not a friendly one. Most of us who have used it in anger have a shell script somewhere with a line in it we no longer understand and daren&amp;rsquo;t touch (I have several).&lt;/p&gt;&#10;&lt;p&gt;The second is the one I actually cared about. Shelling out means handing a process the run of the machine. It opens paths, seeks around, writes where it likes. If you&amp;rsquo;re processing something a stranger uploaded, that&amp;rsquo;s a decoder with your permissions and your filesystem, and &amp;ldquo;it&amp;rsquo;s fine, it&amp;rsquo;s just FFmpeg&amp;rdquo; is doing a lot of work in that sentence.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;ve prided myself on being a security-first engineer for a long time, and that arrangement has always sat badly with me. What I also knew, and it&amp;rsquo;s the only reason I thought this was possible at all, is that you don&amp;rsquo;t &lt;em&gt;have&lt;/em&gt; to talk to FFmpeg through a CLI.&lt;/p&gt;&#10;&lt;p&gt;Fifteen-odd years ago, as a PHP engineer, I used an extension that drove FFmpeg directly, with no shell and no arguments, just calls. So the shape existed already. Just not in Go.&lt;/p&gt;&#10;&lt;h2 id="deliberately-ignorant-of-the-hardware"&gt;Deliberately ignorant of the hardware&#10;&lt;/h2&gt;&lt;p&gt;The answer was to compile FFmpeg to WebAssembly and run it as a guest, reading and writing through the afero filesystem I&amp;rsquo;d handed it. That became &lt;a class="link" href="https://phpboyscout.uk/introducing-afmpeg-and-ffmpeg-wasi/" &gt;ffmpeg-wasi and afmpeg&lt;/a&gt;, and the filesystem trickery has &lt;a class="link" href="https://phpboyscout.uk/ffmpeg-thinks-it-has-a-disk/" &gt;its own post&lt;/a&gt;.&lt;/p&gt;&#10;&lt;p&gt;The important property is one it&amp;rsquo;s easy to mistake for a limitation: &lt;a class="link" href="https://afmpeg.phpboyscout.uk" target="_blank" rel="noopener"&#10; &gt;afmpeg&lt;/a&gt; has no idea what hardware it&amp;rsquo;s on. It doesn&amp;rsquo;t ask and it doesn&amp;rsquo;t care. Hand the same binary to a laptop, a CI runner or a container with nothing in it, and you get the same answer, because it never assumed a thing about the machine.&lt;/p&gt;&#10;&lt;p&gt;Not &lt;em&gt;needing&lt;/em&gt; hardware is what makes it portable. That&amp;rsquo;s the design, and it stays the design. Where it got awkward was the day I wanted the hardware myself.&lt;/p&gt;&#10;&lt;h2 id="i-was-hedging-even-then"&gt;I was hedging even then&#10;&lt;/h2&gt;&lt;p&gt;When the hardware-acceleration question first came up, I wrote the premise into a research prompt so it could be checked properly: Wasm implementations, in their current form, can&amp;rsquo;t reach hardware. And then I tacked &amp;ldquo;please correct me if I&amp;rsquo;m mistaken&amp;rdquo; onto the end of it.&lt;/p&gt;&#10;&lt;p&gt;Just as well. The tidy version of that claim is wrong. WebAssembly in a &lt;em&gt;browser&lt;/em&gt; reaches the GPU perfectly well. That&amp;rsquo;s what WebGPU and WebGL are: host APIs the browser hands the module. The sandbox doesn&amp;rsquo;t grant that access, the host does, and that&amp;rsquo;s what a sandbox is for.&lt;/p&gt;&#10;&lt;p&gt;Outside a browser, same rule. The runtime has to offer it, no WASI standard obliges anybody to, and a runtime that wanted to would be binding to native graphics libraries to get there. afmpeg runs on &lt;a class="link" href="https://wazero.io/" target="_blank" rel="noopener"&#10; &gt;wazero&lt;/a&gt;, which is zero-dependency and pure Go, and there&amp;rsquo;s a line in the justfile building the whole library with &lt;code&gt;CGO_ENABLED=0&lt;/code&gt; on every run so that promise can&amp;rsquo;t rot.&lt;/p&gt;&#10;&lt;p&gt;So the position was consistent. It had also, without me ever deciding it should, become a corner. There&amp;rsquo;s a difference between &lt;em&gt;not requiring&lt;/em&gt; hardware and &lt;em&gt;not permitting&lt;/em&gt; it, and afmpeg had the first only by having the second. So the plan was a small escape hatch: narrow, heavily guarded, and apologised for in the documentation.&lt;/p&gt;&#10;&lt;h2 id="then-i-asked-a-much-better-question"&gt;Then I asked a much better question&#10;&lt;/h2&gt;&lt;p&gt;The thing that changed it was noticing what I&amp;rsquo;d already built. afmpeg doesn&amp;rsquo;t consume anyone else&amp;rsquo;s FFmpeg. &lt;code&gt;ffmpeg-wasi&lt;/code&gt; compiles its own, with its own driver interface, because making FFmpeg work under WASI meant building that interface anyway. It&amp;rsquo;s mine, and I ship it.&lt;/p&gt;&#10;&lt;p&gt;So could I layer an alternative interface onto the same thing, and distribute that variant too? The answer took an embarrassingly long time to land (weeks, if I&amp;rsquo;m honest, for something that now looks obvious). The interface built to make FFmpeg work in a sandbox is not sandbox-shaped at all. It&amp;rsquo;s just a clean way to drive FFmpeg without a command line, and if you point it at a native build instead of a Wasm one it works the same, minus the guest.&lt;/p&gt;&#10;&lt;p&gt;That is how spec 0028 stopped being about an escape hatch and started being about a second engine. The native driver runs as a separate process and is served the caller&amp;rsquo;s &lt;code&gt;afero.Fs&lt;/code&gt; over a Unix socket, so the filesystem promise holds on both sides:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;afero&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Fs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;afmpeg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;You still hand it a filesystem. It still can&amp;rsquo;t go looking for one. Swapping engines is one option, &lt;code&gt;WithBackend&lt;/code&gt;, and the job API doesn&amp;rsquo;t change at all: same calls, byte-compatible results.&lt;/p&gt;&#10;&lt;p&gt;And it is significantly faster. I don&amp;rsquo;t mean marginally, or within noise. I mean the reel job stopped being a thing I started and then went to put the kettle on for, and became a thing that had finished before I&amp;rsquo;d stood up!&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;m deliberately not giving you a multiplier here, and there was one in an earlier draft of this. I&amp;rsquo;ve taken it out. The figures are being re-measured properly, on a quiet machine and against a couple of defects that were found in the meantime, and I suspect a single headline number is a poor way to describe what&amp;rsquo;s actually going on anyway. There&amp;rsquo;ll be a post with the full breakdown once the measurements are worth trusting.&lt;/p&gt;&#10;&lt;p&gt;For now: significantly faster, on the jobs keryx actually runs, which is where this all started. And because the native driver is a native binary, with real asm and real threads, the hardware is reachable from that side in a way it never was from the guest. I haven&amp;rsquo;t wired it up or put it through its paces yet, but it&amp;rsquo;s &lt;em&gt;there&lt;/em&gt;, which is a good deal more than the corner was offering.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;d intended a hatch.&lt;/p&gt;&#10;&lt;p&gt;What I&amp;rsquo;d actually built was a set of french doors&amp;hellip; and I could throw them wide open!&lt;/p&gt;&#10;&lt;h2 id="the-sentence-i-made-myself-write-down"&gt;The sentence I made myself write down&#10;&lt;/h2&gt;&lt;p&gt;Running FFmpeg as a native subprocess is an objective security regression compared to WASM. I can&amp;rsquo;t spin that as a trade-off, or as &amp;ldquo;different security properties&amp;rdquo;. It&amp;rsquo;s worse.&lt;/p&gt;&#10;&lt;p&gt;The process boundary is real and the socket is a real constraint, but a native binary executing on your host with your permissions is not the same animal as a Wasm module that physically cannot make a syscall you didn&amp;rsquo;t hand it. That went into the documentation in those words, so anybody switching engines does it knowing what they&amp;rsquo;ve put down. A door you don&amp;rsquo;t have to answer for is a hole with a nicer name.&lt;/p&gt;&#10;&lt;h2 id="still-not-the-bottom-rung"&gt;Still not the bottom rung&#10;&lt;/h2&gt;&lt;p&gt;Having said all that, and meaning every word of it, there&amp;rsquo;s a comparison worth making. The alternative most people reach for isn&amp;rsquo;t WASM. It&amp;rsquo;s the shell.&lt;/p&gt;&#10;&lt;p&gt;Set the native driver against &lt;code&gt;exec.Command(&amp;quot;ffmpeg&amp;quot;, ...)&lt;/code&gt;, which is what the overwhelming majority of media pipelines actually do, and it comes out ahead on the thing that matters. Shelling out hands a process the run of the machine and a set of paths you typed. The native driver gets a socket, and on the other end of that socket is the afero filesystem afmpeg gave it: a filesystem you built, holding what you decided to put in it, with no directory to climb out of.&lt;/p&gt;&#10;&lt;p&gt;So the ladder runs like this. Shelling out at the bottom, with your whole disk in scope. The native driver in the middle, sandboxed at the filesystem even though the binary is native. WASM at the top, where the guest can&amp;rsquo;t reach anything at all without being handed it.&lt;/p&gt;&#10;&lt;p&gt;The middle rung is a step down from the top, and a long way up from where most of this work gets done. The default stays ignorant (runs anywhere, assumes nothing), and the hardware is a thing you open on purpose, and even then you don&amp;rsquo;t get the keys to the house.&lt;/p&gt;&#10;&lt;h2 id="anyone-can-take-it"&gt;Anyone can take it&#10;&lt;/h2&gt;&lt;p&gt;&lt;code&gt;ffmpeg-wasi&lt;/code&gt; is a standalone project. It isn&amp;rsquo;t a folder inside afmpeg and it doesn&amp;rsquo;t need afmpeg to be useful. It&amp;rsquo;s a custom FFmpeg build with a proper driver interface, distributed on its own, so any language with C or FFI bindings could drive the thing directly.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;d be delighted if they used afmpeg as well, obviously. But they don&amp;rsquo;t have to, and that wasn&amp;rsquo;t the plan either. The plan was to stop keryx from having a disk.&lt;/p&gt;&#10;&lt;p&gt;Somewhere along the way it turned into an FFmpeg you can embed without shelling out, in a sandbox by default, at native speed when you ask, in whatever language you fancy. I still can&amp;rsquo;t tell you what half the FFmpeg arguments do!&lt;/p&gt;</content:encoded></item><item><title>FFmpeg thinks it has a disk</title><link>https://phpboyscout.uk/ffmpeg-thinks-it-has-a-disk/</link><pubDate>Mon, 24 Aug 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/ffmpeg-thinks-it-has-a-disk/</guid><category>afmpeg</category><category>Pioneering</category><description>FFmpeg assumes a disk it can seek around in. Giving it a convincing in-memory filesystem, and testing the behaviour that would hurt most first.</description><content:encoded>&lt;p&gt;There&amp;rsquo;s a moment in every &amp;ldquo;FFmpeg in your own process&amp;rdquo; conversation where somebody asks where the files go. It&amp;rsquo;s a fair question. FFmpeg&amp;rsquo;s whole worldview is files: it opens paths, seeks around in them, writes headers and then goes &lt;em&gt;back&lt;/em&gt; to fix them up. It assumes a disk is just there, the same way it assumes a clock is. Nobody ever wrote it to cope with either being a lie.&lt;/p&gt;&#10;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/introducing-afmpeg-and-ffmpeg-wasi/" &gt;afmpeg&lt;/a&gt; doesn&amp;rsquo;t give it one. The FFmpeg inside is compiled to WebAssembly and the files it reads and writes are, by default, Go objects in memory. The guest never notices. This post is about the piece that does the lying: the vfs bridge.&lt;/p&gt;&#10;&lt;h2 id="two-filesystem-interfaces-neither-of-them-a-filesystem"&gt;Two filesystem interfaces, neither of them a filesystem&#10;&lt;/h2&gt;&lt;p&gt;The requirement (R-AF-2 in the spec, one of the project&amp;rsquo;s founding rules) is that a media job can run with &lt;em&gt;zero&lt;/em&gt; host filesystem access. Not &amp;ldquo;a temp dir we clean up afterwards&amp;rdquo;. None at all. The pipeline this library was built for generates a reel&amp;rsquo;s video in one step and posts it in the next, and there&amp;rsquo;s no reason the bytes moving between two steps of the same program should ever touch a disk.&lt;/p&gt;&#10;&lt;p&gt;So Go already has a beautiful answer to &amp;ldquo;a filesystem that isn&amp;rsquo;t&amp;rdquo;: &lt;a class="link" href="https://github.com/spf13/afero" target="_blank" rel="noopener"&#10; &gt;afero&lt;/a&gt;, the de-facto standard filesystem abstraction, whose &lt;code&gt;MemMapFs&lt;/code&gt; is a complete filesystem living in ordinary memory. And wazero, the wasm runtime, has its own answer from the opposite direction: a &lt;code&gt;sys.FS&lt;/code&gt; interface describing what a &lt;em&gt;guest&lt;/em&gt; expects a filesystem to look like, syscall by syscall.&lt;/p&gt;&#10;&lt;p&gt;The bridge is the adapter between those two worldviews:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;sysCfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithSysFSMount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;vfs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;#34;/&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;vfs.New&lt;/code&gt; takes any &lt;code&gt;afero.Fs&lt;/code&gt; and presents it to wazero as the guest&amp;rsquo;s root. When FFmpeg makes a WASI syscall (&lt;code&gt;path_open&lt;/code&gt;, &lt;code&gt;fd_read&lt;/code&gt;, &lt;code&gt;fd_write&lt;/code&gt;, &lt;code&gt;fd_seek&lt;/code&gt;), wazero routes it to the mounted &lt;code&gt;sys.FS&lt;/code&gt;, and the bridge translates each one onto the corresponding afero operation (&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/blob/b9b7d5e/internal/vfs/vfs.go#L61-70" target="_blank" rel="noopener"&#10; &gt;vfs.go at b9b7d5e&lt;/a&gt;). The comment on the constructor states the payoff plainly: hand it a &lt;code&gt;MemMapFs&lt;/code&gt; and the whole pipeline stays in memory. Hand it a &lt;code&gt;BasePathFs&lt;/code&gt; or a real directory and the same code runs against actual files, which is exactly how the integration tests double-check the abstraction isn&amp;rsquo;t cheating.&lt;/p&gt;&#10;&lt;h2 id="test-the-scariest-behaviour-first"&gt;Test the scariest behaviour first&#10;&lt;/h2&gt;&lt;p&gt;Spec 0003&amp;rsquo;s most opinionated decision wasn&amp;rsquo;t in the code, it was in the ordering: the first test written for the entire bridge was seek-on-write.&lt;/p&gt;&#10;&lt;p&gt;Here&amp;rsquo;s why that&amp;rsquo;s the scary one. When FFmpeg writes an mp4 with &lt;code&gt;+faststart&lt;/code&gt;, it writes the file front to back, then &lt;em&gt;seeks backwards&lt;/em&gt; into the finished output to relocate the moov atom (the index that lets a video start playing before it&amp;rsquo;s fully downloaded). Most toy filesystem shims survive sequential writes and die the moment something rewinds and patches the middle of a file. If afero&amp;rsquo;s in-memory files couldn&amp;rsquo;t take that punch, the whole project was a dead end, so that test was the go/no-go gate before anything else got built:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Pwrite writes at an absolute offset without moving the file offset&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// (fd_pwrite) — the path the mp4 muxer uses to patch the moov atom under&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// +faststart.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Pwrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;experimentalsys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Errno&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;af&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WriteAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;experimentalsys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UnwrapOSError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&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 class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;(&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/blob/661688d/internal/vfs/file.go#L58-69" target="_blank" rel="noopener"&#10; &gt;file.go at 661688d&lt;/a&gt;.) It passed, the gate opened, and the rest of the bridge is properly dull: EOF is reported the way WASI wants it (a zero-byte read with no error, not an error called EOF), errno values are mapped through wazero&amp;rsquo;s own error translation so the guest sees proper POSIX numbers, and anything the bridge doesn&amp;rsquo;t implement fails loudly with ENOSYS rather than pretending. Dull is the point. A filesystem is a contract and it&amp;rsquo;s always the clause you skimmed that bites, so the small print is where all the work went.&lt;/p&gt;&#10;&lt;h2 id="the-paths-that-arent-in-your-filesystem"&gt;The paths that aren&amp;rsquo;t in your filesystem&#10;&lt;/h2&gt;&lt;p&gt;The interesting wrinkle is the handful of paths a C program expects that no caller&amp;rsquo;s filesystem would ever contain. The bridge intercepts these in &lt;code&gt;OpenFile&lt;/code&gt; before they reach afero (&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/blob/b9b7d5e/internal/vfs/vfs.go#L92-101" target="_blank" rel="noopener"&#10; &gt;the dispatch&lt;/a&gt;):&lt;/p&gt;&#10;&lt;ul&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;/tmp&lt;/code&gt;&lt;/strong&gt; routes to a &lt;em&gt;separate&lt;/em&gt; in-memory scratch filesystem. FFmpeg is entitled to its temp files, but guest litter has no business appearing in the filesystem you handed in. Your &lt;code&gt;MemMapFs&lt;/code&gt; stays exactly as clean as you gave it.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;/dev/null&lt;/code&gt;&lt;/strong&gt; is a discard sink, because portable C code will write to it and portable C code must be humoured.&lt;/li&gt;&#10;&lt;li&gt;&lt;strong&gt;&lt;code&gt;/dev/urandom&lt;/code&gt;&lt;/strong&gt; hands back real randomness from Go&amp;rsquo;s &lt;code&gt;crypto/rand&lt;/code&gt;. That one arrived later and the hard way, after the Matroska muxer &lt;a class="link" href="https://phpboyscout.uk/the-afternoon-the-agent-earned-its-keep/" &gt;hung forever waiting for entropy&lt;/a&gt; that WASI never provides. The bridge was the natural home for the fix: not a patch to FFmpeg, just one more fixture the environment was expected to have.&lt;/li&gt;&#10;&lt;/ul&gt;&#10;&lt;p&gt;There&amp;rsquo;s a theme in that list. WASI deliberately gives a guest almost nothing, and every one of these overlays is the bridge handing back a single, controlled piece of the POSIX world the guest was written for. The filesystem, the scratch space, the bit-bucket, the randomness. All of it fabricated, and all of it behaving exactly the way a real one would&amp;hellip; which is the only thing the guest ever bothers to check.&lt;/p&gt;&#10;&lt;h2 id="what-you-get-for-the-deceit"&gt;What you get for the deceit&#10;&lt;/h2&gt;&lt;p&gt;The practical upshot: afmpeg&amp;rsquo;s tests create no temp directories and need no cleanup, a crashed run leaves nothing on disk because nothing was ever on disk, and a server can process untrusted media without granting the decoder so much as a directory. It&amp;rsquo;s the same trick paying off twice over. Painless tests, and a sandbox you can hand somebody else&amp;rsquo;s video without flinching.&lt;/p&gt;&#10;&lt;p&gt;FFmpeg, for its part, never suspects a thing. It opens files, seeks around, patches its moov atoms, litters its &lt;code&gt;/tmp&lt;/code&gt;&amp;hellip; and every last byte of it is a Go map pretending, very carefully, to be a hard drive.&lt;/p&gt;</content:encoded></item><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 isPermaLink="true">https://phpboyscout.uk/one-input-many-outputs/</guid><category>ffmpeg-wasi</category><category>Pioneering</category><description>afmpeg shipped single input to single output. Adding multi-pad filter graphs and multi-output muxing: one decode, two files, one pass.</description><content:encoded>&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;&#10;&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;&#10;&lt;h2 id="why-one-output-was-never-going-to-be-enough"&gt;Why one output was never going to be enough&#10;&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;&#10;&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;&#10;&lt;h2 id="two-commits-two-halves-of-the-problem"&gt;Two commits, two halves of the problem&#10;&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;&#10;&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/ffmpeg-wasi/-/commit/faa5f3c92d66914e0a5daf65233dc42e0ec71a74" target="_blank" rel="noopener"&#10; &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;&#10;&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"&#10; &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;&#10;&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;&#10;&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;&#10;&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;&#10;&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;&#10;&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;&#10;&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;&#10;&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;&#10;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;&#10;&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;&#10;&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;&#10;&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;&#10;&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"&#10; &gt;n8.1.2-5&lt;/a&gt;.&lt;/p&gt;&#10;&lt;h2 id="one-decode-two-files-one-pass"&gt;One decode, two files, one pass&#10;&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"&#10; &gt;&lt;code&gt;TestIntegration_RunJob_MultiOutput&lt;/code&gt;&lt;/a&gt;. One WAV goes in. The graph is:&lt;/p&gt;&#10;&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]&#10;&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;&#10;&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;&#10;&lt;h2 id="the-bench-is-clear"&gt;The bench is clear&#10;&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;&#10;&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;</content:encoded></item><item><title>A progress bar without a CLI to scrape</title><link>https://phpboyscout.uk/a-progress-bar-without-a-cli-to-scrape/</link><pubDate>Fri, 25 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/a-progress-bar-without-a-cli-to-scrape/</guid><category>afmpeg</category><category>keryx</category><category>Pioneering</category><description>Every FFmpeg progress bar I've seen scrapes the CLI's stderr. afmpeg has no CLI to scrape, so progress had to become part of the API instead.</description><content:encoded>&lt;p&gt;If you&amp;rsquo;ve ever wired a progress bar to FFmpeg you know the trick, and it&amp;rsquo;s a good one: run the CLI, watch stderr, and pull the numbers out of the &lt;code&gt;frame=... fps=... time=... speed=...&lt;/code&gt; line it rewrites every half second. Near enough everyone does it, and each wrapper library I&amp;rsquo;ve looked inside does it too.&lt;/p&gt;&#10;&lt;p&gt;I wanted one for &lt;a class="link" href="https://keryx.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;keryx&lt;/a&gt;, which renders its reels through &lt;a class="link" href="https://afmpeg.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;afmpeg&lt;/a&gt;, so I went looking for the streaming stderr writer afmpeg hadn&amp;rsquo;t built yet. It hadn&amp;rsquo;t, and that wasn&amp;rsquo;t the problem.&lt;/p&gt;&#10;&lt;h2 id="theres-no-line-to-scrape"&gt;There&amp;rsquo;s no line to scrape&#10;&lt;/h2&gt;&lt;p&gt;afmpeg isn&amp;rsquo;t running the CLI. It drives a headless libav engine, compiled to WebAssembly by way of &lt;a class="link" href="https://ffmpeg-wasi.phpboyscout.uk/" target="_blank" rel="noopener"&#10; &gt;ffmpeg-wasi&lt;/a&gt;, and that engine writes to stderr on error paths and nowhere else. The encode loop, receive a packet, write a frame, receive a packet, emits nothing per frame at all. So with a perfect stderr stream in place there&amp;rsquo;d still be no signal on it to watch, and building the writer first would have got me a very good window onto an empty room.&lt;/p&gt;&#10;&lt;p&gt;So I stopped looking at stderr. There had to be some other buffer or mechanism to watch, and the shape I wanted was obvious even before I knew where the numbers would come from: a goroutine pushing progress back over a channel. A short spike first, to find out whether it was possible at all, and then a proper spec.&lt;/p&gt;&#10;&lt;p&gt;That instinct turned into the design. Once you&amp;rsquo;re no longer running the CLI, copying the CLI&amp;rsquo;s progress model is cargo-culting. The engine has the truth about where it&amp;rsquo;s up to, so the job is to surface that on purpose, as an API with a contract, and to keep the fallback honest when the engine can&amp;rsquo;t say.&lt;/p&gt;&#10;&lt;h2 id="watching-the-bytes-go-past"&gt;Watching the bytes go past&#10;&lt;/h2&gt;&lt;p&gt;The spike found the first source in an afternoon, and it needed no change to the engine at all.&lt;/p&gt;&#10;&lt;p&gt;afmpeg &lt;em&gt;is&lt;/em&gt; the engine&amp;rsquo;s filesystem. The point of the thing, &lt;a class="link" href="https://phpboyscout.uk/ffmpeg-thinks-it-has-a-disk/" &gt;as I&amp;rsquo;ve written before&lt;/a&gt;, is that FFmpeg believes it has a disk and afmpeg is what&amp;rsquo;s underneath, calling the caller&amp;rsquo;s &lt;code&gt;afero.Fs&lt;/code&gt; read by read and write by write. So the host can watch input being consumed and output being produced in real time by wrapping that filesystem, and the fraction is simply bytes read over input size. The spike gave 808 read events across a 45-second AAC encode, a smooth, monotonic climb from nought to a hundred percent, entirely through the public API.&lt;/p&gt;&#10;&lt;p&gt;That&amp;rsquo;s phase A in &lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/wikis/specs/0031-job-progress-reporting" target="_blank" rel="noopener"&#10; &gt;the spec&lt;/a&gt;, and it shipped first because it satisfied the need now.&lt;/p&gt;&#10;&lt;p&gt;It&amp;rsquo;s byte progress rather than time, and the spec is plain about where it&amp;rsquo;s poor: a demuxer that seeks (an mp4 with its index at the end) makes the raw count wander, so the reported fraction is clamped to the maximum seen and can&amp;rsquo;t go backwards; a generated input, a lavfi source with no file behind it, produces nothing to watch; and a very short job is over before there&amp;rsquo;s anything to say. For those the fraction is reported as -1, which means &amp;ldquo;can&amp;rsquo;t tell&amp;rdquo;, and the elapsed time and output bytes still come through so a consumer can show a spinner instead of a lie.&lt;/p&gt;&#10;&lt;h2 id="the-engine-gets-a-side-channel"&gt;The engine gets a side channel&#10;&lt;/h2&gt;&lt;p&gt;Phase B is where the engine joins in, and it comes in by the same route. The engine already talks to synthetic devices in that filesystem (&lt;code&gt;/dev/null&lt;/code&gt;, &lt;code&gt;/dev/urandom&lt;/code&gt;), so it gained one more: a write-only &lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/blob/5891ac49/internal/vfs/progress.go#L14-40" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;/dev/afmpeg-progress&lt;/code&gt;&lt;/a&gt; that the driver opens from its encode loop and streams newline-delimited JSON records into, one per flush interval, with the frame count and the output timestamp. The host is the filesystem, so it sees each write as it lands, parses the line, and feeds the same reporter that phase A was already using. Best-effort in both directions: if the device won&amp;rsquo;t open the engine carries on without it, and a consumer that doesn&amp;rsquo;t drain the channel misses samples and doesn&amp;rsquo;t stall the encode.&lt;/p&gt;&#10;&lt;p&gt;Two things in there I&amp;rsquo;d do the same way again. Speed is worked out on the host, output time over elapsed, because a WASI engine has no clock worth trusting. And the transport I rejected was the one that would&amp;rsquo;ve felt most natural to anyone who&amp;rsquo;s scraped FFmpeg before: an &lt;code&gt;av_log&lt;/code&gt; callback formatting a stats line to stderr. It would have recreated the coupling the exercise set out to remove, just with the line coming from my code instead of theirs.&lt;/p&gt;&#10;&lt;p&gt;The caller sees none of the plumbing.&lt;/p&gt;&#10;&lt;p&gt;There&amp;rsquo;s &lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/blob/5891ac49/pkg/afmpeg/progress.go#L34-60" target="_blank" rel="noopener"&#10; &gt;one &lt;code&gt;Progress&lt;/code&gt; value&lt;/a&gt;, one channel attached to the context with &lt;code&gt;WithProgress&lt;/code&gt;, and the same call to &lt;code&gt;Run&lt;/code&gt; as before. Phase B filled in fields the type had been carrying empty since phase A, and no signature changed.&lt;/p&gt;&#10;&lt;h2 id="then-it-lied"&gt;Then it lied&#10;&lt;/h2&gt;&lt;p&gt;Then I wired it into keryx, put a real reel render through it, and the fraction read 1.000 on the first sample and stayed there.&lt;/p&gt;&#10;&lt;p&gt;For the whole render.&lt;/p&gt;&#10;&lt;p&gt;That&amp;rsquo;s worse than the -1 the contract reserves for &amp;ldquo;can&amp;rsquo;t tell&amp;rdquo;. A caller can&amp;rsquo;t distinguish it from being finished, so the bar sat at done for the entire render, and a naive &amp;ldquo;did we get samples?&amp;rdquo; test passed. Two things had gone wrong at once, and the second one took me a while to see.&lt;/p&gt;&#10;&lt;p&gt;The first is that the byte ratio saturates when the inputs are tiny relative to the output. A reel is a handful of small PNG cards and a WAV bed, crossfaded into a thirty-second H.264 file, so the inputs are exhausted almost immediately and the denominator, which is only discovered as inputs open, tracks the numerator all the way up. I watched it read 367 of 367, then 734 of 734, then 32,778 of 32,778. The spec had even predicted it, in a sentence that said the effect was &amp;ldquo;safe, but lumpy&amp;rdquo; and that phase B&amp;rsquo;s engine fraction would remove it.&lt;/p&gt;&#10;&lt;p&gt;It didn&amp;rsquo;t.&lt;/p&gt;&#10;&lt;p&gt;That&amp;rsquo;s the second thing. Both sources fed through the same monotonic clamp, and a clamp is a shared maximum, so once the byte ratio touched 1.0 the ceiling was pinned there for good and the engine&amp;rsquo;s honest 0.033 had no way to pull it down. Upgrading the engine didn&amp;rsquo;t fix it.&lt;/p&gt;&#10;&lt;p&gt;I tried that first, reasonably enough, and spent a while convinced the engine was the gap&amp;hellip; when the engine was supplying the right number and the host was throwing it away. My test suite missed the collision because the phase B test used a generated input with no bytes to count, so the two sources never met.&lt;/p&gt;&#10;&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/wikis/specs/0034-fraction-source-precedence" target="_blank" rel="noopener"&#10; &gt;Spec 0034&lt;/a&gt; is the fix, and it&amp;rsquo;s mostly a matter of deciding who wins. The engine&amp;rsquo;s number is authoritative when it exists. The fraction reports -1 during the startup window before the first engine record, instead of a byte ratio the engine is about to contradict, and -1 again for the tail where the inputs are all read and the job is still encoding. And the value now carries a &lt;code&gt;Source&lt;/code&gt; field, bytes or engine or unknown, so a caller can decide how far to trust it.&lt;/p&gt;&#10;&lt;p&gt;On the native engine (there&amp;rsquo;s a post about that one tomorrow) the side channel goes quiet, because the device lives in the WASM backend, and progress falls back to the byte source. The native side channel has a spec of its own, blocked for now.&lt;/p&gt;&#10;&lt;h2 id="an-honest--1"&gt;An honest -1&#10;&lt;/h2&gt;&lt;p&gt;I started this wanting a progress bar for keryx and I got one, and what I&amp;rsquo;d carry to the next thing is the contract underneath it: a number that doesn&amp;rsquo;t go backwards, a -1 when the truth isn&amp;rsquo;t available, and a source label so you know which truth you&amp;rsquo;re looking at. A bar reading 100% for thirty seconds is a bug you&amp;rsquo;ll ship without noticing, because it looks like success, whereas a bar admitting it doesn&amp;rsquo;t know has at least told you something true, and the spinner it earns is the right thing to show.&lt;/p&gt;&#10;&lt;p&gt;The stderr line would&amp;rsquo;ve given me none of that, and it would&amp;rsquo;ve been working by lunchtime.&lt;/p&gt;</content:encoded></item><item><title>Sandboxed isn't safe</title><link>https://phpboyscout.uk/sandboxed-isnt-safe/</link><pubDate>Thu, 03 Sep 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/sandboxed-isnt-safe/</guid><category>afmpeg</category><category>Pioneering</category><description>A security review read "safely process untrusted media" and asked the harder question. A sandbox stops escape; it does not stop exhaustion.</description><content:encoded>&lt;p&gt;When I &lt;a class="link" href="https://phpboyscout.uk/introducing-afmpeg-and-ffmpeg-wasi/" &gt;introduced afmpeg&lt;/a&gt;, one phrase did a lot of heavy lifting: &lt;em&gt;safely process untrusted media&lt;/em&gt;. The FFmpeg inside is compiled to WebAssembly and run in a sandbox with no host filesystem, no network, no environment. Feed it a hostile file and the worst it can do is fail. That was the pitch.&lt;/p&gt;&#10;&lt;p&gt;An external security review read that pitch, then asked a better question than I had: fail &lt;em&gt;taking how much of the host with it?&lt;/em&gt;&lt;/p&gt;&#10;&lt;h2 id="two-different-questions-wearing-one-word"&gt;Two different questions wearing one word&#10;&lt;/h2&gt;&lt;p&gt;&amp;ldquo;Sandboxed&amp;rdquo; answers one of them: &lt;em&gt;what can it reach?&lt;/em&gt; And on that score afmpeg was solid. The guest sees an in-memory filesystem it was handed and nothing else. A malicious file that manages to run code inside the guest has, for all its scheming, broken into a padded room. No door, no window, nothing worth taking.&lt;/p&gt;&#10;&lt;p&gt;But there&amp;rsquo;s a second question the word &amp;ldquo;safe&amp;rdquo; smuggles in without telling you: &lt;em&gt;how much can it take?&lt;/em&gt; Here afmpeg had no answer at all, and that was the review&amp;rsquo;s headline finding, one I&amp;rsquo;d commissioned and then made a point of verifying against the code rather than taking on trust. The runtime set no memory limit. None. A wasm32 guest can grow its linear memory towards 4 GB, and a crafted media file is only too happy to help it get there: declare a 65535×65535 video in the header and libavcodec, doing exactly the job you asked of it, tries to allocate the buffers those dimensions demand. Inside the sandbox. Paid for by the host.&lt;/p&gt;&#10;&lt;p&gt;So the guest can&amp;rsquo;t read your files, can&amp;rsquo;t phone home, can&amp;rsquo;t escape&amp;hellip; and can still get your Go process OOM-killed by the kernel. I&amp;rsquo;d sandboxed the code execution and said nothing whatsoever about the resource bill. My padded room, it turned out, was drawing its air from the rest of the building, and I hadn&amp;rsquo;t thought to check.&lt;/p&gt;&#10;&lt;p&gt;The second finding was the same disease with a different clock: nothing bounded how long an invocation could run. So a pathological decode loop, handed a caller using a background context, would sit on the runtime&amp;rsquo;s lock forever. Not a crash. A wedge. (There was a third one too, minor: some missing type guards in the engine&amp;rsquo;s job-spec parsing, defence-in-depth against a caller the design already trusts. Fixed in the engine for completeness.)&lt;/p&gt;&#10;&lt;h2 id="ceilings-on-by-default"&gt;Ceilings, on by default&#10;&lt;/h2&gt;&lt;p&gt;The fix (&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/blob/0f20cbb/pkg/afmpeg/runtime.go#L200-206" target="_blank" rel="noopener"&#10; &gt;0f20cbb&lt;/a&gt;) gives the sandbox the second half of its job. Memory first:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="nx"&gt;rtCfg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;wazero&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NewRuntimeConfig&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nf"&gt;WithCoreFeatures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;runtimeCoreFeatures&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nf"&gt;WithCloseOnContextDone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;memoryLimitPages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;memoryLimitBytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;rtCfg&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;rtCfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithMemoryLimitPages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The default ceiling is 512 MB, generous for real transcodes and a rounding error compared to &amp;ldquo;all of it&amp;rdquo;. A guest that tries to grow past the ceiling gets a failed allocation &lt;em&gt;inside the sandbox&lt;/em&gt;, which FFmpeg handles the way it handles any allocation failure: the job errors, the host shrugs, life continues.&lt;/p&gt;&#10;&lt;p&gt;Time second (&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/blob/0f20cbb/pkg/afmpeg/runtime.go#L254-262" target="_blank" rel="noopener"&#10; &gt;same commit&lt;/a&gt;):&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="c1"&gt;// Impose the default deadline before locking, but only when the caller brings&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="c1"&gt;// none — a caller&amp;#39;s own deadline is honoured as-is (spec 0027 §4B).&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Deadline&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cancel&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CancelFunc&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cancel&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="k"&gt;defer&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;An hour by default, and it steps aside for whatever deadline the caller already set. Both knobs can be turned, and setting either to zero opts out completely, but the decision that actually matters is the one in &lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/blob/5464a29/docs/development/specs/0027-runtime-security-hardening.md" target="_blank" rel="noopener"&#10; &gt;the spec&amp;rsquo;s&lt;/a&gt; own words: &lt;strong&gt;a sandbox whose protections are off-by-default is not a sandbox.&lt;/strong&gt; The library user who never opens the hardening docs, which is most of us, most of the time, me being exhibit A, gets the ceilings anyway. Anything you have to remember to switch on is a thing you&amp;rsquo;ll forget to switch on.&lt;/p&gt;&#10;&lt;h2 id="found-on-paper-not-in-production"&gt;Found on paper, not in production&#10;&lt;/h2&gt;&lt;p&gt;For the record, nothing OOM&amp;rsquo;d in anger. This gap was found by a review I paid for, corroborated line-by-line against the runtime before a word of spec got written, and closed the same week. That&amp;rsquo;s the least dramatic possible version of this story and exactly the version I wanted: the alternative draft, the one where a user&amp;rsquo;s server dies processing a wedding video, writes itself and is worse in every respect.&lt;/p&gt;&#10;&lt;p&gt;Still, I&amp;rsquo;d rather own the embarrassing half out loud. I shipped &amp;ldquo;safely process untrusted media&amp;rdquo; while the runtime would happily hand a hostile file every byte it asked for, because I&amp;rsquo;d let the word &amp;ldquo;sandboxed&amp;rdquo; answer both questions when it only ever answered one. What can it reach? Fine, covered. How much can it take? Not a clue. A sandbox has to answer both before that little phrase is true, and mine was answering half of it with a completely straight face.&lt;/p&gt;</content:encoded></item><item><title>An encoder on borrowed time</title><link>https://phpboyscout.uk/an-encoder-on-borrowed-time/</link><pubDate>Fri, 28 Aug 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/an-encoder-on-borrowed-time/</guid><category>ffmpeg-wasi</category><category>Pioneering</category><description>The last US AVC patent expires in November 2027. Until then ffmpeg-wasi's H.264 encoder ships on sufferance, on Cisco's binaries taught to run under WASI.</description><content:encoded>&lt;p&gt;There&amp;rsquo;s a date circled in ffmpeg-wasi&amp;rsquo;s documentation: &lt;strong&gt;2027-11-29&lt;/strong&gt;. On that day the last US essential patent in the AVC pool expires, H.264 becomes just another bit of maths in America, and a whole paragraph of careful legal hedging in my docs gets to retire. Until then the LGPL build of ffmpeg-wasi ships an encoder that exists on sufferance. And if you&amp;rsquo;re going to ship something on those terms, you say so. Out loud, prominently, with the date attached.&lt;/p&gt;&#10;&lt;p&gt;This is the story of the most-requested capability in any media toolkit (making mp4s people can actually play) and what it costs to offer it in a permissively-licensed build.&lt;/p&gt;&#10;&lt;h2 id="decode-was-never-the-gap"&gt;Decode was never the gap&#10;&lt;/h2&gt;&lt;p&gt;The &lt;a class="link" href="https://phpboyscout.uk/introducing-afmpeg-and-ffmpeg-wasi/" &gt;two-variant scheme&lt;/a&gt; gives ffmpeg-wasi an LGPL floor and a GPL full-fat build. From day one the LGPL floor could &lt;em&gt;decode&lt;/em&gt; H.264; FFmpeg&amp;rsquo;s own decoder carries no GPL strings. The gap was encode. FFmpeg&amp;rsquo;s blessed H.264 encoder is x264, and x264 is GPL: enable it and the whole artifact graduates to the GPL variant. So the LGPL build, the one a permissively-licensed Go library wants to lean on, could read the world&amp;rsquo;s videos and write none of them.&lt;/p&gt;&#10;&lt;p&gt;Enter &lt;a class="link" href="https://gitlab.com/phpboyscout/ffmpeg-wasi/-/blob/164163ecac85930bfd972faf48ba3ce536bf41c8/build/libav.sh#L22-25" target="_blank" rel="noopener"&#10; &gt;openh264&lt;/a&gt;, Cisco&amp;rsquo;s H.264 codec, BSD-2-Clause licensed. Two build flags (&lt;code&gt;--enable-libopenh264 --enable-encoder=libopenh264&lt;/code&gt;, no &lt;code&gt;--enable-gpl&lt;/code&gt; in sight) and the LGPL artifact encodes H.264. Shipped in &lt;a class="link" href="https://gitlab.com/phpboyscout/ffmpeg-wasi/-/releases/n8.1.2-2" target="_blank" rel="noopener"&#10; &gt;n8.1.2-2&lt;/a&gt;, in both variants.&lt;/p&gt;&#10;&lt;p&gt;If that sounds too easy&amp;hellip; it is. The BSD licence was never the hard bit.&lt;/p&gt;&#10;&lt;h2 id="copyright-is-one-umbrella-patents-are-another"&gt;Copyright is one umbrella; patents are another&#10;&lt;/h2&gt;&lt;p&gt;A codec has two legal layers, and they don&amp;rsquo;t care about each other. The &lt;em&gt;copyright&lt;/em&gt; on openh264&amp;rsquo;s source is Cisco&amp;rsquo;s to give away, and BSD gives it away. The &lt;em&gt;patents&lt;/em&gt; on the H.264 techniques themselves belong to a pool of rights-holders (administered by Via LA), and no source licence in the world can waive them.&lt;/p&gt;&#10;&lt;p&gt;Cisco&amp;rsquo;s famous move was to pay the pool&amp;rsquo;s royalties on everyone&amp;rsquo;s behalf&amp;hellip; for the binary modules &lt;em&gt;Cisco itself builds and distributes&lt;/em&gt;. That grant does not travel with the source code. My docs put it in a red box rather than a footnote: ffmpeg-wasi compiles openh264 from source for &lt;code&gt;wasm32-wasi&lt;/code&gt; (Cisco doesn&amp;rsquo;t publish a wasm binary), so our artifact is &lt;strong&gt;not under Cisco&amp;rsquo;s umbrella&lt;/strong&gt;. Anyone who tells you &amp;ldquo;it&amp;rsquo;s fine, Cisco pays&amp;rdquo; for a self-compiled openh264 has read the first half of the licence page.&lt;/p&gt;&#10;&lt;p&gt;What makes it tenable is duller and more precise, and the &lt;a class="link" href="https://gitlab.com/phpboyscout/ffmpeg-wasi/-/blob/164163ecac85930bfd972faf48ba3ce536bf41c8/docs/explanation/licensing.md#L57-83" target="_blank" rel="noopener"&#10; &gt;licensing docs&lt;/a&gt; spell it out: the pool&amp;rsquo;s terms are royalty-free beneath an annual volume threshold (the first 100,000 units a year), a horizon this project&amp;rsquo;s expectations sit comfortably under. And the docs make a plain commitment on top: if Via LA or any AVC rights-holder asks us to stop distributing the encoder, we pull it. No fight, no fundraiser. The docs also say, twice, that none of this is legal advice, and neither is this post.&lt;/p&gt;&#10;&lt;h2 id="the-horse-had-already-bolted"&gt;The horse had already bolted&#10;&lt;/h2&gt;&lt;p&gt;You might ask why I&amp;rsquo;d take on any of this for the &lt;em&gt;permissive&lt;/em&gt; build when the GPL build already existed. The answer is that the patent exposure was already there. Patents don&amp;rsquo;t read your source licence: an x264 binary practises exactly the same claims as an openh264 one, so the GPL variant had opened whatever door there was to open. Declining to add encode to the LGPL floor wouldn&amp;rsquo;t have closed it; it would only have kept the safer variant less useful. Same risk either way, and turning encode down would&amp;rsquo;ve bought me nothing but a less useful build. Call that caution if you like. I&amp;rsquo;d call it cost with no upside.&lt;/p&gt;&#10;&lt;p&gt;So both variants carry the risk, both carry the caveat, and the caveat comes with a date on it. Rather than embed a list of patent numbers that would only rot in place, the docs link Via LA&amp;rsquo;s maintained roster and state the one fact worth remembering: the final US essential patent lapses on 2027-11-29 (other jurisdictions track a similar horizon). None of this is meant to hold forever. There&amp;rsquo;s a clock running on it.&lt;/p&gt;&#10;&lt;h2 id="making-ciscos-code-believe-in-wasi"&gt;Making Cisco&amp;rsquo;s code believe in WASI&#10;&lt;/h2&gt;&lt;p&gt;Getting openh264 v2.6.0 to build for &lt;code&gt;wasm32-wasi&lt;/code&gt; took &lt;a class="link" href="https://gitlab.com/phpboyscout/ffmpeg-wasi/-/blob/164163ecac85930bfd972faf48ba3ce536bf41c8/build/deps.sh#L50-87" target="_blank" rel="noopener"&#10; &gt;a small patch and a shim&lt;/a&gt;: a &lt;code&gt;__wasi__&lt;/code&gt; path through its threading library, a single-threaded pthread stand-in compiled into the archive, a hand-written pkg-config file (upstream&amp;rsquo;s insists on &lt;code&gt;-lstdc++ -lpthread&lt;/code&gt;, neither of which exists on wasi)&amp;hellip; and one genuinely fun find: a function declared with one signature in C and defined with another in C++, an arity slip native linkers have silently forgiven for years. Wasm&amp;rsquo;s strict function typing forgave nothing, and trapped on the spot. Which makes the sandbox an accidental linter, catching a bug that had sat there unbothered for years.&lt;/p&gt;&#10;&lt;h2 id="a-feature-with-a-use-by-date"&gt;A feature with a use-by date&#10;&lt;/h2&gt;&lt;p&gt;It nets out about as well as this kind of thing can. The LGPL build does the thing everyone actually needs, the risk is written down where you can&amp;rsquo;t miss it, the escape hatch is pre-committed, and the whole lot comes with a sell-by date, after which there&amp;rsquo;s nothing left to hedge and it&amp;rsquo;s back to being maths. Fifteen months on the clock. That red box in the docs is really just a changelog entry with the date left blank.&lt;/p&gt;</content:encoded></item><item><title>The afternoon the agent earned its keep</title><link>https://phpboyscout.uk/the-afternoon-the-agent-earned-its-keep/</link><pubDate>Thu, 09 Jul 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-afternoon-the-agent-earned-its-keep/</guid><category>ffmpeg-wasi</category><category>ai</category><category>Pioneering</category><description>Adding stream-copy remux to a WebAssembly FFmpeg build, where writing Matroska hung forever and the identical MP4 path did not.</description><content:encoded>&lt;p&gt;There&amp;rsquo;s a kind of bug that&amp;rsquo;s worse than a crash. A crash at least tells you &lt;em&gt;where&lt;/em&gt;. This one just&amp;hellip; stopped. I asked &lt;a class="link" href="https://phpboyscout.uk/introducing-afmpeg-and-ffmpeg-wasi/" &gt;ffmpeg-wasi&lt;/a&gt; to remux a clip into a Matroska file, no re-encoding, just repackage the same streams into an &lt;code&gt;.mkv&lt;/code&gt;, and it never came back. No error, no output. The test harness eventually gave up and shot it.&lt;/p&gt;&#10;&lt;p&gt;The same job to an &lt;code&gt;.mp4&lt;/code&gt;? A dozen milliseconds. Change the file extension and a blink becomes a heat-death-of-the-universe operation.&lt;/p&gt;&#10;&lt;p&gt;Normally this is the moment a war story starts costing me evenings. FFmpeg compiled to &lt;code&gt;wasm32-wasi&lt;/code&gt;, running under &lt;a class="link" href="https://wazero.io/" target="_blank" rel="noopener"&#10; &gt;wazero&lt;/a&gt;, reading and writing through an in-memory filesystem&amp;hellip; that&amp;rsquo;s a lot of layers, any of which could be the one that loops, and the traditional way to find out is to build test rigs for each layer by hand and lose a week to the plumbing. I know what that costs because I&amp;rsquo;ve paid it before.&lt;/p&gt;&#10;&lt;p&gt;This time I didn&amp;rsquo;t pay it. The hang surfaced while an agent was implementing stream-copy for me, so rather than pull the work back onto my own bench, I told it to chase the bug too. What follows is the deduction it ran. My contribution was two decisions at two forks, and I&amp;rsquo;ll point at them as we pass.&lt;/p&gt;&#10;&lt;h2 id="it-got-it-wrong-first"&gt;It got it wrong first&#10;&lt;/h2&gt;&lt;p&gt;I want to start with the wrong turn, because it&amp;rsquo;s the most instructive part.&lt;/p&gt;&#10;&lt;p&gt;The agent&amp;rsquo;s first theory was confident and detailed: it traced the guest&amp;rsquo;s filesystem calls, saw the Matroska muxer seeking back and forth rewriting its header, and declared the finalisation seek-backs the culprit, an I/O pattern my filesystem bridge couldn&amp;rsquo;t satisfy. Plausible. Specific. Wrong.&lt;/p&gt;&#10;&lt;p&gt;And here&amp;rsquo;s the bit that sold me: it then designed the experiment that killed its own theory. Matroska has a streamable mode that writes without any seek-backs at all, so it flipped that on&amp;hellip; and the job still hung. Theory dead, evidence in hand, no sulking. I&amp;rsquo;ve watched humans (fine, me) defend a pet theory for two days past its expiry date. The agent binned its own in minutes and moved on to the thing you should always do when guessing stops working: bisect.&lt;/p&gt;&#10;&lt;h2 id="ruling-out-the-world-one-build-at-a-time"&gt;Ruling out the world, one build at a time&#10;&lt;/h2&gt;&lt;p&gt;This was my first fork. The agent asked whether to keep poking at it in place or to bring in native tooling, and I chose native. So it built a &lt;em&gt;real&lt;/em&gt; FFmpeg, same n8.1.2 source, same minimal configure flags, and started eliminating suspects:&lt;/p&gt;&#10;&lt;table&gt;&#10;&#9;&lt;thead&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;It tested&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Result&lt;/th&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;th&gt;Which ruled out&lt;/th&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/thead&gt;&#10;&#9;&lt;tbody&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Native FFmpeg, 64-bit, real files → mkv&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;works&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;the muxer / our configure flags&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;wasm backed by a &lt;strong&gt;real OS directory&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;hangs&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;the in-memory filesystem&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;Native FFmpeg, &lt;strong&gt;32-bit&lt;/strong&gt; (&lt;code&gt;-m32&lt;/code&gt;) → mkv&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;works&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;a 32-bit integer overflow&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&#9;&#9;&lt;tr&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;wasm under wazero&amp;rsquo;s &lt;strong&gt;interpreter&lt;/strong&gt;&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;hangs&lt;/td&gt;&#10;&#9;&#9;&#9;&#9;&#9;&lt;td&gt;a code-generation bug&lt;/td&gt;&#10;&#9;&#9;&#9;&lt;/tr&gt;&#10;&#9;&lt;/tbody&gt;&#10;&lt;/table&gt;&#10;&lt;p&gt;Sit with that table for a second. Two of those rows are &lt;em&gt;whole native builds of FFmpeg&lt;/em&gt;, one of them 32-bit with the toolchain to match, spun up as casually as you&amp;rsquo;d write a unit test. That&amp;rsquo;s the part that would have eaten my week. Each row walks an interesting suspect free, until the only thing left holding the bag is the plainest fact in the whole setup: this is running under WASI. Not the muxer. Not the maths. Not my filesystem. Not the runtime. The environment.&lt;/p&gt;&#10;&lt;h2 id="the-environment-was-missing-a-number"&gt;The environment was missing a number&#10;&lt;/h2&gt;&lt;p&gt;WASI is a smaller world than POSIX. No threads, no network, and, it turns out, no &lt;code&gt;/dev/urandom&lt;/code&gt;. Most code never notices. Right up until it does.&lt;/p&gt;&#10;&lt;p&gt;With the bisection result in hand, the agent went and read the Matroska muxer&amp;rsquo;s source. Every Matroska track carries a unique random identifier, so at startup the muxer seeds a pseudo-random number generator via libavutil&amp;rsquo;s &lt;code&gt;av_get_random_seed()&lt;/code&gt;. In a build configured as tightly as ours, that function has exactly &lt;strong&gt;one&lt;/strong&gt; source of real entropy compiled in: reading &lt;code&gt;/dev/urandom&lt;/code&gt;. The Windows crypto API, &lt;code&gt;arc4random&lt;/code&gt;, OpenSSL, libgcrypt&amp;hellip; all configured out. Just the one device.&lt;/p&gt;&#10;&lt;p&gt;Which WASI doesn&amp;rsquo;t have. So the read fails, and the function falls back to its last resort, &lt;a class="link" href="https://github.com/FFmpeg/FFmpeg/blob/n8.1.2/libavutil/random_seed.c#L75" target="_blank" rel="noopener"&#10; &gt;&lt;code&gt;get_generic_seed()&lt;/code&gt;&lt;/a&gt;: a genuinely clever routine that manufactures entropy out of thin air by reading &lt;code&gt;clock()&lt;/code&gt; in a tight loop and harvesting the jitter in how it advances. It&amp;rsquo;s a lovely trick. It has one requirement. The clock has to advance.&lt;/p&gt;&#10;&lt;p&gt;Under WASI, &lt;code&gt;clock()&lt;/code&gt; doesn&amp;rsquo;t advance. The seeding call never returns. The muxer hangs before it has written a single byte, and the &lt;code&gt;.mp4&lt;/code&gt; muxer never asks for randomness at all, which is the entire reason it was fine all along.&lt;/p&gt;&#10;&lt;p&gt;The agent didn&amp;rsquo;t leave that as a theory either. It wired &lt;code&gt;av_get_random_seed()&lt;/code&gt; into a trivial diagnostic op, one that does nothing else&amp;hellip; and that hung too. Root cause, demonstrated in isolation.&lt;/p&gt;&#10;&lt;h2 id="a-device-not-a-patch"&gt;A device, not a patch&#10;&lt;/h2&gt;&lt;p&gt;The fix isn&amp;rsquo;t a patch to FFmpeg. It&amp;rsquo;s &lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg/-/blob/b9b7d5e/internal/vfs/random.go#L32-40" target="_blank" rel="noopener"&#10; &gt;a device&lt;/a&gt;. My filesystem bridge already fabricates a couple of the POSIX fixtures a guest expects (&lt;code&gt;/tmp&lt;/code&gt; routed to scratch space, &lt;code&gt;/dev/null&lt;/code&gt; as a discard sink), so &lt;code&gt;/dev/urandom&lt;/code&gt; is &lt;a class="link" href="https://afmpeg.phpboyscout.uk/explanation/components/vfs-bridge/#why-devurandom-is-load-bearing" target="_blank" rel="noopener"&#10; &gt;the same trick&lt;/a&gt; with real randomness behind it, backed by Go&amp;rsquo;s &lt;code&gt;crypto/rand&lt;/code&gt;:&lt;/p&gt;&#10;&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-go" data-lang="go"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Read fills buf with random bytes; a short read never happens (crypto/rand&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// fills the whole slice or errors), matching /dev/urandom&amp;#39;s contract.&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="kd"&gt;func&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nx"&gt;randFile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;experimentalsys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Errno&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;:=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;rand&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;nil&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&#9;&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;experimentalsys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EIO&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;&#9;&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;&#10;&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 class="w"&gt;&#10;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now the muxer&amp;rsquo;s very first entropy read succeeds, the seed returns immediately, and the clever-but-doomed clock loop is never reached. The &lt;code&gt;.mkv&lt;/code&gt; remux that had been hanging forever finishes in ten milliseconds. And the fix isn&amp;rsquo;t Matroska-specific: any format that wants a random seed now gets one for free.&lt;/p&gt;&#10;&lt;h2 id="what-the-afternoon-actually-cost"&gt;What the afternoon actually cost&#10;&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the arithmetic that made me want to write this up.&lt;/p&gt;&#10;&lt;p&gt;From the first observed hang to a verified fix was a little over an hour. That hour included the wrong theory and the experiment that disproved it, two complete native builds of FFmpeg (one of them 32-bit), runs across two wazero execution modes, a read through libavutil&amp;rsquo;s entropy code, the diagnostic op that proved the root cause in isolation, and the fix itself. The agent even went looking for prior art and couldn&amp;rsquo;t find anyone who&amp;rsquo;d written this failure up&amp;hellip; a genuinely undocumented corner of WASI, run to ground between lunch and tea.&lt;/p&gt;&#10;&lt;p&gt;By hand? Most of a week, and the bulk of it spent on build plumbing rather than thinking: getting a matching native FFmpeg to configure, wrangling a 32-bit toolchain, wiring the trace points. None of that work is hard. It&amp;rsquo;s just &lt;em&gt;time&lt;/em&gt;, and it&amp;rsquo;s exactly the kind of time a solo developer doesn&amp;rsquo;t have lying around, which is how deep bugs like this end up parked on a &amp;ldquo;someday&amp;rdquo; list with the &lt;code&gt;.mkv&lt;/code&gt; button greyed out.&lt;/p&gt;&#10;&lt;p&gt;My whole part was two forks (dig in place, or go native? and later: document it before touching anything else) and reading the result. The judgement stayed mine. The legwork didn&amp;rsquo;t.&lt;/p&gt;&#10;&lt;p&gt;I&amp;rsquo;ve &lt;a class="link" href="https://phpboyscout.uk/the-off-switch-was-never-a-button/" &gt;compared these agents to Golden Retrievers before&lt;/a&gt;, and I stand by it: eager, tireless, loyal to their training, occasionally proud of a shredded cushion. A dog that plays fetch all day is good company. But this one went down the burrow after something I couldn&amp;rsquo;t see, and came back with the exact rabbit.&lt;/p&gt;&#10;&lt;p&gt;Good boy.&lt;/p&gt;</content:encoded></item></channel></rss>