動画編集サービスにおいて、ウォーターマーク(透かし)やロゴ、不要な小物体の除去は需要の高い機能です。
一般的な実装では、動画をサーバーへアップロードし、GPUでフレームごとに修復した後、再エンコードしたファイルをユーザーへ返します。しかし、この方式にはアップロード時間、プライバシー、GPUコスト、処理待ちなどの課題があります。
そこで、オープンソースのブラウザ動画編集ツール Timeline Studio に、動画を外部へ送信せず、ブラウザ内だけで動作するAI透かし除去機能を実装しました。
- GitHub: https://github.com/MartinDelophy/ai-video-editor
- Demo: https://video-editor.ai-creator.top/
- License: MIT
この記事では、MI-GAN、ONNX Runtime Web、WebGPU、Web Worker、Canvas、FFmpeg WASMを組み合わせ、画像だけでなく動画をフレーム単位で修復するまでに解決した技術的なポイントを紹介します。
実装した機能
今回の実装は、単一画像に対するモデルのデモではなく、動画編集ソフトの機能として利用できることを目標にしました。
- 画像と動画の透かし除去
- 複数の修復領域
- 領域ごとの開始時刻と終了時刻
- 動く透かしに対応する位置キーフレーム
- WebGPUによるローカル推論
- 修復前後を比較するドラッグ可能な境界線
- フレーム列からの動画再生成
- 元の音声トラックの保持
- 処理のキャンセル
- 結果を編集素材として追加
- デスクトップとモバイルへの対応
技術スタック
主に次の技術を使用しています。
- React / Vite
- MI-GAN
- ONNX Runtime Web
- WebGPU
- Web Worker
- HTMLVideoElement
- Canvas / ImageBitmap
- Blob / Object URL
- FFmpeg WASM
ローカルで試す場合は、次のコマンドで起動できます。
git clone https://github.com/MartinDelophy/ai-video-editor.git
cd ai-video-editor
npm install
npm run dev
WebGPUを利用するため、新しいバージョンのChromeまたはEdgeを推奨します。
なぜMI-GANを選んだのか
透かし除去は単純なぼかし処理ではありません。透かしに隠された部分を、周囲の画素から推定して生成するImage Inpaintingの問題です。
高品質なInpaintingモデルの中には、ブラウザで利用するには重すぎるものもあります。MI-GANは比較的軽量で、矩形マスクによる局所修復が速く、ONNX Runtime WebとWebGPUで動作させやすい点が今回の用途に合っていました。
モデル推論はWeb Workerへ分離しています。メインスレッドはReactの描画、領域編集、タイムライン操作を担当し、Workerは次の処理を担当します。
- ONNXモデルの読み込み
- WebGPU Sessionの初期化
- 入力画像とマスクのTensor化
- MI-GAN推論
- 出力画素の変換
- 修復結果のBlob生成
これにより、モデル実行中も編集画面の操作性を維持できます。
修復領域は相対座標で保存する
ユーザーがプレビュー上で選択した領域は、画面ピクセルではなく0〜1の相対座標で保持します。
const selection = {
x: 0.78,
y: 0.88,
width: 0.20,
height: 0.09,
};
推論時に、元フレームの解像度へ変換します。
const pixelRegion = {
x: Math.round(selection.x * frameWidth),
y: Math.round(selection.y * frameHeight),
width: Math.round(selection.width * frameWidth),
height: Math.round(selection.height * frameHeight),
};
相対座標にすることで、プレビューの拡大率、端末サイズ、動画のアスペクト比に依存せず、同じ領域設定を推論と書き出しで共有できます。
動画では領域ごとに時間範囲が必要
動画の透かしは常に表示されるとは限りません。最初の数秒だけ表示される場合や、途中で位置が変わる場合があります。
そこで、各修復領域に独立した時間範囲を持たせています。
const repairRegion = {
id: 'repair-region-1',
start: 0,
end: 4,
selection: {
x: 0.78,
y: 0.88,
width: 0.20,
height: 0.09,
},
keyframes: [],
};
フレームを処理するときは、現在時刻に有効な領域だけを選びます。
const activeRegions = repairRegions.filter((region) => {
return (
timelineTime >= region.start &&
timelineTime <= region.end
);
});
有効な領域がないフレームはMI-GANを実行せず、元フレームをそのまま保持します。これにより、不要な推論を減らせます。
複数領域を順番に修復する
1つの動画に、サービスロゴ、ユーザー名、AI生成ラベルなど、複数の透かしが存在することがあります。
同じフレームで複数領域が有効な場合は、前の修復結果を次の入力として順番に処理します。
let bitmap = originalBitmap;
for (const region of activeRegions) {
const result = await repairMiganFrame({
bitmap,
selection: region.selection,
signal,
});
bitmap.close();
bitmap = await createImageBitmap(result.blob);
}
これにより、1フレーム内のすべての対象領域を修復した最終画像を生成できます。
動く透かしは位置キーフレームで追従する
時間範囲だけでは、移動する透かしに対応できません。そのため、領域には位置キーフレームも記録できます。
const keyframe = {
time: 2.5,
selection: {
x: 0.12,
y: 0.82,
width: 0.20,
height: 0.08,
},
};
2つのキーフレームの間では、位置とサイズを線形補間します。
function interpolateSelection(before, after, time) {
const ratio =
(time - before.time) /
Math.max(0.001, after.time - before.time);
return Object.fromEntries(
['x', 'y', 'width', 'height'].map((key) => [
key,
before.selection[key] +
(after.selection[key] - before.selection[key]) * ratio,
]),
);
}
ユーザーはすべてのフレームで領域を調整する必要がなく、いくつかの代表位置を記録するだけで移動経路を作成できます。
表示用動画と計算用動画を分離する
最初の実装では、画面に表示しているvideo要素をそのままフレーム抽出にも使用していました。
video.currentTime = targetTime;
しかし、この方法では左側の元動画が先に次のフレームへ移動し、右側の修復結果はモデル推論が終わるまで前のフレームに残ります。比較線の左右が別の時刻を表示する問題が発生しました。
そこで、表示用動画と計算用動画を分離しました。
- 非表示のvideo要素: シーク、デコード、フレーム抽出、推論入力
- 表示用のvideo要素: 完了したフレームの表示だけを担当
計算用動画を目的時刻へ移動し、シーク完了後にImageBitmapを作成します。
await seekVideoFrame(hiddenVideo, sourceTime, signal);
const bitmap = await createImageBitmap(hiddenVideo);
表示側は、修復結果が完成するまで次のフレームへ進みません。これにより、元フレームと修復フレームを同じ時刻で比較できます。
比較スライダーの実装
修復前後の画像は横に並べず、同じ位置に重ねています。修復後のレイヤーだけをclip-pathで切り取ります。
.repair-compare-result {
position: absolute;
inset: 0;
clip-path: inset(
0
0
0
var(--compare-position)
);
}
この方式なら、元画像と結果画像のサイズ、位置、ズーム、アスペクト比を完全に一致させたまま、境界線を左右へ動かせます。
動画処理完了後は、元動画と修復動画のcurrentTime、playbackRate、再生、停止も同期させています。
画面・再生ヘッド・進捗を同期する
動画処理には複数の進捗があります。
- モデルのダウンロード
- WebGPUの初期化
- 1フレーム内の推論
- 完了したフレーム数
- PNGのデコード
- 最終動画のエンコード
これらを1つのパーセントへ単純に合成すると、進捗バーだけが先に進み、画面が前のフレームに残ることがあります。
今回の実装では、UI上の動画進捗を「表示まで完了したフレーム」に統一しました。
await commitVisibleFrame({
blob: resultBlob,
index,
time: timelineTime,
});
reportCompletedFrame(index);
修復対象外のフレームも、進捗には含める必要があります。
if (!activeRegions.length) {
const originalFrame = await captureOriginalFrame(hiddenVideo);
await commitVisibleFrame({
blob: originalFrame,
index,
time: timelineTime,
repaired: false,
});
reportCompletedFrame(index);
return originalFrame;
}
これで、左右の画像、現在時刻、再生ヘッド、完了率が同じフレームを基準に更新されます。
Blobを表示する前に画像デコードを待つ
モデル出力からPNG Blobを生成し、Object URLをimg.srcへ設定しても、ブラウザの画像デコードが完了したとは限りません。
高解像度フレームでは、進捗と再生ヘッドが更新された後も、右側に前のフレームが短時間残ることがありました。
そのため、新しいフレームをUIへ反映する前にImage.decode()を待ちます。
const url = URL.createObjectURL(frameBlob);
const image = new Image();
image.src = url;
try {
await image.decode();
} catch {
// decode非対応環境ではObject URLの通常読み込みへフォールバック
}
新フレームを表示した次の描画フレームで、古いObject URLを解放します。
const previousUrl = previewRef.current?.url;
setPreview({
blob: frameBlob,
url,
time: timelineTime,
});
await new Promise((resolve) => {
requestAnimationFrame(resolve);
});
if (previousUrl) {
URL.revokeObjectURL(previousUrl);
}
この順序により、フレーム切り替え時の点滅とメモリリークの両方を抑えられます。
動画の再エンコードと音声保持
すべてのフレーム処理が終わったら、PNGフレーム列から動画を再生成します。
処理は次の段階に分けています。
- 動画フレームの準備
- MI-GANモデルの読み込み
- WebGPUの初期化
- フレーム単位の修復
- 動画エンコーダーの準備
- 修復済み動画の合成
- 元音声の結合
- 新しい編集素材の生成
AI推論の完了率と動画エンコードの進捗を分離することで、ユーザーは現在どの処理を待っているのか理解できます。
生成したMP4 Blobは新しい素材として追加し、ユーザーが「動画に適用」を選ぶまでは元のクリップを変更しません。
キャンセル処理
長い動画の処理では、本当に停止できるキャンセル機能が必要です。ダイアログを閉じるだけでは不十分です。
同じAbortSignalを、フレーム抽出、モデル推論、画像デコード、動画エンコードまで伝播させています。
const controller = new AbortController();
await repairVideo({
signal: controller.signal,
});
function cancelRepair() {
controller.abort();
}
キャンセル時には、残りのフレーム処理を停止し、Workerリクエスト、ImageBitmap、video要素、Object URL、エンコーダーの一時リソースを解放します。
途中結果を自動的に適用したり、不完全な動画をダウンロードしたりすることはありません。
現在の制約
ブラウザ内のMI-GAN修復は、次のような用途に向いています。
- 小さなサービスロゴ
- ユーザー名
- AI生成ラベル
- 日付や時刻
- 小面積の文字
- 静止またはゆっくり移動する透かし
一方、大きな顔の遮蔽、複雑な文字、非常に速く移動する対象、長時間の高解像度動画では、品質やメモリ使用量に課題があります。
今後は、光フローによる追跡、前後フレームの情報融合、時系列一貫性を持つ動画修復モデル、WebCodecsによるエンコードなどを検討できます。
まとめ
ブラウザで動画の透かし除去を製品機能として成立させるには、ONNXモデルを動かすだけでは足りません。
今回、次の課題をまとめて実装しました。
- MI-GANのWebGPU推論
- Web Workerによる推論分離
- 複数領域と独立した時間範囲
- 動く透かしの位置キーフレーム
- 非表示動画によるフレーム抽出
- 修復前後の同期比較
- 画面、再生ヘッド、進捗の同期
- PNGデコードとObject URL管理
- 動画の再エンコードと音声保持
- 処理のキャンセル
WebGPU、ONNX Runtime Web、WebCodecsなどのブラウザAPIが成熟するにつれ、これまでサーバーGPUが必須だったAI動画処理の一部を、ユーザーのローカル環境へ移せるようになっています。
実装はGitHubで公開しています。
- Repository: https://github.com/MartinDelophy/ai-video-editor
- Demo: https://video-editor.ai-creator.top/
改善案や不具合があれば、IssueやPull Requestを歓迎します。