1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザでAI動画編集を実現する:React・WebGPU・Workerで多軌道タイムラインを安定化した設計

1
Posted at

はじめに

WebGPU、WebAssembly、WebCodecs、Web Workers が成熟したことで、動画編集やAI推論の一部をブラウザだけで実行できるようになりました。

しかし、「モデルがブラウザで動く」ことと、「長時間安定して使える動画編集環境を作る」ことは別の問題です。

今回、ローカルファーストのブラウザAI動画エディタ Timeline Studio で、タイムライン同期とWebGPU実行基盤を大きく見直しました。

v1.0.2では37ファイルを変更し、約1,400行を追加しました。主な対象は次のとおりです。

  • クリップのドラッグ、分割、並べ替え、別トラックへの移動
  • プロジェクト時間と動画メディア時間の同期
  • タイムライン用サムネイルフレームの再利用
  • デュアルGPU環境でのWebGPUアダプター選択
  • AI Workerのモデル初期化とセッション再利用
  • Service Workerによるモデルキャッシュの一元管理

この記事では、機能紹介ではなく、今回の変更から得られた設計上の知見をまとめます。

1. タイムラインはUIではなく制約付きデータモデル

最初のプロトタイプでは、動画クリップを絶対配置された要素として扱うだけでもタイムラインを作れます。

const left = startTime * pixelsPerSecond;
const width = duration * pixelsPerSecond;

ドラッグ量を時間に変換すれば、見た目上は編集できます。

const nextStartTime =
  originalStartTime + deltaX / pixelsPerSecond;

しかし、実際のクリップには表示位置以外の状態があります。

const segment = {
  id: "segment-001",
  trackId: "visual-track",
  startTime: 30,
  duration: 10,
  trimStart: 10,
  trimEnd: 20,
  sourceDuration: 60,
  playbackRate: 1,
  locked: false
};

ドラッグ操作だけでも、次の制約を同時に処理する必要があります。

  • 開始時刻を0未満にしない
  • プロジェクト終端を超えない
  • スナップ対象に吸着させる
  • ロックされたトラックへ移動させない
  • 同一トラック上の衝突を判定する
  • 素材種別とトラック種別を一致させる
  • 関連する字幕や音声の状態を保つ

これらをReactコンポーネント内で個別に処理すると、ドラッグ、複製、カット、並べ替えでルールが分岐します。

そこで、操作を次の流れに統一しました。

ポインター座標
  ↓
タイムライン座標
  ↓
候補開始時刻
  ↓
境界・スナップ制約
  ↓
トラック種別・衝突判定
  ↓
プロジェクト状態を更新

UIは入力とプレビューに集中し、最終的な配置はドメインロジックが決定します。

2. 新しい音声クリップが既存クリップを動かしてはいけない

重複した音声を配置する際、すべてのクリップを自動再配置する実装は簡単です。しかし、ユーザーがすでに調整したトラックまで変わってしまいます。

今回の原則は次のとおりです。

新規クリップは空いているトラックを探すが、既存クリップのトラックは変更しない。

function findTrackForNewAudio(newClip, tracks) {
  for (const track of tracks) {
    if (!hasOverlap(track.clips, newClip)) {
      return track.id;
    }
  }

  return createAudioTrack();
}

また、AI Musicで生成した素材は、空いている音声トラックではなく専用のMusicトラックへ送ります。

function resolveAudioTrack(asset) {
  if (asset.type === "ai-music") {
    return MUSIC_TRACK_ID;
  }

  return findAvailableVoiceTrack(asset);
}

トラックは見た目のグループではありません。ミキシング、字幕との関連、ミュート、書き出し規則にも影響するため、素材の意味に基づくルーティングが必要です。

3. プロジェクト時間とメディア時間を分離する

動画エディタには少なくとも2種類の時間があります。

  • プロジェクト時間: 完成動画全体における位置
  • メディア時間: 元ファイル内の再生位置

元動画の10〜20秒を、プロジェクトの30〜40秒に配置したとします。プロジェクトが33秒のとき、表示すべき元動画の位置は13秒です。

function getMediaTimeAtTimelineTime(segment, timelineTime) {
  const localTime = timelineTime - segment.startTime;
  const mediaTime =
    segment.trimStart +
    localTime * segment.playbackRate;

  return Math.max(
    segment.trimStart,
    Math.min(segment.trimEnd, mediaTime)
  );
}

この変換をUIから分離したことで、分割、トリミング、速度変更、クリップ移動でも同じ計算を再利用できます。

4. 毎フレーム video.currentTime を書き換えない

同期のために毎回次の代入を行うと、ブラウザは何度もseekを実行します。

video.currentTime = targetTime;

その結果、映像の揺れ、デコードのやり直し、黒フレーム、CPU負荷の増加が起こり得ます。

そこで、現在位置と目標位置の差が一定以上になった場合だけ補正します。

const drift = Math.abs(video.currentTime - targetTime);

if (isSeeking || drift > MAX_ALLOWED_DRIFT) {
  video.currentTime = targetTime;
}

通常再生では滑らかさを優先し、小さな誤差を許容します。一方、ユーザーが再生ヘッドをドラッグしているときは、正確なフレーム表示を優先して即座に同期します。

「連続再生」と「スクラブ」は同じ同期処理に見えますが、最適化の目的が異なります。

5. タイムライン用フレームを正式なアセットデータにする

動画クリップ上のサムネイル列をUIの一時状態だけで管理すると、分割や移動の後にフレームが欠落しやすくなります。

そのため、動画アセットにコンパクトなサンプルフレームと間隔を保存します。

const videoAsset = {
  id: "video-001",
  duration: 12,
  trackFrameDuration: 0.5,
  trackFrames: [
    { time: 0, image: "..." },
    { time: 0.5, image: "..." },
    { time: 1.0, image: "..." }
  ]
};

同じフレームデータを次の場所で再利用できます。

  • メディアライブラリのカード
  • メイン映像トラック
  • Overlayトラック
  • 分割後の複数クリップ
  • ブラウザ内で生成された動画

表示時には、クリップのトリミング範囲と画面上の幅に合わせて必要なフレームだけを選びます。

6. WebGPUは必ずしも高性能GPUを選ばない

一般的なWebGPU初期化は次のようになります。

const adapter =
  await navigator.gpu.requestAdapter();

デュアルGPU環境では、ブラウザが消費電力を優先して統合GPUを選ぶ可能性があります。通常のUI描画では妥当でも、ONNX Runtimeや生成モデルでは実行時間に影響します。

計算用途では、明示的な既定値を設定しました。

const adapter =
  await navigator.gpu.requestAdapter({
    powerPreference: "high-performance"
  });

ただし、呼び出し元の指定は維持します。

function normalizeAdapterOptions(options = {}) {
  return {
    ...options,
    powerPreference:
      options.powerPreference ?? "high-performance"
  };
}

これにより、既定では高性能GPUを優先しつつ、呼び出し元が明示した low-power も上書きしません。

この方針をAI Music、音声、映像修復、顔処理、超解像などのWorkerへ共通化しました。

7. サードパーティーランタイム内部のrequestAdapter

難しいのは、WebGPUアダプターの要求が自分たちのコードだけに存在するとは限らない点です。

固定バージョンのランタイムが内部で引数なしの requestAdapter() を呼ぶ場合、上位層の設定が届きません。

互換性上すぐに依存関係を更新できない場合は、対象Workerの初期化中だけ局所的にラップできます。

const originalRequestAdapter =
  navigator.gpu.requestAdapter.bind(navigator.gpu);

navigator.gpu.requestAdapter = (options = {}) =>
  originalRequestAdapter({
    powerPreference: "high-performance",
    ...options
  });

重要なのは次の2点です。

  1. グローバルな恒久パッチにしない
  2. 明示的に渡されたオプションを優先する

初期化後は元の関数へ戻します。これは理想的なAPIではありませんが、固定された依存バージョンに対する限定的な互換策になります。

8. AI処理の待ち時間は推論だけではない

ブラウザAIの待ち時間には多くの段階があります。

モデルダウンロード
  ↓
キャッシュ書き込み
  ↓
モデル読み込み
  ↓
推論セッション作成
  ↓
前処理
  ↓
推論
  ↓
後処理

今回は次の方針を採用しました。

モデルファイルは並列ダウンロード

const artifacts = await Promise.all(
  modelFiles.map(downloadModelFile)
);

大きなWebGPUセッションは直列作成

複数の大型セッションを同時に作ると、メモリとGPUリソースのピークが大きくなります。ファイル取得は並列化し、セッション作成は順番に実行する方が安定しました。

初期化済みWorkerを再利用

生成終了ごとにWorkerを破棄すると、2回目もモデル初期化が必要です。

初回利用
  ↓
Worker起動
  ↓
モデル初期化
  ↓
生成1
  ↓
生成2
  ↓
ページ終了時に解放

UI上でも「モデル準備」と「コンテンツ生成」を別の進捗として扱います。2回目の生成を再ダウンロードのように見せないことも重要です。

9. Service Workerを永続キャッシュの唯一の書き手にする

ページ、推論Worker、Service WorkerがそれぞれCache Storageへ保存すると、大型モデルの完全なコピーが複数できる可能性があります。

そこで、永続キャッシュの責任をService Workerへ集約します。

推論Worker
  ↓ モデル要求
Service Worker
  ├─ ミラーURLの正規化
  ├─ バージョン判定
  ├─ 容量確認
  ├─ 古いモデルの削除
  └─ Cache Storageへ保存

ダウンロード元がHugging FaceでもModelScopeでも、モデルID、固定revision、ファイル名が同じなら同じキャッシュIDに正規化します。

また、キャッシュへの保存に失敗しても、モデルデータがメモリ上に存在するなら今回の推論は続行できます。「次回は再ダウンロードが必要」という性能上の劣化と、「今回実行できない」という機能障害を分けて扱います。

10. エラーをそのまま表示しない

ブラウザのネットワーク例外 Failed to fetch は、利用者にとってほとんど情報がありません。

アプリケーション層では、少なくとも次の状態を区別します。

  • ネットワークへ接続できない
  • モデルミラーへ到達できない
  • ブラウザの保存容量が不足している
  • WebGPUを利用できない
  • モデル初期化に失敗した
  • ユーザーが処理をキャンセルした
function toUserFacingError(error) {
  if (isNetworkError(error)) {
    return "モデルに接続できません。ネットワークを確認してください。";
  }

  if (isStorageError(error)) {
    return "保存容量が不足しています。メモリ実行を試みます。";
  }

  if (isAbortError(error)) {
    return "処理をキャンセルしました。";
  }

  return "モデルの初期化に失敗しました。";
}

良いエラー表示は「何が起きたか」「続行できるか」「次に何をすべきか」を伝えます。

まとめ

ブラウザAI動画エディタの難しさは、AIモデルを1回動かすことではありません。

タイムライン、動画・音声要素、React状態、Worker、GPUセッション、ローカルキャッシュを長時間一致させることが本当の課題です。

今回の変更から得られた要点は次のとおりです。

  1. タイムラインを制約付きデータモデルとして設計する
  2. 新規素材の追加で既存トラックを勝手に変更しない
  3. プロジェクト時間とメディア時間を分離する
  4. 通常再生とスクラブで同期方針を変える
  5. 計算用途では高性能WebGPUアダプターを明示する
  6. モデルは並列取得し、大型セッションは直列初期化する
  7. 初期化済みWorkerと推論セッションを再利用する
  8. Service Workerへ永続キャッシュの責任を集約する

ブラウザはコンテンツ表示の場所から、ローカル計算を行う実行環境へ変わりつつあります。その能力をプロダクトとして安定させるには、モデル性能だけでなく、時間・状態・リソースの境界設計が欠かせません。

リンク

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?