All posts
FFmpegEngineering14 Sep 2026 · 4 min read

Three places to run the same FFmpeg command

JPJean Perez

ffmpeg -i input.mp4 -vf "scale=1280:720" -c:v libx264 -preset veryfast output.mp4 means the same thing everywhere. Scale the video, encode it with libx264, write an MP4. The command stays put. What changes is the machine underneath it when it runs.

FFmpeg is a program, and a program has to run somewhere: a process with a filesystem, a CPU, sometimes a GPU. A browser tab, a desktop app, and a small local server are three very different somewheres, and none of them give FFmpeg the same amount of machine to work with.

The browser gives it a sandbox

Compiled to WebAssembly, FFmpeg runs inside the browser's own sandbox as a portable binary the browser itself executes, no install required. That's the appeal: a person opens a page and FFmpeg is just there.

The sandbox is also the limit. FFmpeg can't touch the real filesystem, so the file it's working on has to be copied into an in-memory filesystem first, as bytes. The output comes back the same way, as bytes held in memory rather than a file written to disk. A video large enough to exceed what the tab can hold in memory simply doesn't fit, and there's no hardware encoder to reach for, because reaching a GPU's encode block means calling an OS-level API, and WebAssembly in a browser sandbox has no path to one. Everything runs on the CPU, through whichever encoders the WASM build was compiled with, and by default that's one CPU core: multi-threaded WASM needs SharedArrayBuffer, which needs the page to be cross-origin isolated, which needs the server to send the right Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers.

A desktop app gives it a real machine

Bundle a native FFmpeg binary inside a desktop app instead, and the constraints mostly disappear. The binary runs as a real OS process with a real filesystem: it reads the input file directly off disk and writes the output the same way, with no requirement that the whole thing fit in memory at once. It has whatever encoders it was compiled with, which can include hardware encoders like VideoToolbox on macOS or NVENC on a machine with an NVIDIA GPU, since a native process can make the OS-level calls those need.

The tradeoff moves from the encode itself to the boundary around it. A desktop app's UI usually still runs web code, in Electron's case a Chromium renderer process talking to FFmpeg through an IPC channel to a separate main process that actually spawns it. That boundary is worth defending deliberately: BeemMeUp's version never hands the renderer a real filesystem path, only an opaque token the main process resolves, so a compromised page can't use the IPC bridge to read arbitrary files off the user's disk.

A local bridge gives a plain tab the same machine

There's a third option that gets the desktop app's file access and hardware encoding without requiring a desktop app at all: run a small HTTP server on the user's own machine, and have an ordinary browser tab talk to it over localhost. The tab still runs in a browser sandbox, but the command it asks the server to run executes as a native process on real hardware, the same as the desktop case.

This only works if the server refuses almost everything that isn't the browser tab it's meant to serve. Binding to 127.0.0.1 and nothing else keeps it off the network. A Host header allowlist stops DNS rebinding, where an attacker's domain is made to resolve to 127.0.0.1 so their page can reach the local server as if it were its own origin. The requests still arrive carrying the attacker's hostname, and the allowlist rejects them. An Origin allowlist, matched exactly rather than as a wildcard, stops any other open tab from making the same request. None of that is optional once the server exists: an HTTP server on a well-known port is a standing target for any page in any tab, not just the one it's meant for.

The three side by side

Where it runsFile accessHardware encodingMemory bound
WASM in the browserin-memory bytes, copied in and outnotab's memory limit
Native binary in a desktop appreal files, direct disk I/Oyes, if compiled inmachine's RAM and disk
Native binary via a local bridgereal files, direct disk I/Oyes, if compiled inmachine's RAM and disk

The desktop and bridge rows look identical because they are: both hand the same compiled command to a real FFmpeg process on real hardware. The only difference is how the browser reaches that process: IPC inside one app, or HTTP to a server on the same machine.

None of the three is the "right" one in general. A tutorial that has to run for a visitor with nothing installed needs the browser tab, because it works with zero setup and the file sizes involved are small. A batch job on hours of 4K footage needs real disk I/O and probably a hardware encoder, which rules the sandbox out entirely. Picking one means picking which constraint you can live with.

What makes offering all three possible from one codebase is that the command itself doesn't change. The part of the system that turns a filter graph into an ffmpeg command line has no idea, and doesn't need to know, whether the string it produces is about to run in a WASM worker, get shipped across IPC to a desktop process, or get POSTed to a bridge on 127.0.0.1. It's the same command, running in three different amounts of machine.