WebCodecsでブラウザ内の動画変換を実装する|ffmpeg.wasmを外した理由

作成日:2026年9月17日 11:09読了時間:約 9 分
"サムネイル画像"

ブラウザの中だけで動画を変換したいという話になると、まず名前が挙がるのは「ffmpeg.wasm」です。検索して出てくる実装例もほとんどがこれを使っていて、事実上の「定番」と言っていい位置にありますし、ffmpeg のコマンドの知識がそのまま活きるので選びたくなる気持ちも分かります。

私は画像・音声・動画をブラウザ内で変換するツールを作るときにこれを採用せず、「WebCodecs」と「mediabunny」という組み合わせで組んでいます。

この記事では、ffmpeg.wasm を外した理由と、代わりに何をどう使ったか、実装でつまずいた点、そして実際に測った処理時間を書きます。WebCodecs と mediabunny を選んだ理由はライセンスで、速度の差は後から測って分かったことです。

先に断っておくと、この判断が当てはまるのはソースを公開していないアプリで、処理をクライアント側に置くという条件のもとになります。サーバー側で変換する構成なら ffmpeg をそのまま使えます。ソースを公開しているプロジェクトであれば、GPL も障害になりません。

この記事でわかること 

  • ffmpeg.wasm を採用しなかった理由(ライセンス)
  • WebCodecs と mediabunny で動画を変換する最小構成
  • WebCodecs で動画変換を実装するときにつまずきやすい点
  • ffmpeg.wasm との処理時間の実測値

ブラウザ内で動画を変換する実装を検討している人を対象に、TypeScript と WebCodecs に対応したブラウザ(Chrome / Edge)を前提にして書いています。

ffmpeg.wasm を採用しなかった理由 

ffmpeg.wasm を採用しなかった理由は、ライセンスとメモリの上限、マルチスレッド版がサイト全体に及ぼす影響の 3 つです。

クライアントに配ると GPL の義務が発生する 

いちばん大きい理由がライセンスなのですが、ここは「Web サービスなら GPL は関係ない」と誤解されやすいところなので、先に切り分けておきます。

構成

GPL の義務

サーバー側で ffmpeg を実行する Web アプリ

発生しない(利用者にバイナリを渡していない)

ffmpeg.wasm をブラウザへ配信する

発生する(wasm を利用者の端末へ渡している)

FFmpeg 自体は既定では LGPL 2.1 以降ですが、--enable-gpl と --enable-libx264 を付けてビルドすると全体が GPL になります。そして npm で配られている @ffmpeg/core は、まさにその構成でビルドされていて、package.json の license も GPL-2.0-or-later です(2026 年 9 月時点の 0.12.10)。ラッパーの @ffmpeg/ffmpeg(0.12.15)が MIT なので混同しやすいのですが、実際にコーデックが含まれているのは @ffmpeg/core のほうです。

つまり同じ機能でも、サーバー側で処理する構成にしていれば ffmpeg は問題なく使えたわけで、ブラウザ内で完結させるという選択そのものが、GPL の義務が発生する原因になっています。サーバーで変換すれば処理そのものは速くなります。それでも私がブラウザ内に寄せているのは、アップロードの時間とサーバーの費用がかからず、利用者のファイルを預からずに済むためです。私が作っているのはソースを公開していないツールなので、ここで外す判断をしました。

libx264 を外した LGPL ビルドを自分で作る方法もありますが、x264 を外しただけのビルドでは H.264 を書き出せません。H.264 の MP4 を書き出せない動画変換ツールは成立しないので、この方法は最初から選びませんでした。

メモリの上限 

ffmpeg.wasm は入力ファイルも出力ファイルも wasm の仮想ファイルシステムに置きます。つまり変換するファイルのサイズがそのままメモリを圧迫することになり、扱える大きさに現実的な上限が生まれます。

私が作っているのは「時間制限も回数制限もなし」を掲げたツールなので、入力の大きさで上限に達する構成はこの前提と合わず、ここも外す理由になりました。

マルチスレッド版はサイト全体に影響する 

ffmpeg.wasm にはマルチスレッド版があり、そちらを使えば処理は速くなります。ただし SharedArrayBuffer が必要になるため、サイト全体に COOP と COEP のヘッダーを設定することになります。これはページに埋め込んでいる外部サービスの動作にまで影響が及ぶ設定なので、既存のサイトへ後から足すのは簡単ではありません。

代わりに使ったもの 

代わりに使ったのは WebCodecs と mediabunny の 2 つです。2 つの役割は、次の表のようにはっきり分かれています。


担当

提供元

WebCodecs

コーデック本体(エンコード・デコード)

ブラウザ内蔵

mediabunny

コンテナの読み書き、WebCodecs の抽象化

npm(MPL-2.0。2026 年 9 月時点の 1.60.0)

mediabunny がコンテナの分離と多重化を担い、その内側で WebCodecs がデコードとエンコードを担当する構成の図

WebCodecs はブラウザが持っているエンコーダとデコーダを JavaScript から直接叩く API で、コーデックをアプリに同梱しないので、GPL の義務はここでは発生しません。ただし WebCodecs が扱うのは映像と音声のフレームだけです。MP4 や WebM といった「コンテナ」の読み書きは対象外で、その部分を mediabunny が担当します。

mediabunny を選んだ理由は消去法に近いものです。同じ作者が以前に公開していた mp4-muxer(5.2.2)と webm-muxer(5.1.4)は、2026 年 9 月時点でどちらも npm で非推奨になっていて、後継として mediabunny が案内されています。

導入と最小構成 

Media Tools — ファイルを送信しない画像・音声・動画ツール

画像の変換・圧縮・切り抜き・AI 高画質化・背景除去、音声の変換・無音除去・音量調整、動画の変換・圧縮・トリム・字幕焼き込み・GIF 化を、アップロードせずブラウザ内で処理する無料ツール集。サイズ制限なし、登録不要。

faviconmedia.tools.ryusei.io
bash
pnpm add mediabunny

MP4 へ変換するだけなら、これだけで動きます。

typescript
import {
  ALL_FORMATS, BlobSource, BufferTarget, Conversion,
  Input, Mp4OutputFormat, Output, Quality, canEncodeVideo,
} from "mediabunny";

export async function convertToMp4(file: File): Promise<Blob> {
  // エンコーダの対応はブラウザごとに違うので、処理を始める前に問い合わせる
  if (!(await canEncodeVideo("avc"))) {
    throw new Error("この環境では H.264 を書き出せません");
  }

  const input = new Input({ source: new BlobSource(file), formats: ALL_FORMATS });
  const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() });

  const conversion = await Conversion.init({
    input,
    output,
    video: { codec: "avc", quality: new Quality("medium") },
  });
  await conversion.execute();

  return new Blob([output.target.buffer], { type: "video/mp4" });
}

Conversion が「分離」「デコード」「エンコード」「多重化」をまとめて処理するので、フレームを 1 枚ずつ扱うコードは書かずに済みます。解像度やフレームレートを変えたい場合も、video に height や frameRate を足すだけです。

BlobSource を渡している点が要で、入力のファイル全体をメモリに読み込まずに、必要な範囲だけを順に読みながら変換が進みます。ただし出力先の BufferTarget は完成した出力を 1 本の ArrayBuffer に持つので、書き出すファイルのぶんはメモリに載ります。

実装でつまずいた点 

ここからは、ドキュメントを読んだだけでは避けられなかったものを挙げます。

音声のデコードは mediabunny を先に試す 

音声を PCM に展開する処理では、Web Audio の decodeAudioData を使いたくなります。ところがこの API は、指定しなくても AudioContext のサンプリングレート(多くの環境で 48kHz)へリサンプルして返します。 44.1kHz の音源を読んだつもりが 48kHz になって戻ってくるので、元のレートを保ったまま処理したい場面では使えません。

そこで私は mediabunny のデコーダを先に試し、それが失敗したときだけ Web Audio へ落ちる形にしていて、この順序は今も変えていません。

エンコーダの対応はブラウザと OS で違う 

WebCodecs で一番はまったのがここです。同じ Chrome でも、OS によって使えるコーデックが違います。 具体的には、Linux 版の Chromium には AAC のエンコーダが含まれていません。

そのため canEncodeVideo() と canEncodeAudio() で事前に問い合わせ、書き出せない形式なら処理を始める前にエラーを返す作りにしています。動画で音声だけエンコードできない場合は、映像だけを書き出したうえで「音声を落とした」と画面に表示します。表示せずに音声を落とすと、利用者は無音の動画をダウンロードして初めて気づくことになるからです。

canEncodeAudio() の結果はメモ化される 

これは仕様を読んでも書いていない挙動です。mediabunny の canEncodeAudio() は結果をメモ化するため、エンコーダを登録する前に一度呼んでしまうと、その false が固定されます。

MP3 のエンコーダは @mediabunny/mp3-encoder を別途登録して使うのですが、登録の前に対応可否を調べる処理を挟んでいたせいで、登録後もずっと「MP3 は書き出せない」と判定され続けました。いまは WebCodecs へ直接問い合わせる形に変えています。

React の開発モードでは useEffect が 2 回走るので登録処理が二重に動く点にも注意が必要です。私は登録の Promise を共有して待つようにしています。

mediabunny の判定のメモ化は、公開したあとに実際に不具合の原因になりました。解決済みの結果だけでなく、判定中の Promise そのものも共有されます(mediabunny の encode.ts)。つまり判定が 1 つ返ってこないと、同じ問い合わせを待っている別の処理も止まります。

私の環境では AudioEncoder.isConfigSupported({ codec: "mp3" }) が約 43 秒返ってこないという事象が起きました。その間はページ内の WebCodecs 判定がすべて止まり、動画の変換も進みませんでした。CPU も帯域も使っていない純粋な待ちだったので、処理が重いのだと勘違いして原因の特定に時間がかかっています。

いまは判定に上限時間を設けて、返ってこなくても先へ進むようにしました。本当に未対応であればエンコーダの初期化で落ちるので、判定の失敗を待ち続ける必要はありません。

typescript
// 能力判定が返ってこないことがあるので、待ち時間に上限を設ける
async function withProbeTimeout<T>(probe: Promise<T>, fallback: T, ms = 5000): Promise<T> {
  let timer: ReturnType<typeof setTimeout> | undefined;
  try {
    return await Promise.race([
      probe,
      new Promise<T>((resolve) => {
        timer = setTimeout(() => resolve(fallback), ms);
      }),
    ]);
  } finally {
    clearTimeout(timer);
  }
}

実測:ffmpeg.wasm とどれだけ違うか 

速度を根拠に選んだわけではありませんが、実際に測ってみると差ははっきり出ました。

素材には ffmpeg で生成した 20 秒 / 1280×720 / 30fps / H.264 + AAC / 8.2 MB のファイルを使っています。マシンは Ryzen 7 8745H(8 コア 16 スレッド、内蔵 GPU は Radeon 780M)搭載の Ubuntu サーバーです。ブラウザは Chromium を使い、どれも同じ条件で動かしています。

方式

所要時間

出力サイズ

ffmpeg.wasm シングルスレッド(-preset veryfast -crf 23)

20.1 秒

6.3 MB(約 2.5 Mbps)

ffmpeg.wasm シングルスレッド(-preset medium -crf 23)

10 分を超えたため打ち切り

—

WebCodecs + mediabunny(この記事のコード)

0.82 秒

4.05 MB(1.49 Mbps)

20 秒の動画が 0.82 秒で終わっているので、実時間のおよそ 24 倍の速さになります。書き出したファイルを ffprobe で確かめると映像のビットレートが 3.13 Mbps から 1.49 Mbps へ変わっていたので、コンテナを詰め替えただけではなくきちんと再エンコードされたうえでこの時間でした。

差がここまで開いたのは、WebCodecs がブラウザの内蔵エンコーダをそのまま呼ぶのに対して、ffmpeg.wasm は x264 のコードを wasm 上で実行するためだと考えられます。前者はハードウェアの支援を受けられますが、後者は JavaScript エンジンの上で動く計算です。

ただし ffmpeg.wasm 側の数字は preset の選び方に強く依存する点には注意が必要です。veryfast なら 20 秒で終わったものが、medium では 10 分を超えても終わりませんでした。どこかで速度比較を見かけたときは、どの preset で測ったのかまで確かめるようにしてください。

まとめ 

ブラウザ内で動画を変換する実装として、私は ffmpeg.wasm ではなく WebCodecs と mediabunny を選びました。速度で選んだのではなく、クライアントへ配ると GPL の義務が発生するという一点が決め手になっています。

  • サーバー側で ffmpeg を実行するなら GPL の配布義務は発生しないが、wasm をブラウザへ配ると発生する
  • WebCodecs はブラウザ内蔵のコーデックを使うので、アプリにコーデックを同梱せずに済む
  • コンテナの読み書きは mediabunny に任せる。同じ作者の mp4-muxer と webm-muxer は非推奨になり、mediabunny が後継になっている
  • エンコーダの対応はブラウザと OS で違うので、処理前に必ず問い合わせる
  • 速度でも差は出た。20 秒の動画で ffmpeg.wasm(-preset veryfast)が 20.1 秒、WebCodecs が 0.82 秒

関連記事とツール 

Mediabunny — A complete JavaScript media toolkit for the browser

A JavaScript library for reading, writing, and converting media files. Directly in the browser, and faster than anybunny else.

faviconmediabunny.dev
【完全無料】動画の文字起こしから字幕挿入までブラウザーだけで済ませる方法 | Ryusei.IO

動画に字幕を入れるのに、ソフトのインストールもファイルのアップロードも必要ありません。ブラウザーだけで文字起こしから SRT の作成、字幕の焼き込み(ハードサブ)まで済ませる手順を、無料・アカウント登録不要で説明します。

faviconryusei.io

Latest Tips