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 用於其他複雜的工作,例如物件偵測、特徵擷取、樣式轉移等等。

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