DEV Community

강명석
강명석

Posted on

Four places ffmpeg.wasm fails silently in a Next.js app (and the fixes)

I shipped four browser-only video tools with ffmpeg.wasm: trim, compress, video-to-GIF and MP3 extraction. Files never leave the browser, nothing to install.

Getting there, I hit four walls. Every one of them surfaced as a single "conversion failed" line in the UI and nothing in the console. Writing them down for the next person. Stack: Next.js App Router + webpack, @ffmpeg/ffmpeg 0.12, self-hosted core.

1. webpack hijacks the dynamic import inside the worker

@ffmpeg/ffmpeg spawns its worker like this:

new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
Enter fullscreen mode Exit fullscreen mode

webpack recognises the pattern and bundles the worker. Fine. But it also rewrites the import(coreURL) inside that worker to go through its own module loader. The core URL arrives at runtime as a blob: URL, which webpack's loader has never heard of, so it dies with Cannot find module 'blob:...'. The error is thrown inside the worker, so the main-thread console stays empty.

Fix: keep the worker out of the bundle. Copy node_modules/@ffmpeg/ffmpeg/dist/esm/worker.js to public/ffmpeg/<version>/lib/ and pass it via classWorkerURL in load(). Now the untouched worker runs.

2. classWorkerURL needs the origin

Passing a path like /ffmpeg/0.12.x/lib/worker.js is not enough. The library resolves it with new URL(classWorkerURL, import.meta.url), and inside the bundle import.meta.url is a build-time file:///C:/... path. So it goes looking for file:///C:/ffmpeg/... and fails.

const BASE = `/ffmpeg/${FFMPEG_VERSION}`;
await ffmpeg.load({
  coreURL: `${location.origin}${BASE}/core/ffmpeg-core.js`,
  wasmURL: `${location.origin}${BASE}/core/ffmpeg-core.wasm`,
  classWorkerURL: `${location.origin}${BASE}/lib/worker.js`,
});
Enter fullscreen mode Exit fullscreen mode

Prefix location.origin and it works.

3. You cannot build a GIF palette with -vf

For decent GIF quality you run palettegen first and paletteuse second. Doing it in one pass needs split to fork the stream, and a filter graph with two outputs is something -vf cannot take. It fails silently.

# does not work
-vf "fps=12,scale=480:-1,split[a][b];[a]palettegen[p];[b][p]paletteuse"

# works
-filter_complex "[0:v]fps=12,scale=480:-1,split[a][b];[a]palettegen[p];[b][p]paletteuse"
Enter fullscreen mode Exit fullscreen mode

Switch to -filter_complex and label the input [0:v].

4. Permissions-Policy: display-capture

This one is from the screen-recorder next door. The security headers had display-capture=(). With that, getDisplayMedia() rejects immediately without showing the permission prompt. To the user it looks like the button does nothing. It has to be display-capture=(self). Same story for camera and microphone.

Bonus: there is no denominator for the progress bar

The core wasm is 30+ MB. Over compressed transfer there is no Content-Length, so you have nothing to divide by. I keep the uncompressed wasm size as a constant and use that. The byte count you read from fetch is post-decompression, so the units match. Actual transfer is about 9.8 MB gzip, 8 MB brotli.

Testing with a canvas-generated video

Tools that need a file are awkward to test automatically. A canvas that changes background colour every second, recorded with captureStream() + MediaRecorder into a 6-second clip and injected via DataTransfer, turns out to be enough. Trim is verified by reading pixel colours from the output (cut 2–4 s, first frame green, last frame blue). GIFs are verified by counting 21 F9 04 (frames) and 21 FF 0B (loop block) in the bytes. One gotcha: requestAnimationFrame pauses in background tabs, so draw with setInterval.

Everything is live. If a file fails, tell me the format.

Top comments (0)