用WebAssembly在瀏覽器中對視頻進行轉碼

 
// 每日前端夜話 第470篇
// 正文共:2200 字
// 預計閱讀時間:10 分鐘

咱們能夠憑藉 FFmpeg 的 WebAssembly 版本直接在瀏覽器中運行這個能強大的視頻處理工具。在本文中,咱們來探索一下 FFmpeg.wasm[1],並寫一個簡單的代碼轉換器,把數據流傳輸到視頻元素中並播放出來。php

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

FFmpeg.wasm

通常咱們經過其命令行使用 FFmpeg。例以下面的命令能夠吧 AVI 文件轉碼爲 MP4 格式:html

$ ffmpeg -i input.avi output.mp4

一樣的工做也能夠在瀏覽器中作到。FFmpeg.wasm 是 FFmpeg 的 WebAssembly 端口,像其餘 JavaScript 模塊同樣能夠經過 npm 安裝,並在 Node 或瀏覽器中使用:前端

$ npm install @ffmpeg/ffmpeg @ffmpeg/core

裝好 FFmpeg.wasm 後,能夠在瀏覽器中執行等效的轉碼操做:git

// fetch AVI 文件
const sourceBuffer = await fetch("input.avi").then(r => r.arrayBuffer());

// 建立 FFmpeg 實例並載入
const ffmpeg = createFFmpeg({ log: true });
await ffmpeg.load();

// 把 AVI 寫入 FFmpeg 文件系統
ffmpeg.FS(
  "writeFile",
  "input.avi",
  new Uint8Array(sourceBuffer, 0, sourceBuffer.byteLength)
);

// 執行 FFmpeg 命令行工具, 把 AVI 轉碼爲 MP4
await ffmpeg.run("-i", "input.avi", "output.mp4");

// 把 MP4 文件從 FFmpeg 文件系統中取出
const output = ffmpeg.FS("readFile", "output.mp4");

// 對視頻文件進行後續操做
const video = document.getElementById("video");
video.src = URL.createObjectURL(
  new Blob([output.buffer], { type: "video/mp4" })
);

這裏有不少有趣的東西,接下來深刻研究細節。github

在 fetch API 加載 AVI 文件以後,用下面的步驟初始化 FFmpeg:web

const ffmpeg = createFFmpeg({ log: true });
await ffmpeg.load();

FFmpeg.wasm 由一個很薄的 JavaScript API 層和一個較大的(20M)WebAssembly 二進制文件組成。上面的代碼加載並初始化了可供使用的 WebAssembly 文件。面試

WebAssembly 是在瀏覽器中運行的、通過性能優化的底層字節碼。它被專門設計爲可以用多種語言進行開發和編譯。docker

FFmpeg 的歷史已經超過20年了,有一千多人貢獻過代碼。在 WebAssembly 出現以前,要給它建立 JavaScript 可以調用的接口,所涉及的工做可能會很是繁瑣。npm

將來 WebAssembly 的使用會更加普遍,如今它做爲把大量成熟的 C/C++ 代碼庫引入 Web 的一種機制,已經很是成功了, Google Earth[2],AutoCAD[3] 和 TensorFlow[4] 等都是很是典型的案例。api

在初始化以後,下一步是把 AVI 文件寫入文件系統:

ffmpeg.FS(
  "writeFile",
  "input.avi",
  new Uint8Array(sourceBuffer, 0, sourceBuffer.byteLength)
);

這段代碼有些奇怪,想要知道這是什麼狀況,須要更深刻地研究 FFmpeg.wasm 的編譯方式。

Emscripten[5] 是遵循 WebAssembly 規範開發的把 C/C++ 代碼編譯爲 WebAssembly 的工具鏈,正是它把 FFmpeg.wasm 編譯爲 WebAssembly 的。可是 Emscripten 不僅是一個 C++ 編譯器,爲了簡化現有代碼庫的遷移,它經過基於 Web 的等效項提供對許多 C/C++ API 的支持。例如經過把函數調用映射到 WebGL 來支持 OpenGL。它還支持 SDL、POSIX 和 pthread。

Emscripten 經過提供 file-system API[6] 來映射到內存中的存儲。使用 FFmpeg.wasm 能夠直接經過 ffmpeg.FS 函數公開底層的 Emscripten 文件系統API,你能夠用這個藉口瀏覽目錄、建立文件和其餘各類針對文件系統的操做。

下一步是真正有意思的地方:

await ffmpeg.run("-i", "input.avi", "output.mp4");

若是你在 Chrome 的開發工具中進行觀察,會注意到它建立了許多 Web Worker,每一個 Web Worker 都加載了 ffmpeg.wasm:

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

在這裏用到了 Emscripten 的 Pthread 支持[7]。啓用日誌記錄後,你能夠在控制檯中查看進度;

Output #0, mp4, to 'output.mp4':
   Metadata:
     encoder         : Lavf58.45.100
     Stream #0:0: Video: h264 (libx264) (avc1 / 0x31637661), yuv420p, 256x240, q=-1--1, 35 fps, 17920 tbn, 35 tbc
     Metadata:
       encoder         : Lavc58.91.100 libx264
     Side data:
       cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A
frame=   47 fps=0.0 q=0.0 size=       0kB time=00:00:00.00 bitrate=N/A speed=   0x
frame=   76 fps= 68 q=30.0 size=       0kB time=00:00:00.65 bitrate=   0.6kbits/s speed=0.589x
frame=  102 fps= 62 q=30.0 size=       0kB time=00:00:01.40 bitrate=   0.3kbits/s speed=0.846x

最後一步是讀取輸出文件並將其提供給 video 元素:

const output = ffmpeg.FS("readFile", "output.mp4");
const video = document.getElementById("video");
video.src = URL.createObjectURL(
  new Blob([output.buffer], { type: "video/mp4" })
);

有趣的是,帶有虛擬文件系統的命令行工具 FFmpeg.wasm 有點像 docker!

建立流式轉碼器

對大視頻文件進行編碼轉換可能耗時較長。咱們能夠先把文件轉碼爲切片,並將其逐步添加到視頻緩衝區中。

你能夠用 Media Source Extension APIs[8] 來構建流媒體播放,其中包括 MediaSourceSourceBuffer 對象。建立和加載緩衝區的操做可能很是棘手,由於這兩個對象都提供了生命週期事件,必須經過處理這些事件才能在正確的時間添加新的緩衝區。爲了管理這些事件的協調,我用到了 RxJS[9]

下面的函數基於 FFmpeg.wasm 轉碼後的輸出建立一個 RxJS Observable:

const bufferStream = filename =>
  new Observable(async subscriber => {
    const ffmpeg = FFmpeg.createFFmpeg({
      corePath: "thirdparty/ffmpeg-core.js",
      log: false
    });

    const fileExists = file => ffmpeg.FS("readdir", "/").includes(file);
    const readFile = file => ffmpeg.FS("readFile", file);

    await ffmpeg.load();
    const sourceBuffer = await fetch(filename).then(r => r.arrayBuffer());
    ffmpeg.FS(
      "writeFile", "input.mp4",
      new Uint8Array(sourceBuffer, 0, sourceBuffer.byteLength)
    );

    let index = 0;

    ffmpeg
      .run(
        "-i", "input.mp4",
        // 給流進行編碼
        "-segment_format_options", "movflags=frag_keyframe+empty_moov+default_base_moof",
        // 編碼爲 5 秒鐘的片斷
        "-segment_time", "5",
        // 經過索引寫入文件系統
        "-f", "segment", "%d.mp4"
      )
      .then(() => {
        // 發送剩餘的文件內容
        while (fileExists(`${index}.mp4`)) {
          subscriber.next(readFile(`${index}.mp4`));
          index++;
        }
        subscriber.complete();
      });

    setInterval(() => {
      // 按期檢查是否已寫入文件
      if (fileExists(`${index + 1}.mp4`)) {
        subscriber.next(readFile(`${index}.mp4`));
        index++;
      }
    }, 200);
  });

上面的代碼用了和之前相同的 FFmpeg.wasm 設置,將要轉碼的文件寫入內存文件系統。爲了建立分段輸出,ffmpeg.run 的配置與上一個例子有所不一樣,須要設置合適的轉碼器。在運行時 FFmpeg 把帶有增量索引(0.mp41.mp4, …)的文件寫入內存文件系統。

爲了實現流式傳輸輸出,須要經過輪詢文件系統以獲取轉碼後的輸出,並經過 subscriber.next 把數據做爲事件發出。最後當 ffmpeg.run 完成時,餘下的文件內容被送出並關閉流。

須要建立一個MediaSource對象來把數據流傳輸到視頻元素中,並等待 sourceopen 事件觸發。下面的代碼用到了 RxJS 的 combineLatest 來確保在觸發這個事件以前不處理 FFmpeg 輸出:

const mediaSource = new MediaSource();
videoPlayer.src = URL.createObjectURL(mediaSource);
videoPlayer.play();

const mediaSourceOpen = fromEvent(mediaSource, "sourceopen");

const bufferStreamReady = combineLatest(
  mediaSourceOpen,
  bufferStream("4club-JTV-i63.avi")
).pipe(map(([, a]) => a));

當接收到第一個視頻切片或緩衝時,須要在正確的時間向 SourceBuffer 添加 MediaSource,並將原始緩衝區附加到 SourceBuffer。在此以後,還有一個須要注意的地方,新緩衝不能立刻添加到 SourceBuffer 中,須要等到它發出 updateend 事件代表先前的緩衝區已被處理後才行。

下面的代碼用 take 處理第一個緩衝區,並用 mux.js[10] 庫讀取 mime 類型。而後從 updateend 事件返回一個新的可觀察流:

const sourceBufferUpdateEnd = bufferStreamReady.pipe(
  take(1),
  map(buffer => {
    // 基於當前的 mime type 建立一個buffer
    const mime = `video/mp4; codecs="${muxjs.mp4.probe
      .tracks(buffer)
      .map(t => t.codec)
      .join(",")}"`;
    const sourceBuf = mediaSource.addSourceBuffer(mime);

    // 追加道緩衝區
    mediaSource.duration = 5;
    sourceBuf.timestampOffset = 0;
    sourceBuf.appendBuffer(buffer);

    // 建立一個新的事件流 
    return fromEvent(sourceBuf, "updateend").pipe(map(() => sourceBuf));
  }),
  flatMap(value => value)
);

剩下的就是在緩衝區到達及 SourceBuffer 準備就緒時追加緩衝區。能夠經過 RxJS 的 zip 函數實現:

zip(sourceBufferUpdateEnd, bufferStreamReady.pipe(skip(1)))
  .pipe(
    map(([sourceBuf, buffer], index) => {
      mediaSource.duration = 10 + index * 5;
      sourceBuf.timestampOffset = 5 + index * 5;
      sourceBuf.appendBuffer(buffer.buffer);
    })
  )
  .subscribe();

就這樣對事件進行了一些協調,最終只需不多的代碼就能對視頻進行轉碼了,並將結果逐漸添加到視頻元素中。

在公衆號對話框中發送 wasm 獲取源碼。

Reference

[1]

FFmpeg.wasm: https://github.com/ffmpegwasm/ffmpeg.wasm

[2]

Google Earth: https://blog.chromium.org/2019/06/webassembly-brings-google-earth-to-more.html

[3]

AutoCAD: https://qconnewyork.com/ny2018/presentation/autocad-webassembly-moving-30-year-code-base-web

[4]

TensorFlow: https://blog.tensorflow.org/2020/03/introducing-webassembly-backend-for-tensorflow-js.html

[5]

Emscripten: https://emscripten.org/

[6]

file-system API: https://emscripten.org/docs/api_reference/Filesystem-API.html

[7]

Pthread 支持: https://emscripten.org/docs/porting/pthreads.html

[8]

Media Source Extension APIs: https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API

[9]

RxJS: https://rxjs.dev/

[10]

mux.js: https://github.com/videojs/mux.js/




強力推薦前端面試刷題神器


watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=

watermark,size_16,text_QDUxQ1RP5Y2a5a6i,color_FFFFFF,t_100,g_se,x_10,y_10,shadow_90,type_ZmFuZ3poZW5naGVpdGk=
相關文章
相關標籤/搜索