ClipCast ができるまで — FFmpeg.wasm をブラウザで動かして動画をトリミングするまでの設計
FFmpeg.wasm をブラウザ内で動かし、動画を一切アップロードせずにトリミングできるWebサービスの設計記録。シングルスレッド core の選択、UX上の工夫、エンコード設計を解説します。
ClipCast ができるまで — FFmpeg.wasm をブラウザで動かして動画をトリミングするまでの設計
ClipCast は、動画を 一切サーバーに送らずに トリミングできる無料Webサービスです。 FFmpeg を WebAssembly でブラウザ内に呼び込み、タイムラインで切る → 書き出す、という編集アプリの基本動作を全部クライアント側で完結させています。
なぜこの形にしたか
「動画を切るためにクラウドにアップロードする」 のは、技術的にはともかく心理的にあまり良くありません。 撮影直後の素材、子供の動画、社内の研修録画、面接動画、家族の通話キャプチャ。 これらをアップロードしないと切れない、というのは正しい設計ではないと思っています。
FFmpeg は単一バイナリで動画処理の世界をかなりカバーできるツールで、それが WebAssembly でそのままブラウザに乗ります (@ffmpeg/ffmpeg)。 「FFmpeg がブラウザで普通に動く」 という事実を、サービスの差別化点としてそのまま使ったのが ClipCast です。
visual direction
ビデオ編集機 / ポストプロダクションのコンソール。
- グラファイト系 (
#0a0807) に琥珀 (#ffb13d) と熾火 (#ff5b2e) のアクセント - 太く存在感のある 横タイムライン に、ノックル付きの両端ハンドル
- フレーム刻みのストライプ、再生ヘッド、選択範囲のグラデーション
- voice-scribe (ミントシアン × 縦バー) と意図的に温度を逆方向に振って、 「同じラボの別バーティカル」 として認識させる
実装の見どころ
1. 単一スレッド core を選んだ理由
@ffmpeg/ffmpeg v0.12 は、デフォルトで multi-thread core (@ffmpeg/core-mt) を読み込みたがります。 これは SharedArrayBuffer を要求 し、ページに COOP: same-origin + COEP: require-corp を立てないと動きません。
ところが COOP/COEP を立てると、 AdSense や Google Tag Manager、その他多くのサードパーティスクリプトが壊れます。 サイト全体に広告と計測を載せている前提では、これは現実的ではない。
そこで シングルスレッド core (@ffmpeg/core) を unpkg から明示的にロード する形にしました。
const CORE_BASE = "https://unpkg.com/@ffmpeg/core@0.12.10/dist/umd"
const [coreURL, wasmURL] = await Promise.all([
toBlobURL(`${CORE_BASE}/ffmpeg-core.js`, "text/javascript"),
toBlobURL(`${CORE_BASE}/ffmpeg-core.wasm`, "application/wasm"),
])
await ffmpeg.load({ coreURL, wasmURL })
multi-thread に比べて遅いですが、トリミング (-c copy) のように再エンコードしない処理はそもそも CPU を使わないので、現実問題はほとんどありません。 短尺なら体感差はゼロです。
2. ストリームコピー優先 → 失敗時に再エンコード
トリミングは多くの場合、フレームを切り直さずに そのまま該当区間だけ切り出す (-c copy) のが最も速く、画質劣化もありません。
const args = [
"-ss", start.toFixed(3),
"-i", input,
"-t", duration.toFixed(3),
"-c", "copy",
"-avoid_negative_ts", "make_zero",
output,
]
ただし、ファイルのキーフレーム位置やタイムスタンプの状態によってはストリームコピーが失敗します。 その場合は 自動的に H.264/AAC (mp4) または VP9/Opus (webm) で再エンコードに切り替える フォールバックを入れました。 ユーザーは何が起きているか分からなくても、結果のファイルが必ず手に入ります。
3. タイムラインのハンドル UI
ポイントは 「ハンドルを 物理的にドラッグできる UI にしたかった」 こと。 input range や数値入力では編集アプリ感が出ません。
実装は素直に Pointer Events で組みました。
pointerdownでdraggingRef.current = "start" | "end"を立てるwindowのpointermoveを聞き、タイムライン要素の左端からの相対 X 座標を0..1に正規化- それを
start/endに反映し、video.currentTimeも同時に更新してプレビューする pointerupでwindowのリスナーを外す
両端ハンドルが互いに 0.1 秒以上離れるようクランプして、 0 幅クリップが作れないようにしてあります。
4. ストリームコピーの結果サイズを正しく Blob 化する
@xenova/transformers 系のWASMモジュールから返ってくる Uint8Array を Blob に渡すとき、 内部 ArrayBuffer の所有権で時々はまります。 そこで以下のように明示的にコピーしてから Blob にしました。
const blobBuf = new Uint8Array(outputData.byteLength)
blobBuf.set(outputData)
const blob = new Blob([blobBuf.buffer], { type: mime })
これで型のあいまいさ (BufferSource vs ArrayBuffer) を回避し、書き出し直後にダウンロードリンクとして安全に貼れます。
5. UX: 初回ロードの体感
unpkg からの coreURL/wasmURL 取得は初回のみで ~30MB 程度。 これを画面に伏せると 「動かない」 と誤解されるので、 kind: "load" → kind: "exec" → kind: "done" の進捗を全部 UI に出すようにしました。
トリミングそのものは ffmpeg.on("progress", ev => ev.progress) を listen して、 0〜100% のバーに反映。 数値が取れない区間 (ストリームコピーは内部的にすぐ終わる) は左右に流れるバーで 「動いている感」 を出しています。
苦労したところ
output: standaloneで Next.js 16 のビルドが COEP まわりで突き刺さるかと思ったが、実際は CDN 経由 + シングルスレッドにしたので何も困らなかった。- AudioContext + VideoFrame の同期 は今回は未着手 (波形プレビューやサムネ抽出は次の拡張)。
- モバイルでのメモリ上限。 iOS Safari は WebAssembly メモリ上限が低く、 1080p × 数分でもクラッシュしやすい。 これは FAQ で明記し、 推奨レンジは 「短尺 (1〜5分)」 に絞っています。
今後の拡張
- 音声抽出 (mp4 → mp3):
-vn -c:a libmp3lame一発でつくれる - 形式変換 (mp4 ↔ webm ↔ gif): プリセットで選ばせる
- 結合 (concat): 複数ファイルを順番にくっつけるモード。 ファイル管理 UI を増やす必要あり
- 字幕焼き付け: VoiceScribe の出力 .srt をそのまま受け取って動画に焼ける
- multi-thread モード: COEP の影響範囲を限定する仕組み (例: iframe にラップ) を準備できたら、 mt-core にスイッチして速度を倍以上にする
このサービスから言える事
WebAssembly がここまで来た以上、 動画編集の "切る・繋ぐ・形式変える" は ブラウザ完結が標準 であるべきです。 ClipCast はその主張をシンプルな1機能で出した実装ログです。 音声 (VoiceScribe) と動画 (ClipCast) で同じ立場を取り続けることで、 「ラボの方針」 が見えるサイトを作っていきたいと思っています。
Next Action
読んだあとに、そのまま使って確かめる。
この開発ログは ClipCast をどう育てているかの記録です。読んだらそのままサービス本体へ戻って、価値を確かめられるようにしています。