有了強大的 API (例如 IndexedDB 和 WebCodecs) 和效能工具,創作者現在可以使用 Kapwing 在網路上編輯高品質影片內容。
自疫情爆發以來,線上影片的觀看次數迅速成長。使用者花在 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
函式。這可用於初始化,或在必要時更新結構定義。我們會傳遞錯誤處理回呼 blocked
和 blocking
,這對系統不穩定的使用者來說相當實用,可避免發生問題。
最後,請注意我們對主鍵 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
音訊示波圖對於影片編輯相當重要。如要瞭解原因,請參閱編輯器的螢幕截圖:
這是 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 影格) 組成。解碼作業一律必須從關鍵影格開始。
應用程式會透過以下方式解碼影格:
- 使用影格輸出回呼來例項化解碼器。
- 針對特定編解碼器和輸入解析度設定解碼器。
- 使用解多工器的資料建立
encodedVideoChunk
。 - 呼叫
decodeEncodedFrame
方法。
直到找到所需時間戳記的影格為止。
後續步驟
我們在前端定義的規模,是指在專案規模變大且複雜度提高時,維持精確且高效的播放能力。擴大效能的方式之一,就是一次掛載盡可能少的影片,但這樣做可能會導致轉場速度變慢且不流暢。雖然我們已開發內部系統,可快取影片元件以供重複使用,但 HTML5 影片代碼可提供的控制程度仍有限制。
日後,我們可能會嘗試使用 WebCodecs 播放所有媒體。這樣一來,我們就能精確地緩衝哪些資料,進而提升效能。
我們也可以更有效地將大型觸控板運算作業卸載至網路工作者,並更聰明地預先擷取檔案和預先產生影格。我們發現許多機會,可用於改善整體應用程式效能,並透過 WebGL 等工具擴充功能。
我們希望持續投資 TensorFlow.js,目前我們使用這項工具進行智慧背景移除作業。我們計畫將 TensorFlow.js 用於其他複雜的工作,例如物件偵測、特徵擷取、樣式轉移等等。
最終,我們很高興能在免費開放的網路上,繼續打造具備原生效能和功能的產品。