はじめに
動画編集とAI推論を組み合わせたアプリケーションでは、一般的に素材をサーバーへアップロードし、推論やレンダリングをバックエンドで実行します。しかし、個人の動画や音声を扱う場合、アップロード時間、プライバシー、サーバーコストが課題になります。
そこで、AI推論からタイムライン編集、動画の書き出しまでを可能な限りブラウザ内で完結させる、ローカルファーストの動画エディター「Timeline Studio」を開発しました。
- デモ: https://video-editor.ai-creator.top/
- GitHub: https://github.com/MartinDelophy/ai-video-editor
- ライセンス: MIT
本記事では製品の機能紹介ではなく、実装時に直面した次の技術課題と、その解決方法を共有します。
- ブラウザ内で複数のONNXモデルを扱う方法
- タイムライン上の時間と、元素材の時間を分離する方法
- プレビューと書き出し結果を一致させる方法
- 多言語音声モデルの差異を吸収する方法
技術構成
主要な技術は次のとおりです。
| 領域 | 技術 |
|---|---|
| UI | React 19 / Vite |
| AI推論 | ONNX Runtime Web / Transformers.js |
| 音声合成 | Piper/VITS、Kokoro 82M、MMS、Supertonic 3 |
| 自動字幕 | Whisper small q8 ONNX |
| 画像解析 | YOLOS tiny、MODNet |
| 動画処理 | WebCodecs、Canvas API |
| 音声ミックス | Web Audio API、OfflineAudioContext |
| 永続化 | IndexedDB、Cache Storage、Service Worker |
| 配布 | PWA / Netlify |
AIモデルは初期画面で一括ロードせず、ユーザーが該当機能を初めて使用した時点で動的に読み込みます。これにより、通常の編集操作で不要なモデルまでダウンロードされることを防いでいます。
const { predictSupertonicVoice } = await import(
"../lib/supertonicVoiceRuntime.js"
);
const wav = await predictSupertonicVoice({
text,
speed,
});
一度取得したモデルはService Workerとブラウザキャッシュで再利用します。初回ダウンロードは重いものの、2回目以降はネットワークへの依存を減らせます。
タイムライン時間とソース時間を分離する
動画編集で特に重要だったのが、「タイムライン上の位置」と「元ファイル内の再生位置」を別の値として保持することでした。
各クリップには、少なくとも次の値があります。
{
start: 3.5, // プロジェクト上で3.5秒から表示
duration: 2.5, // タイムライン上の長さ
sourceStart: 2.5, // 元素材の2.5秒から再生
}
例えば音声クリップを途中で分割した場合、後半クリップのstartだけを変更しても不十分です。sourceStartも分割位置に合わせて進めなければ、後半クリップが元音声の先頭から再生されてしまいます。
const firstDuration = time - source.start;
const second = {
...source,
start: time,
duration: source.duration - firstDuration,
sourceStart: (source.sourceStart || 0) + firstDuration,
};
再生時は、現在のタイムライン時間を次のように元素材の時間へ変換します。
const mediaTime = (segment.sourceStart || 0)
+ getTimelineTrackLocalTime(
timelineTime,
segment.start,
segment.duration,
);
この変換を、通常再生、シーク、波形、字幕生成、音声分離、オフライン書き出しのすべてで共有することが重要でした。一部の処理だけsourceStartを考慮すると、プレビューは正しいのに書き出した動画では音がずれる、といった不整合が発生します。
プレビューと書き出しを一致させる
リアルタイムプレビューでは、ネイティブのvideo要素を使い、UI更新頻度を抑えて滑らかな再生を優先しています。一方、書き出しではReactの描画タイミングに依存せず、タイムライン上の正確な時刻から各フレームを決定します。
書き出し処理の概略は次のとおりです。
- 出力フレームのタイムスタンプを固定FPSから決定
- その時刻に対応する動画・画像クリップを解決
- Canvasへ変形、マスク、字幕、ステッカーを描画
-
VideoEncoderでフレームをエンコード -
OfflineAudioContextで音声、ナレーション、BGMをミックス - 映像と音声をMP4またはWebMコンテナへ格納
if (typeof VideoEncoder === "undefined") {
throw new Error("WebCodecs offline encoding is unavailable");
}
const encoder = new VideoEncoder({
output: handleEncodedChunk,
error: handleEncoderError,
});
音声側では、各クリップのタイムライン位置、ソースオフセット、再生速度を解決してからOfflineAudioContextへ配置します。
const offline = new OfflineAudioContext(
channels,
totalFrames,
sampleRate,
);
source.start(
timelineStart,
sourceOffset,
sourceDuration,
);
プレビューと書き出しで別々の計算式を持たせると、位置、クロップ、字幕、音声が少しずつずれていきます。そのため、画面合成の幾何計算とソース時間の解決関数は両方の経路で共有しています。
ブラウザ内AI推論のバックエンド切り替え
WebGPUは高速ですが、すべてのブラウザやGPUで安定して利用できるわけではありません。音声モデルでは、まずWebGPUセッションの作成を試し、初期化または推論に失敗した場合はWASMへフォールバックします。
try {
session = await createSession({ device: "webgpu" });
backend = "webgpu";
} catch (error) {
session = await createSession({ device: "wasm" });
backend = "wasm";
}
重要なのは、単にフォールバックするだけでなく、実際に使用しているバックエンドをUIへ表示することです。ユーザーから見て処理時間が大きく変わるため、「WebGPUを使う予定」ではなく「今回の推論で実際に使われたバックエンド」を伝えます。
多言語音声で発生した問題
現在、UIが対応する中国語、英語、日本語、韓国語、スペイン語、フランス語、ドイツ語、ポルトガル語、タイ語、ベトナム語、ロシア語に対して、ブラウザ内音声合成を用意しています。
ただし、モデルごとに入力仕様が異なるため、モデルファイルを追加するだけでは動きませんでした。
韓国語: Hangulをそのまま入力できない
使用したMMS韓国語モデルは、Hangulではなくローマ字化された音素列を期待します。Hangulを直接渡すと、有効なトークン列を作れず推論に失敗しました。
そこで、入力テキストをブラウザ内でローマ字へ変換してからモデルへ渡します。
if (voiceId === "ko_KR-mms-medium") {
const { convert } = await import("hangul-romanization");
text = convert(text);
}
タイ語: 変換済みTokenizerの語彙数が合わない
タイ語モデルでは、公開されていたONNX変換のTokenizerが文章全体をunknownとして扱い、モデルのEmbedding範囲外のIDを返す問題がありました。
元モデルのタイ文字語彙を読み込み、文字単位でID列を作ることで回避しました。
const characterIds = [...text]
.map((character) => vocab[character])
.filter(Number.isInteger);
const ids = [0];
characterIds.forEach((id) => ids.push(id, 0));
日本語: 専用の多言語モデルを遅延ロード
日本語にはSupertonic 3の公式ブラウザONNXランタイムを使用しました。モデルサイズが大きいため、日本語音声が選択された場合だけ専用チャンクとモデルをロードします。
この経験から、「モデルがONNX形式で公開されていること」と「ブラウザで実用的に動作すること」は別問題だと分かりました。Tokenizer、前処理、WASMアセット、CORS、量子化形式まで含めて検証する必要があります。
E2EテストではモデルURLの存在だけを確認しない
HTTP 200やモデルメタデータだけでは、実際に音声が生成できる保証になりません。そのため、各音声についてエディター上の操作を通すE2Eテストを実行しています。
// 音色を選択
// → テキストを入力
// → 音声を生成
// → タイムラインへ追加
// → WAVをダウンロード
expect(wav.byteLength).toBeGreaterThan(44);
expect(wav.subarray(0, 4).toString("ascii")).toBe("RIFF");
expect(wav.subarray(8, 12).toString("ascii")).toBe("WAVE");
現在のテストスイートでは、38ファイル、150テストに加えて、モデルを実際にロードする音声生成E2Eを用意しています。モデルの初回ダウンロードを含むため時間はかかりますが、「カードが表示されるだけで生成できない」状態を防ぐには必要でした。
現時点の課題
ブラウザ内で完結する構成にも、まだ次の課題があります。
- 初回利用時のAIモデルのダウンロードが大きい
- WebGPUの実装差やドライバ差を完全には吸収できない
- 長時間動画ではメモリ使用量を慎重に管理する必要がある
- すべてのブラウザが同じWebCodecsコーデックを提供するわけではない
- 高解像度のニューラルレンダリングはブラウザGPUでも時間がかかる
そのため、重い機能は遅延ロードし、WASMフォールバックを用意し、書き出し失敗時には互換性の高い経路へ切り替える設計にしています。
まとめ
ブラウザだけでAI動画編集を完結させるには、モデル推論だけでなく、タイムラインの時間モデル、メディア同期、キャッシュ、音声ミックス、動画エンコードを一つの整合したシステムとして扱う必要がありました。
特に重要だったのは次の3点です。
- タイムライン時間と元素材の時間を分離する
- プレビューと書き出しで同じ合成・時間解決ロジックを使う
- AIモデルは実際のユーザーフローでE2E検証する
実装はGitHubで公開しています。ブラウザ動画編集、WebGPU、WebCodecs、ONNX Runtime Webに関する改善案やフィードバックがあれば、Issueで教えていただけると嬉しいです。
- デモ: https://video-editor.ai-creator.top/
- GitHub: https://github.com/MartinDelophy/ai-video-editor
- v0.4.1 Release: https://github.com/MartinDelophy/ai-video-editor/releases/tag/v0.4.1
