Cutter - Web影片剪輯工具原理淺析
最近一直在開發 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使用,目前主要用於兩種不同的使用場景:

流程 | 優點 | 劣勢 | |
---|---|---|---|
模式一:同步顯示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
參考資料
多媒體前端技術入門指南 - 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/
騰訊雲剪 - web多媒體技術在影片編輯場景的應用: http://cloud.tencent.com/developer/article/1694656
愛奇藝雲剪輯Web端的技術實現: http://blog.51cto.com/u_15282126/3000742
ffmpeg: http://ffmpeg.org/ffmpeg.html
OffscreenCanvas: http://developer.mozilla.org/zh-CN/docs/Web/API/OffscreenCanvas
Transferable: http://developer.mozilla.org/zh-CN/docs/Web/API/Transferable
Transferable: http://developer.mozilla.org/zh-CN/docs/Web/API/Transferable
ffmpeg.wasm: http://ffmpegwasm.netlify.app/
asm.js 和 Emscripten 入門教程 - 阮一峰的網路日誌: http://www.ruanyifeng.com/blog/2017/09/asmjs_emscripten.html
Serverless Wasm: http://www.zhihu.com/column/c_1311629555841826816
webassembly 基礎: http://quickapp.vivo.com.cn/webassembly/#toc-8
OffscreenCanvas - 概念說明及使用解析: http://zhuanlan.zhihu.com/p/34698375
OffscreenCanvas-離屏canvas使用說明: http://blog.csdn.net/netcy/article/details/103781610
- END -
- 使用 WebAssembly 打造定製 JS Runtime
- 前端也要懂演算法,不會演算法也能微調一個 NLP 預訓練模型
- 聯機遊戲原理入門即入土 -- 入門篇
- Plasmo Framework:次世代的瀏覽器外掛開發框架
- 深入理解 Mocha 測試框架:從零實現一個 Mocha
- Single Source of Truth:XCode SwiftUI 的介面編輯的設計理念
- 深入理解 D3.js 視覺化庫之力導向圖原理與實現
- 淺析神經網路 Neural Networks
- Cutter - Web影片剪輯工具原理淺析
- 你可能需要一個四捨五入的工具函式
- 淺析eslint原理
- 最小編譯器the-super-tiny-compiler
- Git儲存原理及部分實現
- 淺談短鏈的設計
- Web元件構建庫-Lit
- 使用Svelte開發Chrome Extension
- Web3.0開發入門
- vscode外掛原理淺析與實戰
- 深入淺出 Web Audio API
- 探祕HTTPS