Webcodecs 音視頻編解碼與封裝技術探索

1. 背景

在 web 端處理音視頻是一個複雜而又重要的課題,市場上主流的視頻編輯通常採用服務端進行渲染導出,因爲專用的服務器對音視頻的編解碼能力更強,所以服務端渲染導出的速度很不錯;

少數編輯器在瀏覽器本地對視頻進行處理,一方面對服務器成本非常友好,另一方面可以不需要註冊等流程,在小型視頻的渲染上用戶體驗更好。但是瀏覽器本地渲染對用戶設備有一定要求,對瀏覽器的兼容性等等也有要求。

而經典的在瀏覽器本地處理視頻的方案是通過ffmpeg.wasm,近些年Webcodecs API的出現與普及逐漸改變了這一現象。

ffmpeg.wasm的底層 webassembly 對 ffmpeg 多線程處理視頻的兼容很差,GPU 調用效果也不盡人如意,導致渲染視頻的速度非常不理想,並且還要額外下載編解碼器,整體使用體驗存在很多不適。

WebCodecs API可以利用瀏覽器自帶的 FFmpeg,而且可以充分利用 GPU,所以其執行效率是遠高於 webassembly 的。

(該圖取自 Bilibili 團隊實驗 [1])

1.1 功能對比

rsR3RF

2.WebCodecs 介紹

如果要問 WebCodecs 是什麼,可以簡單的概括爲 JavaScript 賦予了通過瀏覽器底層對視頻流的單個幀和音頻數據塊的底層訪問能力的一項 web 技術。

簡單地說,就是設置一個解碼器,將視頻編碼字節塊處理爲視頻幀/音頻數據,或者反之,設置一個編碼器,將視頻幀/音頻數據處理回編碼字節塊。

上文所說的 WebCodecs API 的解碼器有:

1SxMNh

上表中EncodedAudioChunkEncodedVideoChunk就是上文提到的編碼字節塊。

上文所說的 WebCodecs API 的編碼器有:

ElvLvS

上表中AudioDataVideoFrame就是上文提到的視頻幀/音頻數據

請注意,WebCodecs API 並不提供對某一視頻類型具體的編解碼器,解碼視頻時,你需要自行將這個視頻轉爲EncodedVideoChunkEncodedAudioChunk,再交由 WebCodecs API 進行處理。渲染合成視頻同理。

常見的方案有 Mp4Box.js。

3.WebCodecs 支持情況

WebCodecs 在 Chrome 94 上得到支持,下面是一個可供參考的瀏覽器支持表。

6FmbHI

可以看到,不少瀏覽器的在 23 年才提供支持。

可用如下代碼進行判定瀏覽器是否支持:

if('VideoEncoder' in window){ 
    console.log("webcodecs is supported.")
 }

4. 視頻播放原理

衆所周知,視頻由畫面和音頻構成。而畫面由一幀一幀的圖像組成,音頻由一段一段的聲波構成。按照某個頻率不斷地同步切換幀和聲波,就可以實現視頻的播放。

但是,視頻並不會完整的將每一幀以圖片的形式進行保存,而是通過一些複雜的結構,將視頻的畫面進行壓縮,並將時長等元數據整合到一起,形成一個完整地視頻文件。

下面介紹下視頻文件的結構。

5. 視頻結構

HTML5 提供了 HTMLMediaElement,可以直接使用 HTML 標籤播放視頻音頻,而對於 m3u8 或 Flash 時代留存的大量 Flv 視頻,也有例如 FLV.js 等相應的庫,使其可以被 HTMLMediaElement 播放。

這些高度封裝的庫也使得我們對視頻文件的結構比較陌生,這裏以最常見的 MP4 格式簡單介紹一下。

5.1 視頻的編碼

視頻編碼是將原始視頻數據轉換爲壓縮格式的過程,以減小文件大小並提高傳輸效率。

編碼的目的是爲了壓縮,不同的編碼格式則對應不同的壓縮算法。

MP4 文件常用的編碼格式有 H.264(即 AVC)、H.265(HEVC)、VP8、VP9 等。

H.265 在市場上有很高的佔有量,但因爲其高昂的授權費用,免費的 AV1 編碼正逐步被市場接納。

5.2 視頻的封裝

視頻編碼後,將其和文件的元數據封裝到容器格式中,以創建完整的視頻文件。

壓縮後的原始數據,需要有元數據的配合才能被解析播放;

常見的元數據包括:時間信息,編碼格式,分辨率,作者,標題等等。

5.3 動態補償與幀間壓縮

對視頻進行二次壓縮,無需掌握具體算法。

動態補償指的是,連續的兩幀之間有相同的部分,只是位置發生了變化,所以第二幀可以只儲存偏移量

幀間壓縮是對兩幀之間進行 diff,第二幀只儲存 diff 運算出的不同的那一部分

5.4 幀的類型

根據上面的過程,幀之間相互可能並不獨立,於是產生了三種幀類型

I 幀:也就是關鍵幀,保留完整的畫面信息,沒有被二次壓縮,可以被獨立還原爲圖像

P 幀:依賴前一幀的解碼結果才能還原爲圖像

B 幀:依賴前一幀與後一幀的解碼結果才能還原爲圖像,但佔用空間一般最少

6.Demo

前面介紹了非常多的 Webcodecs 和視頻相關的概念,我們來做一個小的 demo,利用 MP4Box.js 作爲編解碼器,嘗試解析一個視頻。

先放一個 Demo 地址:https://codesandbox.io/p/devbox/nifty-dawn-gghryr?embed=1&file=%2Findex.js%3A111%2C1

(代碼基於張鑫旭 blob 修改 [2])

6.1 解析部分

我們先創建一個 Mp4box 實例:

const mp4box = MP4Box.createFile();

Webcodecs 基於 Stream 的思想,所以我們需要用 Stream 去提供數據。比較簡單的方法是用 fetch 去請求:

fetch(mp4url)
  .then((res) => res.arrayBuffer())
  .then((buffer) => {
    state.innerHTML = "開始解碼視頻";
    buffer.fileStart = 0;
    mp4box.appendBuffer(buffer);
    mp4box.flush();
  });

請注意,mp4box.appendBuffer 接受 ArrayBuffer 類型的數據。

加載小型視頻時,可以直接用上面的代碼。但若是視頻較大,上面的代碼效率就不太夠看。可以用reader.read().then(({ done, value })替代,但是要注意,這樣獲取的data是 Unit8Array 類型,需要手動轉爲 ArrayBuffer,並且要修改 buffer.fileStart 爲這一段 data 的起點。

然後,我們對 mp4box 進行監聽,當文件開始解碼會首先觸發onMoovStart(Demo 中未用到),這裏的 Moov 可能不好理解,他指的是"Movie Box",也被稱爲 "moov atom",包含了視頻文件的關鍵信息,如視頻和音頻的媒體數據、時長、軌道信息等。

當 moov 解析完成,會觸發onReadyonReady會將視頻的詳細信息也就是 moov 傳給回調函數的第一個參數。詳細的數據結構可以參考 Mp4Box.js 官方文檔:地址

我們姑且叫這個信息爲 info,這裏面我們在意的參數是軌道info.videoTracks。他是一個數組,包含了這個軌道的採樣率、編碼方法等等信息,一般長度是 2,第 0 個是視頻軌道,第 1 個是音頻軌道。(不過例如專業電影等更復雜的視頻可能會有更多軌道,這裏不做考慮)

我們將軌道拉出來,扔到下面的萃取環節中。

6.2 萃取部分

這是一個非常形象的名稱,在官方文檔裏叫做Extraction,它用來提取軌道並進行採樣。

我們在 onReady 過程中,設置了Extraction的參數:

    mp4box.setExtractionOptions(videoTrack.id, "video", {
      nbSamples: 100,
    });

第二個參數指的是 user,指的是此軌道的分段調用方,將會被傳到後面介紹的 onSamples 中,可以是任意字符串,表示唯一標識

第三個參數中 nbSamples 表示每次回調調用的樣本數。如果收到的數據不足以提取樣本數量,則保留迄今爲止收到的樣本。如果未提供,則默認值爲 1000。越大獲取的幀數越多。

當一組樣本準備就緒時,將根據 setExtractionOptions 中傳遞的選項,啓動onSamples的回調函數。

mp4box.onSamples = function (trackId, ref, samples) {
//......
}

onSamples會給回掉傳入三個參數:trackId, ref, samples分別代表軌道 id,user,上一步採樣的樣品數組。

通過遍歷這個數組,將樣品編碼成EncodedVideoChunk數據:

    for (const sample of samples) {
      const type = sample.is_sync ? "key" : "delta";

      const chunk = new EncodedVideoChunk({
        type,
        timestamp: sample.cts,
        duration: sample.duration,
        data: sample.data,
      });

      videoDecoder.decode(chunk);
    }

其中sample.is_sync爲 true,則爲關鍵字。然後將EncodedVideoChunk送入videoDecoder.decode進行解碼,從而獲取幀數據。

videoDecoder是在 onReady 中創建的:

  videoDecoder = new VideoDecoder({
    output: (videoFrame) => {
      createImageBitmap(videoFrame).then((img) => {
        videoFrames.push({
          img,
          duration: videoFrame.duration,
          timestamp: videoFrame.timestamp,
        });
        state.innerHTML = "已獲取幀數:" + videoFrames.length;
        videoFrame.close();
      });
    },
    error: (err) => {
      console.error("videoDecoder錯誤:", err);
    },
  });

其本質還是使用 Webcodecs API 的 VideoDecoder,在接受onSamples送來的數據後,解碼爲videoFrame數據。此時的操作可根據業務來,Demo 中將他送到createImageBitmap轉爲位圖,然後推入videoFrames中。

在 Demo 的控制檯中打印videoFrames,即可直接看到幀的數組。

我們可以在頁面上再創建一個 canvas,然後ctx.drawImage(videoFrames[0].img,0,0)即可將任意一幀繪製到畫面上。(Demo 裏沒有加 canvas,大家可以在控制檯自己加)

6.3 音頻

音頻的操作與視頻類似,onReady 中的 info 也有 audioTracks 屬性,從裏面取出來並配置Extraction

if (audioTrack) {
    mp4box.setExtractionOptions(audioTrack.id, 'audio', {
            nbSamples: 100000
        })
    }

配置音頻解碼器AudioDecoder

audioDecoder = new AudioDecoder({
    output: (audioFrame) => {
        console.log('audioFrame:', audioFrame);
    },
    error: (err) => {
        console.error('audioDecoder錯誤:', err);
    }
})
const config = {
    codec: audioTrack.codec,
    sampleRate: audioTrack.audio.sample_rate,
    numberOfChannels: audioTrack.audio.channel_count,
}

audioDecoder.configure(config);

其他操作不再贅述,可以在 Demo 的AudioTest.html中查看。

可以發現,最後獲取到的數據與上文,視頻幀的結構非常類似,是AudioData數據結構,可以將他轉換爲Float32Array,就可以進行對音頻的各種操作了。

視頻的單位是幀,音頻的單位可以說是波,任意一個波可以用若干個三角函數 sin、cos 之和表示。

根據高中物理,波可以相加。我們可以將兩個Float32Array每一項相加,實現兩個聲波的混流:

function mixAudioBuffers(buffer1, buffer2) {  
    if (buffer1.length !== buffer2.length) {  
        return
    } 
    const mixedBuffer = new Float32Array(buffer1.length);  
   
    for (let i = 0; i < buffer1.length; i++) {  
        const mixedSample = buffer1[i] + buffer2[i];  
        mixedBuffer[i] = Math.min(1, Math.max(-1, mixedSample));  
    }  
    return mixedBuffer;  
}

上述代碼進行了歸一化,避免求和的值超過 1,而這裏的波的振幅範圍是 [-1,1]。更常見的做法是提前對波進行縮放。

此外,我們可以通過改變波的振幅來修改音量,只需要把Float32Array的每一項 * 2 即可放大兩倍音量:

function increaseVolume(audioBuffer, volumeFactor) {   
    const adjustedBuffer = new Float32Array(audioBuffer.length);    
    for (let i = 0; i < audioBuffer.length; i++) {  
        const adjustedSample = audioBuffer[i] * volumeFactor;  
        adjustedBuffer[i] = Math.min(1, Math.max(-1, adjustedSample));  
    } 
    return adjustedBuffer;  
}

可以參考:AudioData 文檔,摸索更多有意思的操作。

7. 可能的應用場景

  1. 在上傳視頻的場景截取視頻封面

  2. 輕量級視頻剪輯

  3. 封裝不易被爬蟲的視頻播放器

8. 部分參考資料

  1. https://www.bilibili.com/read/cv30358687/

  2. https://www.zhangxinxu.com/study/202311/js-mp4-parse-effect-pixi-demo.php

  3. https://developer.mozilla.org/en-US/docs/Web/API/AudioData

  4. https://zhuanlan.zhihu.com/p/648657440

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/RiUz-l3Hzn0Xrq4vrEWgNQ