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()專案。該專案作者也提供瞭如何自行編譯FFmpeg到WebAssembly的系列博文()
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,此物件用於表達命令列引數的結構,可以設定有哪些輸入(多個)和哪些輸出(一個),並提供一些簡便的方法用以操作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命令列引數
動畫能力
適當的元素動畫有助提高影片的畫面豐富度,我們實現的影片合成能力中,也對元素動畫能力進行了初步支援。
業務端如何配置動畫
在影片剪輯軟體中,為元素配置動畫主要是基於關鍵幀模型,典型操作步驟如下:
選中畫布中的一個元素後
在時間軸上為元素的某一屬性新增若干個關鍵幀
在每個關鍵幀上,為該屬性設定不同的值。例如將位於第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=t*100:y=t*100,可以用來表達頂層影片從左上到右下的位移動畫,逐幀計算可知第0秒座標為0,0,第1秒時座標為100,100,以此類推。
像overlay=x=expr:y=expr這樣的,expr的部分被稱為FFmpeg的表示式,它也可以看成是以時間(以及其他一些可用的變數)作為輸入,以filter的屬性值作為輸出的函式。表示式中除了可以使用實數、t變數、各類算術運算子之外,還可以使用很多內建函式,具體可參考FFmpeg文件中對於表示式取值的說明()
常見動畫模式的表示式總結
由於表示式的本質是函式,我們在把動畫翻譯成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.對於中間部分
3.1 將keyframes中宣告的每個關鍵幀點(某個百分比及其對應值),結合動畫的duration配置,縮放為新的關鍵幀點(某個時間點及其對應值)
3.2 根據上述關鍵幀,獲取predicates陣列(也就是動畫中間部分,進入每一個分支的條件表示式,例如t<2, t<5 等)
3.3 根據上述關鍵幀,獲取branches陣列(也就是動畫中間部分,每一個分支本身的表示式)。每一個branch宣告瞭一個關鍵幀到下一個關鍵幀的連線,也就是補幀表示式
3.4 使用nestedIfElse(branches, predicates)組合出中間部分的表示式
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.
分析後我們認為,記憶體不足的問題主要是由於以下這些因素導致的
影片合成本身是開銷很大的計算過程,這是由於音影片檔案往往都有著很高的壓縮率,在合成時,音影片檔案被解碼成未壓縮的資料,佔用了大量記憶體
和原生環境相比,瀏覽器中的應用會額外受到單個標籤頁可使用的最大記憶體的限制。例如在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為瀏覽器提供了基於顯示卡的平行計算的能力,特別適合對影片摳像、濾鏡、轉場等應用場景進行加速。
來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70027824/viewspace-3007651/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- FFmpeg 圖片合成影片
- ffmpeg實戰-音視訊合成案例
- 記錄---前端Vue使用ffmpeg壓縮影片再上傳前端Vue
- ffmpeg合併影片
- FFmpeg應用實踐之命令查詢
- GPT-SoVITS語音合成模型實踐GPT模型
- Java ffmpeg 實現影片加文字/圖片水印功能Java
- ffmpeg用法-mp4檔案合成,切割功能
- 微前端實踐前端
- FFmpeg 影片處理入門教程
- FFMpeg 常用命令格式轉換,視訊合成
- wasm + ffmpeg實現前端擷取視訊幀功能ASM前端
- [實踐系列] 前端路由前端路由
- qiankun微前端實踐前端
- 基於FFmpeg和Qt實現簡易影片播放器QT播放器
- 前端圖床搭建實踐(前端篇)前端圖床
- 視訊提取圖片/圖片合成視訊ffmpeg(二十)
- FFMPEG+SDL簡單影片播放器——影片快進播放器
- ffmpeg提取H264影片資料
- 前端快取最佳實踐前端快取
- 兩個影片怎麼合成一個影片?合併影片的方法分享
- 高效前端程式設計實踐前端程式設計
- 前端可用性保障實踐前端
- 前端最佳實踐(一)——DOM操作前端
- 前端面試之vue實踐前端面試Vue
- 實踐指南-前端效能提升 270%前端
- 前端微架構實踐(Vue)前端架構Vue
- lerna管理前端模組實踐前端
- 前端異常監控實踐前端
- 前端:WebP自適應實踐前端Web
- 前端工程化最佳實踐前端
- 使用PHP結合Ffmpeg快速搭建流媒體服務實踐PHP
- 影片直播系統原始碼,C語言實現圖片合成功能原始碼C語言
- C# 使用ffmpeg讀取監控影片流C#
- ffmpeg 匯出影片檔案中的音訊音訊
- Ffmpeg分散式影片轉碼問題總結分散式
- thinkphp6 使用FFMpeg獲取影片資訊PHP
- laravel 使用PHP-FFMpeg處理影片檔案LaravelPHP