梁晴天
嗶哩嗶哩高級開發工程師
想要開發一個具有視頻合成功能的應用,從原理層面和應用層面都有一定的復雜度。原理上,視頻合成需要應用使用各種算法對音視頻數據進行編解碼,并處理各類不同音視頻格式的封裝;應用上,視頻合成流程較長,需要對多個輸入文件進行并行處理,以實現視頻濾鏡、剪輯、拼接等功能,使用應用場景變得復雜。
視頻合成應用的代表是各類視頻剪輯軟件,過去主要以原生應用的形式存在。近年來隨著瀏覽器的接口和能力的不斷開放,逐漸也有了Web端視頻合成能力的解決思路和方案。
本文介紹的是一種基于FFmpeg + WebAssembly開發的視頻合成能力,與社區既有的方案相比,此方案通過JSON來描述視頻合成過程,可提高業務側使用的便利性和靈活性,對應更多視頻合成業務場景。
2023年上半年,基于AI進行內容創作的AIGC趨勢來襲。筆者所在的團隊負責B站的創作、投稿等業務,也在此期間參與了相關的AIGC創作工具類項目,并負責項目中的Web前端視頻合成能力的開發。
如果需要在應用中引入音視頻相關能力,目前業界常見的方案之一是使用FFmpeg。FFmpeg是知名的音視頻綜合處理框架,使用C語言寫成,可提供音視頻的錄制、格式轉換、編輯合成、推流等多種功能。
而為了在瀏覽器中能夠使用FFmpeg,我們則需要WebAssembly + Emscripten這兩種技術:
想要通過Emscripten將FFmpeg編譯至WebAssembly,需要使用Emscripten。Emscripten本身是一系列編譯工具的合稱,它仿照gcc中的編譯器、鏈接器、匯編器等程序的分類方式,實現了處理wasm32對象文件的對應工具,例如emcc用于編譯到wasm32、wasm-ld用于鏈接wasm32格式的對象文件等。
而對于FFmpeg這個大型項目來說,其模塊主要分為以下三個部分
自行編譯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程序輸入不同的參數,來完成各類不同的視頻合成任務。例如在終端中輸入以下命令,則可以將視頻縮放至原來一半大小,并且只保留前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的方式,會導致業務層需要處理大量代碼處理命令行字符串的構建、組合邏輯,就顯得不合適宜。同時,我們在項目實踐的過程中發現,由于項目需要接入 WebCodecs 和 FFmpeg 兩種視頻合成能力,這就需要一個中間層,從上層接收業務層表達的視頻合成意圖,并傳遞到下層的WebCodecs 或 FFmpeg 進行具體的視頻合成邏輯的“翻譯”和執行。
API設計
圖片
如上所示,描述一個視頻合成任務,可以采用類似“基于時間軸的視頻合成工程文件”的方式:在視頻剪輯軟件中,用戶通過可視化的操作界面導入素材,向軌道上拖入素材成為片段,為每個片段設置位移、寬高、不透明度、特效等屬性;同理,對于我們的項目來說,業務方自行準備素材資源,并按一定的結構搭建描述視頻合成工程的對象樹,然后調用中間層的方法執行合成任務。
圖片
以上是我們最終形成的一個分層結構:
以上是我們最終實現的FFmpeg前端視頻合成能力,各個模塊在運行時的相互調用時序圖。各個模塊之間并不是簡單地按順序層層向下調用,再層層向上返回。有以下這些點值得注意
例如,業務方想要把一個寬高未知的視頻片段,放置在最終合成視頻(假設為1280x720)的正中央時,我們需要將視頻片段的transform.left設置為(1280 - videoWidth) / 2,transform.top 設置為 (720 - videoHeight) / 2。這里的videoWidth, videoHeight就需要通過FFmpeg讀取文件元信息得到。因此我們設計的流程中,需要對所有輸入的資源文件進行預加載,再生成狀態樹。
實踐過程中我們發現,業務方在使用FFmpeg能力時,至少需要使用以下三種不同的形式的輸出結果:
因此我們為執行層的輸出設計了這樣的統一接口
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,此對象用于表達命令行參數的結構,可以設置有哪些輸入(多個)和哪些輸出(一個),并提供一些簡便的方法用以操作filtergraph2. 初始化一個視頻流的空數組layers(這里指廣義的視頻流,只要是有圖像信息的輸入流(例如視頻、占一定時長的圖片、文字片段轉成的圖片),都算作視頻流);初始化一個音頻流的空數組audios3. (作為最終合成的視頻或音頻內容的基底)在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命令行參數
適當的元素動畫有助提高視頻的畫面豐富度,我們實現的視頻合成能力中,也對元素動畫能力進行了初步支持。
在視頻剪輯軟件中,為元素配置動畫主要是基于關鍵幀模型,典型操作步驟如下:
在視頻合成描述JSON中,我們參照了CSS動畫聲明進行了以下設計,來滿足元素動畫的配置
以下是元素動畫配置的例子
// 視頻片段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的視頻合成的實際操作都是由filter完成的,所以想要在FFmpeg視頻合成中添加動畫,則需要視頻類的filter支持按視頻的當前時間,逐幀動態設置filter的參數值。
以overlay filter為例,此filter可以將兩個視頻層疊在一起,并設置位于頂層的視頻相對位置。如果無需設置動畫時,我們可以將參數寫成overlay=x=100:y=100表示將頂層視頻放置在距離底層視頻左上角100,100的位置。
需要設置動畫時,我們也可以設置x, y為包含了t變量(當前時間)的表達式。例如overlay=x=t*100:y=t*100,可以用來表達頂層視頻從左上到右下的位移動畫,逐幀計算可知第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的整體算法大致如下:
1.將動畫分成前,中,后三部分。前半部分是由于delay配置導致的,元素已出現但動畫還未開始的靜止部分;中間部分是動畫的主體部分;后半部分是由于動畫重復次數較少,元素未消失但動畫已結束的靜止部分
2.對于前半部分,表達式設置為等于關鍵幀中第一幀的值;對于后半部分,表達式設置為等于關鍵幀中最后一值的值
3.對于中間部分
4.再次使用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.
分析后我們認為,內存不足的問題主要是由于以下這些因素導致的
為了應對以上問題,在實踐中,我們采取了以下這些策略,來減少內存不足導致的合成失敗率:
視頻合成的過程出現了并發時,會加劇內存不足現象的產生。因此我們在runProject以及其他FFmpeg執行方法背后實現了一個統一的任務隊列,確保一個任務在執行完成后再進行下一個任務,并且在下一個任務開始執行前,重啟ffmpeg.wasm的運行時,實現內存垃圾回收。
實踐中我們發現,如果一個FFmpeg命令中輸入的音視頻素材文件過多時,即使這些素材在時間線上都重疊(也就是某一時間點上,所有的素材視頻畫面都需要出現在最終畫面中)的情況很少,也會大大提高內存不足的概率。
我們采取了對視頻合成的結果進行時間分段的策略。根據每個片段在時間軸上的分布情況,將整個視頻合成的FFmpeg任務,拆分成多個規模更小的FFmpeg任務。每個任務僅需要2-3個輸入文件(常規的視頻合成需求中,同屏同時播放的視頻最多也在3個左右),各任務單獨進行視頻合成,最后再使用FFmpeg的concat功能,將視頻前后相接即可。
視頻合成的重編碼(解碼輸入文件,操作數據并再編碼),會消耗大量的CPU和內存資源。而視頻和音頻的前后拼接操作,則無需重編碼,可以在非常短的時間內完成。
對于不太復雜的視頻合成場景,往往并不是畫面的每一幀都需要重新編碼再輸出的。我們可以分析視頻合成的時間軸,找出不需要重編碼的時間段(指的是此時畫面內容僅來自一個輸入文件,并且沒有縮放旋轉等濾鏡效果,沒有其他層疊的內容的時間段)。對這些時間段,我們通過FFmpeg的流拷貝功能截取出來(通過-vcodec copy命令行參數實現)即可,這樣進一步減少了CPU和內存的消耗。
在視頻中添加文字是視頻合成的常見需求,這類需求可以大致分為兩種情況:少量的樣式復雜的藝術字,大量的字幕文字。
FFmpeg自帶的filters中提供了以下的文字繪制能力,包括:
最初在支持視頻合成方案的文字能力時,我們選擇了后者的文字轉圖片技術,基本滿足了業務需求。這一做法的優勢在于:復用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 的編譯、運行方式都可以與時俱進,以達到提高性能的目的。目前可以預見的一些優化點有:
本文鏈接:http://www.www897cc.com/showinfo-26-75342-0.htmlFFmpeg前端視頻合成實踐
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
上一篇: 終于有篇文章把后管權限系統設計講清楚了