Media Prepare

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/util

FFmpeg.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-origin

Basic Usage

Initialize FFmpeg.wasm

transcode.ts
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

transcode.ts
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

transcode.ts
// 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

transcode.ts
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

transcode.ts
// 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:

FeatureNative FFmpegFFmpeg.wasm
Encoding speedFast10-50x slower
MemorySystem RAMBrowser heap (~2GB limit)
CodecsAllLimited subset
Multi-threadingYesLimited
File sizeUnlimitedLimited 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.

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

browser-transcode.ts
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

vite.config.ts
export default {
  server: {
    headers: {
      "Cross-Origin-Embedder-Policy": "require-corp",
      "Cross-Origin-Opener-Policy": "same-origin",
    },
  },
};

Next.js

next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: [
          { key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
          { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
        ],
      },
    ];
  },
};