Cutter - Web影片剪輯工具原理淺析

語言: CN / TW / HK

最近一直在開發 web影片剪輯工具(cutter),這個工具可以方便老師們編輯拍攝好的影片。

這是一個挺有意思的專案,預計分多章和大家分享介紹。

本期主要介紹下其大體流程,方便大家對其原理有一個簡單認知

Cutter 剪輯工具效果演示:

web編輯器

匯出效果

閱讀完本文,預計可以收穫的知識點:

  • 瞭解web影片剪輯工具 基本原理

  • 實戰一個ffmpeg+wasm+offscreen canvas demo

技術鏈路

全鏈路可以簡單分為

  • 頁面依賴的底層:vesdk

  • 頁面核心互動:web剪輯工具

  • 後端:影片架構-影片合成

問題丟擲

為了更好的理解全鏈路,這裡我們丟擲兩個問題,帶著問題來看整體鏈路,增強我們的理解:

Q1:影片是怎麼在網頁端實現編輯預覽的效果?

Q2:怎麼保證預覽效果和合成效果一致性?

Q1:影片是怎麼在網頁端實現編輯預覽的效果

目前web影片編輯主要有兩個方向

  • 一種是使用原生JS,基於瀏覽器提供的

  • 多媒體前端技術入門指南 - TeqNG [1]

  • 騰訊雲剪 - web多媒體技術在影片編輯場景的應用 [2]

  • 愛奇藝雲剪輯Web端的技術實現 [3]

  • 另一種是直接使用WebAssembly將現有基於C/C++等程式碼的影片編輯框架編譯到瀏覽器上執行

  • 《VESDK技術演進之Web音影片編輯技術》

二者的對比,可以參考如下:

圖片來源 《VESDK技術演進之Web音影片編輯技術》

vesdk採用的是第二種方式(ffmpeg+wasm),大體流程轉換圖可參考如下:

排程邏輯:

解碼、繪製時盡力出幀frame,將frame放入快取池中,上屏時結合raf 自行根據fps計算下一幀渲染時間點

剪輯中 音訊、文字 是怎麼繪製的:

音訊:在主執行緒中,基於Web Audio的OpenAL API來構建

文字、特效:基於webgl shader等直接繪製

為了更好的理解上面的流程圖,介紹下里面的一些關鍵名詞

YUV是一種顏色編碼格式,能夠通過公式計算還原為RGB,相比於RGB編碼體積佔用更小。

R = Y + 1.140*V

G = Y - 0.394 U - 0.581 V

B = Y + 2.032*U

“Y”表示明亮度(Luminance或Luma),也就是灰階值

“U”和“V”表示的則是色度(Chrominance或Chroma),作用是描述影像色彩及飽和度,用於指定畫素的顏色

例如:通過YUV預覽工具,我們可以看到各個分量單獨顯示時的成像

  • 媒體內容原始檔都是比較大的,為了便於傳輸和儲存,需要對原影片通過編碼來壓縮,再通過容器封裝將壓縮後的影片、音訊、字幕等組合到一個容器內,這就是 編碼容器封裝 的過程(例如可以用 壓縮餅乾封袋包裝 來理解,會出現很多不同的壓縮工藝和包裝規格)

  • 在播放端進行播放時,進行相應的 解封裝解碼 ,得到原檔案資料

在上述過程中,FFmpeg就是這樣一款領先的多媒體框架,幾乎實現了當下所有常見的資料封裝格式、多媒體傳輸協議、音影片編解碼器。

FFmpeg提供了兩種呼叫姿勢,可以面向不同場景需求:

  • 呼叫方法一:應用層可以呼叫 ffmpeg [4] \ffprobe等命令列 cli 工具 來讀寫媒體檔案;

// 例如:
ffmpeg -i tempalte.mp4 -pix_fmt yuv420p tempalte.yuv

可以看到解封裝後,原檔案體積是遠大於1.1MB的

  • 呼叫方法二:c層面可以呼叫 FFmpeg 下層編解碼器等外部庫用來實現編解碼,獲取原生影象和音訊資料

// 可以參考該文章Mp4讀取yuv資料
http://www.jianshu.com/p/f4516e6df9f1
// 幾個關鍵api
av_read_frame 讀取影片流的h264幀資料
avcodec_send_packet 將h264幀資料傳送給解碼器
avcodec_receive_frame 從解碼器中讀出解碼後的yuv資料

// demo
void decode() {
char *path = "/template.mp4";
...
while(true) {
av_read_frame(avformat_context, packet);//讀取檔案中的h264幀
if (packet->stream_index == videoStream) {
int ret = avcodec_send_packet(avcodec_context, packet);//將h264幀傳送到解碼器
if (ret < 0) {
break;
}
while (true) {
int ret = avcodec_receive_frame(avcodec_context, frame);//從解碼器獲取幀
sws_scale(sws_context,
(uint8_t const * const *) frame->data,
frame->linesize, 0, avcodec_context->height, pFrameYUV->data,
pFrameYUV->linesize);//將幀資料轉為yuv420p
fwrite(pFrameYUV->data[0], sizeof( uint8_t ), avcodec_context->width * avcodec_context->height, pFile);//將y資料寫入檔案中
fwrite(pFrameYUV->data[1], sizeof( uint8_t ), avcodec_context->width * avcodec_context->height / 4, pFile);//將u資料寫入檔案中
fwrite(pFrameYUV->data[2], sizeof( uint8_t ), avcodec_context->width * avcodec_context->height / 4, pFile);//將v資料寫入檔案中
}
}
}
...
}

WebAssembly是一種安全、可移植、效率高、檔案小的格式,其提供的命令列工具wasm可以將高階語言(如 C++)編寫的程式碼轉換為瀏覽器可理解的機器碼,所以實現了在瀏覽器中直接執行。

例如c程式碼 經過如下步驟,可以被瀏覽器直接執行

加法 demo小例子:

step1、可利用線上工具(http://mbebenita.github.io/WasmExplorer/)編寫一個加法例子,然後下載得到 編譯好的 test.wasm 檔案

step2、載入test.wasm

step3、瀏覽器中直接執行

OffscreenCanvas

OffscreenCanvas [5] 非常有意思,這是一個離前端開發人員比較近的概念,它是一個可以脫離螢幕渲染的canvas物件,在主執行緒環境和web worker環境均有效。

OffscreenCanvas 一般搭配worker使用,目前主要用於兩種不同的使用場景:

image.png
流程 優點 劣勢
模式一:同步顯示offscrrenCanvas中的幀 step1、在 Worker 執行緒建立一個 OffscreenCanvas 做後臺渲染step2、再把渲染好的緩衝區 Transfer 回主執行緒顯示 主執行緒可以直接控制渲染內容 canvas渲染受主執行緒影響
模式二:非同步顯示offscrrenCanvas中的幀 step1、將主執行緒中 Canvas 轉換為 OffscreenCanvas,併發送給worker執行緒step2、worker執行緒獲取到OffscreenCanvas後,進行繪製計算操作,最後把繪製結果直接 Commit 到瀏覽器的 Display Compositor (相當於在 Worker 執行緒直接更新 Canvas 元素的內容,不走常規的渲染流程)(參考表格下面的圖) canvas渲染不受主執行緒影響-   避免繪製過程中的大量的計算阻塞主執行緒-   避免主執行緒的耗時任務阻塞渲染 主執行緒無法控制繪製內容

模式一:

 // 主執行緒 進行渲染  
const ctx = renderCanvas.getContext( '2d' );
const worker = new Worker( 'worker.js' );
worker.onmessage = function ( msg ) {
if (msg.data.method === 'transfer' ) {
ctx.drawImage(msg.data.buffer, 0 , 0 );
}
};

// worker執行緒
onmessage = async (event) => {
const offscreenCanvas = new OffscreenCanvas( 480 , 270 );
const ctx = offscreenCanvas.getContext( "2d" );
// ctx繪製工作
...
const imageBitmap = await self.createImageBitmap( new Blob([data.buffer]));
ctx.drawImage(imageBitmap, 0 , 0 );
let imageBitmap = offscreenCanvas.transferToImageBitmap();
// bitmap傳送給主執行緒
postMessage({ method : "transfer" , buffer : imageBitmap}, [imageBitmap])
}

備註:postMessage 常規傳遞是通過拷貝的方式;對此postMessage提供了第二個引數,可以傳入實現了 Transferable [6] 介面的資料(例如 ImageBitmap),這些資料的控制權會被轉移到子執行緒,轉移後主執行緒無法使用這些資料(會拋錯)

備註:postMessage 常規傳遞是通過拷貝的方式;對此postMessage提供了第二個引數,可以傳入實現了 Transferable [7] 介面的資料(例如 ImageBitmap),這些資料的控制權會被轉移到子執行緒,轉移後主執行緒無法使用這些資料(會拋錯)

模式二:

// 主執行緒
const worker = new Worker('worker.js');
const offscreenCanvas = canvas.transferControlToOffscreen();
worker.postMessage({
canvas: offscreenCanvas,
}, [offscreenCanvas])


// worker
onmessage = async (event) => {
const canvas = event.data.canvas;
const ctx = canvas.getContext("2d");
// ctx繪製工作 ...
const imageBitmap = await self.createImageBitmap(new Blob([data.buffer]));
// 開始渲染
ctx.drawImage(imageBitmap, 0, 0);
}

一個對照試驗:

對照 主執行緒解碼+主執行緒渲染(參考動圖 1)
實驗組 demo1:主執行緒解碼 + 主執行緒渲染 -   解碼被卡住 :x:-   渲染被卡住 :x:(參考動圖 2) demo2:work 執行緒 解碼 + 主執行緒canvas渲染 -   解碼不被卡住 :white_check_mark:-   渲染被卡住 :x:(參考動圖 3)
實驗組 demo3:worker 執行緒 解碼 + offscreenCanvas(同步模式) -   解碼不被卡住 :white_check_mark:-   渲染被卡住 :x:(參考動圖 4) demo4:worker 執行緒 解碼 + offscreenCanvas( 非同步模式 ) -   解碼不被卡住 :white_check_mark:-   渲染不被卡住 :white_check_mark:(參考動圖 5)

動圖 1

動圖 2

動圖 3

動圖 4

動圖 5

通過實驗,我們可以發現:

解碼任務放在worker執行緒,不會被主執行緒打斷;渲染任務放在offscreenCanvas,不會被主執行緒打斷

Q2:怎麼保證預覽效果和合成效果一致性?

這個問題比較容易理解,受限於瀏覽器自身的效能和限制,前端合成問題較多,穩定性和效能不足,所以是採用服務端合成的方式。

為了保證服務端匯出和前端編輯預覽一致,約定一個草稿協議,雲端合成時基於草稿做類似前端合成操作即可

可以看到 ffmpeg + wasm + worker + offscreenCanvas搭配起來後,還是能做出一款效能不錯的有意思的音影片小工具

我們進入一個小實戰環節 :point_down:

小實戰探索:搭建一個 web版gif字幕離線生成器

之所以做下面這個實戰

  • 一方面是因為之前在校弄過一個app,底層原理是在服務端做gif字幕的合成,合成時使用到了ffmpeg,和影片剪輯底層有些相似;

  • 另一方面是剛好嘗試改造為web版本,以便實戰下 ffmpeg + wasm + worker + offscreenCanvas

:cookie: 想要達到的目標:一個 web版gif字幕離線生成器(支援離線合成,也支援播控合成後的gif)

之前效果:

合成鏈路改造方案:

之前的鏈路,存在的問題:

  • 雲端合成,流量大時伺服器CPU容易被擠滿,導致伺服器不可用

  • 生成的gif不支援預覽播控

具體鏈路

實現效果

具體程式碼

為了使用ffmpeg,我們需要將其編譯為wasm,這裡我們直接使用一個編譯好的三方庫 ffmpeg.wasm [8]

step1、載入worker

index.jsx

useEffect(() => {
const gifWorker = new Worker('http://localhost:3000/gif_worker_offscreen.js');
gifWorker.onmessage = function (msg) {
if (msg.data.method === 'transfer') {
setGifSrc(msg.data.url);
}
};
setGifWorker(gifWorker);
}, []);

<>
<canvas id='gif-canvas' ref={gifCanvasRef}/>
<button onClick={() => {
if (!gifWorker) {
return;
}
// 定義一個離屏canvas
const offscreenCanvas = gifCanvasRef.current.transferControlToOffscreen();
gifWorker.postMessage({
method: 'init',
canvas: offscreenCanvas,
inputList: gifInputList,
}, [offscreen]);
}}>生成gif
</button>
</>

step2、worker初始化,引入ffmepg

gif_worker_offscreen.js

importScripts('/ffmpeg.dev.js');
const {createFFmpeg, fetchFile} = self.FFmpeg;

const ffmpeg = createFFmpeg({
corePath: 'http://localhost:3000/ffmpeg-core.js',
...
});

onmessage = async (event) => {
const method = event.data.method;
if (method === 'init') {
if (!canvas) {
canvas = event.data.canvas;
...
ctx = canvas.getContext("2d");
}
await decodeResource();
play();
await playCore(ctx);
} else if (method === 'pause') {
pause();
} else if (method === 'play') {
play();
} else if (method === 'replay') {
...
playIndex = 0;
await playCore(ctx);
}
}

step3、合成gif

gif_worker_offscreen.js

async function decodeResource() {
if (!ffmpeg.isLoaded()) {
await ffmpeg.load();
}
ffmpeg.FS('writeFile', 'template.mp4', await fetchFile('http://localhost:3000/1/template.mp4'));
ffmpeg.FS('writeFile', 'template.ass', await replaceAssTemplate(inputList));
ffmpeg.FS('writeFile', 'tmp/Yahei', await fetchFile('http://localhost:3000/1/yahei.ttf'));
await ffmpeg.run('-i', 'template.mp4', '-vf', "subtitles=template.ass:fontsdir=/tmp:force_style='Fontname=Microsoft YaHei'", 'export.gif');
const data = ffmpeg.FS('readFile', 'export.gif');
await ffmpeg.run('-i', 'export.gif', '-vf', 'fps=25', '-s', '480x270', 'image%d.jpg');
const url = URL.createObjectURL(new Blob([data.buffer], {type: 'image/gif'}));
postMessage({method: "transfer", url});
}

step4、播控gif

gif_worker_offscreen.js

async function playCore(ctx) {
const totalLength = Math.floor(duration / timeInterval);
clearInterval(playTimer);
playTimer = setInterval(async () => {
if (!canPlay) {
return;
}
playIndex++;
if (playIndex === totalLength) {
clearInterval(playTimer);
return;
}
const data = ffmpeg.FS('readFile', `image${playIndex}.jpg`);
const imageBitmap = await self.createImageBitmap(new Blob([data.buffer]));
ctx.drawImage(imageBitmap, 0, 0);
}, timeInterval);
}

總結

以上便是做剪輯工具過程中,發現的一些比較有意思的點,本篇文章屬於拋磚引玉,每個方面大家都還可以繼續深挖,發現更多有意思的點。

在後續的分享中,打算分享下 web剪輯編輯器 前端部分的具體實現(前端工程師會比較熟悉的領域)

:heart: 謝謝支援

以上便是本次分享的全部內容,希望對你有所幫助^_^

喜歡的話別忘了 分享、點贊、收藏 三連哦~。

歡迎關注公眾號 ELab團隊 收貨大廠一手好文章~

我們來自位元組跳動,是旗下大力教育前端部門,負責位元組跳動教育全線產品前端開發工作。

我們圍繞產品品質提升、開發效率、創意與前沿技術等方向沉澱與傳播專業知識及案例,為業界貢獻經驗價值。包括但不限於效能監控、元件庫、多端技術、Serverless、視覺化搭建、音影片、人工智慧、產品設計與營銷等內容。

歡迎感興趣的同學在評論區或使用內推碼內推到作者部門拍磚哦

位元組跳動校/社招投遞連結: http://job.toutiao.com/s/2jML178

內推碼: C4QC2V7

參考資料

[1]

多媒體前端技術入門指南 - TeqNG: http://www.teqng.com/2021/06/29/%E5%A4%9A%E5%AA%92%E4%BD%93%E5%89%8D%E7%AB%AF%E6%8A%80%E6%9C%AF%E5%85%A5%E9%97%A8%E6%8C%87%E5%8D%97/

[2]

騰訊雲剪 - web多媒體技術在影片編輯場景的應用: http://cloud.tencent.com/developer/article/1694656

[3]

愛奇藝雲剪輯Web端的技術實現: http://blog.51cto.com/u_15282126/3000742

[4]

ffmpeg: http://ffmpeg.org/ffmpeg.html

[5]

OffscreenCanvas: http://developer.mozilla.org/zh-CN/docs/Web/API/OffscreenCanvas

[6]

Transferable: http://developer.mozilla.org/zh-CN/docs/Web/API/Transferable

[7]

Transferable: http://developer.mozilla.org/zh-CN/docs/Web/API/Transferable

[8]

ffmpeg.wasm: http://ffmpegwasm.netlify.app/

[9]

asm.js 和 Emscripten 入門教程 - 阮一峰的網路日誌: http://www.ruanyifeng.com/blog/2017/09/asmjs_emscripten.html

[10]

Serverless Wasm: http://www.zhihu.com/column/c_1311629555841826816

[11]

webassembly 基礎: http://quickapp.vivo.com.cn/webassembly/#toc-8

[12]

OffscreenCanvas - 概念說明及使用解析: http://zhuanlan.zhihu.com/p/34698375

[13]

OffscreenCanvas-離屏canvas使用說明: http://blog.csdn.net/netcy/article/details/103781610

- END -