Browser Guide
Transcoding with FFmpeg.wasm in the browser
Experimental: Browser-based transcoding using FFmpeg.wasm is experimental and has significant limitations. For production workloads, we strongly recommend using the Node.js guide with native FFmpeg instead.
This guide covers using @shelby-protocol/media-prepare with FFmpeg.wasm in the browser.
Prerequisites
Install the required packages:
npm install @shelby-protocol/media-prepare @ffmpeg/ffmpeg @ffmpeg/core @ffmpeg/utilFFmpeg.wasm requires SharedArrayBuffer which needs specific HTTP headers.
Your server must send these headers:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-originBasic Usage
Initialize FFmpeg.wasm
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile } from "@ffmpeg/util";
const ffmpeg = new FFmpeg();
// Load FFmpeg.wasm (downloads ~30MB)
await ffmpeg.load({
coreURL: "/ffmpeg-core.js",
wasmURL: "/ffmpeg-core.wasm",
});Create a transcoding plan
import { CmafPlanBuilder } from "@shelby-protocol/media-prepare/core";
const plan = new CmafPlanBuilder()
.withInput("/input.mp4")
.withOutputDir("/output")
.withVideoLadder([
{ width: 854, height: 480, bitrateBps: 1_000_000, name: "480p" },
])
.withVideoCodec({ kind: "x264", preset: "ultrafast" })
.addAudioTrack({
language: "eng",
bitrateBps: 96_000,
default: true,
name: "audio-eng",
})
.withSegmentDuration(4)
.withHlsOutput()
.skipDash() // FFmpeg packager only supports HLS in the browser
.build();Use .skipDash() because the FFmpeg-based packager bundled with the browser exports
supports HLS output only. DASH manifests require the Shaka packager, which is not
available in FFmpeg.wasm.
Load input file
// From file input
const file = document.querySelector<HTMLInputElement>("#file-input")!.files![0];
await ffmpeg.writeFile("/input.mp4", await fetchFile(file));
// Or from URL
await ffmpeg.writeFile("/input.mp4", await fetchFile("https://example.com/video.mp4"));Execute the plan
import {
CmafPlanExecutor,
FfmpegCmafPackager,
FfmpegTranscoder,
} from "@shelby-protocol/media-prepare/core";
import {
FfmpegWasmFilesystemApi,
WasmFfmpegExecutor,
} from "@shelby-protocol/media-prepare/browser";
const filesystem = new FfmpegWasmFilesystemApi(ffmpeg);
const wasmExecutor = new WasmFfmpegExecutor(ffmpeg);
const executor = new CmafPlanExecutor({
fs: filesystem,
transcoder: new FfmpegTranscoder(wasmExecutor),
packager: new FfmpegCmafPackager(wasmExecutor),
});
await executor.execute(plan);Read output files
// Read the master playlist
const masterPlaylist = await ffmpeg.readFile("/output/master.m3u8");
console.log(new TextDecoder().decode(masterPlaylist));
// Read a segment
const segment = await ffmpeg.readFile("/output/480p/segment_0.m4s");
const blob = new Blob([segment], { type: "video/mp4" });Browser Limitations
FFmpeg.wasm is significantly slower than native FFmpeg (10-50x slower in many cases). See the FFmpeg.wasm Performance documentation for detailed benchmarks and optimization tips.
FFmpeg.wasm has significant limitations compared to native FFmpeg:
| Feature | Native FFmpeg | FFmpeg.wasm |
|---|---|---|
| Encoding speed | Fast | 10-50x slower |
| Memory | System RAM | Browser heap (~2GB limit) |
| Codecs | All | Limited subset |
| Multi-threading | Yes | Limited |
| File size | Unlimited | Limited by browser memory |
When to use browser transcoding:
- Quick previews or demos
- Small files (< 100MB)
- When server-side processing isn't available
When to use Node.js instead:
- Production workloads
- Large files or batch processing
- Multi-rung bitrate ladders
- When encoding speed matters
For browser use, consider using lower resolution ladders and ultrafast preset
to reduce encoding time. Single-rung encoding is often more practical.
Recommended Settings
For practical browser transcoding, use conservative settings:
const plan = new CmafPlanBuilder()
.withInput("/input.mp4")
.withOutputDir("/output")
// Single rung for faster encoding
.withVideoLadder([
{ width: 854, height: 480, bitrateBps: 1_000_000, name: "480p" },
])
// Fastest preset
.withVideoCodec({ kind: "x264", preset: "ultrafast" })
// Lower audio bitrate
.addAudioTrack({
language: "eng",
bitrateBps: 96_000,
default: true,
name: "audio-eng",
})
.withSegmentDuration(4)
.withHlsOutput()
.skipDash()
.build();Progress Reporting
Monitor progress during browser transcoding:
import {
CmafPlanExecutor,
FfmpegCmafPackager,
FfmpegTranscoder,
} from "@shelby-protocol/media-prepare/core";
import {
FfmpegWasmFilesystemApi,
WasmFfmpegExecutor,
} from "@shelby-protocol/media-prepare/browser";
const filesystem = new FfmpegWasmFilesystemApi(ffmpeg);
const wasmExecutor = new WasmFfmpegExecutor(ffmpeg);
const executor = new CmafPlanExecutor({
fs: filesystem,
transcoder: new FfmpegTranscoder(wasmExecutor),
packager: new FfmpegCmafPackager(wasmExecutor),
});
ffmpeg.on("progress", ({ progress, time }) => {
console.log(`Progress: ${(progress * 100).toFixed(1)}%`);
console.log(`Time: ${time}ms`);
});
ffmpeg.on("log", ({ message }) => {
console.log("FFmpeg:", message);
});
await executor.execute(plan);Memory Management
FFmpeg.wasm stores files in memory. Clean up after transcoding:
// After reading output files, clean up
await ffmpeg.deleteFile("/input.mp4");
// Delete output directory recursively
const deleteDir = async (path: string) => {
const entries = await ffmpeg.listDir(path);
for (const entry of entries) {
if (entry.name === "." || entry.name === "..") continue;
const fullPath = `${path}/${entry.name}`;
if (entry.isDir) {
await deleteDir(fullPath);
} else {
await ffmpeg.deleteFile(fullPath);
}
}
await ffmpeg.deleteDir(path);
};
await deleteDir("/output");Complete Example
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile } from "@ffmpeg/util";
import {
CmafPlanBuilder,
CmafPlanExecutor,
FfmpegCmafPackager,
FfmpegTranscoder,
} from "@shelby-protocol/media-prepare/core";
import {
FfmpegWasmFilesystemApi,
WasmFfmpegExecutor,
} from "@shelby-protocol/media-prepare/browser";
async function transcodeInBrowser(file: File): Promise<Blob[]> {
// Initialize FFmpeg
const ffmpeg = new FFmpeg();
ffmpeg.on("progress", ({ progress }) => {
console.log(`Progress: ${(progress * 100).toFixed(1)}%`);
});
await ffmpeg.load();
// Write input file
await ffmpeg.writeFile("/input.mp4", await fetchFile(file));
// Build plan
const plan = new CmafPlanBuilder()
.withInput("/input.mp4")
.withOutputDir("/output")
.withVideoLadder([
{ width: 854, height: 480, bitrateBps: 1_000_000, name: "480p" },
])
.withVideoCodec({ kind: "x264", preset: "ultrafast" })
.addAudioTrack({
language: "eng",
bitrateBps: 96_000,
default: true,
name: "audio-eng",
})
.withSegmentDuration(4)
.withHlsOutput()
.skipDash()
.build();
// Execute
const filesystem = new FfmpegWasmFilesystemApi(ffmpeg);
const wasmExecutor = new WasmFfmpegExecutor(ffmpeg);
const executor = new CmafPlanExecutor({
fs: filesystem,
transcoder: new FfmpegTranscoder(wasmExecutor),
packager: new FfmpegCmafPackager(wasmExecutor),
});
await executor.execute(plan);
// Collect output blobs
const blobs: Blob[] = [];
const masterPlaylist = await ffmpeg.readFile("/output/master.m3u8");
blobs.push(new Blob([masterPlaylist], { type: "application/x-mpegURL" }));
// Read segments...
const entries = await ffmpeg.listDir("/output/480p");
for (const entry of entries) {
if (entry.name.endsWith(".m4s") || entry.name.endsWith(".mp4")) {
const data = await ffmpeg.readFile(`/output/480p/${entry.name}`);
blobs.push(new Blob([data], { type: "video/mp4" }));
}
}
// Clean up
await ffmpeg.deleteFile("/input.mp4");
return blobs;
}Server Configuration
Configure your server to enable SharedArrayBuffer:
Vite
export default {
server: {
headers: {
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Opener-Policy": "same-origin",
},
},
};Next.js
module.exports = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
],
},
];
},
};