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:
- npm registry metadata.
npm view @ffmpeg/core licenseandnpm view @ffmpeg/core-mt licenseboth reportGPL-2.0-or-later. Not LGPL. For either package. - The upstream build recipe. ffmpegwasm/ffmpeg.wasm's own
Dockerfilebuilds 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-threadMakefiletargets share the same Dockerfile — the only difference is a pthreads flag. "mt" was never a license boundary, just a compile flag. - The compiled binary, asked directly. We loaded the actual
@ffmpeg/coreand@ffmpeg/core-mtwasm builds in a real Chromium instance (Playwright, not a description of Chromium — the literal engine that runs in end users' browsers) and ranffmpeg -encoders,-codecs, and-versionagainst them, the same way you'd interrogate a normal FFmpeg install. The version string printed its own configure flags back at us:
andffmpeg 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 ...-encoderslistedlibx264andlibx265right 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-builderandx265-builderDocker stages entirely — don't build the GPL encoders at all. - Drop
--enable-gpl,--enable-libx264,--enable-libx265from FFmpeg's ownconfigureinvocation, and add--disable-gpl --disable-nonfreeexplicitly instead of leaving it implicit. - Remove
-lx264and-lx265from 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-gplshows up in the reported configuration string, and--enable-gpldoes 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 forSharedArrayBufferis 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.