Featured image of post Introducing afmpeg and ffmpeg-wasi: FFmpeg with no install, no CGO, no disk

Introducing afmpeg and ffmpeg-wasi: FFmpeg with no install, no CGO, no disk

keryx renders short promo reels, and the way it does that, today, is the way nearly everything does: it shells out to the ffmpeg binary. Which is fine, until you ask it to render a project that doesn’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 ffmpeg, the whole thing falls over. The binary wants real files in a real directory. There aren’t any.

I went looking for a way out and didn’t find one I could live with. The bindings that use purego/dlopen are immature and still need the host’s libav libraries installed. The CGO bindings to libav are mature and can absolutely work in memory, but they’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 ffmpeg.wasm is an emscripten 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’m not shipping an out-of-support media decoder whose entire job is parsing untrusted files. Every road had a tollbooth I wasn’t willing to pay.

So I built two new things instead, and because they’re so closely tied I’m introducing them together.

ffmpeg-wasi: current FFmpeg, sandboxed, CGO-free

ffmpeg-wasi (repo) is the foundation, and it’s the harder of the two to build. It takes FFmpeg’s media libraries, the libav* family, and builds them to wasm32-wasi, then drives them with a small purpose-built engine, producing a single .wasm 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’s built to run under wazero, 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.

The interesting bit, the reason this didn’t already exist, is a wall that FFmpeg 7.0 put up. The 7.x series rewrote the command-line tool to be mandatorily multithreaded, and a pure-Go WASI runtime can’t run that, because the threading model it needs (wasi-threads, spawning real threads) isn’t something wazero does. Every project trying to get current 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’t compile the CLI at all. It links the libav* libraries directly, which build single-threaded without complaint, and drives them with its own engine. That’s the move nobody else has made, and it’s the whole reason this can track current, maintained FFmpeg rather than a frozen one.

afmpeg: the pure-Go binding that lives in memory

afmpeg (repo) was the catalyst for all this, and it’s the part a Go developer actually touches. It’s a small, idiomatic Go API (New, Run, Probe, Close) sitting on top of the ffmpeg-wasi artifact, with one important twist: its I/O is bridged to an afero.Fs. afero isn’t in the standard library, but it’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 “something else” 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 ffmpeg is none the wiser. keryx gets to render its in-memory project without ever touching the disk, which was the whole reason I started.

This isn’t a roadmap post. Both projects are released and run today: afmpeg is at v0.4.0, ffmpeg-wasi at n8.1.2-1 (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’s reel-specific filtergraph, the shape a caller actually deals with is about this small:

// Compile the ffmpeg-wasi module once, then reuse the runtime.
rt, err := afmpeg.New(ctx, afmpeg.WithModuleFile("ffmpeg-wasi-lgpl.wasm"))
if err != nil {
    return err
}
defer rt.Close(ctx)

// The whole job lives in memory: no temp dir, nothing on disk.
fs := afero.NewMemMapFs()
afero.WriteFile(fs, "in.wav", input, 0o644)

// Drive it with the ffmpeg arguments you already know...
res, err := rt.Run(ctx, fs, "-i", "in.wav", "-c:a", "aac", "out.m4a")
if err != nil || res.ExitCode != 0 {
    return fmt.Errorf("render failed: %w (%s)", err, res.Stderr)
}

// ...then read the finished file straight back out of memory.
out, _ := afero.ReadFile(fs, "out.m4a")

The .wasm itself is a published release artifact you pin by SHA-256 (or let afmpeg fetch and verify for you with WithModuleURL), so the licence boundary stays explicit and nothing surprising ends up in your binary.

It doesn’t do everything yet, and I’d rather say so than let you find out the hard way. Single input to single output and Probe work now; the full multi-pad filter_complex and multi-output muxing are the next thing on the bench. 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.

Why it’s two repos

There’s a reason these are separate projects rather than one, and it’s about licences, not tidiness. The moment you compile FFmpeg you’re handling LGPL and GPL code, and I wanted that boundary to be obvious rather than smeared across one repo where nobody’s quite sure what’s covered by what. So the build tooling and the (L)GPL .wasm artifacts live in ffmpeg-wasi, with no grey areas, and afmpeg stays a clean permissive layer on top of the published artifact. I’m also shipping both LGPL and GPL builds of the artifact, so anyone who just wants the output and doesn’t fancy doing their own FFmpeg build can pick the licence that suits them and get on with it.

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.

There’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’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’t buy one off the shelf, so I built it in the open, and as of today it’s there to pull.

Built with Hugo
Theme Stack designed by Jimmy