<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Wasi on PHP Boy Scout</title><link>https://blog-570662.gitlab.io/tags/wasi/</link><description>Recent content in Wasi on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Sun, 28 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://blog-570662.gitlab.io/tags/wasi/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://blog-570662.gitlab.io/introducing-afmpeg-and-ffmpeg-wasi/</link><pubDate>Sun, 28 Jun 2026 00:00:00 +0000</pubDate><guid>https://blog-570662.gitlab.io/introducing-afmpeg-and-ffmpeg-wasi/</guid><description>&lt;img src="https://blog-570662.gitlab.io/introducing-afmpeg-and-ffmpeg-wasi/cover-introducing-afmpeg-and-ffmpeg-wasi.png" alt="Featured image of post Introducing afmpeg and ffmpeg-wasi: FFmpeg with no install, no CGO, no disk" /&gt;&lt;p&gt;&lt;a class="link" href="https://keryx.phpboyscout.uk" target="_blank" rel="noopener"
 &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;
&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;
&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;
&lt;h2 id="ffmpeg-wasi-current-ffmpeg-sandboxed-cgo-free"&gt;ffmpeg-wasi: current FFmpeg, sandboxed, CGO-free
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://ffmpeg-wasi.phpboyscout.uk" target="_blank" rel="noopener"
 &gt;ffmpeg-wasi&lt;/a&gt; (&lt;a class="link" href="https://gitlab.com/phpboyscout/ffmpeg-wasi" target="_blank" rel="noopener"
 &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"
 &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;
&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;
&lt;h2 id="afmpeg-the-pure-go-binding-that-lives-in-memory"&gt;afmpeg: the pure-Go binding that lives in memory
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://afmpeg.phpboyscout.uk" target="_blank" rel="noopener"
 &gt;afmpeg&lt;/a&gt; (&lt;a class="link" href="https://gitlab.com/phpboyscout/afmpeg" target="_blank" rel="noopener"
 &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"
 &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;
&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;
&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;
&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;
&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;
&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;
&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;
&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;
&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&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;
&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;
&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;
&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&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;
&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;
&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;
&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;
&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;
&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&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;
&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;
&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;
&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;
&lt;h2 id="why-its-two-repos"&gt;Why it&amp;rsquo;s two repos
&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;
&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;
&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;</description></item></channel></rss>