Shipping a Rust MP4 Parser to the Browser with WebAssembly

July 26, 2026

Asking someone to download and install a desktop app before they know whether it solves their problem is a lot to ask. Screenshots do not really help. They prove a UI exists, not that it works on your file.

So Video Commander now has a playground: drop an MP4 into a browser tab and get the real Inspect module, running the same Rust parser the desktop app uses, with nothing uploaded anywhere.

The Inspect overview, running in the browser

This is a writeup of how it works, because the interesting part turned out not to be the WebAssembly. It was finding the one seam in the app where the browser could be spliced in.

The rule: no fork

The obvious way to build a web demo is to write a web demo. A trimmed-down parser, a simplified UI, some sample output.

I did not want that. A rewritten demo drifts from the product within two releases, and then it is a liability: it shows people something that is no longer true. The demo had to be the actual app, built from the actual source, or it was not worth doing.

That constraint drove every decision below.

Teaching the Rust core that it might not be native

The inspection engine lives in a crate called vc-mp4. It reads MP4 files from paths, and it also fetches them over HTTP with reqwest, which pulls in tokio. None of that compiles for wasm32-unknown-unknown in any form you would want to ship.

The fix was a default cargo feature:

[features]
default = ["native"]
native = ["dep:reqwest", "dep:tokio"]

Desktop builds get native and behave exactly as before. The wasm build turns it off, and the HTTP reader, the edit paths, and the manifest code all disappear behind #[cfg(feature = "native")].

That leaves the question of where the bytes come from. Every public function in vc-mp4 takes a source path, and I was not going to rewrite that signature across the whole crate. Instead, the reader grew an in-memory registry:

pub fn register_memory_source(source_path: &str, bytes: Vec<u8>);

Register some bytes under /Local Files/clip.mp4, and every path-based function in the crate resolves that path against memory instead of the filesystem. The parser has no idea it is running in a browser. It just opens a path like it always has.

This is the piece I would recommend to anyone attempting the same thing. Do not port your core to the browser. Give it one indirection at the I/O boundary and let it stay ignorant.

A wasm crate that speaks JSON

vc-wasm is a small wasm-bindgen crate that exposes the inspect command set. It is deliberately its own cargo workspace, excluded from the desktop workspace, so a cargo build for the app never tries to compile it.

Every function returns a JSON string:

#[wasm_bindgen]
pub fn get_boxes(source_path: &str, decode: bool) -> Result<String, JsValue>

Returning JSON rather than modeling types across the wasm-bindgen boundary is not elegant, but it is the same shape the desktop app already gets back from its IPC layer, which is the whole point. It means the TypeScript on the other side needs zero changes.

Built with wasm-pack at opt-level = "s" with LTO, the result is 583 KB of wasm, 234 KB gzipped. For a full ISOBMFF parser that is a fine trade.

The seam that made the UI free

Here is the part I got lucky on.

Video Commander is a Tauri app. Every call from the React frontend to the Rust backend goes through one function:

import { invoke } from '@tauri-apps/api/core';

One import. One choke point. Every view, every panel, every tab in the app funnels through it.

So the web build aliases that module to a shim:

// vite.web.config.ts
resolve: {
  alias: {
    '@tauri-apps/api/core': path.resolve(__dirname, 'src/web/shims/tauri-core.ts'),
    // ...
  },
}

And the shim dispatches to wasm instead of IPC:

export async function invoke<T>(cmd: string, args?: Args): Promise<T> {
  await ensureWasm();
  const src = args?.sourcePath ?? '';
  if (src) await ensureSource(src);

  switch (cmd) {
    case 'get_media_info':
      return json<T>(vc.get_media_info(src));
    case 'get_boxes':
      return json<T>(vc.get_boxes(src, args?.decode ?? false));
    // ...
  }
}

That is the trick. The box tree, the sample table, the GOP view, the size graph, the hex dump panel, the DRM tab: all of it is the unmodified product code, importing the same commands.ts it always did, unaware that the answers are now coming from a wasm module in the same tab rather than a Rust process over IPC.

The other Tauri plugins got the same treatment. plugin-store becomes localStorage. plugin-log becomes the console. plugin-dialog is my favorite one: its open() becomes a real browser file picker that reads the chosen file into wasm memory and returns a path like /Local Files/clip.mp4, so the caller genuinely cannot tell it did not get a native file dialog.

The shell is the real one too. WebShell.tsx reuses the app's actual sidebar, project tree, tab strip, and navigation. Even the title bar is replicated, fake traffic lights and all, with a "Demo" badge where the Pro badge goes.

Removing what the browser cannot do

Convert, Analyze, and Delivery are all FFmpeg work, and FFmpeg is not going into this bundle.

Player and Tools are stubbed for different reasons. The player is a <video> element pointed at a Tauri asset URL, and demo sources live in wasm memory with no URL to point at. Tools is only the PSSH decoder, which the wasm build already exposes; it is stubbed to keep the demo's surface small, and it is the one panel I could switch on tomorrow.

Rather than conditionally rendering around any of them, a tiny vite plugin swaps the modules at resolve time:

const replacements = {
  './convert/ConvertView': stub,
  './analyze/AnalyzeView': stub,
  './player/PlayerView': stub,
  './delivery/DeliveryView': stub,
  './tools/ToolsView': stub,
};

The tabs still render, in the right order, looking exactly like the desktop app. Clicking one gives you a panel explaining that it needs the desktop app. The demo shows you the shape of the whole product while being honest about which quarter of it actually runs here.

Reading a 158 MB file you never downloaded

The playground's default tab opens Big Buck Bunny from a URL. The file is 158 MB, and loading the overview transfers about 8 MB of it.

This was the ugliest part of the project, because vc-mp4's reader trait is blocking:

pub trait ReadSeek: Read + Seek {}

Blocking reads, inside wasm, where the only fetch API is async. I went with synchronous XMLHttpRequest: deprecated, blocks the tab, and exactly the right tool here, since an inspect pass reads under a megabyte in small range requests. Behind it sits 256 KB paging and a per-URL cache, so repeated commands do not re-probe the file size and re-fetch the same windows.

Then CORS. Chromium worked immediately, since HEAD plus single-range GETs skip preflight entirely. Firefox did not, and it was right: the host was not exposing Content-Length or Content-Range, so the size probe had nothing to read. Test in Firefox early. It will find your CORS gaps for you.

The small things that broke

A short list, in case any of these save you an afternoon:

  • Several views use router hooks, so the web entry needs a MemoryRouter wrapping everything. There is no URL to route against.
  • The desktop command layer adds a supported: true field to report responses that the raw serde structs do not carry. The wasm path bypasses that layer, so the TypeScript dispatcher has to inject it. This one cost me a while, since the symptom was a blank panel with no error.
  • Clicking a file in the sidebar stages it without loading it on desktop, which is correct there and confusing in a demo. The web shell wraps the handler to auto-load.
  • Telemetry commands are no-oped rather than left to throw, so the console stays clean.
  • The demo never loads on screens under 1024px. It is a desktop app UI and it needs the room. Note that a hidden iframe still downloads its payload, so the check has to happen before the iframe renders, not in CSS.
  • Files are capped at 500 MB, since the whole thing sits in wasm memory. The desktop app has no such limit.

Was it worth it

The build is one command, and the demo tracks the product automatically because it is the product. That was the entire goal, and the cargo feature plus the memory registry are what bought it.

If you want to see the result, the playground is live. Drop in one of your own files. Nothing leaves your machine, which is easy to claim and easy to verify: open the network tab and watch it stay quiet.

For everything the browser cannot do, including MKV and AVI inspection, encoding, VMAF analysis, stream validation, and remote S3 browsing, there is the desktop app.