I run SquishyFile, a video compressor that works entirely in the browser. Most files go through WebCodecs with Mediabunny. Files the browser can't decode natively, like AVI, MPG, MTS and FLV, fall back to ffmpeg.wasm. The same fallback kicks in when a browser has no working H.264 encoder.
Last week a user sent me a bug report. A 2.24 GB .avi failed in Chromium with my generic "Something went wrong" message, before encoding even started. They had already traced it to one line in my code, and they were right.
The line
await ffmpeg.writeFile(inName, new Uint8Array(await file.arrayBuffer()));
Almost every ffmpeg.wasm example I found online uses this pattern, and I copied it without thinking. It does two expensive things:
-
file.arrayBuffer()reads the entire file into one JavaScript buffer. In Chromium that read fails at just under 2 GiB withNotReadableError. -
writeFilethen copies those bytes into MEMFS, ffmpeg's in-memory filesystem. MEMFS lives on the wasm heap, and a 32-bit wasm module can't address more than 4 GB in total. The input, the output and the codec buffers all fight for that space.
So even if step 1 passed, step 2 would hit a wall soon after. My probe step, which runs ffmpeg -i to read duration and resolution, used the same line. That's why the error showed up before any encoding.
I had the same code in two other tools on the site, MP4 to audio and video to MP3. Same bug, nobody had reported it there yet.
WORKERFS
Emscripten ships a filesystem called WORKERFS. Instead of copying a File into memory, it mounts the File object and reads slices from it on demand with FileReaderSync inside the worker. ffmpeg sees a normal path and seeks around in it as usual. @ffmpeg/ffmpeg 0.12 exposes it through ffmpeg.mount().
Before switching, I checked that the core build I load (@ffmpeg/core 0.12.10) actually includes WORKERFS. Emscripten only bundles it when the build asks for it, so a quick grep through ffmpeg-core.js is worth doing. Mine had it.
I wrote a small helper and pointed all six call sites at it:
import type { FFmpeg, FFFSType } from '@ffmpeg/ffmpeg';
let seq = 0;
export async function mountInput(ffmpeg: FFmpeg, file: File, baseName: string) {
const ext = file.name.split('.').pop()?.toLowerCase().replace(/[^a-z0-9]/g, '') || 'bin';
const name = `${baseName}.${ext}`;
const dir = `/in${++seq}`;
// Wrapping a File in a new File doesn't read it. It only renames it.
const renamed = new File([file], name, { type: file.type });
await ffmpeg.createDir(dir);
await ffmpeg.mount('WORKERFS' as unknown as FFFSType, { files: [renamed] }, dir);
return {
path: `${dir}/${name}`,
release: async () => {
await ffmpeg.unmount(dir).catch(() => {});
await ffmpeg.deleteDir(dir).catch(() => {});
}
};
}
Then each call site looks like this:
const input = await mountInput(ffmpeg, file, 'input');
try {
await ffmpeg.exec(['-i', input.path, ...args, 'output.mp4']);
} finally {
await input.release();
}
A few details I chose on purpose:
-
I rename the file. WORKERFS uses
file.nameas the filename inside the mount. User filenames have spaces, emoji and odd characters that make ffmpeg arguments annoying.new File([file], name)creates a new reference to the same data, so it costs nothing. - Each mount gets its own directory. The probe and the encode share one ffmpeg instance. A counter avoids a clash if a mount from a cancelled run never got cleaned up.
-
I pass the string instead of importing the enum.
FFFSTypeis a runtime enum. I load @ffmpeg/ffmpeg lazily with a dynamic import, and importing the enum at the top of the file would pull the package into the main bundle. A type-only import plus the string value keeps it lazy.
What it doesn't fix
The output still goes into MEMFS, because ffmpeg has to write it somewhere. For a compressor that's fine, since the output is usually a fraction of the input. A tool that makes files bigger would still hit the ceiling.
It also doesn't make anything faster. I use the single-threaded core so I don't need COOP/COEP headers, and a 2 GB AVI on one thread takes a while. I'll take a slow encode that finishes over a fast "Something went wrong".
What I'd tell anyone using ffmpeg.wasm
Search your code for arrayBuffer() next to writeFile. That pattern works in every test you run with a 50 MB sample and breaks the first time a real user drops in a long recording. Mount the File instead.
Thanks to the person who sent such a precise bug report. If you want to try it, the compressor is at SquishyFile.
Top comments (0)