FFmpeg 前端視頻合成實踐
本期作者
梁晴天
嗶哩嗶哩高級開發工程師
視頻合成能力的開發背景
想要開發一個具有視頻合成功能的應用,從原理層面和應用層面都有一定的複雜度。原理上,視頻合成需要應用使用各種算法對音視頻數據進行編解碼,並處理各類不同音視頻格式的封裝;應用上,視頻合成流程較長,需要對多個輸入文件進行並行處理,以實現視頻濾鏡、剪輯、拼接等功能,使用應用場景變得複雜。
視頻合成應用的代表是各類視頻剪輯軟件,過去主要以原生應用的形式存在。近年來隨着瀏覽器的接口和能力的不斷開放,逐漸也有了 Web 端視頻合成能力的解決思路和方案。
本文介紹的是一種基於 FFmpeg + WebAssembly 開發的視頻合成能力,與社區既有的方案相比,此方案通過 JSON 來描述視頻合成過程,可提高業務側使用的便利性和靈活性,對應更多視頻合成業務場景。
2023 年上半年,基於 AI 進行內容創作的 AIGC 趨勢來襲。筆者所在的團隊負責 B 站的創作、投稿等業務,也在此期間參與了相關的 AIGC 創作工具類項目,並負責項目中的 Web 前端視頻合成能力的開發。
技術選型
如果需要在應用中引入音視頻相關能力,目前業界常見的方案之一是使用 FFmpeg。FFmpeg 是知名的音視頻綜合處理框架,使用 C 語言寫成,可提供音視頻的錄製、格式轉換、編輯合成、推流等多種功能。
而爲了在瀏覽器中能夠使用 FFmpeg,我們則需要 WebAssembly + Emscripten 這兩種技術:
-
WebAssembly 是瀏覽器可以運行的一種類彙編語言,常用於瀏覽器端上高性能運算的場景。彙編語言一般難以手寫,因此有了通過其他高級語言(C/C++, Go, Rust 等)編譯到 WebAssembly 的方案。
-
Emscripten 則是一個適用於 C/C++ 項目的編譯工具包,我們可以用它來將 C/C++ 項目編譯成 WebAssembly,並移植到瀏覽器中運行。WebAssembly + Emscripten 兩者構築了 C 語言項目在瀏覽器中運行的環境。再加上 FFmpeg 模塊提供的實際的音視頻處理能力,理論上我們就可以在瀏覽器中進行視頻合成了。
編譯 FFmpeg 至 WebAssembly
想要通過 Emscripten 將 FFmpeg 編譯至 WebAssembly,需要使用 Emscripten。Emscripten 本身是一系列編譯工具的合稱,它仿照 gcc 中的編譯器、鏈接器、彙編器等程序的分類方式,實現了處理 wasm32 對象文件的對應工具,例如 emcc 用於編譯到 wasm32、wasm-ld 用於鏈接 wasm32 格式的對象文件等。
而對於 FFmpeg 這個大型項目來說,其模塊主要分爲以下三個部分
-
libav 系列庫,是構成 FFmpeg 本身的重要組成部分。提供了用於音視頻處理的大量函數,涵蓋格式封裝、編解碼、濾鏡、工具函數等多方面
-
第三方庫,指的是並非 FFmpeg 原生提供,需要在編譯 FFmpeg 時,通過編譯配置來選擇性添加的模塊。包括第三方的格式、編解碼、協議、硬件加速能力等
-
fftools,FFmpeg 提供的三個可執行程序,提供命令行參數界面,使得音視頻相關功能的使用更加方便。三個可執行程序分別用於音視頻合成、音視頻播放、音視頻文件元信息提取。因此在編譯 FFmpeg 至 WebAssembly 時,我們需要按照 “優先庫,最終可執行程序” 的順序,首先將 libav 系列庫和第三方庫編譯至 wasm32 對象文件,最後再編譯可執行程序至 wasm32 對象文件,並與前面的產物鏈接爲完整的 FFmpeg WebAssembly 版。
自行編譯 FFmpeg 到 WebAsssembly 難度較大,我們在實際在爲項目落地時,選擇了社區維護的版本。目前社區內維護比較積極,功能相對全面的是 ffmpeg.wasm(https://github.com/ffmpegwasm/ffmpeg.wasm)項目。該項目作者也提供瞭如何自行編譯 FFmpeg 到 WebAssembly 的系列博文(https://itnext.io/build-ffmpeg-webassembly-version-ffmpeg-js-part-1-preparation-ed12bf4c8fac)
FFmpeg 在瀏覽器的運行
FFmpeg 本身是一個可執行命令行程序。我們可以通過爲 FFmpeg 程序輸入不同的參數,來完成各類不同的視頻合成任務。例如在終端中輸入以下命令,則可以將視頻縮放至原來一半大小,並且只保留前 5 秒:
ffmpeg -i input.mp4 -vf scale=w='0.5*iw':h='0.5*ih' -t 5 output.mp4
而在瀏覽器中,FFmpeg 以及視頻合成的運行機制如上所示:在業務層,我們爲視頻合成準備好需要的 FFmpeg 命令以及若干個輸入文件,將其預加載到 Emscripten 模塊的 MEMFS(一種虛擬文件系統)中,並同時傳遞命令至 Emscripten 模塊,最後通過 Emscripten 的膠水代碼驅動 WebAssembly 進行邏輯計算。視頻合成的輸出視頻會在 MEMFS 中逐步寫入完成,最終可以被取回到業務層
對 FFmpeg 命令行界面進行封裝
上面的例子中,我們爲 FFmpeg 輸入了一個視頻文件,以及一串命令行參數,實現了對視頻的簡單縮放加截斷操作。實際情況下,業務側產生的視頻合成需求可能是千變萬化的,這樣直接調用 FFmpeg 的方式,會導致業務層需要處理大量代碼處理命令行字符串的構建、組合邏輯,就顯得不合適宜。同時,我們在項目實踐的過程中發現,由於項目需要接入 WebCodecs 和 FFmpeg 兩種視頻合成能力,這就需要一箇中間層,從上層接收業務層表達的視頻合成意圖,並傳遞到下層的 WebCodecs 或 FFmpeg 進行具體的視頻合成邏輯的 “翻譯” 和執行。
API 設計
如上所示,描述一個視頻合成任務,可以採用類似 “基於時間軸的視頻合成工程文件” 的方式:在視頻剪輯軟件中,用戶通過可視化的操作界面導入素材,向軌道上拖入素材成爲片段,爲每個片段設置位移、寬高、不透明度、特效等屬性;同理,對於我們的項目來說,業務方自行準備素材資源,並按一定的結構搭建描述視頻合成工程的對象樹,然後調用中間層的方法執行合成任務。
分層設計
以上是我們最終形成的一個分層結構:
-
業務方代碼使用一個 JSON 對象來描述自己的視頻合成意圖。爲了方便業務方使用,這一層允許大量使用默認值,無需過多配置;
-
狀態層是一個對象樹,將視頻的全局屬性、片段的屬性等狀態補齊,方便後續的翻譯;同時,這一層的各個對象都支持讀寫,未來可以用於可視化視頻編輯器的場景等;
-
執行層負責 FFmpeg 命令的翻譯和執行邏輯。如果狀態層抽象得當,則這個執行層也可以被 WebCodecs 的翻譯和執行模塊替換
執行流程
以上是我們最終實現的 FFmpeg 前端視頻合成能力,各個模塊在運行時的相互調用時序圖。各個模塊之間並不是簡單地按順序層層向下調用,再層層向上返回。有以下這些點值得注意
狀態樹,是 JSON + 文件元信息綜合生成的
例如,業務方想要把一個寬高未知的視頻片段,放置在最終合成視頻(假設爲 1280x720)的正中央時,我們需要將視頻片段的 transform.left 設置爲 (1280 - videoWidth) / 2,transform.top 設置爲 (720 - videoHeight) / 2。這裏的 videoWidth, videoHeight 就需要通過 FFmpeg 讀取文件元信息得到。因此我們設計的流程中,需要對所有輸入的資源文件進行預加載,再生成狀態樹。
輸出結果多樣化
實踐過程中我們發現,業務方在使用 FFmpeg 能力時,至少需要使用以下三種不同的形式的輸出結果:
-
事件回調:例如業務方所需的合成進度、合成開始、合成結束等
-
合成結果的二進制文件:合成結束時異步返回
-
日誌結果:例如獲取文件元信息,獲取音頻的平均音量等操作,FFmpeg 的輸出都是以 log 的形式
因此我們爲執行層的輸出設計了這樣的統一接口
export interface RunTaskResult {
/** 日誌樹結果 */
log: LogNode
/** 二進制文件結果 */
output: Uint8Array
}
function runProject(json: ProjectJson): {
/** 事件結果 */
evt: EventEmitter<RunProjectEvents, any>;
result: Promise<RunTaskResult>;
}
部分代碼實現
執行主流程
runProject 函數是我們對外提供的視頻合成的主函數。包含了 “對輸入 JSON 進行校驗,補全、預加載文件並獲取文件元信息、預加載字幕相關文件、翻譯 FFmpeg 命令、執行、emit 事件” 等多種邏輯。
/**
* 按照projectJson執行視頻合成
* @public
* @param json - 一個視頻合成工程的描述JSON
* @returns 一個evt對象,用以獲取合成進度,以及異步返回的視頻合成結果數據
*/
export function runProject(json: ProjectJson) {
const evt = new EventEmitter<RunProjectEvents>()
const steps = async () => {
// hack 這裏需要加入一個異步,使得最早在evt上emit的事件可以被evt.on所設置的回調函數監聽到
await Promise.resolve()
const parsedJson = ProjectSchema.parse(json) // 使用json schema驗證並補全一些默認值
// 預加載並獲取文件元信息
evt.emit('preload_all_start')
const preloadedClips = [
...await preloadAllResourceClips(parsedJson, evt),
...await preloadAllTextClips(parsedJson)
]
// 預加載字幕相關信息
const subtitleInfo = await preloadSubtitle(parsedJson, evt)
evt.emit('preload_all_end')
// 生成project對象樹
const projectObj = initProject(parsedJson, preloadedClips)
// 生成ffmpeg命令
const { fsOutputPath, fsInputs, args } = parseProject(projectObj, parsedJson, preloadedClips, subtitleInfo)
if (subtitleInfo.hasSubtitle) {
fsInputs.push(subtitleInfo.srtInfo!, subtitleInfo.fontInfo!)
}
// 在ffmpeg任務隊列裏執行
const task: FFmpegTask = {
fsOutputPath,
fsInputs,
args
}
// 處理進度事件
task.logHandler = (log) => {
const p = getProgressFromLog(log, project.timeline.end)
if (p !== undefined) {
evt.emit('progress', p)
}
}
evt.emit('start')
// 返回執行日誌,最終合成文件,事件等多種形式的結果
const res = runInQueue(task)
await res
evt.emit('end')
return res
}
return {
evt,
result: steps()
}
}
翻譯流程
FFmpeg 命令的翻譯流程,對應的是上述 runProject 方法中的 parseProject,是在所有的上下文(視頻合成描述 JSON 對象,狀態樹文件預加載後的元信息等)都齊備的情況下執行的。本身是一段很長,且下游較深的同步執行代碼。這裏用僞代碼描述一下 parseProject 的過程
1. 實例化一個命令行參數操作對象ctx,此對象用於表達命令行參數的結構,可以設置有哪些輸入(多個)和哪些輸出(一個),並提供一些簡便的方法用以操作filtergraph
2. 初始化一個視頻流的空數組layers(這裏指廣義的視頻流,只要是有圖像信息的輸入流(例如視頻、佔一定時長的圖片、文字片段轉成的圖片),都算作視頻流);初始化一個音頻流的空數組audios
3. (作爲最終合成的視頻或音頻內容的基底)在layers中加入一個顏色爲project.backgroundColor, 大小爲project.size,時長爲無限長的純色的視頻流;在audios中加入一個無聲的,時長爲無限長的靜音音頻流
4. 對於每一個project中的片段
1. 將片段中所包含的資源的url添加到ctx的輸入數組中
2. (從所有已預加載的文件元信息中)找到這個片段對應的元信息(寬高、時長等)
3. (處理片段本身的截取、寬高、旋轉、不透明度、動畫等的處理)基於此片段的JSON定義和預加載信息,翻譯成一組作用於該片段的FFmpeg filters,並且這一組filters之間需要相互串聯,filters頭部連接到此片段的輸入流。得到片段對應的中間流。
4. 獲取到的中間流,如果是廣義的視頻流的,推入layers數組;如果是廣義的音頻流的,推入audios數組
5. 視頻流layers數組做一個類似reduce的操作,按照畫面中內容疊放的順序,從最底層到最頂層,逐個合併流,得到單個視頻流作爲最終視頻輸出流。
6. 音頻流audios數組進行混音,得到單個音頻流作爲最終輸出流。
7. 調用ctx的toString方法,此方法是會將整個命令行參數結構輸出爲string。ctx下屬的各類對象(Input, Option, FilterGraph)都有自己的toString方法,它們會依次層層toString,最終形成整體的ffmpeg命令行參數
動畫能力
適當的元素動畫有助提高視頻的畫面豐富度,我們實現的視頻合成能力中,也對元素動畫能力進行了初步支持。
** 業務端如何配置動畫**
在視頻剪輯軟件中,爲元素配置動畫主要是基於關鍵幀模型,典型操作步驟如下:
-
選中畫布中的一個元素後
-
在時間軸上爲元素的某一屬性添加若干個關鍵幀
-
在每個關鍵幀上,爲該屬性設置不同的值。例如將位於第 1 秒的關鍵幀的 x 方向位移設置爲 0,將位於第 5 秒的關鍵幀的 x 方向位移設置爲 100
-
軟件會自動將 1-5 秒的動畫過程補幀出來,預覽播放(以及最後合成的結果中)就可以看到元素從第 1 秒到第 5 秒向下平移的效果。而在前端開發中,通過 CSS 的 @keyframes 所聲明的動畫,也與上述關鍵幀模型吻合。除此之外,在 CSS 動畫標準中,我們還需要附加以下這些信息,才能將一段關鍵幀動畫應用到元素上
-
delay 延遲(動畫在元素出現後,延遲多少時間再開始播放)
-
iterationCount(動畫需要重複播放多少次)
-
duration(在單次重複播放內,動畫所佔總時長)
-
timingFunction(動畫的補幀方式。線性方式實現簡單但關鍵幀之間的過渡生硬,因此一般會採用 “ease-in-out” 等帶有緩進緩出的非線性方式)。除此之外還有 direction, fillMode 等配置,這些並未在我們的視頻合成能力中實現,故不再贅述。
在視頻合成描述 JSON 中,我們參照了 CSS 動畫聲明進行了以下設計,來滿足元素動畫的配置
-
爲片段了定義了 x, y, w, h, angle, opacity 這六種可配置的屬性(涵蓋了位移、縮放、旋轉、不透明度等)
-
對於需要靜態配置的屬性,在 static 字段的子字段中配置
-
對於需要動畫配置的屬性,在 animation 字段的子字段中逐個關鍵幀進行配置
-
animation 字段同時可以進行 duration, delay 等動畫附加信息的配置
以下是元素動畫配置的例子
// 視頻片段bg.mp4,在畫面的100,100處出現,並伴隨有閃爍(不透明度從0到1再到0)的動畫,動畫延遲1秒,時長5秒
{
"type": "video",
"url": "/bg.mp4",
"static": {
"x": 100,
"y": 100
},
"animation": {
"properties": {
"delay": 1,
"duration": 5
},
"keyframes": {
"0": {
"opacity": 0
},
"50": {
"opacity": 1
},
"100": {
"opacity": 0
}
}
}
}
FFmpeg 合成添加動畫效果的原理
動畫效果的本質是一定時間內,元素的某個狀態逐幀連續變化。而 FFmpeg 的視頻合成的實際操作都是由 filter 完成的,所以想要在 FFmpeg 視頻合成中添加動畫,則需要視頻類的 filter 支持按視頻的當前時間,逐幀動態設置 filter 的參數值。
以 overlay filter 爲例,此 filter 可以將兩個視頻層疊在一起,並設置位於頂層的視頻相對位置。如果無需設置動畫時,我們可以將參數寫成 overlay=x=100:y=100 表示將頂層視頻放置在距離底層視頻左上角 100,100 的位置。
需要設置動畫時,我們也可以設置 x, y 爲包含了 t 變量(當前時間)的表達式。例如 overlay=x=t100:y=t100,可以用來表達頂層視頻從左上到右下的位移動畫,逐幀計算可知第 0 秒座標爲 0,0,第 1 秒時座標爲 100,100,以此類推。
像 overlay=x=expr:y=expr 這樣的,expr 的部分被稱爲 FFmpeg 的表達式,它也可以看成是以時間(以及其他一些可用的變量)作爲輸入,以 filter 的屬性值作爲輸出的函數。表達式中除了可以使用實數、t 變量、各類算術運算符之外,還可以使用很多內置函數,具體可參考 FFmpeg 文檔中對於表達式取值的說明(https://ffmpeg.org/ffmpeg-utils.html#Expression-Evaluation)
常見動畫模式的表達式總結
由於表達式的本質是函數,我們在把動畫翻譯成 FFmpeg 表達式時,可以先繪製動畫的函數圖像,然後再從 FFmpeg 表達式的可用變量、內置函數、運算符中,進行適當組合來還原函數圖像。下面是一些常見的動畫模式的 FFmpeg 表達式對應實現
動畫的分段
假設對於某元素,我們設置了一個向上彈跳一次的動畫,此動畫有一定延遲,並且只循環一次,動畫已結束後又過了一段時間,元素再消失。則此元素的 y 屬性函數圖像及其公式可能如下
通過以上函數圖像我們可知,此類函數無法通過一個單一部分表達出來。在 FFmpeg 表達式中,我們需要將三個子表達式,按條件組合到一個大表達式中。對於分段的函數,我們可以使用 FFmpeg 自帶的 if(x,y,z) 函數(類似腳本語言中的三元表達式)來等價模擬,將條件判斷 / then 分支 / else 分支 這三個子表達式 分別傳入並組合到一起。對於分支有兩個以上的情況,則在 else 分支處再嵌入新的 if(x,y,z) 即可。
# 實際在生成表達式時,所有的換行和空格可以省略
y=
if(
lt(t,2), # lt函數相當於<操作符
1,
if(
lt(t,4),
sin(-PI*t/2)+1,
1
)
)
我們可以實現一個遞歸函數 nestedIfElse,來將 N 個條件判斷表達式和 N+1 個分支表達式組合起來,成爲一個大的 FFmpeg 表達式,用於分段動畫的場景
function nestedIfElse(branches: string[], predicates: string[]) {
// 如果只有一個邏輯分支,則返回此分支的表達式
if (branches.length === 1) {
return branches[0]
// 如果有兩個邏輯分支,則只有一個條件判斷表達式,使用if(x,y,z)組合在一些即可
} else if (branches.length === 2) {
const predicate = predicates[0]
const [ifBranch, elseBranch] = branches
return `if(${predicate},${ifBranch},${elseBranch})`
// 遞歸case
} else {
const predicate = predicates.shift()
const ifBranch = branches.shift()
const elseBranch = nestedIfElse(branches, predicates) as string
return `if(${predicate},${ifBranch},${elseBranch})`
}
}
線性和非線性補幀
補幀是將關鍵幀間的空白填補,並連接爲動畫的基本方式。被補出來的每一幀中,對應的屬性值需要使用插值函數進行計算。
對於線性插值,FFmpeg 自帶了 lerp(x,y,z) 函數,表示從 x 開始到 y 結束,按 z 的比例(z 爲 0 到 1 的比值)線性插值的結果。因此我們可以結合上面的 if(x,y,z) 函數的分段功能,實現一個多關鍵幀的線性補幀動畫。例如,某屬性有兩個關鍵幀,在 t1 時屬性值爲 a,在 t2 時屬性值爲 b,則補幀表達式爲
對於非線性補幀,我們可以將其理解爲在上述線性補幀公式的基礎上,將 lerp(x,y,z) 函數的 z 參數(進度的比例)再進行一次變換,使得動畫的行進變得不均勻即可。以下公式中的 t'代表了一種典型的緩慢開始和緩慢結束的緩動函數 (timing function),將其代入原公式即可
(圖中展示了從左下角的關鍵幀到右上角的關鍵幀的
線性 / 非線性 補幀的函數圖像)
以下是對應的代碼實現
// 假設有關鍵幀(t1, v1)和(t2, v2),返回這兩個關鍵幀之間的非線性補幀表達式
function easeInOut(
t1: number, v1: number,
t2: number, v2: number
) {
const t = `t-${t1})/(${t2-t1})`
const tp = `if(lt(${t},0.5),4*pow(${t},3),1-pow(-2*${t}+2,3)/2)`
return `lerp(${v1},${v2},${tp})`
}
循環
如果我們需要表達一個帶有循環的動畫,最直接的方式是將某個時段上的映射關係,複製並平移到其他的時段上。例如,想要實現一個從畫面左側平移至右側的動畫,重複多次時,我們可能使用下面這樣的函數
以上使用分段函數的寫法的問題在於,如果循環次數過多時,函數的分支較多,產生的表達式很長,也會影響在視頻合成時對錶達式求值的性能。
事實上,我們可以引入 FFmpeg 表達式中自帶的 mod(x,y) 函數(取餘操作)來實現循環。由於取餘操作常用來生成一個固定範圍內的輸出,例如不斷重複播放的過程。上面的函數,在引入 mod(x,y) 後,可以簡化爲 x=mod(t,1)。
上述對於動畫分段、循環、補幀如何實現的問題,其共通點都是如何找到其對應函數,並在 FFmpeg 中翻譯爲對應的表達式,或者對已有表達式進行組合。
據此,我們實現了 KFAttr(關鍵幀屬性,用以封裝關鍵幀和動畫全局配置等信息)和 TimeExpr(以 KFAttr 作爲入參,並翻譯爲 FFmpeg 表達式)兩個類。其中,TimeExpr 的整體算法大致如下:
-
將動畫分成前,中,後三部分。前半部分是由於 delay 配置導致的,元素已出現但動畫還未開始的靜止部分;中間部分是動畫的主體部分;後半部分是由於動畫重複次數較少,元素未消失但動畫已結束的靜止部分
-
對於前半部分,表達式設置爲等於關鍵幀中第一幀的值;對於後半部分,表達式設置爲等於關鍵幀中最後一值的值
-
對於中間部分
-
3.1 將 keyframes 中聲明的每個關鍵幀點(某個百分比及其對應值),結合動畫的 duration 配置,縮放爲新的關鍵幀點(某個時間點及其對應值)
-
3.2 根據上述關鍵幀,獲取 predicates 數組(也就是動畫中間部分,進入每一個分支的條件表達式,例如 t<2, t<5 等)
-
3.3 根據上述關鍵幀,獲取 branches 數組(也就是動畫中間部分,每一個分支本身的表達式)。每一個 branch 聲明瞭一個關鍵幀到下一個關鍵幀的連接,也就是補幀表達式
-
3.4 使用 nestedIfElse(branches, predicates) 組合出中間部分的表達式
- 再次使用 nestedIfElse,將前、中、後三部分組合成最終的表達式
瀏覽器裏視頻合成的內存不足問題
在項目實踐的過程中,我們發現瀏覽器中通過 ffmpeg.wasm 進行視頻合成時,有一定機率出現內存不足的現象。表現爲以下 Emscripten 的運行時報錯(OOM 爲 Out of memory 的縮寫)
exception thrown: RuntimeError: abort (00M). Build with -s ASSERTIONS=1 for more info.RuntimeError: abort (00M). Build with -s ASSERTIONS=1 for more info.
分析後我們認爲,內存不足的問題主要是由於以下這些因素導致的
-
視頻合成本身是開銷很大的計算過程,這是由於音視頻文件往往都有着很高的壓縮率,在合成時,音視頻文件被解碼成未壓縮的數據,佔用了大量內存
-
和原生環境相比,瀏覽器中的應用會額外受到單個標籤頁可使用的最大內存的限制。例如在 64 位系統的 Chrome 中,一個標籤頁最多可使用的內存大小爲 4GB
-
瀏覽器沙盒機制,不允許 Web 應用直接讀寫客戶端本地文件。而 Emscripten 爲了使得移植的 C/C++ 項目仍能夠擁有原來的文件讀寫的能力,實現了一個 MEMFS 的虛擬文件系統。將文件預加載到內存中,把對磁盤的讀寫轉換爲對內存的讀寫。這部分文件的讀寫也佔用了一定的內存。在瀏覽器中運行視頻合成時,還會額外受到瀏覽器對於單個標籤頁可使用的最大內存的限制(在 64 位的 Chrome 中,最多可爲一個標籤頁分配 4G 內存)
爲了應對以上問題,在實踐中,我們採取了以下這些策略,來減少內存不足導致的合成失敗率:
視頻合成的嚴格串行執行
視頻合成的過程出現了併發時,會加劇內存不足現象的產生。因此我們在 runProject 以及其他 FFmpeg 執行方法背後實現了一個統一的任務隊列,確保一個任務在執行完成後再進行下一個任務,並且在下一個任務開始執行前,重啓 ffmpeg.wasm 的運行時,實現內存垃圾回收。
時間分段,多次合成
實踐中我們發現,如果一個 FFmpeg 命令中輸入的音視頻素材文件過多時,即使這些素材在時間線上都重疊(也就是某一時間點上,所有的素材視頻畫面都需要出現在最終畫面中)的情況很少,也會大大提高內存不足的概率。
我們採取了對視頻合成的結果進行時間分段的策略。根據每個片段在時間軸上的分佈情況,將整個視頻合成的 FFmpeg 任務,拆分成多個規模更小的 FFmpeg 任務。每個任務僅需要 2-3 個輸入文件(常規的視頻合成需求中,同屏同時播放的視頻最多也在 3 個左右),各任務單獨進行視頻合成,最後再使用 FFmpeg 的 concat 功能,將視頻前後相接即可。
減少重編碼的場景
視頻合成的重編碼(解碼輸入文件,操作數據並再編碼),會消耗大量的 CPU 和內存資源。而視頻和音頻的前後拼接操作,則無需重編碼,可以在非常短的時間內完成。
對於不太複雜的視頻合成場景,往往並不是畫面的每一幀都需要重新編碼再輸出的。我們可以分析視頻合成的時間軸,找出不需要重編碼的時間段(指的是此時畫面內容僅來自一個輸入文件,並且沒有縮放旋轉等濾鏡效果,沒有其他層疊的內容的時間段)。對這些時間段,我們通過 FFmpeg 的流拷貝功能截取出來(通過 - vcodec copy 命令行參數實現)即可,這樣進一步減少了 CPU 和內存的消耗。
在視頻中添加文字的實踐
在視頻中添加文字是視頻合成的常見需求,這類需求可以大致分爲兩種情況:少量的樣式複雜的藝術字,大量的字幕文字。
FFmpeg 自帶的 filters 中提供了以下的文字繪製能力,包括:
-
subtitles,配合 srt 格式的字幕文件。適合大量添加字幕,對樣式定製化不高的場景
-
drawtext,繪製單條文字,並進行一些簡單的樣式配置。如果不使用 filters,由於我們是在瀏覽器作爲上層環境使用 FFmpeg 的,此時也可以使用 DOM API 提供的一些文字轉圖片的技術(例如直接使用 Canvas API 的 fillText 繪製文字,或者使用 SVG 的 foreignObject 對包含文字的 html 文檔進行圖片轉換等),把文字當作圖片文件進行處理。
最初在支持視頻合成方案的文字能力時,我們選擇了後者的文字轉圖片技術,基本滿足了業務需求。這一做法的優勢在於:複用 DOM 的文字渲染能力,繪製效果好並且支持的文字樣式豐富;並且由於轉換爲圖片處理,可以讓文字直接支持縮放、旋轉、動畫等許多已經在圖片上實現的能力。
但正如上面提到的 “爲 FFmpeg 的命令一次性輸入過多的文件容易引起 OOM” 的問題,文字轉爲圖片後,視頻合成時需要額外導入的圖片輸入文件也增加了。這也促使我們開始關注 FFmpeg 自帶的文字渲染能力。
FFmpeg 自帶 subtitles, drawtext 等文字渲染能力,底層都使用了 C 語言的字體字符庫(包括 freetype 字體光柵化,harfbuzz 文字塑形,fribidi 雙向編碼等),在每一幀編碼前的 filter 階段,將字符按指定的字體和樣式即時繪製成位圖,並與當前的 framebuffer 混合來實現的。這種做法會耗費更多的計算資源,但同時因爲不需要緩存或文件,使用的內存更少。因此我們對於製作字幕這樣需要大量添加固定樣式的文字的場景,提供了相應的 JSON 配置,並在底層使用 FFmpeg 的 subtitles filter 進行繪製,避免了 OOM 的問題。
基於瀏覽器和 FFmpeg 本身的現有能力,在視頻中添加文字的方案還可以有更多探索的可能。例如可以 “使用 SVG 來聲明文字的內容和樣式,並在 FFmpeg 側進行渲染” 來實現。SVG 方案的優點在於:文字的樣式控制能力強;可以隨意添加任意的文字的前景、背景矢量圖形;與位圖相比佔用資源少等。後續在進行自編譯的 FFmpeg WebAssembly 版相關調研時,會嘗試支持。
後續迭代
通過 Emscripten 移植到瀏覽器運行的 FFmpeg,在性能上與原生 FFmpeg 有很大差距,大體原因在於瀏覽器作爲中間環境,其現有的 API 能力不足,以及一些安全政策的限制,導致 FFmpeg 對於硬件能力的利用受限。隨着瀏覽器能力和 API 的逐步演進,FFmpeg + WebAssembly 的編譯、運行方式都可以與時俱進,以達到提高性能的目的。目前可以預見的一些優化點有:
-
文件 IO 方面,接入瀏覽器的 OPFS(https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API#origin_private_file_system)。這是瀏覽器中訪問文件系統的一種新 API,有較高的讀寫性能。未來有可能被 Emscripten 實現,以替換掉當前默認的 MEMFS
-
並行計算方面,考慮使用 WebAssembly SIMD(https://v8.dev/features/simd)。SIMD 可以更充分地使用 CPU 進行並行計算。對於圖像處理較多的編碼場景(例如 x264 編碼器),適當地使用 WebAssembly 的 SIMD 來優化代碼有助於提高編碼性能
-
圖像處理方面,嘗試使用 WebGL 優化。WebGL 爲瀏覽器提供了基於顯卡的並行計算的能力,特別適合對視頻摳像、濾鏡、轉場等應用場景進行加速。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/1XmH55nqs7wavsWJNT-tSw