All posts
EngineeringFFmpeg14 Sep 2026 · 3 min read

Three ways to run one command

JPJean Perez

RunEngine didn't add a new way to run FFmpeg. It named the one that already existed.

Until then the Studio had exactly one execution path: compile the graph to a command, hand it to a WASM worker in the browser, get bytes back. Nobody had written that path as an abstraction. It was just how store.ts worked. Extracting RunEngine out of it was mechanical: give the existing wasm path a name (FFRun), define the interface it already satisfied, and change nothing about its behavior. The refactor was the dull part. A named interface is a thing you can add two more implementations of, and that's what happened next.

Three engines, one compiled command

The Studio's graph compiler produces the same ffmpeg command regardless of where it's going to run. What changes is the engine that executes it.

EngineWhere it runsWhat a run returns
FFRunffmpeg.wasm in a browser Workerin-memory bytes plus an object URL
NativeRunnerElectron IPC to the desktop app's local FFmpega real filesystem path, no bytes cross into the browser
BridgeRunnera localhost HTTP server on 127.0.0.1:7341a result read back from an HTTP call to your own machine

FFRun is the only one of the three that's been there since day one. NativeRunner (BMU-133) drives the desktop app's Electron IPC. BridgeRunner (BMU-135) talks to @beemmeup/ffmpeg-bridge, a standalone package that runs a small HTTP server on your machine so a plain browser tab (no Electron) can drive your own FFmpeg install instead of the one bundled into wasm.

A RunFile from any of the three is typed with optional blob, url, and path fields, and every consumer checks which one it got: preview, download, the exports tray, What The FFMPEG's store, the tutorial challenges. A Local CLI output has a path and no bytes, so the download button becomes a "Reveal" button that opens the OS file browser instead. Nothing pretends the three engines are interchangeable at the byte level, because they aren't.

The IPC surface hides real paths on purpose

NativeRunner talks to the desktop app through a preload script, and the renderer process (the web code running inside Electron) never sees a real filesystem path. Every input and output crosses that boundary as an opaque token minted by the main process:

// apps/desktop/src/preload.ts
// Inputs/output-dir cross this boundary as opaque tokens minted by main —
// see src/local-runtime/tokens.ts. This preload layer never sees or
// handles real filesystem paths for them.
localRuntime: {
  capabilities: async (): Promise<LocalRuntimeCapabilities> => {
    return ipcRenderer.invoke('local-runtime:capabilities');
  },
  resolveInputs: async (
    names: string[]
  ): Promise<Record<string, string> | null> => {
    return ipcRenderer.invoke('local-runtime:resolve-inputs', names);
  },
  selectOutputDir: async (): Promise<string | null> => {
    return ipcRenderer.invoke('local-runtime:select-output-dir');
  },
  run: async (req) => ipcRenderer.invoke('local-runtime:run', req),
  cancel: async (id: string) => ipcRenderer.invoke('local-runtime:cancel', id),
  reveal: async (path: string) => ipcRenderer.invoke('local-runtime:reveal', path),
}

resolveInputs takes file names and hands back a token map; run takes tokens, not paths; reveal opens a path in the OS file browser only after main has already resolved it. The renderer asks for capabilities and results; main is the only process that ever touches a real path on disk.

The bridge has to defend itself

NativeRunner is safe by construction: it only exists inside Electron, talking to a process it's bundled with. BridgeRunner is a different problem, because @beemmeup/ffmpeg-bridge is a small HTTP server that any page in your browser could, in principle, try to talk to. It binds to 127.0.0.1 only and rejects any attempt to override the bind address. Every request is checked against a Host header allowlist (127.0.0.1:7341 or localhost:7341, nothing else) to stop DNS rebinding, and every Origin has to match an explicit list rather than a wildcard. Because the Studio itself runs under COEP: require-corp for the WASM engine's SharedArrayBuffer, the bridge also tags every response Cross-Origin-Resource-Policy: cross-origin, or the Studio's own isolation would refuse to read its answers.

The runtime cog, the picker that lets you choose an engine, only shows what's actually available. A plain browser tab sees one row: Browser · WASM. Local CLI is missing from that list entirely, since there's no Electron IPC to offer it over. Open the same Studio inside the desktop app and Local CLI appears. Either context adds a Bridge row the moment the localhost server answers a probe.

@beemmeup/ffmpeg-bridge isn't on npm yet. npm view @beemmeup/ffmpeg-bridge returns a 404, same as @beemmeup/ffmpeg-runner, the core package both the desktop app and the bridge share so the two can't drift apart. The /download page's own instructions for running the bridge assume a package that doesn't resolve. Until it's published, the Bridge row is something only I can see.