32blogby StudioMitsu
ffmpeg12 min read

ffmpeg.wasmでブラウザだけで動画変換する

サーバー不要でブラウザ上でFFmpegを実行できるffmpeg.wasmの使い方を解説。セットアップ、SharedArrayBuffer、React/Next.jsでの実装まで。

FFmpegWebAssemblyffmpeg.wasmブラウザフロントエンド
目次

動画ファイルをブラウザだけで変換できたら、サーバーコストがゼロになる。アップロード待ちもない。ユーザーのファイルがサーバーに送られないのでプライバシー面でも有利だ。

ffmpeg.wasmはFFmpegをWebAssemblyにコンパイルしたライブラリで、まさにそれを実現する。この記事では僕がセットアップから実際のReact/Next.js実装まで、実用的な動画変換コンポーネントを作りながら解説する。


サーバーなしで動画処理する時代

従来のブラウザベースの動画変換サービスは、こんな仕組みで動いていた。

  1. ユーザーが動画をアップロード
  2. サーバー側でFFmpegを実行して変換
  3. 変換済みファイルをダウンロード

この方式にはコストと遅さという問題がある。ファイルを2回転送しなければならず、サーバーのCPUパワーも消費する。大きなファイルなら数分待つことも珍しくない。

ffmpeg.wasmはFFmpegのCコードをEmscriptenでWebAssemblyにコンパイルしたものだ。ブラウザのJavaScriptエンジン上で動く。処理はすべてクライアント側で完結するため、サーバーへのファイル転送が発生しない。

主なメリット:

  • サーバーインフラが不要(フロントエンドのみで完結)
  • ファイルがサーバーに送られないのでプライバシーに優れる
  • オフラインでも動作する
  • スケールアップのコストがゼロ

現実的なデメリット:

  • WASMバイナリのダウンロードに数秒かかる(初回ロード時)
  • 処理速度はネイティブFFmpegより遅い(数倍〜数十倍)
  • 大きなファイルはメモリ制限に引っかかる

小〜中サイズのファイルで実用的な変換を行うユースケースには十分マッチする。


セットアップと基本的な使い方

まずパッケージをインストールする。

bash
npm install @ffmpeg/ffmpeg @ffmpeg/util

基本的な使い方はこうなる。

ts
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";

const ffmpeg = new FFmpeg();

async function convertVideo(inputFile: File): Promise<Blob> {
  // WASMファイルをロード(初回のみ)
  if (!ffmpeg.loaded) {
    const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd";
    await ffmpeg.load({
      coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
      wasmURL: await toBlobURL(
        `${baseURL}/ffmpeg-core.wasm`,
        "application/wasm"
      ),
    });
  }

  // 入力ファイルをffmpeg.wasmの仮想ファイルシステムに書き込む
  await ffmpeg.writeFile("input.mp4", await fetchFile(inputFile));

  // FFmpegコマンドを実行(MP4 → WebM変換の例)
  await ffmpeg.exec(["-i", "input.mp4", "-c:v", "libvpx-vp9", "output.webm"]);

  // 出力ファイルを読み取る
  const data = await ffmpeg.readFile("output.webm");

  // Uint8Array → Blob に変換して返す
  return new Blob([data], { type: "video/webm" });
}

ffmpeg.exec() に渡す配列は、通常のFFmpegコマンドの ffmpeg 以降の引数をそのまま書けばいい。ffmpeg -i input.mp4 output.webm というコマンドなら ["-i", "input.mp4", "output.webm"] になる。


SharedArrayBufferの壁(COOP/COEPヘッダー)

SharedArrayBufferを有効にするには、サーバーのレスポンスに以下の2つのHTTPヘッダーを追加する必要がある。

  • Cross-Origin-Opener-Policy: same-origin
  • Cross-Origin-Embedder-Policy: require-corp

Next.jsの場合、next.config.js または next.config.ts に設定を追加する。

ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        // ffmpeg.wasmを使うページのみに適用する場合はパスを絞る
        // 全ページに適用するなら "/(.*)" のまま
        source: "/(.*)",
        headers: [
          {
            key: "Cross-Origin-Opener-Policy",
            value: "same-origin",
          },
          {
            key: "Cross-Origin-Embedder-Policy",
            value: "require-corp",
          },
        ],
      },
    ];
  },
};

export default nextConfig;

注意点:

COOP/COEPヘッダーはクロスオリジンのリソース読み込みに制限をかける。Google Analyticsや外部フォントなど、クロスオリジンのリソースを使っている場合は crossorigin="anonymous" 属性や Cross-Origin-Resource-Policy ヘッダーの設定が別途必要になることがある。

シングルスレッド版(@ffmpeg/core-mt ではなく @ffmpeg/core)を使えばSharedArrayBufferなしで動作するが、マルチコアが活用されないため処理速度が落ちる。開発・テスト段階ではシングルスレッド版から始めて、必要に応じてマルチスレッド版に移行するのが無難だ。

Vercelにデプロイする場合も next.config.js のヘッダー設定がそのまま反映されるので追加の設定は不要だ。


動画変換の実装例

実際のユースケースに近い変換例を紹介する。

MP4 → WebM(VP9コーデック、ファイルサイズ優先):

ts
await ffmpeg.exec([
  "-i",
  "input.mp4",
  "-c:v",
  "libvpx-vp9",
  "-crf",
  "33", // 品質設定(低いほど高品質、高いほど小さいファイル)
  "-b:v",
  "0", // VBRモード(-crfと組み合わせて使う)
  "-c:a",
  "libopus",
  "output.webm",
]);

MP4 → GIF(最初の5秒をGIFに変換):

ts
await ffmpeg.exec([
  "-i",
  "input.mp4",
  "-t",
  "5", // 5秒まで
  "-vf",
  "fps=15,scale=480:-1:flags=lanczos", // フレームレートとサイズ調整
  "-loop",
  "0",
  "output.gif",
]);

動画から音声を抽出(MP3):

ts
await ffmpeg.exec([
  "-i",
  "input.mp4",
  "-vn", // 映像を除外
  "-c:a",
  "libmp3lame",
  "-q:a",
  "2", // 品質設定(0-9、低いほど高品質)
  "output.mp3",
]);

進捗を取得したい場合は ffmpeg.on("progress", callback) でリスナーを登録できる。

ts
ffmpeg.on("progress", ({ progress, time }) => {
  // progress: 0.0 〜 1.0
  // time: 現在処理中の動画の時間位置(マイクロ秒)
  console.log(`進捗: ${Math.round(progress * 100)}%`);
});

React/Next.jsコンポーネントとして組み込む

実際にユーザーがファイルをドロップして変換できるコンポーネントを作る。

tsx
"use client";

import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";
import { useRef, useState } from "react";

const CORE_BASE_URL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/umd";

export function VideoConverter() {
  const ffmpegRef = useRef<FFmpeg>(new FFmpeg());
  const [loaded, setLoaded] = useState(false);
  const [loading, setLoading] = useState(false);
  const [progress, setProgress] = useState<number | null>(null);
  const [outputUrl, setOutputUrl] = useState<string | null>(null);

  async function loadFFmpeg() {
    if (loaded) return;
    setLoading(true);
    const ffmpeg = ffmpegRef.current;

    await ffmpeg.load({
      coreURL: await toBlobURL(
        `${CORE_BASE_URL}/ffmpeg-core.js`,
        "text/javascript"
      ),
      wasmURL: await toBlobURL(
        `${CORE_BASE_URL}/ffmpeg-core.wasm`,
        "application/wasm"
      ),
    });

    ffmpeg.on("progress", ({ progress }) => {
      setProgress(Math.round(progress * 100));
    });

    setLoaded(true);
    setLoading(false);
  }

  async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;

    await loadFFmpeg();

    const ffmpeg = ffmpegRef.current;
    setProgress(0);
    setOutputUrl(null);

    // 入力ファイルを書き込む
    await ffmpeg.writeFile("input.mp4", await fetchFile(file));

    // 変換実行
    await ffmpeg.exec(["-i", "input.mp4", "-c:v", "libvpx-vp9", "-crf", "33", "-b:v", "0", "output.webm"]);

    // 出力を読み取ってBlobURLを生成
    const data = await ffmpeg.readFile("output.webm");
    const blob = new Blob([data as Uint8Array], { type: "video/webm" });
    const url = URL.createObjectURL(blob);
    setOutputUrl(url);
    setProgress(null);
  }

  return (
    <div className="space-y-4 p-6 border rounded-lg">
      <h2 className="text-xl font-bold">動画変換(MP4 → WebM)</h2>

      <input
        type="file"
        accept="video/mp4"
        onChange={handleFileChange}
        disabled={loading || progress !== null}
        className="block"
      />

      {loading && <p className="text-sm text-muted-foreground">FFmpegを読み込み中...</p>}

      {progress !== null && (
        <div>
          <div className="h-2 bg-muted rounded-full overflow-hidden">
            <div
              className="h-full bg-primary transition-all"
              style={{ width: `${progress}%` }}
            />
          </div>
          <p className="text-sm mt-1">{progress}%</p>
        </div>
      )}

      {outputUrl && (
        <div className="space-y-2">
          <video src={outputUrl} controls className="w-full max-w-lg" />
          <a
            href={outputUrl}
            download="output.webm"
            className="inline-block px-4 py-2 bg-primary text-primary-foreground rounded"
          >
            ダウンロード
          </a>
        </div>
      )}
    </div>
  );
}

useRef でFFmpegインスタンスを保持している点がポイントだ。useState に入れるとレンダリングごとに再生成されてしまう。FFmpegのロードは重い処理なので、初回だけ実行して以降は使い回す。

URL.createObjectURL で生成したBlobURLはコンポーネントがアンマウントされたときに解放するのが本来は正しいが、この例では省略している。本番コードでは useEffect のクリーンアップで URL.revokeObjectURL(url) を呼ぶようにしてほしい。


制限事項とパフォーマンス

処理速度について:

ffmpeg.wasmはネイティブのFFmpegより明らかに遅い。目安として、5分のMP4をWebMに変換する場合、ネイティブなら30秒程度で終わるものがブラウザでは3〜10分かかることがある。処理時間はデバイスのCPU性能に依存する。

コーデックの制限:

ffmpeg.wasmのデフォルトビルドに含まれるコーデックは限定的だ。H.265(HEVC)やAV1エンコードなど、特許の関係でビルドに含まれないコーデックがある。一般的な用途(H.264/VP9エンコード、フォーマット変換)は問題なく使える。

ブラウザ互換性:

WebAssemblyとSharedArrayBufferに対応したモダンブラウザ(Chrome 92+、Firefox 79+、Safari 15.2+)であれば動作する。IEは非対応だが、2026年時点ではほぼ考慮不要だろう。

Web Workersとの組み合わせ:

変換処理はメインスレッドをブロックしない(ffmpeg.wasmが内部でWorkerを使う)ので、UIは変換中も操作可能だ。ただしWorkerとメインスレッド間の通信オーバーヘッドがあるため、大きなファイルの読み書きは体感的に遅く感じることがある。


まとめ

ffmpeg.wasmを使えば、サーバーレスの動画変換ツールをフロントエンドだけで構築できる。

ポイントを整理する。

  • v0.12以降のAPIを使う: FFmpeg クラスをインポートして new FFmpeg() で初期化する
  • COOP/COEPヘッダーを設定する: SharedArrayBufferを使うためのCross-Origin Isolation設定は必須
  • ファイルサイズに注意: 1GB超のファイルはメモリ不足で失敗しやすい。事前にファイルサイズチェックをUIに組み込むといい
  • FFmpegインスタンスは使い回す: useRef で保持して初回ロードのコストを1回に抑える
  • 処理速度は割り切る: ネイティブより遅いのは仕様。ユーザーにプログレスバーで状況を伝えれば体験を補える

小〜中サイズのファイル変換、GIF生成、音声抽出といった用途では実用的なパフォーマンスが出る。僕はプライバシー重視の社内ツールに採用して、サーバーコストをゼロに抑えることができた。ユーザーのファイルをサーバーに送りたくないプライバシー重視のツールや、インフラコストをゼロにしたいサービスにとって、ffmpeg.wasmは有力な選択肢だ。