使用 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 工作器

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

为此,ReadableStream 提供了一种便捷的方式,可自动将来自媒体轨道的所有帧传输到 worker。例如,MediaStreamTrackProcessor 可用于获取来自网络摄像头的媒体流轨道的 ReadableStream。然后,该流会传输到 Web 工作器,在其中逐一读取帧并将其加入 VideoEncoder 队列。

借助 HTMLCanvasElement.transferControlToOffscreen,甚至可以在主线程之外进行渲染。不过,如果所有高级工具都很不方便,VideoFrame 本身是可传输的,并且可以在工作器之间移动。

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 编解码器可能需要 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() 以释放底层内存,这将减少 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 开发者工具中的媒体面板查看媒体日志并调试 WebCodec。

用于调试 WebCodec 的“Media”面板的屏幕截图
Chrome DevTools 中的媒体面板,用于调试 WebCodec。

演示

以下演示展示了画布中的动画帧是如何:

  • MediaStreamTrackProcessor 以 25fps 的帧速率捕获到 ReadableStream
  • 转移到 Web Worker
  • 编码为 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 实现中的 bug?还是实现与规范不同?请访问 new.crbug.com 提交 bug。务必提供尽可能详细的信息、简单的重现说明,并在 Components 框中输入 Blink>Media>WebCodecs故障非常适合分享快速简便的重现步骤。

显示对该 API 的支持

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

请发送电子邮件至 media-dev@chromium.org,或使用 #WebCodecs 标签向 @ChromiumDev 发送推文,告诉我们您在哪里以及如何使用该工具。

主打图片Unsplash 用户 Denise Jans 提供。