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

為了達到高效編輯,所有使用者的內容都必須在用戶端上運作,盡可能避免使用網路。與串流服務不同,使用者通常只會使用內容一次,但我們的客戶經常重複使用資產,甚至在上傳後數天或數月後重複使用。

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

我們有自己的資料結構 idbCache,用於盡量減少 IndexedDB 存取次數。雖然 IndexedDB 速度很快,但存取本機記憶體的速度更快。只要您管理快取的大小,我們就建議採用這種方法。

subscribers 陣列用於防止同時存取 IndexedDB,否則在載入時會很常見。

Web Audio API

音訊示波圖對於影片編輯相當重要。如要瞭解原因,請參閱編輯器的螢幕截圖:

Kapwing 編輯器提供媒體選單,其中包含多個範本和自訂元素,包括一些專屬於特定平台 (例如 LinkedIn) 的範本;可區分影片、音訊和動畫的時間軸;可設定匯出品質選項的畫布編輯器;影片預覽畫面;以及其他功能。

這是 YouTube 風格的影片,在我們的應用程式中很常見。使用者在整個短片中移動的幅度不大,因此時間軸的視覺縮圖在切換各個部分時不太實用。另一方面,音訊波形會顯示峰值和谷值,其中谷值通常對應錄音中的無聲時間。如果您將時間軸放大,就能看到更精細的音訊資訊,其中的谷地對應於斷斷續續和暫停的情況。

我們的使用者研究顯示,創作者在剪輯內容時,經常會參考這些波形圖。網路音訊 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,這足以產生有用且視覺效果良好的波形。

WebCodecs

對於某些影片,軌道縮圖比波形圖更適合用於時間軸導覽。不過,產生縮圖比產生波形圖需要更多資源。

我們無法在載入時快取所有可能的縮圖,因此在時間軸上快速解碼平移/縮放功能,對於效能良好且回應迅速的應用程式至關重要。要順暢繪製影格,關鍵在於解碼影格,而我們最近才使用 HTML5 影片播放器完成這項工作。這種做法的成效不穩定,我們經常發現在影格算繪期間,應用程式反應速度會變慢。

我們最近已改用 WebCodecs,可用於網路 worker。這應該可提升我們為大量圖層繪製縮圖的能力,且不會影響主執行緒效能。雖然網頁工作站實作功能仍在進行中,但我們會在下文中概略說明現有的主執行緒實作方式。

影片檔案包含多個串流:影片、音訊、字幕等,這些串流會「混合」在一起。如要使用 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 方法。

直到找到所需時間戳記的影格為止。

後續步驟

我們在前端定義的規模,是指在專案規模變大且複雜度提高時,維持精確且高效的播放能力。擴大效能的方式之一,就是一次掛載盡可能少的影片,但這樣做可能會導致轉場速度變慢且不流暢。雖然我們已開發內部系統,可快取影片元件以供重複使用,但 HTML5 影片代碼可提供的控制程度仍有限制。

日後,我們可能會嘗試使用 WebCodecs 播放所有媒體。這樣一來,我們就能精確地緩衝哪些資料,進而提升效能。

我們也可以更有效地將大型觸控板運算作業卸載至網路工作者,並更聰明地預先擷取檔案和預先產生影格。我們發現許多機會,可用於改善整體應用程式效能,並透過 WebGL 等工具擴充功能。

我們希望持續投資 TensorFlow.js,目前我們使用這項工具進行智慧背景移除作業。我們計畫將 TensorFlow.js 用於其他複雜的工作,例如物件偵測、特徵擷取、樣式轉移等等。

最終,我們很高興能在免費開放的網路上,繼續打造具備原生效能和功能的產品。