使用 WebCodecs 处理视频

操控视频流组件。

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

现代 Web 技术提供了多种处理视频的方式。 Media Stream APIMedia Recording APIMedia Source APIWebRTC API共同构成了一套丰富的工具,用于录制、传输和播放视频流。虽然这些 API 可以解决某些高级任务,但 Web 程序员无法使用视频流的各个组件,例如帧和未多路分解的编码视频或音频块。 为了以较低级别访问这些基本组件,开发者一直使用 WebAssembly 将 视频和音频编解码器引入浏览器。但是,考虑到现代浏览器已经附带了各种编解码器(通常由硬件加速),将它们重新打包为 WebAssembly 似乎是在浪费人力和计算机资源。

WebCodecs API 通过让程序员使用浏览器中已有的媒体组件来消除这种低效问题。具体而言:

  • 视频和音频解码器
  • 视频和音频编码器
  • 原始视频帧
  • 图像解码器

WebCodecs API 适用于需要完全控制媒体内容处理方式的 Web 应用,例如视频编辑器、视频会议、视频流式传输等。

视频处理工作流

帧是视频处理的核心。因此,在 WebCodecs 中,大多数类要么使用帧,要么生成帧。视频编码器将帧转换为编码块。视频解码器则相反。

此外,VideoFrame 还可以作为 CanvasImageSource,并且具有接受 CanvasImageSource构造函数,因此可以与其他 Web API 很好地配合使用。 因此,它可以在 drawImage()texImage2D() 等函数中使用。此外,它还可以从画布、位图、视频元素和其他视频帧构建。

WebCodecs API 可以与 Insertable Streams API 中的类协同工作,这些类将 WebCodecs 连接到 媒体流轨道

  • MediaStreamTrackProcessor 将媒体轨道分解为各个帧。
  • MediaStreamTrackGenerator 从帧流创建媒体轨道。

WebCodecs 和 Web Worker

按照设计,WebCodecs API 会异步执行所有繁重的工作,并且不会在主线程上执行。 但是,由于帧和块回调通常每秒可以调用多次,因此它们可能会使主线程变得杂乱,从而降低网站的响应速度。 因此,最好将各个帧和编码块的处理移到 Web Worker 中。

为了帮助实现这一点,ReadableStream 提供了一种便捷的方式,可自动将来自媒体 轨道的所有帧传输到 Worker。例如,MediaStreamTrackProcessor 可用于获取来自网络摄像头的媒体流轨道的 ReadableStream。之后,该流会传输到 Web Worker,在该 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(),以便提前检查是否支持该配置并等待其 Promise。

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() 并等待其 Promise。

await encoder.flush();

解码

从网络或存储空间到 Canvas 或 ImageBitmap 的路径。
从网络或存储到 CanvasImageBitmap 的路径。

设置 VideoDecoder 与为 VideoEncoder 所做的设置类似:创建解码器时会传递两个函数,并且会将编解码器参数提供给 configure()

编解码器参数集因编解码器而异。例如,H.264 编解码器 可能需要 二进制 blob 的 AVCC,除非它采用所谓的 Annex 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() 以释放底层内存,这将减少 Web 应用使用的平均内存量。

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 以 25fps 的速度捕获到 ReadableStream
  • 传输到 Web Worker
  • 编码为 H.264 视频格式
  • 再次解码为一系列视频帧
  • 并使用 transferControlToOffscreen() 在第二个画布上渲染

其他演示

另请查看我们的其他演示:

使用 WebCodecs API

功能检测

如需检查是否支持 WebCodecs,请执行以下操作:

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

请注意,WebCodecs API 仅在 安全环境中可用, 因此如果 self.isSecureContext 为 false,检测将失败。

了解详情

如果您是 WebCodecs 新手,WebCodecs 基础知识提供了许多示例的深入文章,可帮助您了解更多信息。

反馈

Chrome 团队希望了解您在使用 WebCodecs API 方面的体验。

向我们介绍 API 设计

API 是否有某些方面未按预期运行?或者,是否有您需要实现自己的想法但缺少的方法或属性?对安全模型有疑问或意见?在 相应的 GitHub 代码库中提交规范问题,或将 您的想法添加到现有问题中。

报告实现方面的问题

您是否发现了 Chrome 实现中的 bug?或者,实现是否与规范不同?请在 new.crbug.com 上提交 bug。 请务必尽可能详细地说明问题,提供简单的重现说明,并在 **组件** 框中输入 Blink>Media>WebCodecs

表示对 API 的支持

您是否打算使用 WebCodecs API?您的公开支持有助于 Chrome 团队确定功能的优先级,并向其他浏览器供应商展示支持这些功能的重要性。

请发送电子邮件至 media-dev@chromium.org 或使用主题标签 #WebCodecs@ChromiumDev 发送推文,告知我们您在何处以及如何使用该 API。