DEV Community

Cover image for I replaced FFmpeg.wasm with WebCodecs in my browser video tools. Compression went from 97 s to 5.6 s
Muhammad Afan
Muhammad Afan

Posted on

I replaced FFmpeg.wasm with WebCodecs in my browser video tools. Compression went from 97 s to 5.6 s

I run DashUtils, a site with 392 free file tools: image and PDF converters, a video compressor, an MP3 cutter and so on. The rule for every tool is simple. Nothing gets uploaded; the work happens in your browser.

Until last week, every video and audio tool ran on FFmpeg.wasm. It handles every format you can think of, but it has costs:

  • A 31 MB engine (about 10 MB compressed) has to download before the first job starts.
  • One CPU thread. The multi-threaded build needs SharedArrayBuffer, which means cross-origin isolation, which fights with third-party embeds.
  • No hardware acceleration.

I tried my own MP4-to-GIF tool and closed the tab before it finished. That was the push I needed.

The switch: WebCodecs, through Mediabunny

WebCodecs gives JavaScript the browser's own video and audio encoders and decoders, using the GPU where the platform allows. On its own it only handles raw frames, so I used Mediabunny (MPL-2.0) for reading and writing MP4, MOV, WebM, MKV, MP3, WAV, OGG and FLAC. Its core is about 140 KB compressed, and it's only loaded when someone opens a file.

The core of a conversion is short:

import { ALL_FORMATS, BlobSource, BufferTarget, Conversion, Input, Mp4OutputFormat, Output, QUALITY_MEDIUM } from "mediabunny";

const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS });
const target = new BufferTarget();
const output = new Output({ format: new Mp4OutputFormat({ fastStart: "in-memory" }), target });

const conversion = await Conversion.init({
  input, output,
  video: { codec: "avc", height: 720, quality: QUALITY_MEDIUM },
  audio: { codec: "aac" },
});

// A track we can't encode here gets discarded, not converted. Treat that as "not supported".
if (!conversion.isValid || conversion.discardedTracks.some((t) => t.reason !== "discarded_by_user")) {
  return runWithFFmpeg(file);
}
await conversion.execute();
const mp4 = new Blob([target.buffer!], { type: "video/mp4" });
Enter fullscreen mode Exit fullscreen mode

FFmpeg.wasm didn't go away. It's the fallback for AVI, FLV and WMV, which browsers can't read, and for the speed and volume filters.

Five things that quietly made it slow

1. A silent fallback

The first benchmark looked great everywhere except compression, which still took 97 seconds. The reason: Chrome on Linux can't encode AAC audio. Mediabunny discarded the audio track, my check saw that, and the job fell back to FFmpeg without telling anyone.

The fix was to check canEncodeAudio("aac") and, only when it's missing, register @mediabunny/aac-encoder, a WebAssembly encoder of about 260 KB compressed. After that, compression took 5.6 seconds. Lesson: log which engine actually ran, not which one you expected.

2. The progress bar was the bottleneck

onProgress fires for every frame and every audio chunk. I passed it straight into React state, so converting 30 seconds of MP3 to WAV re-rendered the page about 1,150 times. The conversion itself took 0.43 seconds; the page took 5.1. Throttling progress to ten updates a second brought it to 0.62 seconds.

3. Browser encoders have a bitrate floor

To fit a video under Discord's limit, you work backwards: bitrate = size รท duration. At 1080p, Chrome's software H.264 encoder simply ignored targets below about 2 Mbps. A "10 MB" export came out at 14.7 MB, even after re-encoding at lower bitrates.

Now the compressor picks the resolution from the budget (roughly 0.04 bits per pixel per frame), checks the result, and re-encodes smaller only if it's still over. A 60-second 1080p clip lands at 9.3 MB, scaled to 720p, in 21 seconds.

4. Reading pixels back from the GPU

GIFs need raw pixels for colour quantisation. getImageData() on the canvas the decoder drew into cost about 45 ms per frame, because it forces a GPU-to-CPU copy. createImageBitmap(frame, { resizeWidth, resizeHeight }) followed by a willReadFrequently canvas was about three times faster. Moving the quantisation (gifenc) into a pool of workers, one per core, took a 72-frame portrait GIF from 15.2 to 3.5 seconds.

5. Asking for a bitrate turned copies into encodes

Trimming a clip should only copy data. But I always passed an audio bitrate, and that made Mediabunny re-encode the audio. Setting the bitrate only when the audio really is being transcoded took a trim from 5.5 seconds to 0.31.

Results

Before and after: in-browser video jobs with FFmpeg.wasm vs WebCodecs

All measured in the browser on a 2016 laptop (Intel i7-6820HQ, Linux, no hardware video encoder). "Before" is FFmpeg.wasm for compression and my first WebCodecs version for the others.

Job Before After
Compress a 20 s 1080p clip 97.1 s 5.6 s
72-frame portrait GIF 15.2 s 3.5 s
Trim a 20 s clip 5.5 s 0.31 s
30 s MP3 to WAV 5.1 s 0.62 s

Opening a file went from "download 31 MB first" to about a tenth of a second.

What I'd tell anyone trying this

  • Detect support per codec, not per API. VideoEncoder existing tells you nothing about AAC or HEVC.
  • Keep the old engine as a fallback, and make the switch visible in your logs.
  • Test on your slowest supported setup. Every problem above was invisible on a fast Mac.
  • Throttle anything that fires per frame before it reaches your UI.

If you want to try it, the video compressor and MP4 to GIF are the best demos. I'd love to hear how they run on your machine.

Top comments (0)