動画ファイルをブラウザだけで変換できたら、サーバーコストがゼロになる。アップロード待ちもない。ユーザーのファイルがサーバーに送られないのでプライバシー面でも有利だ。
ffmpeg.wasmはFFmpegをWebAssemblyにコンパイルしたライブラリで、まさにそれを実現する。この記事では僕がセットアップから実際のReact/Next.js実装まで、実用的な動画変換コンポーネントを作りながら解説する。
サーバーなしで動画処理する時代
従来のブラウザベースの動画変換サービスは、こんな仕組みで動いていた。
- ユーザーが動画をアップロード
- サーバー側でFFmpegを実行して変換
- 変換済みファイルをダウンロード
この方式にはコストと遅さという問題がある。ファイルを2回転送しなければならず、サーバーのCPUパワーも消費する。大きなファイルなら数分待つことも珍しくない。
ffmpeg.wasmはFFmpegのCコードをEmscriptenでWebAssemblyにコンパイルしたものだ。ブラウザのJavaScriptエンジン上で動く。処理はすべてクライアント側で完結するため、サーバーへのファイル転送が発生しない。
主なメリット:
- サーバーインフラが不要(フロントエンドのみで完結)
- ファイルがサーバーに送られないのでプライバシーに優れる
- オフラインでも動作する
- スケールアップのコストがゼロ
現実的なデメリット:
- WASMバイナリのダウンロードに数秒かかる(初回ロード時)
- 処理速度はネイティブFFmpegより遅い(数倍〜数十倍)
- 大きなファイルはメモリ制限に引っかかる
小〜中サイズのファイルで実用的な変換を行うユースケースには十分マッチする。
セットアップと基本的な使い方
まずパッケージをインストールする。
npm install @ffmpeg/ffmpeg @ffmpeg/util
基本的な使い方はこうなる。
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-originCross-Origin-Embedder-Policy: require-corp
Next.jsの場合、next.config.js または next.config.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コーデック、ファイルサイズ優先):
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に変換):
await ffmpeg.exec([
"-i",
"input.mp4",
"-t",
"5", // 5秒まで
"-vf",
"fps=15,scale=480:-1:flags=lanczos", // フレームレートとサイズ調整
"-loop",
"0",
"output.gif",
]);
動画から音声を抽出(MP3):
await ffmpeg.exec([
"-i",
"input.mp4",
"-vn", // 映像を除外
"-c:a",
"libmp3lame",
"-q:a",
"2", // 品質設定(0-9、低いほど高品質)
"output.mp3",
]);
進捗を取得したい場合は ffmpeg.on("progress", callback) でリスナーを登録できる。
ffmpeg.on("progress", ({ progress, time }) => {
// progress: 0.0 〜 1.0
// time: 現在処理中の動画の時間位置(マイクロ秒)
console.log(`進捗: ${Math.round(progress * 100)}%`);
});
React/Next.jsコンポーネントとして組み込む
実際にユーザーがファイルをドロップして変換できるコンポーネントを作る。
"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は有力な選択肢だ。