Showcase · Media library · Go

Media in pure Go

FFmpeg in pure Go with no install, no CGO and no disk: a WASM build of FFmpeg running over a virtualised, in-memory filesystem, so media processing ships as a single binary and stays sandboxed.

v0.16.1, released Pre-1.0Covers 2 projects

DocsRepository

The problem it exists for

keryx makes its promo reels with FFmpeg, and it used to do that by running the ffmpeg program, which needs real files on a real disk. So a project held only in memory couldn’t be rendered at all. The usual ways round that each had a catch: pure-Go bindings weren’t mature, CGO bindings break static cross-compiles, and the existing WebAssembly builds of FFmpeg were missing the crossfade filter and AAC audio, had nowhere to put files, and were pinned to FFmpeg 5.1, which no longer gets security fixes.

So the goal was FFmpeg in a Go program with no CGO, nothing installed on the host and no temp files. It’s two projects. ffmpeg-wasi builds the engine, and afmpeg runs it from Go.

How it works

ffmpeg-wasi: current FFmpeg in a sandbox

ffmpeg-wasi compiles FFmpeg’s libraries to WebAssembly and drives them with an engine of its own, which takes a structured job (inputs, filters, outputs) instead of an ffmpeg command line. What comes out is a single .wasm file that runs on any machine and any processor architecture with a WebAssembly runtime, and it tracks current FFmpeg (n9.0.1 today). That matters for a library whose whole job is reading media from strangers.

The sandbox is the point. The engine sees only the files it’s handed, and nothing else on the machine. Its memory is capped, so a crafted file that claims enormous frame sizes and asks for gigabytes gets an allocation failure inside the sandbox, and the job fails. Without the cap that allocation lands in the host process, the kernel kills it, and every other request in flight goes with it.

afmpeg: running it from Go

afmpeg runs that engine inside the Go program under wazero, a WebAssembly runtime written in Go. Every file the engine opens is answered from a filesystem the caller hands it, an in-memory one or anything else, and each job gets fresh in-memory scratch space, so FFmpeg believes it has a disk and never touches one (FFmpeg thinks it has a disk). The limits are on before you ask: 512 MB of memory, an hour per job, and one job at a time per runtime.

The native driver: same engine, native speed

The sandbox has a price. WebAssembly here means one thread and no SIMD (the processor instructions that do many sums at once), and video encoders lean on both. So ffmpeg-wasi also compiles the same engine as a native Linux program with real threads and SIMD, and afmpeg runs it as a separate process, still without CGO. The caller’s filesystem is served to it over a local socket, with reads, writes and seeks each sent as a message, so even the way an MP4 writer jumps back to patch its header goes through the caller’s filesystem. Nothing touches the host disk on this path either.

The custom driver is what makes this work. Against the sandboxed engine it encodes about 50 times faster with openh264 and about 170 times faster with libx264. Against the host’s own ffmpeg program, on the same jobs, it took between a fifth of the time and slightly under the same time (the speed report). That comparison isn’t exact: the host program was an older FFmpeg, and it used temp files where the driver stayed in memory. The fair reading is that the socket bridge and the no-disk guarantee cost nothing measurable, and the driver runs at native speed. It also carries the heavy HEVC and AV1 encoders that are impractical in WebAssembly.

The driver isn’t a sandbox, so it’s fenced in instead. On Linux the kernel’s Landlock file restrictions limit what it can open, and any access it can’t account for is fatal rather than silent.

Signed from source to runtime

Every input to the build is pinned: the toolchain image, the FFmpeg tag, and each codec library by tag, commit or checksum. Downloaded source has to match a hard-coded checksum, and a mirror that disagrees is rejected. Each release publishes a checksum file covering every .wasm module, every native driver and the build record, and one OpenPGP signature over that file certifies the lot.

The signing key lives in AWS KMS and never leaves it, and only this project’s release-tag pipeline can use it, by proving its identity to AWS rather than holding a credential (signing and trust covers how). afmpeg carries the public key inside its own code and cross-checks it against a copy published on phpboyscout.uk, which GitLab doesn’t control. It checks the signature and the checksum before an engine or a driver is written to disk or run, so a program using afmpeg never runs an engine it hasn’t verified.

Decisions and what they cost

  • Libraries, not the program. Linking FFmpeg’s libraries into an engine of its own beat compiling the ffmpeg command-line program, which would have meant either an end-of-life FFmpeg or a multithreaded version the WebAssembly runtime can’t run (spec 0007). What it cost: a C engine and a versioned job vocabulary to own, and ffmpeg command lines aren’t accepted.
  • Licences kept apart. The Go package stays MIT, and the engine is a separate, signature-checked download that’s never embedded, so FFmpeg’s GPL or LGPL terms land only on whoever chooses and bundles an engine (spec 0001). What it cost: you have to pick and fetch an engine, and without one the library won’t start.
  • Native, but no CGO. The fast path is the same engine in a separate process rather than FFmpeg linked in through CGO, which would taint the MIT licence and lose the static cross-compile (spec 0028). What it cost: a socket protocol to own, the memory cap and engine progress don’t apply to the driver, and it’s published for Linux on amd64 only.
  • Fence the native driver. After two independent architecture reviews threw out two earlier designs, the native driver’s file access is restricted by default and any access it can’t account for is fatal (spec 0043). The escape hatch that turned out to be french doors is how that came about.

Proof in use

  • keryx renders its reels with it, fetching the engine as a signed download.
  • 22 releases of afmpeg and 20 of the engine, which is on FFmpeg n9.0.1. Every engine release, native drivers included, is covered by one signature.
  • The speed gap was re-measured across a major FFmpeg version and didn’t move, because it comes from WebAssembly’s single thread and missing SIMD rather than from FFmpeg.
  • The lean engine is about 5 to 6 MB to download.

Use it when, and when not to

Use it if a Go program needs to transcode, mix or cut media without asking anyone to install FFmpeg, and without CGO or temp files. Reach for the sandboxed engine when the media comes from people you don’t trust or the program has to run anywhere, and for the native driver when speed matters and you’re on Linux.

The sandboxed engine is slow on encodes, roughly 50 to 170 times slower than the native driver, which is fine for short clips and a reason to switch for anything heavy. Decoding and filtering cost far less. There’s no network streaming, GPU encoding or capture devices, HEVC and AV1 encoding exist only in the native driver, jobs run one at a time per runtime, and the native driver is Linux amd64 only. The limitations page has the rest.

Where it’s going

Better audio and video sync is in review. Multithreading inside the WebAssembly engine, progress reporting from the native driver and a command-line tool for people who’d rather not write Go are all drafted and parked, each until something needs it.

What it covers

The story in posts

Last reviewed .