使用 WebCodecs 处理视频

操控视频流组件。

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

现代网络技术提供了丰富的视频处理方式。 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 工作器。

为此,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 对象:

  • Init 字典,包含两个用于处理编码区块和错误的函数。这些函数由开发者定义,在传递给 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 开发者工具中的媒体面板查看媒体日志和调试 WebCodecs。

用于调试 WebCodecs 的“媒体”面板的屏幕截图
Chrome 开发者工具中的媒体面板,用于调试 WebCodecs。

演示

以下演示显示了画布中的动画帧是如何生成的:

  • MediaStreamTrackProcessor 以 25fps 的速率拍摄到 ReadableStream
  • 已转移到 Web 工作器
  • 编码为 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 提交 bug。请务必提供尽可能多的详情和简单的重现说明,并在组件框中输入 Blink>Media>WebCodecsGlitch 非常适合快速轻松地分享重现的视频。

显示对该 API 的支持

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

请向 media-dev@chromium.org 发送电子邮件,或使用 # 标签 #WebCodecs@ChromiumDev 发一条推文,并告知我们您使用该标签的位置和方式。

主打图片,作者:Denise Jans,来源于 Unsplash 用户。