3行まとめ
- ffmpeg.wasm で動画→GIF 変換をブラウザだけで完結させた(サーバー送信なし)
- マルチスレッド core は
SharedArrayBufferを要求し、COOP/COEP ヘッダが必須になる。静的ホスティングでこれを避けるためシングルスレッド core を選ぶのが実装上の最大の判断 - 高品質GIFの肝は
palettegen/paletteuseの2パスパレット最適化。出力 Blob 化時のバッファコピーも地味にハマる
ぱんだツールズに「動画→GIF変換」ツールを追加した。MP4・WebM・MOV をブラウザだけで GIF アニメに変換するツールで、ファイルは一切サーバーに送らない。
動画処理をブラウザ完結でやるとなると選択肢は実質 ffmpeg.wasm 一択になる。ただ、ffmpeg.wasm は「とりあえず動かす」までに踏みやすい罠がいくつかあって、特に COOP/COEP ヘッダ問題は静的ホスティング(Cloudflare Pages や Vercel の static export 等)で詰まりやすい。実装しながら踏んだポイントをまとめる。
なぜ ffmpeg.wasm なのか
ブラウザ単体で動画をデコードしてフレームを取り出す方法自体は <video> + Canvas でも一応できる。だが GIF にエンコードする段になると、
- 256色パレットの最適化
- フレーム間の差分圧縮
- ループ制御
あたりを自前で書くのは現実的じゃない。ffmpeg.wasm は FFmpeg を WebAssembly にコンパイルしたもので、コマンドラインの FFmpeg とほぼ同じフィルタグラフがそのままブラウザで動く。パレット最適化フィルタまで含めて使えるのが大きい。
使うのは @ffmpeg/ffmpeg と @ffmpeg/util の2パッケージ。
const { FFmpeg } = await import('@ffmpeg/ffmpeg')
const { fetchFile, toBlobURL } = await import('@ffmpeg/util')
import() で動的ロードしているのは、ffmpeg 関連のコードがそこそこ大きいので、ツールページに来たときだけ読み込ませてバンドルを分割するため。変換ボタンを押した瞬間に初めて import が走る。
ハマりどころ①:マルチスレッド core は COOP/COEP を要求する
ffmpeg.wasm には大きく2種類の core がある。
| core | パッケージ | SharedArrayBuffer | 必要ヘッダ |
|---|---|---|---|
| マルチスレッド | @ffmpeg/core-mt |
必要 | COOP + COEP 必須 |
| シングルスレッド | @ffmpeg/core |
不要 | なし |
速度を求めて何も考えずに core-mt(マルチスレッド版)を入れると、これは SharedArrayBuffer を使う。そして SharedArrayBuffer はセキュリティ上、ページが**クロスオリジン分離(cross-origin isolated)**された状態でしか有効にならない。具体的には以下のレスポンスヘッダが両方必要になる。
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
このヘッダを付けると、今度は外部リソースの読み込みが軒並みブロックされる。COEP require-corp 下では、CORP/CORS ヘッダが付いていない画像・スクリプト・iframe が全部読めなくなる。AdSense や外部の埋め込み、CDN 配信のフォントなどを使っているサイトだと、ページ全体が壊れる。
ぱんだツールズは Cloudflare Pages で配信していて、サイト全体に COOP/COEP を掛けるのは影響範囲が大きすぎる。1ツールのために全ページをクロスオリジン分離するのは割に合わない。
なのでシングルスレッド core(@ffmpeg/core)を選んだ。マルチスレッドより遅いが、SharedArrayBuffer を使わないので COOP/COEP が一切要らない。普通の静的ホスティングにそのまま置ける。動画→GIF はそもそも数秒〜十数秒の短いクリップが主用途なので、シングルスレッドでも実用上は問題ない。
「ffmpeg.wasm を入れたらサイトの他のページが壊れた」という事故は、だいたいこの core-mt + COOP/COEP が原因。まず core の種類を確認するのが正解。
core のロード:toBlobURL で CDN から取る
core 本体(.js と .wasm、合わせて約6MB)は npm パッケージに同梱せず、CDN から取得して Blob URL 化してロードしている。
const ffmpeg = new FFmpeg()
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'),
})
toBlobURL は指定 URL を fetch して Blob にし、blob: URL を返すユーティリティ。なぜ直接 URL を渡さず Blob 化するかというと、ffmpeg.wasm が内部で Worker を生成する際に、クロスオリジンの URL をそのまま Worker のスクリプトとして使えないため。同一オリジン扱いになる blob: URL に変換しておくことで Worker 起動のオリジン制約を回避できる。
6MB の初回ダウンロードは避けられないので、UI 側では「初回のみ数秒かかります」と明示してプログレスバーを出している。2回目以降はブラウザキャッシュが効く。
高品質GIFの肝:2パスパレット最適化
GIF は1フレーム最大256色という強い制約がある。何も指定せず変換すると色が破綻して汚くなる。FFmpeg の定番テクニックが palettegen(最適パレット生成)と paletteuse(そのパレットで描画)の2パス処理で、これを1本のフィルタグラフで書く。
const duration = Math.max(0.1, endTime - startTime)
await ffmpeg.exec([
'-i', inputName,
'-ss', startTime.toString(),
'-t', duration.toString(),
'-vf', `fps=${fps},scale=${width}:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse`,
'-loop', '0',
'output.gif',
])
-vf の中身を分解すると、
-
fps=${fps}— フレームレートを落とす(5/10/15 から選択)。GIF の容量はフレーム数に効くので、ここが一番サイズに影響する -
scale=${width}:-1:flags=lanczos— 幅を指定(320/480/640px)、高さは-1でアスペクト比維持。lanczosは高品質な縮小フィルタ -
split[s0][s1]— 同じ映像ストリームを2つに分岐 -
[s0]palettegen[p]— 片方からこの動画に最適な256色パレットを生成 -
[s1][p]paletteuse— もう片方を、生成したパレットで描画
split で分岐させているのは、palettegen(パレットを作る側)と paletteuse(そのパレットで塗る側)に同じ入力を渡すため。これをやらないと2回デコードすることになる。
-ss(開始位置)と -t(長さ)で範囲切り出しもしている。GIF はとにかく容量が膨らむので、「全編変換」より「必要な数秒だけ切り出す」のが現実的な使い方になる。
進捗表示
変換の進捗は ffmpeg 側のイベントで取れる。
ffmpeg.on('progress', ({ progress: p }: { progress: number }) => {
setProgress(Math.round(p * 100))
})
progress は 0〜1 の値で飛んでくる。ただし core のロード中(6MB DL)はこのイベントが来ないので、UI 上は「ロード中」と「変換中」でフェーズを分けて、ロード中はバーを固定値(10%)で見せている。CPU 依存の処理なので、低スペック端末だと変換に数分かかることもあり、進捗が見えないと固まったように感じられるため。
ハマりどころ②:出力 Blob 化でのバッファコピー
変換後の GIF を取り出して Blob にする部分で、地味だが踏みやすい罠がある。
const data = await ffmpeg.readFile('output.gif')
if (typeof data === 'string') throw new Error('GIF変換結果の読み込みに失敗しました')
// SharedArrayBuffer を避けるため新しい ArrayBuffer へコピー
const copied = new Uint8Array((data as Uint8Array).length)
copied.set(data as Uint8Array)
const blob = new Blob([copied.buffer], { type: 'image/gif' })
ffmpeg.readFile が返す Uint8Array は、wasm のメモリ(ヒープ)を参照していることがある。これをそのまま new Blob([data]) に渡すと、core の実装やバージョンによっては「SharedArrayBuffer を Blob に渡せない」系のエラーになったり、後続処理でバッファが detach されて中身が壊れたりする。
なので一度プレーンな ArrayBuffer にコピーしてから Blob 化している。copied.set(data) で中身を新しいバッファに写し取って、その .buffer を渡す。これで wasm ヒープから切り離された安全な Blob になる。readFile の戻り値は文字列の場合もある(テキストファイル読み込み時)ので、型ガードも入れている。
あとは Blob を URL.createObjectURL でプレビュー表示し、ダウンロードさせるだけ。生成した object URL は useEffect のクリーンアップと再変換時に revokeObjectURL してメモリリークを防いでいる。
まとめ
ffmpeg.wasm でブラウザ完結の動画→GIF を作るときの要点は、
-
core の種類を最初に決める。マルチスレッド(
core-mt)は速いが COOP/COEP 必須で、静的ホスティングだとサイト全体に副作用が出る。シングルスレッド(@ffmpeg/core)なら追加ヘッダ不要でどこでも動く - core は CDN から
toBlobURLで取得して Worker のオリジン制約を回避 - GIF 品質は
palettegen/paletteuseの2パスパレット最適化+Lanczos でほぼ決まる -
readFileの結果はそのまま Blob にせず、ArrayBufferにコピーしてから渡す
「動画処理=サーバー必須」と思いがちだが、用途が短いクリップの GIF 化くらいなら ffmpeg.wasm でブラウザ完結に倒せる。サーバーにアップロードしないので、社内の画面録画みたいな外に出したくない動画でも安心して変換できるのが個人的には一番うれしいポイント。
実際に動かせるツールはこちら。
ぱんだツールズ では他にも PDF・画像・CSV・テキスト処理など、開発者向けのブラウザ完結ツールを多数公開中。全部無料・登録不要・ファイルはサーバーに送られない。
https://sakutto-panda.com
この記事は Zenn にも同じ内容を投稿しています。