はじめに
ブラウザだけで動作するローカルファーストの動画エディタを開発しています。
開発初期は、動画処理ならFFmpegを中心に据えるのが自然だと考えていました。しかし、マルチトラックのタイムライン、字幕、ステッカー、オーバーレイ、マスク、キーフレーム、トランジション、複数の音声トラックまで実装すると、ffmpeg.wasmにすべてを任せる構成はブラウザ上の編集体験と相性がよくないことが分かりました。
現在はCanvas・WebCodecs・OfflineAudioContextを主経路にし、ffmpeg.wasmを限定された高価値な処理に使うハイブリッド構成にしています。
ffmpeg.wasmを中心にしたときの課題
ffmpeg.wasmは非常に強力です。デコード、エンコード、トランスコード、mux/demux、フィルターなど、多くの処理をブラウザに持ち込めます。
一方、動画エディタ全体のレンダリングエンジンとして使うと、次の課題がありました。
- WASMコアの初回ロードが重い
- 仮想ファイルシステムへの入出力で、大きなBlobを複製しやすい
- 長時間の処理でメモリ使用量が増えやすい
- フレーム単位の合成結果をUIと共有しにくい
- キャンセルや細かな進捗表示を設計しづらい
- プレビュー用の描画ロジックとFFmpegフィルターグラフが二重化する
- ブラウザごとのコーデック対応差を、結局別レイヤーで扱う必要がある
特に問題だったのは、プレビューと書き出しの不一致です。エディタ上ではCanvas/CSSで字幕やステッカーを表示し、書き出し時だけ別のFFmpegフィルターグラフを使うと、位置、折り返し、透明度、クロップ、マスクの境界が少しずつずれます。機能が増えるほど、2つのレンダラーを同じ状態に保つコストが大きくなります。
現在のハイブリッド構成
1. Canvas + WebCodecsによる決定論的な映像合成
プロジェクト全体をフレームレートに基づく正確なフレーム計画へ変換します。
export function createOfflineFramePlan(duration, frameRate) {
const fps = Math.max(24, Math.min(60, Math.round(frameRate || 30)));
const frameCount = Math.max(1, Math.ceil(duration * fps));
return Array.from({ length: frameCount }, (_, index) => ({
index,
timestamp: index / fps,
duration: 1 / fps,
keyFrame: index % (fps * 2) === 0,
}));
}
各タイムスタンプで、現在の映像クリップ、ソース時刻、contain/coverの表示ジオメトリ、字幕、ステッカー、オーバーレイ、マスク、キーフレーム、トランジションを解決します。それらを共通のCanvas描画関数で合成し、WebCodecs経由でエンコードします。コンテナ生成とmuxにはMediabunnyを使用しています。
重要なのは、プレビューと書き出しで同じジオメトリ計算を共有することです。これにより、字幕や変形を別々の実装で再現する必要がなくなります。
2. OfflineAudioContextによる音声ミックス
動画の原音、ナレーション、BGMを独立したトラックとして扱います。各クリップはタイムライン上の開始時刻、ソースオフセット、再生速度、音量、フェードを持ちます。
すべての音源をデコードした後、OfflineAudioContextで最終尺と同じ長さのAudioBufferへミックスします。速度変更があるクリップは、ピッチを維持する処理を通してから配置します。
const context = new OfflineAudioContext(
2,
Math.ceil(duration * 48_000),
48_000,
);
for (const clip of clips) {
const source = context.createBufferSource();
const gain = context.createGain();
source.buffer = clip.audioBuffer;
gain.gain.value = clip.volume;
source.connect(gain).connect(context.destination);
source.start(clip.start, clip.sourceOffset, clip.sourceDuration);
}
const mixedAudio = await context.startRendering();
最終的なAudioBufferをエンコードし、映像ストリームとmuxします。
3. ffmpeg.wasmを限定的に使う
ffmpeg.wasmは削除したわけではありません。現在も次の処理で重要な役割を持っています。
インポート動画から原音を抽出する
動画から音声トラックを分離し、波形解析やタイムライン編集に使います。
音声アセットを連結・復元する
複数の動画から抽出した音声をまとめる処理や、ブラウザAPIだけでは扱いにくい形式の正規化に利用します。
WebMからMP4への互換トランスコード
ブラウザがH.264/AACのネイティブエンコードに対応していない場合でも、まずWebMとしてレンダリングを完了させます。その後、ユーザーがMP4を指定していた場合だけffmpeg.wasmを遅延ロードします。
ここで大切なのは、MP4変換に失敗しても完成済みのWebMを捨てないことです。
if (result.nativeMp4) {
download(result.blob, "video.mp4");
} else {
try {
const mp4 = await transcodeWebmToMp4(result.blob);
download(mp4, "video.mp4");
} catch (error) {
console.warn("MP4 transcode failed", error);
download(result.blob, "video.webm");
}
}
最後の変換に失敗しただけで、数分かけて生成した映像まで失わせない設計です。
書き出しのフォールバック順序
Canvas + WebCodecs + OfflineAudioContext
↓ 利用不可
MediaRecorderによる互換書き出し
↓ MP4が要求され、結果がWebM
ffmpeg.wasmでMP4へトランスコード
↓ 変換失敗
完成済みWebMを保存
WebCodecsの決定論的なオフライン書き出しを主経路にし、MediaRecorderは互換性のために残しています。ffmpeg.wasmは、ネイティブ機能で埋められない最後の差分を担当します。
実装上のポイント
必要になるまでロードしない
アプリ起動時にロードすると、FFmpegを使わないユーザーにもコストが発生します。音声抽出やMP4変換の直前に動的importします。
仮想ファイルシステムをメモリ予算として扱う
入力Blob、変換中のファイル、出力Blobが同時に存在します。大きな動画では数回のコピーでも無視できません。処理ごとに一意なファイル名を使い、成功・失敗のどちらでも一時ファイルを削除します。
進捗を工程別に表示する
「処理中」だけではなく、ランタイムのロード、入力準備、音声抽出または変換、出力読み込み、保存を別の工程として表示します。
SharedArrayBufferはデプロイ要件でもある
マルチスレッド版にはCOOP/COEPなどのcross-origin isolation設定が必要です。ローカル開発だけでなく、CDN、ワーカー、外部リソースを含む本番環境で確認します。
Blobが存在するだけでは成功としない
最終成果物は可能な限りデコードし、解像度、duration、フレーム数、字幕、透明ステッカー、音声トラックの存在を検証します。「0バイトではない」だけでは正常な動画とは限りません。
まとめ
ffmpeg.wasmが多くの処理を実行できることと、ブラウザ動画エディタのすべてをFFmpegに任せるべきことは同じではありません。
今回の構成では、次の役割分担が安定しました。
- Canvas: 画面と共有するフレーム合成
- WebCodecs: 決定論的な映像エンコード
- OfflineAudioContext: タイムライン音声のミックス
- MediaRecorder: 互換フォールバック
- ffmpeg.wasm: 音声抽出、音声連結、MP4互換変換
ffmpeg.wasmを「何でも行う中心」ではなく、「ブラウザAPIの穴を埋める専門レイヤー」として使うことで、初期ロード、メモリ、障害時の挙動が分かりやすくなりました。
実際の書き出しとフォールバック処理は、以下のオープンソース実装で確認できます。
Safariやメモリ制約の厳しい端末で、ffmpeg.wasmとWebCodecsをどのように分担しているか、他の事例もぜひ知りたいです。