Kapwing: ウェブ向けの高度な動画編集

クリエイターは、強力な API(IndexedDB や WebCodecs など)とパフォーマンス ツールにより、Kapwing で高品質の動画コンテンツを編集できるようになりました。

Joshua Grossberg
Joshua Grossberg

パンデミックの始まり以来、オンライン動画の視聴は急速に増加しています。ユーザーは、TikTok、Instagram、YouTube などのプラットフォームで、高品質の動画に多くの時間を費やしています。世界中のクリエイターや小規模ビジネス オーナーは、動画コンテンツを作成するための迅速で使いやすいツールを必要としています。

Kapwing などの企業は、最新の強力な API とパフォーマンス ツールを使用して、こうした動画コンテンツをすべてウェブ上で作成できるようにしています。

Kapwing について

Kapwing は、ゲーム ストリーマー、ミュージシャン、YouTube クリエイター、ミーマーなどのカジュアルなクリエイターを主な対象とした、ウェブベースのコラボレーション動画エディタです。また、Facebook や Instagram の広告など、独自のソーシャル コンテンツを簡単に制作する必要があるビジネス オーナーにとっても、頼りになるリソースです。

ユーザーは、「動画の切り抜き方法」、「動画に音楽を追加する方法」、「動画のサイズを変更する方法」など、特定のタスクを検索して Kapwing を見つけます。アプリストアに移動してアプリをダウンロードする手間をかけることなく、検索した内容をワンクリックで実行できます。ウェブでは、サポートが必要なタスクを正確に検索して実行できます。

最初のクリックの後、Kapwing ユーザーはさらに多くのことができます。無料のテンプレートを探したり、無料のストック動画の新しいレイヤを追加したり、字幕を挿入したり、動画を文字起こししたり、バックグラウンド ミュージックをアップロードしたりできます。

Kapwing がウェブにリアルタイム編集とコラボレーションをもたらす仕組み

ウェブには独自のメリットがありますが、独自の課題もあります。Kapwing は、さまざまなデバイスとネットワークの状況で、複雑で多層的なプロジェクトをスムーズかつ正確に再生する必要があります。そのために、さまざまなウェブ API を使用して、パフォーマンスと機能の目標を達成しています。

IndexedDB

高パフォーマンスの編集では、ユーザーのすべてのコンテンツがクライアントに存在し、可能な限りネットワークを回避する必要があります。通常、ユーザーがコンテンツに 1 回アクセスするだけのストリーミング サービスとは異なり、YouTube のユーザーは、アップロード後数日、数か月経ってもアセットを頻繁に再利用します。

IndexedDB を使用すると、ファイル システムのような永続ストレージをユーザーに提供できます。その結果、アプリ内のメディア リクエストの 90% 以上がローカルで処理されます。IndexedDB をシステムに統合するのは非常に簡単でした。

以下に、アプリの読み込み時に実行されるボイラープレートの初期化コードを示します。

import {DBSchema, openDB, deleteDB, IDBPDatabase} from 'idb';

let openIdb: Promise <IDBPDatabase<Schema>>;

const db =
  (await openDB) <
  Schema >
  (
    'kapwing',
    version, {
      upgrade(db, oldVersion) {
        if (oldVersion >= 1) {
          // assets store schema changed, need to recreate
          db.deleteObjectStore('assets');
        }

        db.createObjectStore('assets', {
          keyPath: 'mediaLibraryID'
        });
      },
      async blocked() {
        await deleteDB('kapwing');
      },
      async blocking() {
        await deleteDB('kapwing');
      },
    }
  );

バージョンを渡して upgrade 関数を定義します。これは、初期化や、必要に応じてスキーマの更新に使用されます。エラー処理コールバック(blockedblocking)を渡します。これは、システムが不安定なユーザーの問題を防ぐのに役立つことが判明しています。

最後に、主キー keyPath の定義に注目してください。この例では、mediaLibraryID という一意の ID です。ユーザーがアップローダーまたはサードパーティ拡張機能を使用してメディアをシステムに追加すると、次のコードを使用してメディアをメディア ライブラリに追加します。

export async function addAsset(mediaLibraryID: string, file: File) {
  return runWithAssetMutex(mediaLibraryID, async () => {
    const assetAlreadyInStore = await (await openIdb).get(
      'assets',
      mediaLibraryID
    );    
    if (assetAlreadyInStore) return;
        
    const idbVideo: IdbVideo = {
      file,
      mediaLibraryID,
    };

    await (await openIdb).add('assets', idbVideo);
  });
}

runWithAssetMutex は、IndexedDB アクセスをシリアル化する Google 独自の内部定義関数です。IndexedDB API は非同期であるため、読み取り / 変更 / 書き込みタイプのオペレーションではこれが必須です。

次に、ファイルにアクセスする方法について説明します。getAsset 関数は次のとおりです。

export async function getAsset(
  mediaLibraryID: string,
  source: LayerSource | null | undefined,
  location: string
): Promise<IdbAsset | undefined> {
  let asset: IdbAsset | undefined;
  const { idbCache } = window;
  const assetInCache = idbCache[mediaLibraryID];

  if (assetInCache && assetInCache.status === 'complete') {
    asset = assetInCache.asset;
  } else if (assetInCache && assetInCache.status === 'pending') {
    asset = await new Promise((res) => {
      assetInCache.subscribers.push(res);
    }); 
  } else {
    idbCache[mediaLibraryID] = { subscribers: [], status: 'pending' };
    asset = (await openIdb).get('assets', mediaLibraryID);

    idbCache[mediaLibraryID].asset = asset;
    idbCache[mediaLibraryID].subscribers.forEach((res: any) => {
      res(asset);
    });

    delete (idbCache[mediaLibraryID] as any).subscribers;

    if (asset) {
      idbCache[mediaLibraryID].status = 'complete';
    } else {
      idbCache[mediaLibraryID].status = 'failed';
    }
  } 
  return asset;
}

IndexedDB へのアクセスを最小限に抑えるために、独自のデータ構造 idbCache が使用されます。IndexedDB は高速ですが、ローカルメモリへのアクセスの方が高速です。キャッシュのサイズを管理する場合は、この方法をおすすめします。

IndexedDB への同時アクセスを防ぐために使用される subscribers 配列は、読み込み時に共通です。

Web Audio API

音声の可視化は動画編集において非常に重要です。理由を理解するには、エディタのスクリーンショットをご覧ください。

Kapwing の編集ツールには、複数のテンプレートとカスタム要素を含むメディアのメニューがあります。LinkedIn などの特定のプラットフォームに固有のテンプレート、動画、音声、アニメーションを分離するタイムライン、エクスポート品質オプション付きのキャンバス エディタ、動画のプレビューなど、さまざまな機能があります。

これは YouTube スタイルの動画で、アプリでは一般的です。ユーザーがクリップ内を移動することはほとんどないため、タイムラインのビジュアル サムネイルはセクション間の移動にはあまり役立ちません。一方、音声の波形にはピークと谷があり、谷は通常、録音中のデッドタイムに対応しています。タイムラインを拡大すると、途切れや一時停止に対応する谷がある、よりきめ細かいオーディオ情報が表示されます。

ユーザー調査によると、クリエイターはコンテンツを分割する際に、これらの波形を参考にすることがよくあります。web audio API を使用すると、この情報を高パフォーマンスで表示し、タイムラインのズームやパンで迅速に更新できます。

次のスニペットは、この方法を示しています。

const getDownsampledBuffer = (idbAsset: IdbAsset) =>
  decodeMutex.runExclusive(
    async (): Promise<Float32Array> => {
      const arrayBuffer = await idbAsset.file.arrayBuffer();
      const audioContext = new AudioContext();
      const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);

      const offline = new OfflineAudioContext(
        audioBuffer.numberOfChannels,
        audioBuffer.duration * MIN_BROWSER_SUPPORTED_SAMPLE_RATE,
        MIN_BROWSER_SUPPORTED_SAMPLE_RATE
      );

      const downsampleSource = offline.createBufferSource();
      downsampleSource.buffer = audioBuffer;
      downsampleSource.start(0);
      downsampleSource.connect(offline.destination);

      const downsampledBuffer22K = await offline.startRendering();

      const downsampledBuffer22KData = downsampledBuffer22K.getChannelData(0);

      const downsampledBuffer = new Float32Array(
        Math.floor(
          downsampledBuffer22KData.length / POST_BROWSER_SAMPLE_INTERVAL
        )
      );

      for (
        let i = 0, j = 0;
        i < downsampledBuffer22KData.length;
        i += POST_BROWSER_SAMPLE_INTERVAL, j += 1
      ) {
        let sum = 0;
        for (let k = 0; k < POST_BROWSER_SAMPLE_INTERVAL; k += 1) {
          sum += Math.abs(downsampledBuffer22KData[i + k]);
        }
        const avg = sum / POST_BROWSER_SAMPLE_INTERVAL;
        downsampledBuffer[j] = avg;
      }

      return downsampledBuffer;
    } 
  );

このヘルパーには、IndexedDB に保存されているアセットを渡します。完了すると、IndexedDB のアセットと独自のキャッシュが更新されます。

AudioContext コンストラクタを使用して audioBuffer に関するデータを収集しますが、デバイスのハードウェアにレンダリングしないため、OfflineAudioContext を使用して ArrayBuffer にレンダリングし、振幅データを保存します。

API 自体は、効果的な可視化に必要なサンプルレートよりもはるかに高いサンプルレートでデータを返します。そのため、Google では 200 Hz に手動でダウンサンプリングしています。これは、有用で視覚的に魅力的な波形に十分であることがわかったためです。

WebCodecs

特定の動画では、波形よりもトラックのサムネイルの方がタイムラインのナビゲーションに便利です。ただし、サムネイルの生成は、波形の生成よりもリソースを消費します。

読み込み時にすべてのサムネイルをキャッシュに保存することはできないため、パフォーマンスと応答性に優れたアプリを実現するには、タイムラインのパン/ズームでの高速デコードが重要です。フレームのスムーズな描画のボトルネックとなるのはフレームのデコードです。最近までは、HTML5 動画プレーヤーを使用していました。このアプローチのパフォーマンスは信頼性が低く、フレーム レンダリング中にアプリの応答性が低下することがよくありました。

最近、ウェブワーカーで使用できる WebCodecs に移行しました。これにより、メインスレッドのパフォーマンスに影響を与えることなく、大量のレイヤのサムネイルを描画できるようになります。ウェブワーカーの実装はまだ進行中ですが、既存のメインスレッドの実装の概要を以下に示します。

動画ファイルには、動画、音声、字幕などの複数のストリームが「結合」されています。WebCodecs を使用するには、まずデマルチプレックスされた動画ストリームが必要です。次に示すように、mp4box ライブラリを使用して mp4 をデマルチプレックスします。

async function create(demuxer: any) {
  demuxer.file = (await MP4Box).createFile();
  demuxer.file.onReady = (info: any) => {
    demuxer.info = info;
    demuxer._info_resolver(info);
  };
  demuxer.loadMetadata();
}

const loadMetadata = async () => {
  let offset = 0;
  const asset = await getAsset(this.mediaLibraryId, null, this.url);
  const maxFetchOffset = asset?.file.size || 0;

  const end = offset + FETCH_SIZE;
  const response = await fetch(this.url, {
    headers: { range: `bytes=${offset}-${end}` },
  });
  const reader = response.body.getReader();

  let done, value;
  while (!done) {
    ({ done, value } = await reader.read());
    if (done) {
      this.file.flush();
      break;
    }

    const buf: ArrayBufferLike & { fileStart?: number } = value.buffer;
    buf.fileStart = offset;
    offset = this.file.appendBuffer(buf);
  }
};

このスニペットは demuxer クラスを参照します。このクラスは、MP4Box へのインターフェースのカプセル化に使用します。IndexedDB からアセットに再度アクセスします。これらのセグメントはバイト順で保存されている必要はなく、appendBuffer メソッドは次のチャンクのオフセットを返します。

動画フレームのデコード方法は次のとおりです。

const getFrameFromVideoDecoder = async (demuxer: any): Promise<any> => {
  let desiredSampleIndex = demuxer.getFrameIndexForTimestamp(this.frameTime);
  let timestampToMatch: number;
  let decodedSample: VideoFrame | null = null;

  const outputCallback = (frame: VideoFrame) => {
    if (frame.timestamp === timestampToMatch) decodedSample = frame;
    else frame.close();
  };  

  const decoder = new VideoDecoder({
    output: outputCallback,
  }); 
  const {
    codec,
    codecWidth,
    codecHeight,
    description,
  } = demuxer.getDecoderConfigurationInfo();
  decoder.configure({ codec, codecWidth, codecHeight, description }); 

  /* begin demuxer interface */
  const preceedingKeyFrameIndex = demuxer.getPreceedingKeyFrameIndex(
    desiredSampleIndex
  );  
  const trak_id = demuxer.trak_id
  const trak = demuxer.moov.traks.find((trak: any) => trak.tkhd.track_id === trak_id);
  const data = await demuxer.getFrameDataRange(
    preceedingKeyFrameIndex,
    desiredSampleIndex
  );  
  /* end demuxer interface */

  for (let i = preceedingKeyFrameIndex; i <= desiredSampleIndex; i += 1) {
    const sample = trak.samples[i];
    const sampleData = data.readNBytes(
      sample.offset,
      sample.size
    );  

    const sampleType = sample.is_sync ? 'key' : 'delta';
    const encodedFrame = new EncodedVideoChunk({
      sampleType,
      timestamp: sample.cts,
      duration: sample.duration,
      samapleData,
    }); 

    if (i === desiredSampleIndex)
      timestampToMatch = encodedFrame.timestamp;
    decoder.decodeEncodedFrame(encodedFrame, i); 
  }
  await decoder.flush();

  return { type: 'value', value: decodedSample };
};

デマルチプレクサの構造は非常に複雑であり、この記事の範囲外です。各フレームは、samples という名前の配列に保存されます。デマルチプライヤーを使用して、目的のタイムスタンプに最も近い前のキーフレームを見つけます。このフレームで動画のデコードを開始する必要があります。

動画は、キーフレームまたは I フレームと呼ばれるフルフレームと、p フレームまたは b フレームと呼ばれるはるかに小さい差分フレームで構成されています。デコードは常にキーフレームから開始する必要があります。

アプリケーションは、次の方法でフレームをデコードします。

  1. フレーム出力コールバックを使用してデコーダをインスタンス化します。
  2. 特定のコーデックと入力解像度のデコーダを構成する。
  3. デマルチプライヤーのデータを使用して encodedVideoChunk を作成する。
  4. decodeEncodedFrame メソッドを呼び出す。

目的のタイムスタンプを含むフレームに到達するまで、この処理を繰り返します。

次のステップ

フロントエンドのスケールは、プロジェクトの規模や複雑さが増しても、正確で高性能な再生を維持する能力と定義されています。パフォーマンスをスケーリングする 1 つの方法は、一度にマウントする動画をできるだけ少なくすることですが、そうすると、切り替えが遅く途切れる可能性があります。Google では、動画コンポーネントをキャッシュに保存して再利用できるようにする内部システムを開発していますが、HTML5 動画タグで提供できる制御には限界があります。

今後、すべてのメディアを WebCodecs を使用して再生する可能性があります。これにより、バッファリングするデータを非常に正確に指定できるため、パフォーマンスのスケーリングに役立ちます。

また、大規模なトラックパッドの計算を ウェブワーカーにオフロードする処理をより効率的に行うことができ、ファイルのプリフェッチやフレームのプリ生成をよりスマートに行うことができます。アプリケーション全体のパフォーマンスを最適化し、WebGL などのツールを使用して機能を拡張する大きな機会があります。

Google は、現在インテリジェントな背景除去に使用している TensorFlow.js への投資を継続したいと考えています。Google は、オブジェクト検出、特徴抽出、スタイル転送などの他の高度なタスクにも TensorFlow.js を活用する予定です。

最終的には、無料かつオープンなウェブ上で、ネイティブに近いパフォーマンスと機能を備えたプロダクトの開発を継続していく予定です。