NearVid

2026-07-20

How we built a GPL-free ffmpeg.wasm (and the -lpostproc bug that nearly stopped us)

NearVid trims and converts video entirely in the browser — no upload, ever. To do that we needed a real video engine running as WebAssembly, and the obvious choice was ffmpeg.wasm, specifically the official @ffmpeg/core / @ffmpeg/core-mt npm packages. Our product spec assumed @ffmpeg/core-mt was LGPL — permissive enough to ship in a closed-source app. It isn't. Here's how we found that out, why the obvious workaround doesn't work, the self-built replacement we shipped instead, and a genuinely dumb linker error we hit along the way.

The assumption that turned out to be wrong

"-mt" in @ffmpeg/core-mt stands for "multi-thread." We — like, we'd guess, most people skimming the package list — read that as a build variant, and assumed the non-multi-thread @ffmpeg/core was the "plain," presumably LGPL-only build and core-mt just added threading on top. Before shipping on that assumption, we checked it three independent ways instead of trusting the package name:

  1. npm registry metadata. npm view @ffmpeg/core license and npm view @ffmpeg/core-mt license both report GPL-2.0-or-later. Not LGPL. For either package.
  2. The upstream build recipe. ffmpegwasm/ffmpeg.wasm's own Dockerfile builds libx264 and libx265 (both GPL-only encoders) into every core it produces, and configures FFmpeg itself with --enable-gpl --enable-libx264 --enable-libx265. The single-thread and multi-thread Makefile targets share the same Dockerfile — the only difference is a pthreads flag. "mt" was never a license boundary, just a compile flag.
  3. The compiled binary, asked directly. We loaded the actual @ffmpeg/core and @ffmpeg/core-mt wasm builds in a real Chromium instance (Playwright, not a description of Chromium — the literal engine that runs in end users' browsers) and ran ffmpeg -encoders, -codecs, and -version against them, the same way you'd interrogate a normal FFmpeg install. The version string printed its own configure flags back at us:
    ffmpeg version 5.1.4 Copyright (c) 2000-2023 the FFmpeg developers
    configuration: ... --enable-gpl --enable-libx264 --enable-libx265 --enable-libvpx
      --enable-libmp3lame --enable-libtheora --enable-libvorbis --enable-libopus --enable-zlib ...
    and -encoders listed libx264 and libx265 right there in the output. Not inferred from a README — read straight out of the binary we'd be shipping.

Three sources, same answer: both official packages are full GPL builds. Distributing either one inside a closed-source app would mean distributing GPL-licensed code under terms it doesn't permit.

Why "just don't call the GPL parts" doesn't work

The first instinct — ours included — was: what if NearVid only exposes trim (stream-copy, no re-encoding) in the UI? Surely if we never call the H.264 encoder, GPL doesn't apply?

That's a category error. GPL obligations attach to what code gets compiled into the binary you distribute, not to which functions a particular user session happens to invoke at runtime. ffmpeg-core.wasm is one statically-linked blob — libavcodec, libx264, libx265, and everything else, all baked into a single file that ships to every visitor's browser. Hiding the re-encode button in the UI doesn't remove libx264 from that file; the GPL code is still being distributed, whether or not a given user's click path ever reaches it. FFmpeg's own license page is explicit about this, and its compliance checklist's first item is blunt: compile without --enable-gpl and without --enable-nonfree in the first place. There's no UI-level fix for a compile-time problem.

The fix: build our own core, minus the GPL parts

The good news is that ffmpegwasm/ffmpeg.wasm's Docker-based build pipeline is a genuinely reusable template — you don't have to reinvent the WASM/Emscripten plumbing, just change what gets compiled in. Starting from their Dockerfile at a pinned commit, the diff to get an LGPL-only core is small and mechanical:

  • Delete the x264-builder and x265-builder Docker stages entirely — don't build the GPL encoders at all.
  • Drop --enable-gpl, --enable-libx264, --enable-libx265 from FFmpeg's own configure invocation, and add --disable-gpl --disable-nonfree explicitly instead of leaving it implicit.
  • Remove -lx264 and -lx265 from the final wasm link step (FFMPEG_LIBS).

Everything else in the dependency graph — libvpx (BSD), libmp3lame (LGPL), ogg/theora/vorbis (BSD-style), opus (BSD), zlib, libwebp (BSD), freetype/fribidi/harfbuzz/libass (permissive/LGPL), zimg (WTFPL/BSD-2) — stays exactly as upstream ships it. None of those are GPL, so their presence doesn't trigger copyleft on the resulting binary; there was no reason to touch them.

The bug: a linker error that had nothing to do with what we'd actually changed

First build attempt, after stripping the GPL pieces:

wasm-ld: error: unable to find library -lpostproc

Which is a strange thing to fail on — we hadn't touched libpostproc at all. It turned out to be a second-order consequence of the GPL strip, not a mistake in it. The generic upstream link script (build/ffmpeg-wasm.sh) unconditionally passes -lpostproc to the linker, on the assumption that libpostproc.a will always exist by the time linking happens. Normally it does. But libpostproc is itself GPL-only in FFmpeg — its configure dependency line reads postproc_deps="avutil gpl", so --enable-postproc hard-fails without --enable-gpl, and postprocess.c carries its own GPLv2-or-later header. Our Dockerfile's FFmpeg-builder stage passes --disable-gpl and never passes --enable-postproc, so libpostproc.a is legitimately never built in this configuration — but the link script still expected it to be there. A library the configure step correctly refused to build, that the link step unconditionally assumed existed.

The fix was equally small once the cause was clear: delete -Llibpostproc and -lpostproc from the link flags. NearVid's trim/convert/GIF pipeline never used libpostproc's video-filtering functions anyway, so there was no functionality to route around — just a stale link-time assumption to remove.

Verifying it actually worked — not just that it compiled

A GPL-free build that silently doesn't work is worse than useless, so we didn't stop at "the Docker build exited 0." We loaded the compiled core in real Playwright Chromium and ran the same -encoders / -codecs / -version probe from earlier against our own binary this time. The results:

  • libx264 present: false, libx265 present: false — confirmed by actually running the compiled wasm, not by re-reading the Dockerfile.
  • --disable-gpl shows up in the reported configuration string, and --enable-gpl does not.
  • Full pipeline exercised end to end against a real H.264 fixture: load (563 ms) → -encoders/-codecs/-version → load fixture → trim via stream copy (260 ms) → convert to WebM (7.22 s) → convert to GIF (158 ms). All on a 4-core sandbox VM — real device numbers, not estimates, though we haven't yet re-measured on low-end mobile hardware, which is a known gap we're not papering over.
  • Threading verified live too: crossOriginIsolated: true, sharedArrayBufferAvailable: true, worker pool actually spinning up — the COEP/COOP header requirement for SharedArrayBuffer is a hard, non-negotiable browser requirement for the multi-threaded build, confirmed by the exact same "load fails immediately, not silently" behavior without those headers that the original spike also found.

What this build can't do, on purpose

Being upfront about the tradeoff: this core cannot re-encode video to MP4 or MOV with H.264 or H.265 output. That's not an oversight — FFmpeg simply has no non-GPL H.264/H.265 encoder to fall back to; libx264/libx265 are the only source-available encoder path for those codecs, and they're exactly what we removed. What still works without any caveat: lossless stream-copy trimming of any container, decoding H.264/H.265 sources (the native FFmpeg decoder is separate from the GPL encoders and stays LGPL), encoding to WebM (VP8/VP9 + Opus/Vorbis, all permissive licenses), and GIF export. NearVid separately added a second, independent pipeline for real browser-native MP4 output using the WebCodecs API — that calls the browser's own licensed H.264 implementation instead of bundling one, sidesteps the GPL question entirely, and is a genuinely different piece of engineering with its own story. That's a topic for another post, not this one.

The compliance part that's easy to forget

LGPL's own compliance checklist recommends dynamic linking but explicitly says it isn't the only path to compliance — source-availability obligations are the alternative, and WASM effectively forces static linking anyway. So the shipped core is paired with a pointer to the exact upstream commit the build started from, the full modified Dockerfile kept alongside the build output for reproducibility, and an LGPL notice due on the app's about/licenses page before this ships publicly. Standard open-source attribution hygiene — not exotic, just easy to skip if you don't write it down as an explicit checklist item.

The honest summary

"@ffmpeg/core-mt is LGPL" was the wrong assumption, caught before shipping instead of after by literally asking the compiled binary rather than trusting a package name or a README's implication. The fix cost us MP4/MOV re-encode output from this particular engine (recovered separately via WebCodecs), and cost us one afternoon chasing a linker error that turned out to be a one-line, well-understood fix once we traced why the link script expected a library our own configure flags had correctly never built. Neither the discovery nor the bug fix required guessing — both came from running the actual thing and reading what it said back.

Sponsored
← NearVid

This page shows ads only if you consent.