自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

FFmpeg前端視頻合成實(shí)踐

開(kāi)發(fā) 前端
通過(guò) Emscripten 移植到瀏覽器運(yùn)行的 FFmpeg,在性能上與原生FFmpeg有很大差距,大體原因在于瀏覽器作為中間環(huán)境,其現(xiàn)有的API能力不足,以及一些安全政策的限制,導(dǎo)致 FFmpeg 對(duì)于硬件能力的利用受限。

梁晴天

嗶哩嗶哩高級(jí)開(kāi)發(fā)工程師

視頻合成能力的開(kāi)發(fā)背景

想要開(kāi)發(fā)一個(gè)具有視頻合成功能的應(yīng)用,從原理層面和應(yīng)用層面都有一定的復(fù)雜度。原理上,視頻合成需要應(yīng)用使用各種算法對(duì)音視頻數(shù)據(jù)進(jìn)行編解碼,并處理各類不同音視頻格式的封裝;應(yīng)用上,視頻合成流程較長(zhǎng),需要對(duì)多個(gè)輸入文件進(jìn)行并行處理,以實(shí)現(xiàn)視頻濾鏡、剪輯、拼接等功能,使用應(yīng)用場(chǎng)景變得復(fù)雜。

視頻合成應(yīng)用的代表是各類視頻剪輯軟件,過(guò)去主要以原生應(yīng)用的形式存在。近年來(lái)隨著瀏覽器的接口和能力的不斷開(kāi)放,逐漸也有了Web端視頻合成能力的解決思路和方案。

本文介紹的是一種基于FFmpeg + WebAssembly開(kāi)發(fā)的視頻合成能力,與社區(qū)既有的方案相比,此方案通過(guò)JSON來(lái)描述視頻合成過(guò)程,可提高業(yè)務(wù)側(cè)使用的便利性和靈活性,對(duì)應(yīng)更多視頻合成業(yè)務(wù)場(chǎng)景。

2023年上半年,基于AI進(jìn)行內(nèi)容創(chuàng)作的AIGC趨勢(shì)來(lái)襲。筆者所在的團(tuán)隊(duì)負(fù)責(zé)B站的創(chuàng)作、投稿等業(yè)務(wù),也在此期間參與了相關(guān)的AIGC創(chuàng)作工具類項(xiàng)目,并負(fù)責(zé)項(xiàng)目中的Web前端視頻合成能力的開(kāi)發(fā)。

技術(shù)選型

如果需要在應(yīng)用中引入音視頻相關(guān)能力,目前業(yè)界常見(jiàn)的方案之一是使用FFmpeg。FFmpeg是知名的音視頻綜合處理框架,使用C語(yǔ)言寫成,可提供音視頻的錄制、格式轉(zhuǎn)換、編輯合成、推流等多種功能。

而為了在瀏覽器中能夠使用FFmpeg,我們則需要WebAssembly + Emscripten這兩種技術(shù):

  • WebAssembly是瀏覽器可以運(yùn)行的一種類匯編語(yǔ)言,常用于瀏覽器端上高性能運(yùn)算的場(chǎng)景。匯編語(yǔ)言一般難以手寫,因此有了通過(guò)其他高級(jí)語(yǔ)言(C/C++, Go, Rust等)編譯到WebAssembly的方案。
  • Emscripten則是一個(gè)適用于C/C++項(xiàng)目的編譯工具包,我們可以用它來(lái)將C/C++項(xiàng)目編譯成WebAssembly,并移植到瀏覽器中運(yùn)行。WebAssembly + Emscripten兩者構(gòu)筑了C語(yǔ)言項(xiàng)目在瀏覽器中運(yùn)行的環(huán)境。再加上FFmpeg模塊提供的實(shí)際的音視頻處理能力,理論上我們就可以在瀏覽器中進(jìn)行視頻合成了。

編譯FFmpeg至WebAssembly

想要通過(guò)Emscripten將FFmpeg編譯至WebAssembly,需要使用Emscripten。Emscripten本身是一系列編譯工具的合稱,它仿照gcc中的編譯器、鏈接器、匯編器等程序的分類方式,實(shí)現(xiàn)了處理wasm32對(duì)象文件的對(duì)應(yīng)工具,例如emcc用于編譯到wasm32、wasm-ld用于鏈接wasm32格式的對(duì)象文件等。

而對(duì)于FFmpeg這個(gè)大型項(xiàng)目來(lái)說(shuō),其模塊主要分為以下三個(gè)部分

  • libav系列庫(kù),是構(gòu)成FFmpeg本身的重要組成部分。提供了用于音視頻處理的大量函數(shù),涵蓋格式封裝、編解碼、濾鏡、工具函數(shù)等多方面
  • 第三方庫(kù),指的是并非FFmpeg原生提供,需要在編譯FFmpeg時(shí),通過(guò)編譯配置來(lái)選擇性添加的模塊。包括第三方的格式、編解碼、協(xié)議、硬件加速能力等
  • fftools,F(xiàn)Fmpeg提供的三個(gè)可執(zhí)行程序,提供命令行參數(shù)界面,使得音視頻相關(guān)功能的使用更加方便。三個(gè)可執(zhí)行程序分別用于音視頻合成、音視頻播放、音視頻文件元信息提取。因此在編譯FFmpeg至WebAssembly時(shí),我們需要按照“優(yōu)先庫(kù),最終可執(zhí)行程序”的順序,首先將libav系列庫(kù)和第三方庫(kù)編譯至wasm32對(duì)象文件,最后再編譯可執(zhí)行程序至wasm32對(duì)象文件,并與前面的產(chǎn)物鏈接為完整的FFmpeg WebAssembly版。

自行編譯FFmpeg到WebAsssembly難度較大,我們?cè)趯?shí)際在為項(xiàng)目落地時(shí),選擇了社區(qū)維護(hù)的版本。目前社區(qū)內(nèi)維護(hù)比較積極,功能相對(duì)全面的是ffmpeg.wasm(https://github.com/ffmpegwasm/ffmpeg.wasm)項(xiàng)目。該項(xiàng)目作者也提供了如何自行編譯FFmpeg到WebAssembly的系列博文(https://itnext.io/build-ffmpeg-webassembly-version-ffmpeg-js-part-1-preparation-ed12bf4c8fac)

FFmpeg在瀏覽器的運(yùn)行

FFmpeg本身是一個(gè)可執(zhí)行命令行程序。我們可以通過(guò)為FFmpeg程序輸入不同的參數(shù),來(lái)完成各類不同的視頻合成任務(wù)。例如在終端中輸入以下命令,則可以將視頻縮放至原來(lái)一半大小,并且只保留前5秒:

ffmpeg -i input.mp4 -vf scale=w='0.5*iw':h='0.5*ih' -t 5 output.mp4

圖片圖片

而在瀏覽器中,F(xiàn)Fmpeg以及視頻合成的運(yùn)行機(jī)制如上所示:在業(yè)務(wù)層,我們?yōu)橐曨l合成準(zhǔn)備好需要的FFmpeg命令以及若干個(gè)輸入文件,將其預(yù)加載到Emscripten模塊的MEMFS(一種虛擬文件系統(tǒng))中,并同時(shí)傳遞命令至Emscripten模塊,最后通過(guò)Emscripten的膠水代碼驅(qū)動(dòng)WebAssembly進(jìn)行邏輯計(jì)算。視頻合成的輸出視頻會(huì)在MEMFS中逐步寫入完成,最終可以被取回到業(yè)務(wù)層

對(duì)FFmpeg命令行界面進(jìn)行封裝

上面的例子中,我們?yōu)镕Fmpeg輸入了一個(gè)視頻文件,以及一串命令行參數(shù),實(shí)現(xiàn)了對(duì)視頻的簡(jiǎn)單縮放加截?cái)嗖僮?。?shí)際情況下,業(yè)務(wù)側(cè)產(chǎn)生的視頻合成需求可能是千變?nèi)f化的,這樣直接調(diào)用FFmpeg的方式,會(huì)導(dǎo)致業(yè)務(wù)層需要處理大量代碼處理命令行字符串的構(gòu)建、組合邏輯,就顯得不合適宜。同時(shí),我們?cè)陧?xiàng)目實(shí)踐的過(guò)程中發(fā)現(xiàn),由于項(xiàng)目需要接入 WebCodecs 和 FFmpeg 兩種視頻合成能力,這就需要一個(gè)中間層,從上層接收業(yè)務(wù)層表達(dá)的視頻合成意圖,并傳遞到下層的WebCodecs 或 FFmpeg 進(jìn)行具體的視頻合成邏輯的“翻譯”和執(zhí)行。

API設(shè)計(jì)

圖片圖片

如上所示,描述一個(gè)視頻合成任務(wù),可以采用類似“基于時(shí)間軸的視頻合成工程文件”的方式:在視頻剪輯軟件中,用戶通過(guò)可視化的操作界面導(dǎo)入素材,向軌道上拖入素材成為片段,為每個(gè)片段設(shè)置位移、寬高、不透明度、特效等屬性;同理,對(duì)于我們的項(xiàng)目來(lái)說(shuō),業(yè)務(wù)方自行準(zhǔn)備素材資源,并按一定的結(jié)構(gòu)搭建描述視頻合成工程的對(duì)象樹(shù),然后調(diào)用中間層的方法執(zhí)行合成任務(wù)。

分層設(shè)計(jì)

圖片圖片

以上是我們最終形成的一個(gè)分層結(jié)構(gòu):

  • 業(yè)務(wù)方代碼使用一個(gè)JSON對(duì)象來(lái)描述自己的視頻合成意圖。為了方便業(yè)務(wù)方使用,這一層允許大量使用默認(rèn)值,無(wú)需過(guò)多配置;
  • 狀態(tài)層是一個(gè)對(duì)象樹(shù),將視頻的全局屬性、片段的屬性等狀態(tài)補(bǔ)齊,方便后續(xù)的翻譯;同時(shí),這一層的各個(gè)對(duì)象都支持讀寫,未來(lái)可以用于可視化視頻編輯器的場(chǎng)景等;
  • 執(zhí)行層負(fù)責(zé)FFmpeg命令的翻譯和執(zhí)行邏輯。如果狀態(tài)層抽象得當(dāng),則這個(gè)執(zhí)行層也可以被WebCodecs的翻譯和執(zhí)行模塊替換

執(zhí)行流程

圖片

以上是我們最終實(shí)現(xiàn)的FFmpeg前端視頻合成能力,各個(gè)模塊在運(yùn)行時(shí)的相互調(diào)用時(shí)序圖。各個(gè)模塊之間并不是簡(jiǎn)單地按順序?qū)訉酉蛳抡{(diào)用,再層層向上返回。有以下這些點(diǎn)值得注意

狀態(tài)樹(shù),是JSON + 文件元信息綜合生成的

例如,業(yè)務(wù)方想要把一個(gè)寬高未知的視頻片段,放置在最終合成視頻(假設(shè)為1280x720)的正中央時(shí),我們需要將視頻片段的transform.left設(shè)置為(1280 - videoWidth) / 2,transform.top 設(shè)置為 (720 - videoHeight) / 2。這里的videoWidth, videoHeight就需要通過(guò)FFmpeg讀取文件元信息得到。因此我們?cè)O(shè)計(jì)的流程中,需要對(duì)所有輸入的資源文件進(jìn)行預(yù)加載,再生成狀態(tài)樹(shù)。

輸出結(jié)果多樣化

實(shí)踐過(guò)程中我們發(fā)現(xiàn),業(yè)務(wù)方在使用FFmpeg能力時(shí),至少需要使用以下三種不同的形式的輸出結(jié)果:

  • 事件回調(diào):例如業(yè)務(wù)方所需的合成進(jìn)度、合成開(kāi)始、合成結(jié)束等
  • 合成結(jié)果的二進(jìn)制文件:合成結(jié)束時(shí)異步返回
  • 日志結(jié)果:例如獲取文件元信息,獲取音頻的平均音量等操作,F(xiàn)Fmpeg的輸出都是以log的形式

因此我們?yōu)閳?zhí)行層的輸出設(shè)計(jì)了這樣的統(tǒng)一接口

export interface RunTaskResult {
  /** 日志樹(shù)結(jié)果 */
  log: LogNode
  /** 二進(jìn)制文件結(jié)果 */
  output: Uint8Array
}
 
function runProject(json: ProjectJson): {
  /** 事件結(jié)果 */
  evt: EventEmitter<RunProjectEvents, any>;
  result: Promise<RunTaskResult>;
}

部分代碼實(shí)現(xiàn)

執(zhí)行主流程

runProject 函數(shù)是我們對(duì)外提供的視頻合成的主函數(shù)。包含了“對(duì)輸入JSON進(jìn)行校驗(yàn),補(bǔ)全、預(yù)加載文件并獲取文件元信息、預(yù)加載字幕相關(guān)文件、翻譯FFmpeg命令、執(zhí)行、emit事件”等多種邏輯。

/**
 * 按照projectJson執(zhí)行視頻合成
 * @public
 * @param json - 一個(gè)視頻合成工程的描述JSON
 * @returns 一個(gè)evt對(duì)象,用以獲取合成進(jìn)度,以及異步返回的視頻合成結(jié)果數(shù)據(jù)
 */
export function runProject(json: ProjectJson) {
  const evt = new EventEmitter<RunProjectEvents>()
  const steps = async () => {
    // hack 這里需要加入一個(gè)異步,使得最早在evt上emit的事件可以被evt.on所設(shè)置的回調(diào)函數(shù)監(jiān)聽(tīng)到
    await Promise.resolve()
    const parsedJson = ProjectSchema.parse(json) // 使用json schema驗(yàn)證并補(bǔ)全一些默認(rèn)值
    // 預(yù)加載并獲取文件元信息
    evt.emit('preload_all_start')
    const preloadedClips = [
      ...await preloadAllResourceClips(parsedJson, evt),
      ...await preloadAllTextClips(parsedJson)
    ]
    // 預(yù)加載字幕相關(guān)信息
    const subtitleInfo = await preloadSubtitle(parsedJson, evt)
    evt.emit('preload_all_end')
    // 生成project對(duì)象樹(shù)
    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任務(wù)隊(duì)列里執(zhí)行
    const task: FFmpegTask = {
      fsOutputPath,
      fsInputs,
      args
    }
    // 處理進(jìn)度事件
    task.logHandler = (log) => {
      const p = getProgressFromLog(log, project.timeline.end)
      if (p !== undefined) {
        evt.emit('progress', p)
      }
    }
    evt.emit('start')
    // 返回執(zhí)行日志,最終合成文件,事件等多種形式的結(jié)果
    const res = runInQueue(task)
    await res
    evt.emit('end')
    return res
  }
 
  return {
    evt,
    result: steps()
  }
}

翻譯流程

FFmpeg命令的翻譯流程,對(duì)應(yīng)的是上述runProject方法中的parseProject,是在所有的上下文(視頻合成描述JSON對(duì)象,狀態(tài)樹(shù)文件預(yù)加載后的元信息等)都齊備的情況下執(zhí)行的。本身是一段很長(zhǎng),且下游較深的同步執(zhí)行代碼。這里用偽代碼描述一下parseProject的過(guò)程

1. 實(shí)例化一個(gè)命令行參數(shù)操作對(duì)象ctx,此對(duì)象用于表達(dá)命令行參數(shù)的結(jié)構(gòu),可以設(shè)置有哪些輸入(多個(gè))和哪些輸出(一個(gè)),并提供一些簡(jiǎn)便的方法用以操作filtergraph
2. 初始化一個(gè)視頻流的空數(shù)組layers(這里指廣義的視頻流,只要是有圖像信息的輸入流(例如視頻、占一定時(shí)長(zhǎng)的圖片、文字片段轉(zhuǎn)成的圖片),都算作視頻流);初始化一個(gè)音頻流的空數(shù)組audios
3. (作為最終合成的視頻或音頻內(nèi)容的基底)在layers中加入一個(gè)顏色為project.backgroundColor, 大小為project.size,時(shí)長(zhǎng)為無(wú)限長(zhǎng)的純色的視頻流;在audios中加入一個(gè)無(wú)聲的,時(shí)長(zhǎng)為無(wú)限長(zhǎng)的靜音音頻流
4. 對(duì)于每一個(gè)project中的片段
    1. 將片段中所包含的資源的url添加到ctx的輸入數(shù)組中
    2. (從所有已預(yù)加載的文件元信息中)找到這個(gè)片段對(duì)應(yīng)的元信息(寬高、時(shí)長(zhǎng)等)
    3. (處理片段本身的截取、寬高、旋轉(zhuǎn)、不透明度、動(dòng)畫等的處理)基于此片段的JSON定義和預(yù)加載信息,翻譯成一組作用于該片段的FFmpeg filters,并且這一組filters之間需要相互串聯(lián),filters頭部連接到此片段的輸入流。得到片段對(duì)應(yīng)的中間流。
    4. 獲取到的中間流,如果是廣義的視頻流的,推入layers數(shù)組;如果是廣義的音頻流的,推入audios數(shù)組
5. 視頻流layers數(shù)組做一個(gè)類似reduce的操作,按照畫面中內(nèi)容疊放的順序,從最底層到最頂層,逐個(gè)合并流,得到單個(gè)視頻流作為最終視頻輸出流。
6. 音頻流audios數(shù)組進(jìn)行混音,得到單個(gè)音頻流作為最終輸出流。
7. 調(diào)用ctx的toString方法,此方法是會(huì)將整個(gè)命令行參數(shù)結(jié)構(gòu)輸出為string。ctx下屬的各類對(duì)象(Input, Option, FilterGraph)都有自己的toString方法,它們會(huì)依次層層toString,最終形成整體的ffmpeg命令行參數(shù)

動(dòng)畫能力

適當(dāng)?shù)脑貏?dòng)畫有助提高視頻的畫面豐富度,我們實(shí)現(xiàn)的視頻合成能力中,也對(duì)元素動(dòng)畫能力進(jìn)行了初步支持。

 業(yè)務(wù)端如何配置動(dòng)畫

在視頻剪輯軟件中,為元素配置動(dòng)畫主要是基于關(guān)鍵幀模型,典型操作步驟如下:

  • 選中畫布中的一個(gè)元素后
  • 在時(shí)間軸上為元素的某一屬性添加若干個(gè)關(guān)鍵幀
  • 在每個(gè)關(guān)鍵幀上,為該屬性設(shè)置不同的值。例如將位于第1秒的關(guān)鍵幀的x方向位移設(shè)置為0,將位于第5秒的關(guān)鍵幀的x方向位移設(shè)置為100
  • 軟件會(huì)自動(dòng)將1-5秒的動(dòng)畫過(guò)程補(bǔ)幀出來(lái),預(yù)覽播放(以及最后合成的結(jié)果中)就可以看到元素從第1秒到第5秒向下平移的效果。而在前端開(kāi)發(fā)中,通過(guò)CSS的@keyframes所聲明的動(dòng)畫,也與上述關(guān)鍵幀模型吻合。除此之外,在CSS動(dòng)畫標(biāo)準(zhǔn)中,我們還需要附加以下這些信息,才能將一段關(guān)鍵幀動(dòng)畫應(yīng)用到元素上
  • delay延遲(動(dòng)畫在元素出現(xiàn)后,延遲多少時(shí)間再開(kāi)始播放)
  • iterationCount(動(dòng)畫需要重復(fù)播放多少次)
  • duration(在單次重復(fù)播放內(nèi),動(dòng)畫所占總時(shí)長(zhǎng))
  • timingFunction(動(dòng)畫的補(bǔ)幀方式。線性方式實(shí)現(xiàn)簡(jiǎn)單但關(guān)鍵幀之間的過(guò)渡生硬,因此一般會(huì)采用“ease-in-out”等帶有緩進(jìn)緩出的非線性方式)。除此之外還有direction, fillMode等配置,這些并未在我們的視頻合成能力中實(shí)現(xiàn),故不再贅述。

在視頻合成描述JSON中,我們參照了CSS動(dòng)畫聲明進(jìn)行了以下設(shè)計(jì),來(lái)滿足元素動(dòng)畫的配置

  • 為片段了定義了 x, y, w, h, angle, opacity這六種可配置的屬性(涵蓋了位移、縮放、旋轉(zhuǎn)、不透明度等)
  • 對(duì)于需要靜態(tài)配置的屬性,在static字段的子字段中配置
  • 對(duì)于需要?jiǎng)赢嬇渲玫膶傩裕赼nimation字段的子字段中逐個(gè)關(guān)鍵幀進(jìn)行配置
  • animation字段同時(shí)可以進(jìn)行duration, delay等動(dòng)畫附加信息的配置

以下是元素動(dòng)畫配置的例子

// 視頻片段bg.mp4,在畫面的100,100處出現(xiàn),并伴隨有閃爍(不透明度從0到1再到0)的動(dòng)畫,動(dòng)畫延遲1秒,時(shí)長(zhǎng)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合成添加動(dòng)畫效果的原理

動(dòng)畫效果的本質(zhì)是一定時(shí)間內(nèi),元素的某個(gè)狀態(tài)逐幀連續(xù)變化。而FFmpeg的視頻合成的實(shí)際操作都是由filter完成的,所以想要在FFmpeg視頻合成中添加動(dòng)畫,則需要視頻類的filter支持按視頻的當(dāng)前時(shí)間,逐幀動(dòng)態(tài)設(shè)置filter的參數(shù)值。

以overlay filter為例,此filter可以將兩個(gè)視頻層疊在一起,并設(shè)置位于頂層的視頻相對(duì)位置。如果無(wú)需設(shè)置動(dòng)畫時(shí),我們可以將參數(shù)寫成overlay=x=100:y=100表示將頂層視頻放置在距離底層視頻左上角100,100的位置。

需要設(shè)置動(dòng)畫時(shí),我們也可以設(shè)置x, y為包含了t變量(當(dāng)前時(shí)間)的表達(dá)式。例如overlay=x=t*100:y=t*100,可以用來(lái)表達(dá)頂層視頻從左上到右下的位移動(dòng)畫,逐幀計(jì)算可知第0秒坐標(biāo)為0,0,第1秒時(shí)坐標(biāo)為100,100,以此類推。

像overlay=x=expr:y=expr這樣的,expr的部分被稱為FFmpeg的表達(dá)式,它也可以看成是以時(shí)間(以及其他一些可用的變量)作為輸入,以filter的屬性值作為輸出的函數(shù)。表達(dá)式中除了可以使用實(shí)數(shù)、t變量、各類算術(shù)運(yùn)算符之外,還可以使用很多內(nèi)置函數(shù),具體可參考FFmpeg文檔中對(duì)于表達(dá)式取值的說(shuō)明(https://ffmpeg.org/ffmpeg-utils.html#Expression-Evaluation)

常見(jiàn)動(dòng)畫模式的表達(dá)式總結(jié)

由于表達(dá)式的本質(zhì)是函數(shù),我們?cè)诎褎?dòng)畫翻譯成FFmpeg表達(dá)式時(shí),可以先繪制動(dòng)畫的函數(shù)圖像,然后再?gòu)腇Fmpeg表達(dá)式的可用變量、內(nèi)置函數(shù)、運(yùn)算符中,進(jìn)行適當(dāng)組合來(lái)還原函數(shù)圖像。下面是一些常見(jiàn)的動(dòng)畫模式的FFmpeg表達(dá)式對(duì)應(yīng)實(shí)現(xiàn)

動(dòng)畫的分段

假設(shè)對(duì)于某元素,我們?cè)O(shè)置了一個(gè)向上彈跳一次的動(dòng)畫,此動(dòng)畫有一定延遲,并且只循環(huán)一次,動(dòng)畫已結(jié)束后又過(guò)了一段時(shí)間,元素再消失。則此元素的y屬性函數(shù)圖像及其公式可能如下

圖片圖片

圖片圖片

通過(guò)以上函數(shù)圖像我們可知,此類函數(shù)無(wú)法通過(guò)一個(gè)單一部分表達(dá)出來(lái)。在FFmpeg表達(dá)式中,我們需要將三個(gè)子表達(dá)式,按條件組合到一個(gè)大表達(dá)式中。對(duì)于分段的函數(shù),我們可以使用FFmpeg自帶的if(x,y,z)函數(shù)(類似腳本語(yǔ)言中的三元表達(dá)式)來(lái)等價(jià)模擬,將條件判斷/then分支/else分支 這三個(gè)子表達(dá)式 分別傳入并組合到一起。對(duì)于分支有兩個(gè)以上的情況,則在else分支處再嵌入新的if(x,y,z)即可。

# 實(shí)際在生成表達(dá)式時(shí),所有的換行和空格可以省略
y=
if(
  lt(t,2),  # lt函數(shù)相當(dāng)于<操作符
  1,
  if(
    lt(t,4),
    sin(-PI*t/2)+1,
    1
  )
)

我們可以實(shí)現(xiàn)一個(gè)遞歸函數(shù)nestedIfElse,來(lái)將N個(gè)條件判斷表達(dá)式和N+1個(gè)分支表達(dá)式組合起來(lái),成為一個(gè)大的FFmpeg表達(dá)式,用于分段動(dòng)畫的場(chǎng)景

function nestedIfElse(branches: string[], predicates: string[]) {
  // 如果只有一個(gè)邏輯分支,則返回此分支的表達(dá)式
  if (branches.length === 1) {
    return branches[0]
  // 如果有兩個(gè)邏輯分支,則只有一個(gè)條件判斷表達(dá)式,使用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})`
  }
}

線性和非線性補(bǔ)幀

補(bǔ)幀是將關(guān)鍵幀間的空白填補(bǔ),并連接為動(dòng)畫的基本方式。被補(bǔ)出來(lái)的每一幀中,對(duì)應(yīng)的屬性值需要使用插值函數(shù)進(jìn)行計(jì)算。

對(duì)于線性插值,F(xiàn)Fmpeg自帶了lerp(x,y,z)函數(shù),表示從x開(kāi)始到y(tǒng)結(jié)束,按z的比例(z為0到1的比值)線性插值的結(jié)果。因此我們可以結(jié)合上面的if(x,y,z)函數(shù)的分段功能,實(shí)現(xiàn)一個(gè)多關(guān)鍵幀的線性補(bǔ)幀動(dòng)畫。例如,某屬性有兩個(gè)關(guān)鍵幀,在t1時(shí)屬性值為a,在t2時(shí)屬性值為b,則補(bǔ)幀表達(dá)式為

圖片圖片

對(duì)于非線性補(bǔ)幀,我們可以將其理解為在上述線性補(bǔ)幀公式的基礎(chǔ)上,將lerp(x,y,z)函數(shù)的z參數(shù)(進(jìn)度的比例)再進(jìn)行一次變換,使得動(dòng)畫的行進(jìn)變得不均勻即可。以下公式中的t'代表了一種典型的緩慢開(kāi)始和緩慢結(jié)束的緩動(dòng)函數(shù)(timing function),將其代入原公式即可

圖片圖片

(圖中展示了從左下角的關(guān)鍵幀到右上角的關(guān)鍵幀的線性/非線性 補(bǔ)幀的函數(shù)圖像)(圖中展示了從左下角的關(guān)鍵幀到右上角的關(guān)鍵幀的線性/非線性 補(bǔ)幀的函數(shù)圖像)



以下是對(duì)應(yīng)的代碼實(shí)現(xiàn)

// 假設(shè)有關(guān)鍵幀(t1, v1)和(t2, v2),返回這兩個(gè)關(guān)鍵幀之間的非線性補(bǔ)幀表達(dá)式
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})`
}

循環(huán)

如果我們需要表達(dá)一個(gè)帶有循環(huán)的動(dòng)畫,最直接的方式是將某個(gè)時(shí)段上的映射關(guān)系,復(fù)制并平移到其他的時(shí)段上。例如,想要實(shí)現(xiàn)一個(gè)從畫面左側(cè)平移至右側(cè)的動(dòng)畫,重復(fù)多次時(shí),我們可能使用下面這樣的函數(shù)

圖片圖片

以上使用分段函數(shù)的寫法的問(wèn)題在于,如果循環(huán)次數(shù)過(guò)多時(shí),函數(shù)的分支較多,產(chǎn)生的表達(dá)式很長(zhǎng),也會(huì)影響在視頻合成時(shí)對(duì)表達(dá)式求值的性能。

事實(shí)上,我們可以引入FFmpeg表達(dá)式中自帶的mod(x,y)函數(shù)(取余操作)來(lái)實(shí)現(xiàn)循環(huán)。由于取余操作常用來(lái)生成一個(gè)固定范圍內(nèi)的輸出,例如不斷重復(fù)播放的過(guò)程。上面的函數(shù),在引入mod(x,y)后,可以簡(jiǎn)化為 x=mod(t,1)。

上述對(duì)于動(dòng)畫分段、循環(huán)、補(bǔ)幀如何實(shí)現(xiàn)的問(wèn)題,其共通點(diǎn)都是如何找到其對(duì)應(yīng)函數(shù),并在FFmpeg中翻譯為對(duì)應(yīng)的表達(dá)式,或者對(duì)已有表達(dá)式進(jìn)行組合。

據(jù)此,我們實(shí)現(xiàn)了KFAttr(關(guān)鍵幀屬性,用以封裝關(guān)鍵幀和動(dòng)畫全局配置等信息)和TimeExpr(以KFAttr作為入?yún)?,并翻譯為FFmpeg表達(dá)式)兩個(gè)類。其中,TimeExpr的整體算法大致如下:

1.將動(dòng)畫分成前,中,后三部分。前半部分是由于delay配置導(dǎo)致的,元素已出現(xiàn)但動(dòng)畫還未開(kāi)始的靜止部分;中間部分是動(dòng)畫的主體部分;后半部分是由于動(dòng)畫重復(fù)次數(shù)較少,元素未消失但動(dòng)畫已結(jié)束的靜止部分

2.對(duì)于前半部分,表達(dá)式設(shè)置為等于關(guān)鍵幀中第一幀的值;對(duì)于后半部分,表達(dá)式設(shè)置為等于關(guān)鍵幀中最后一值的值

3.對(duì)于中間部分  

  • 3.1 將keyframes中聲明的每個(gè)關(guān)鍵幀點(diǎn)(某個(gè)百分比及其對(duì)應(yīng)值),結(jié)合動(dòng)畫的duration配置,縮放為新的關(guān)鍵幀點(diǎn)(某個(gè)時(shí)間點(diǎn)及其對(duì)應(yīng)值)  
  • 3.2 根據(jù)上述關(guān)鍵幀,獲取predicates數(shù)組(也就是動(dòng)畫中間部分,進(jìn)入每一個(gè)分支的條件表達(dá)式,例如t<2, t<5 等)
  • 3.3 根據(jù)上述關(guān)鍵幀,獲取branches數(shù)組(也就是動(dòng)畫中間部分,每一個(gè)分支本身的表達(dá)式)。每一個(gè)branch聲明了一個(gè)關(guān)鍵幀到下一個(gè)關(guān)鍵幀的連接,也就是補(bǔ)幀表達(dá)式  
  • 3.4 使用nestedIfElse(branches, predicates)組合出中間部分的表達(dá)式

4.再次使用nestedIfElse,將前、中、后三部分組合成最終的表達(dá)式

瀏覽器里視頻合成的內(nèi)存不足問(wèn)題

在項(xiàng)目實(shí)踐的過(guò)程中,我們發(fā)現(xiàn)瀏覽器中通過(guò)ffmpeg.wasm進(jìn)行視頻合成時(shí),有一定機(jī)率出現(xiàn)內(nèi)存不足的現(xiàn)象。表現(xiàn)為以下Emscripten的運(yùn)行時(shí)報(bào)錯(cuò)(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.

分析后我們認(rèn)為,內(nèi)存不足的問(wèn)題主要是由于以下這些因素導(dǎo)致的

  • 視頻合成本身是開(kāi)銷很大的計(jì)算過(guò)程,這是由于音視頻文件往往都有著很高的壓縮率,在合成時(shí),音視頻文件被解碼成未壓縮的數(shù)據(jù),占用了大量?jī)?nèi)存
  • 和原生環(huán)境相比,瀏覽器中的應(yīng)用會(huì)額外受到單個(gè)標(biāo)簽頁(yè)可使用的最大內(nèi)存的限制。例如在64位系統(tǒng)的Chrome中,一個(gè)標(biāo)簽頁(yè)最多可使用的內(nèi)存大小為4GB
  • 瀏覽器沙盒機(jī)制,不允許Web應(yīng)用直接讀寫客戶端本地文件。而Emscripten為了使得移植的C/C++項(xiàng)目仍能夠擁有原來(lái)的文件讀寫的能力,實(shí)現(xiàn)了一個(gè)MEMFS的虛擬文件系統(tǒng)。將文件預(yù)加載到內(nèi)存中,把對(duì)磁盤的讀寫轉(zhuǎn)換為對(duì)內(nèi)存的讀寫。這部分文件的讀寫也占用了一定的內(nèi)存。在瀏覽器中運(yùn)行視頻合成時(shí),還會(huì)額外受到瀏覽器對(duì)于單個(gè)標(biāo)簽頁(yè)可使用的最大內(nèi)存的限制(在64位的Chrome中,最多可為一個(gè)標(biāo)簽頁(yè)分配4G內(nèi)存)

為了應(yīng)對(duì)以上問(wèn)題,在實(shí)踐中,我們采取了以下這些策略,來(lái)減少內(nèi)存不足導(dǎo)致的合成失敗率:

視頻合成的嚴(yán)格串行執(zhí)行

視頻合成的過(guò)程出現(xiàn)了并發(fā)時(shí),會(huì)加劇內(nèi)存不足現(xiàn)象的產(chǎn)生。因此我們?cè)趓unProject以及其他FFmpeg執(zhí)行方法背后實(shí)現(xiàn)了一個(gè)統(tǒng)一的任務(wù)隊(duì)列,確保一個(gè)任務(wù)在執(zhí)行完成后再進(jìn)行下一個(gè)任務(wù),并且在下一個(gè)任務(wù)開(kāi)始執(zhí)行前,重啟ffmpeg.wasm的運(yùn)行時(shí),實(shí)現(xiàn)內(nèi)存垃圾回收。

時(shí)間分段,多次合成

實(shí)踐中我們發(fā)現(xiàn),如果一個(gè)FFmpeg命令中輸入的音視頻素材文件過(guò)多時(shí),即使這些素材在時(shí)間線上都重疊(也就是某一時(shí)間點(diǎn)上,所有的素材視頻畫面都需要出現(xiàn)在最終畫面中)的情況很少,也會(huì)大大提高內(nèi)存不足的概率。

我們采取了對(duì)視頻合成的結(jié)果進(jìn)行時(shí)間分段的策略。根據(jù)每個(gè)片段在時(shí)間軸上的分布情況,將整個(gè)視頻合成的FFmpeg任務(wù),拆分成多個(gè)規(guī)模更小的FFmpeg任務(wù)。每個(gè)任務(wù)僅需要2-3個(gè)輸入文件(常規(guī)的視頻合成需求中,同屏同時(shí)播放的視頻最多也在3個(gè)左右),各任務(wù)單獨(dú)進(jìn)行視頻合成,最后再使用FFmpeg的concat功能,將視頻前后相接即可。

減少重編碼的場(chǎng)景

視頻合成的重編碼(解碼輸入文件,操作數(shù)據(jù)并再編碼),會(huì)消耗大量的CPU和內(nèi)存資源。而視頻和音頻的前后拼接操作,則無(wú)需重編碼,可以在非常短的時(shí)間內(nèi)完成。

對(duì)于不太復(fù)雜的視頻合成場(chǎng)景,往往并不是畫面的每一幀都需要重新編碼再輸出的。我們可以分析視頻合成的時(shí)間軸,找出不需要重編碼的時(shí)間段(指的是此時(shí)畫面內(nèi)容僅來(lái)自一個(gè)輸入文件,并且沒(méi)有縮放旋轉(zhuǎn)等濾鏡效果,沒(méi)有其他層疊的內(nèi)容的時(shí)間段)。對(duì)這些時(shí)間段,我們通過(guò)FFmpeg的流拷貝功能截取出來(lái)(通過(guò)-vcodec copy命令行參數(shù)實(shí)現(xiàn))即可,這樣進(jìn)一步減少了CPU和內(nèi)存的消耗。

在視頻中添加文字的實(shí)踐

在視頻中添加文字是視頻合成的常見(jiàn)需求,這類需求可以大致分為兩種情況:少量的樣式復(fù)雜的藝術(shù)字,大量的字幕文字。

FFmpeg自帶的filters中提供了以下的文字繪制能力,包括:

  • subtitles,配合srt格式的字幕文件。適合大量添加字幕,對(duì)樣式定制化不高的場(chǎng)景
  • drawtext,繪制單條文字,并進(jìn)行一些簡(jiǎn)單的樣式配置。如果不使用filters,由于我們是在瀏覽器作為上層環(huán)境使用FFmpeg的,此時(shí)也可以使用DOM API提供的一些文字轉(zhuǎn)圖片的技術(shù)(例如直接使用Canvas API的fillText繪制文字,或者使用SVG的foreignObject對(duì)包含文字的html文檔進(jìn)行圖片轉(zhuǎn)換等),把文字當(dāng)作圖片文件進(jìn)行處理。

最初在支持視頻合成方案的文字能力時(shí),我們選擇了后者的文字轉(zhuǎn)圖片技術(shù),基本滿足了業(yè)務(wù)需求。這一做法的優(yōu)勢(shì)在于:復(fù)用DOM的文字渲染能力,繪制效果好并且支持的文字樣式豐富;并且由于轉(zhuǎn)換為圖片處理,可以讓文字直接支持縮放、旋轉(zhuǎn)、動(dòng)畫等許多已經(jīng)在圖片上實(shí)現(xiàn)的能力。

但正如上面提到的“為FFmpeg的命令一次性輸入過(guò)多的文件容易引起OOM”的問(wèn)題,文字轉(zhuǎn)為圖片后,視頻合成時(shí)需要額外導(dǎo)入的圖片輸入文件也增加了。這也促使我們開(kāi)始關(guān)注FFmpeg自帶的文字渲染能力。

FFmpeg自帶subtitles, drawtext等文字渲染能力,底層都使用了C語(yǔ)言的字體字符庫(kù)(包括freetype字體光柵化,harfbuzz文字塑形,fribidi雙向編碼等),在每一幀編碼前的filter階段,將字符按指定的字體和樣式即時(shí)繪制成位圖,并與當(dāng)前的framebuffer混合來(lái)實(shí)現(xiàn)的。這種做法會(huì)耗費(fèi)更多的計(jì)算資源,但同時(shí)因?yàn)椴恍枰彺婊蛭募?,使用的?nèi)存更少。因此我們對(duì)于制作字幕這樣需要大量添加固定樣式的文字的場(chǎng)景,提供了相應(yīng)的JSON配置,并在底層使用FFmpeg的subtitles filter進(jìn)行繪制,避免了OOM的問(wèn)題。

基于瀏覽器和FFmpeg本身的現(xiàn)有能力,在視頻中添加文字的方案還可以有更多探索的可能。例如可以“使用SVG來(lái)聲明文字的內(nèi)容和樣式,并在FFmpeg側(cè)進(jìn)行渲染”來(lái)實(shí)現(xiàn)。SVG方案的優(yōu)點(diǎn)在于:文字的樣式控制能力強(qiáng);可以隨意添加任意的文字的前景、背景矢量圖形;與位圖相比占用資源少等。后續(xù)在進(jìn)行自編譯的FFmpeg WebAssembly版相關(guān)調(diào)研時(shí),會(huì)嘗試支持。

后續(xù)迭代

通過(guò) Emscripten 移植到瀏覽器運(yùn)行的 FFmpeg,在性能上與原生FFmpeg有很大差距,大體原因在于瀏覽器作為中間環(huán)境,其現(xiàn)有的API能力不足,以及一些安全政策的限制,導(dǎo)致 FFmpeg 對(duì)于硬件能力的利用受限。隨著瀏覽器能力和API的逐步演進(jìn),F(xiàn)Fmpeg + WebAssembly 的編譯、運(yùn)行方式都可以與時(shí)俱進(jìn),以達(dá)到提高性能的目的。目前可以預(yù)見(jiàn)的一些優(yōu)化點(diǎn)有:

  • 文件IO方面,接入瀏覽器的OPFS(https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API#origin_private_file_system)。這是瀏覽器中訪問(wèn)文件系統(tǒng)的一種新API,有較高的讀寫性能。未來(lái)有可能被Emscripten實(shí)現(xiàn),以替換掉當(dāng)前默認(rèn)的MEMFS
  • 并行計(jì)算方面,考慮使用WebAssembly SIMD(https://v8.dev/features/simd)。SIMD可以更充分地使用CPU進(jìn)行并行計(jì)算。對(duì)于圖像處理較多的編碼場(chǎng)景(例如x264編碼器),適當(dāng)?shù)厥褂肳ebAssembly的SIMD來(lái)優(yōu)化代碼有助于提高編碼性能
  • 圖像處理方面,嘗試使用WebGL優(yōu)化。WebGL為瀏覽器提供了基于顯卡的并行計(jì)算的能力,特別適合對(duì)視頻摳像、濾鏡、轉(zhuǎn)場(chǎng)等應(yīng)用場(chǎng)景進(jìn)行加速。
責(zé)任編輯:武曉燕 來(lái)源: 嗶哩嗶哩技術(shù)
相關(guān)推薦

2011-01-21 17:09:06

Zimbra

2022-01-19 09:00:51

UI前端手機(jī)開(kāi)發(fā)

2024-02-28 08:38:07

Rust前端效率

2023-03-31 09:02:37

前端客服通信

2022-11-01 09:02:04

前端售后業(yè)務(wù)

2016-11-21 18:15:09

FreeWheel高端視頻廣告服務(wù)

2017-09-18 16:13:59

前端圖像處理人臉識(shí)別

2021-01-21 05:32:26

云端視頻監(jiān)控

2021-12-02 15:14:02

ffmpeg視頻Python

2023-09-07 20:04:06

前后端趨勢(shì)Node.js

2017-04-05 16:30:09

Node.jsFFmpeg Canvas

2021-04-15 08:08:48

微前端Web開(kāi)發(fā)

2020-05-06 09:25:10

微前端qiankun架構(gòu)

2024-07-22 14:53:04

2024-11-11 08:50:24

2021-06-30 08:49:02

人工智能AI深度學(xué)習(xí)

2022-08-29 10:39:32

FFmpeg多媒體框架開(kāi)源

2021-07-26 11:41:38

數(shù)字化

2021-04-22 13:38:21

前端開(kāi)發(fā)技術(shù)

2022-10-20 10:02:16

前端測(cè)試開(kāi)發(fā)
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號(hào)