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

如要進行高效能編輯,所有使用者的內容都必須位於用戶端上,盡量避免網路。不同於串流服務,使用者通常只會存取一次內容,但在上傳後,我們的客戶會經常、數天甚至數月重複使用資產。

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 陣列用於防止同時存取索引資料庫,否則在載入時很常見。

網路音訊 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 中的資產和自己的快取。

我們會透過 AudioContext 建構函式收集 audioBuffer 的相關資料,但由於我們並未向裝置硬體算繪資料,因此我們使用 OfflineAudioContext 轉譯至 ArrayBuffer,以在此儲存振幅資料。

API 本身傳回的資料取樣率遠高於有效視覺化作業所需的資料。因此,我們手動調降至 200 Hz 的取樣率,發現足以提供實用且視覺吸引力的波形。

WebCodecs

對於某些影片,追蹤縮圖比波形更用於時間軸導覽。不過,與產生波形相比,產生縮圖需要更大量的資源。

我們無法在載入時快取所有可能的縮圖,因此在時間軸平移/縮放上快速解碼,對於高效能的回應式應用程式至關重要。要達到流暢的影格繪製,這個瓶頸就是解碼影格,直到近期使用 HTML5 影片播放器為止。該方法的效能並不可靠,我們也在影格轉譯期間經常看到應用程式回應速度降低。

我們最近已移至可用於網路工作站的 WebCodecs。這可讓我們針對大量圖層繪製縮圖,同時不會影響主執行緒的效能。雖然網路工作站實作仍在進行中,但我們在下方概述現有的主要執行緒實作方式。

影片檔案包含多個串流:影片、音訊和字幕等會「混合」。如要使用 WebCodecs,我們必須先建立經過去處理的影片串流。我們使用 mp4box 程式庫解譯 mp4s,如下所示:

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

demuxer 的結構相當複雜,而且超出本文的討論範圍。它會將每個影格儲存在標題為 samples 的陣列中。我們會使用 demuxer 來找出與所需時間戳記最相符的前幾個按鍵影格,也就是系統必須開始視訊解碼的位置。

影片是由完整影格 (稱為鍵或 i-Frame) 和較小的差異影格組成,通常稱為 p 或 b 影格。解碼作業一律應從主要影格開始。

應用程式會透過下列方式解碼影格:

  1. 使用影格輸出回呼將解碼器例項化。
  2. 針對特定轉碼器和輸入解析度設定解碼器。
  3. 使用 Demuxer 的資料建立 encodedVideoChunk
  4. 呼叫 decodeEncodedFrame 方法。

直到觸及含有所需時間戳記的影格為止。

後續步驟

我們定義了前端的資源調度能力,因為專案規模較大和複雜,所以能夠持續提供準確和高效的播放體驗。擴充效能的一種方法是一次盡量減少影片,但進行這項操作時,可能會有速度緩慢且流暢的轉場效果。雖然我們開發了內部系統來快取影片元件以供重複使用,但 HTML5 影片代碼的控制範圍有限。

日後,我們可能會嘗試使用 WebCodecs 播放所有媒體。如此一來,我們就能準確判斷要為哪些資料進行緩衝,進而擴充效能。

將大型觸控板運算作業卸載給網路工作站,可以更妥善地利用預先擷取檔案及預先產生影格。我們會把握大好機會,改善整體應用程式效能,並使用 WebGL 等工具擴充功能。

我們想繼續投資 TensorFlow.js,這項工具目前用於移除智慧背景。我們打算利用 TensorFlow.js 處理其他更複雜的工作,例如物件偵測、特徵擷取、樣式轉移等。

最後,我們很高興能在免費的開放網路上,繼續以類似原生的效能和功能來建構產品。