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

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

Joshua Grossberg
Joshua Grossberg

パンデミックが始まって以来、オンライン動画の消費は急速に増加しています。TikTok、Instagram、YouTube などのプラットフォームでは、絶え間なく高品質な動画を消費する時間が増加しています。世界中のクリエイティブ オーナーや小規模ビジネス オーナーは、すばやく簡単に動画コンテンツを作成できるツールを必要としています。

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

Kapwing について

Kapwing はウェブベースの共同編集動画エディタで、主にゲーム配信者、ミュージシャン、YouTube クリエイター、ミームなどのカジュアルなクリエイティブ向けに設計されています。また、Facebook や Instagram 広告など、独自のソーシャル コンテンツを簡単に制作する方法を必要としているビジネス オーナーにとっても有用なリソースとなっています。

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

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

Kapwing がリアルタイムの編集とコラボレーションをウェブで実現した方法

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

IndexedDB

高パフォーマンスな編集を行うには、可能な限りネットワークを回避し、すべてのユーザー コンテンツをクライアント上に置く必要があります。ユーザーが通常 1 回のコンテンツにアクセスするストリーミング サービスとは異なり、Google のお客様はアップロード後、何日も、場合によっては数か月もアセットを再利用します。

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 アクセスをシリアル化する独自の内部定義関数です。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 配列は、それ以外の場合は、読み込み時に一般的です。

ウェブ オーディオ API

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

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

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

Google のユーザー調査によると、クリエイターはコンテンツをスプライスする際にこれらの波形に従うことが多いことがわかりました。ウェブ オーディオ 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 自体は、効果的な可視化に必要な値よりもはるかに高いサンプルレートでデータを返します。そのため、手動で 200 Hz にダウンサンプリングしています。この 200 Hz は、有用で視覚に訴える波形には十分であることがわかりました。

WebCodecs

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

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

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

動画ファイルには、動画、音声、字幕などの複数のストリームが含まれ、「多重化」されます。WebCodec を使用するには、まず、分離された動画ストリームが必要です。次に示すように、mp4s を mp4box ライブラリで分離します。

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);
  }
};

このスニペットは、MP4Box へのインターフェースをカプセル化するために使用する demuxer クラスを参照しています。ここでも、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 への投資を継続したいと考えています。TensorFlow.js は現在、インテリジェントな背景の削除に使用しています。今後、オブジェクト検出、特徴抽出、スタイル転送など、高度なタスクにも TensorFlow.js を活用する予定です。

最終的には、自由でオープンなウェブにおけるネイティブ並みのパフォーマンスと機能を備えた Google プロダクトの開発を続けていきます。