DEV Community

XZMHXDXH
XZMHXDXH

Posted on

How I built a 100% local video compressor in the browser with FFmpeg.wasm (and what breaks)

If you’ve ever tried to compress a video online, you’ve probably hit at least one of these:

  • You don’t want to upload private footage to a random server
  • Uploading a 500MB file on hotel Wi‑Fi is a non-starter
  • “Free” tools add watermarks or hide the download behind paywalls

So I built a local-first video compressor: it runs entirely in the browser using FFmpeg.wasm. Your file never leaves your device.

Try it: https://compressvideo.net/

Source (demo repo): https://github.com/xzmhxdxh/compressvideo-wasm

What “100% local” really means

The UX is still “online” (a web page), but the video bytes stay local:

  1. You pick a video file
  2. The browser loads the FFmpeg WebAssembly core
  3. FFmpeg runs in your tab and writes the output into its in-memory filesystem
  4. You download the compressed MP4 from a Blob URL

No upload step, no server storage, no post-processing watermarking.

The minimal demo (Vite + React)

I kept the repo intentionally small: one page, one component, and a safe default preset.

  • Video codec: H.264 (MP4 output)
  • Mode: CRF-based compression (default CRF 28)
  • Resolution cap: 1080p (scale=-2:1080)
  • Audio: AAC 128k

If you want the full product UI (modes, advanced settings, i18n, etc.), use the hosted site. This repo focuses on the core engine loop.

Loading FFmpeg.wasm in a way that actually works

One common gotcha: you usually need to load the core from a URL, and many setups require converting it into a Blob URL first.

In this demo, I load the core directly from the official @ffmpeg/core package on unpkg:

TypeScript

const CORE_BASE = 'https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd'

const coreURL = await toBlobURL(`${CORE_BASE}/ffmpeg-core.js`, 'text/javascript')
const wasmURL = await toBlobURL(`${CORE_BASE}/ffmpeg-core.wasm`, 'application/wasm')
await ffmpeg.load({ coreURL, wasmURL })

Enter fullscreen mode Exit fullscreen mode

The compression loop

At a high level, local compression is just four steps:

Write the input file into FFmpeg FS
Run FFmpeg
Read the output file from FFmpeg FS
Create a downloadable Blob URL

TypeScript

await ffmpeg.writeFile(inputName, await fetchFile(file))
await ffmpeg.exec([
  '-i', inputName,
  '-vcodec', 'libx264',
  '-preset', 'fast',
  '-crf', '28',
  '-vf', 'scale=-2:1080',
  '-c:a', 'aac',
  '-b:a', '128k',
  'output.mp4',
])
const data = await ffmpeg.readFile('output.mp4')
const bytes = data as Uint8Array
const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
const url = URL.createObjectURL(new Blob([buf], { type: 'video/mp4' }))
Enter fullscreen mode Exit fullscreen mode

Progress UI (and why it can lie)

FFmpeg.wasm provides an encoding progress callback:

TypeScript

ffmpeg.on('progress', ({ progress }) => {
  setRunProgress(Math.round((progress ?? 0) * 100))
})
Enter fullscreen mode Exit fullscreen mode

It’s useful, but you should treat it as “best effort”:

Progress may jump
Some steps (loading core, writing FS, reading FS) aren’t part of encoding progress
In the demo I show two independent progress bars: “Engine loading” and “Encoding”.

The real-world limits (what breaks)

1) Browser memory limits
Local compression means your browser must hold the input file (and often the output) in memory. Very large files can fail even on desktop browsers, and mobile browsers are much tighter.

2) iOS is the hardest environment
Safari on iPhone/iPad tends to kill heavy WebAssembly workloads earlier. If you need to reliably process large videos on iOS, a cloud fallback is often necessary.

3) CPU usage and battery
FFmpeg encoding is CPU-heavy. This is expected. For long videos, cloud processing can be significantly faster and will save battery.

When to choose local vs cloud

  • Choose local if privacy matters, or your file is moderate in size, or uploading is slow.
  • Choose cloud if you need speed, you’re on mobile, or the file is huge. My default is “local-first”, with a cloud fallback only when it’s clearly needed.

Try it + source

If you want, tell me your target use case (email attachments, social media uploads, archiving, etc.) and I’ll share an FFmpeg argument preset that matches it.

Top comments (0)