使用 WebCodecs 處理影片

操控影片串流元件。

Eugene Zemtsov
Eugene Zemtsov
François Beaufort
François Beaufort

現代化的網路技術提供許多與影片相關的工作方式。Media Stream APIMedia Recording APIMedia Source APIWebRTC API 新增到豐富的工具集,可用於錄製、傳輸和播放影片串流。這些 API 可解決特定高層級工作,但不允許網頁程式設計師使用影片串流的個別元件,例如影格和未經多路復用編碼的影片或音訊區塊。為了取得這些基本元件的低階存取權,開發人員一直使用 WebAssembly 將影片和音訊轉碼器帶入瀏覽器。不過,由於新式瀏覽器已提供各種編解碼器 (通常由硬體加速),因此將這些編解碼器重新包裝為 WebAssembly 似乎會浪費人力和電腦資源。

WebCodecs API 可讓程式設計師使用瀏覽器中現有的媒體元件,藉此降低這種效率不彰的情況。詳細說明:

  • 影片和音訊解碼器
  • 影片和音訊編碼器
  • 原始影片影格
  • 圖片解碼器

WebCodecs API 適用於需要完全控制媒體內容處理方式的網頁應用程式,例如影片編輯器、視訊會議、影片串流等。

影片處理工作流程

影格是影片處理作業的核心,因此,在 WebCodecs 中,大多數類別都會使用或產生影格。影片編碼器會將影格轉換為已編碼的區塊。而影片解碼器則是相反。

此外,VideoFrameCanvasImageSource,並具有可接受 CanvasImageSourceconstructor,因此可與其他 Web API 搭配使用。因此,您可以在 drawImage()texImage2D() 等函式中使用此函式。也可以使用畫布、位圖、影片元素和其他影片影格來建構。

WebCodecs API 可與 Insertable Streams API 的類別搭配使用,將 WebCodecs 連結至媒體串流音軌

  • MediaStreamTrackProcessor 會將媒體音軌拆解為個別影格。
  • MediaStreamTrackGenerator 會根據影格串流建立媒體音軌。

WebCodecs 和 Web Workers

根據設計,WebCodecs API 會以非同步方式執行所有繁重工作,並在主執行緒外執行。不過,由於影格和區塊回呼通常每秒可呼叫多次,因此可能會造成主執行緒雜亂,進而導致網站的回應速度變慢。因此,建議將個別影格和已編碼的區塊處理作業移至 Web worker。

為方便起見,ReadableStream 提供簡單的方式,可將來自媒體追蹤的所有影格自動傳輸到 worker。例如,MediaStreamTrackProcessor 可用於取得來自網路攝影機的媒體串流音軌的 ReadableStream。之後,這項作業會將串流傳送至 Web Worker,在該處逐一讀取影格,並將影格排入 VideoEncoder 的佇列。

有了 HTMLCanvasElement.transferControlToOffscreen,您甚至可以在主執行緒外執行算繪作業。不過,如果所有高階工具都無法使用,VideoFrame 本身是可以轉移的,且可在 worker 之間移動。

WebCodecs 的實際應用

編碼

從 Canvas 或 ImageBitmap 到網路或儲存空間的路徑
CanvasImageBitmap 到網路或儲存空間的路徑

一切始於 VideoFrame。建構影片影格的方式有三種。

  • 圖片來源包含圖片來源,例如畫布、圖片點陣圖或影片元素。

    const canvas = document.createElement("canvas");
    // Draw something on the canvas...
    
    const frameFromCanvas = new VideoFrame(canvas, { timestamp: 0 });
    
  • 使用 MediaStreamTrackProcessorMediaStreamTrack 提取影格

    const stream = await navigator.mediaDevices.getUserMedia({});
    const track = stream.getTracks()[0];
    
    const trackProcessor = new MediaStreamTrackProcessor(track);
    
    const reader = trackProcessor.readable.getReader();
    while (true) {
      const result = await reader.read();
      if (result.done) break;
      const frameFromCamera = result.value;
    }
    
  • 根據 BufferSource 中的二進位像素表示法建立影格

    const pixelSize = 4;
    const init = {
      timestamp: 0,
      codedWidth: 320,
      codedHeight: 200,
      format: "RGBA",
    };
    const data = new Uint8Array(init.codedWidth * init.codedHeight * pixelSize);
    for (let x = 0; x < init.codedWidth; x++) {
      for (let y = 0; y < init.codedHeight; y++) {
        const offset = (y * init.codedWidth + x) * pixelSize;
        data[offset] = 0x7f;      // Red
        data[offset + 1] = 0xff;  // Green
        data[offset + 2] = 0xd4;  // Blue
        data[offset + 3] = 0x0ff; // Alpha
      }
    }
    const frame = new VideoFrame(data, init);
    

無論來源為何,影格都可以透過 VideoEncoder 編碼至 EncodedVideoChunk 物件。

在編碼前,VideoEncoder 需要提供兩個 JavaScript 物件:

  • 使用兩個函式初始化字典,以便處理已編碼的區塊和錯誤。這些函式是由開發人員定義,且在傳遞至 VideoEncoder 建構函式後就無法變更。
  • 編碼器設定物件,包含輸出影片串流的參數。您之後可以呼叫 configure() 變更這些參數。

如果瀏覽器不支援設定,configure() 方法就會擲回 NotSupportedError。建議您使用設定呼叫靜態方法 VideoEncoder.isConfigSupported(),以便事先檢查是否支援設定,並等待承諾。

const init = {
  output: handleChunk,
  error: (e) => {
    console.log(e.message);
  },
};

const config = {
  codec: "vp8",
  width: 640,
  height: 480,
  bitrate: 2_000_000, // 2 Mbps
  framerate: 30,
};

const { supported } = await VideoEncoder.isConfigSupported(config);
if (supported) {
  const encoder = new VideoEncoder(init);
  encoder.configure(config);
} else {
  // Try another config.
}

編碼器設定完成後,即可透過 encode() 方法接受影格。configure()encode() 都會立即傳回,不會等待實際工作完成。這可讓多個影格同時排入編碼佇列,而 encodeQueueSize 則會顯示佇列中等待先前編碼完成的請求數量。當引數或方法呼叫順序違反 API 合約時,系統會立即擲回例外狀況來回報錯誤,或是呼叫 error() 回呼,以便處理編解碼實作中遇到的問題。如果編碼順利完成,系統會呼叫 output() 回呼,並將新的已編碼區塊做為引數。另一個重要的細節是,您必須透過呼叫 close() 告知影格何時不再需要。

let frameCounter = 0;

const track = stream.getVideoTracks()[0];
const trackProcessor = new MediaStreamTrackProcessor(track);

const reader = trackProcessor.readable.getReader();
while (true) {
  const result = await reader.read();
  if (result.done) break;

  const frame = result.value;
  if (encoder.encodeQueueSize > 2) {
    // Too many frames in flight, encoder is overwhelmed
    // let's drop this frame.
    frame.close();
  } else {
    frameCounter++;
    const keyFrame = frameCounter % 150 == 0;
    encoder.encode(frame, { keyFrame });
    frame.close();
  }
}

最後,編寫函式來處理從編碼器輸出的已編碼影片區塊,完成編碼程式碼。這項函式通常會透過網路傳送資料區塊,或將資料區塊多路復用至媒體容器進行儲存。

function handleChunk(chunk, metadata) {
  if (metadata.decoderConfig) {
    // Decoder needs to be configured (or reconfigured) with new parameters
    // when metadata has a new decoderConfig.
    // Usually it happens in the beginning or when the encoder has a new
    // codec specific binary configuration. (VideoDecoderConfig.description).
    fetch("/upload_extra_data", {
      method: "POST",
      headers: { "Content-Type": "application/octet-stream" },
      body: metadata.decoderConfig.description,
    });
  }

  // actual bytes of encoded data
  const chunkData = new Uint8Array(chunk.byteLength);
  chunk.copyTo(chunkData);

  fetch(`/upload_chunk?timestamp=${chunk.timestamp}&type=${chunk.type}`, {
    method: "POST",
    headers: { "Content-Type": "application/octet-stream" },
    body: chunkData,
  });
}

如果您需要確保已完成所有待處理的編碼要求,可以呼叫 flush() 並等待其承諾。

await encoder.flush();

解碼

從網路或儲存空間到 Canvas 或 ImageBitmap 的路徑。
從網路或儲存空間到 CanvasImageBitmap 的路徑。

設定 VideoDecoder 的做法與 VideoEncoder 類似:建立解碼器時會傳遞兩個函式,並將編碼器參數提供給 configure()

轉碼器參數組合因轉碼器而異。舉例來說,H.264 轉碼器可能需要 AVCC 的二進位資料 blob,除非是以所謂的附錄 B 格式 (encoderConfig.avc = { format: "annexb" }) 編碼。

const init = {
  output: handleFrame,
  error: (e) => {
    console.log(e.message);
  },
};

const config = {
  codec: "vp8",
  codedWidth: 640,
  codedHeight: 480,
};

const { supported } = await VideoDecoder.isConfigSupported(config);
if (supported) {
  const decoder = new VideoDecoder(init);
  decoder.configure(config);
} else {
  // Try another config.
}

解碼器初始化後,您就可以開始使用 EncodedVideoChunk 物件進行餵入。如要建立區塊,您需要:

  • 已編碼影片資料的 BufferSource
  • 區塊的開始時間戳記,以微秒為單位 (區塊中第一個編碼影格的媒體時間)
  • 區塊的類型,可選項目如下:
    • 如果區塊可從先前的區塊獨立解碼,則為 key
    • delta 如果只有在解碼一或多個先前區塊後,才能解碼該區塊

此外,編碼器發出的任何區塊都會以原樣方式供解碼器使用。上述所有關於錯誤回報和編碼器方法非同步性質的說明,同樣也適用於解碼器。

const responses = await downloadVideoChunksFromServer(timestamp);
for (let i = 0; i < responses.length; i++) {
  const chunk = new EncodedVideoChunk({
    timestamp: responses[i].timestamp,
    type: responses[i].key ? "key" : "delta",
    data: new Uint8Array(responses[i].body),
  });
  decoder.decode(chunk);
}
await decoder.flush();

接下來,我們將示範如何在頁面上顯示新解碼的框架。建議您確保解碼器輸出回呼 (handleFrame()) 能快速傳回。在下方範例中,它只會將影格新增至可供轉譯的影格佇列。轉譯作業會分開進行,並包含兩個步驟:

  1. 等待適當的時間顯示影格。
  2. 在畫布上繪製框架。

當不再需要某個影格時,請在垃圾收集器處理前呼叫 close() 釋放基礎記憶體,這樣可降低網頁應用程式使用的平均記憶體量。

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
let pendingFrames = [];
let underflow = true;
let baseTime = 0;

function handleFrame(frame) {
  pendingFrames.push(frame);
  if (underflow) setTimeout(renderFrame, 0);
}

function calculateTimeUntilNextFrame(timestamp) {
  if (baseTime == 0) baseTime = performance.now();
  let mediaTime = performance.now() - baseTime;
  return Math.max(0, timestamp / 1000 - mediaTime);
}

async function renderFrame() {
  underflow = pendingFrames.length == 0;
  if (underflow) return;

  const frame = pendingFrames.shift();

  // Based on the frame's timestamp calculate how much of real time waiting
  // is needed before showing the next frame.
  const timeUntilNextFrame = calculateTimeUntilNextFrame(frame.timestamp);
  await new Promise((r) => {
    setTimeout(r, timeUntilNextFrame);
  });
  ctx.drawImage(frame, 0, 0);
  frame.close();

  // Immediately schedule rendering of the next frame
  setTimeout(renderFrame, 0);
}

開發人員提示

使用 Chrome 開發人員工具中的媒體面板查看媒體記錄並為 WebCodecs 偵錯。

用於偵錯 WebCodecs 的媒體面板螢幕截圖
Chrome 開發人員工具中的媒體面板,可用於偵錯 WebCodecs。

示範

以下示範如何使用畫布中的動畫影格:

  • MediaStreamTrackProcessor 以 25 FPS 擷取到 ReadableStream
  • 已轉移至網路工作者
  • 已編碼為 H.264 影片格式
  • 再次解碼為一系列的影片影格
  • 並使用 transferControlToOffscreen() 在第二個畫布上算繪

其他示範

歡迎參考其他示範:

使用 WebCodecs API

特徵偵測

如何檢查 WebCodecs 支援:

if ('VideoEncoder' in window) {
  // WebCodecs API is supported.
}

請注意,WebCodecs API 只能在安全內容中使用,因此如果 self.isSecureContext 為 false,偵測作業就會失敗。

意見回饋

Chrome 小組想瞭解您的 WebCodecs API 使用體驗。

請說明 API 設計

API 是否有任何部分無法正常運作?或者,您是否缺少實作想法所需的方法或屬性?對安全性模型有任何疑問或意見嗎?在對應的 GitHub 存放區中提出規格問題,或在現有問題中加入您的想法。

回報實作問題

您發現 Chrome 實作錯誤嗎?或者實作方式與規格不同?前往 new.crbug.com 回報錯誤。請盡可能加入所有詳細資料、簡易的重現操作說明,並在「Components」(元件) 方塊中輸入 Blink>Media>WebCodecsGlitch 有便捷的報復工具,

顯示對 API 的支援

您打算使用 WebCodecs API 嗎?您的公開支援可協助 Chrome 團隊優先處理各項功能,並讓其他瀏覽器廠商知道對於這些功能的支援度。

您可以傳送電子郵件至 media-dev@chromium.org,或使用主題標記 #WebCodecs 在 Twitter 上透過 Tweet 傳送給 @ChromiumDev,並告知我們您使用該標記的位置和方式。

主頁橫幅,圖片來源:Denise Jans,圖片來源:Unsplash