使用 WebCodecs 处理视频

处理视频流组件。

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

现代网络技术提供了多种处理视频的方法。 Media Stream APIMedia Recording APIMedia Source API、 和 WebRTC API 相加 功能丰富的工具集,用于录制、传输和播放视频串流。 虽然处理某些高级任务,但这些 API 不允许 Web 编程人员处理视频流的各个组件,例如帧、 以及未混用的已编码视频或音频块 为了获取这些基本组件的低级别访问权限,开发者一直使用 WebAssembly 将视频和音频编解码器引入到浏览器中。但考虑到 现代浏览器已经附带了多种编解码器(通常 通过硬件加速),将它们重新打包为 WebAssembly 似乎是一种浪费, 人力和计算机资源。

WebCodecs API 可消除这种低效问题 让程序员能够使用 。具体而言:

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

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

视频处理工作流程

帧是视频处理的核心。因此,在 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,其中帧会逐个读取并加入队列 转换为 VideoEncoder

借助 HTMLCanvasElement.transferControlToOffscreen,可以在主线程外完成渲染。但是,如果所有高级工具 VideoFrame 本身可转让, 在工作器之间移动。

WebCodecs 的实际运用

编码

<ph type="x-smartling-placeholder">
</ph> 从画布或 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);
    

无论帧来自何处,都可以将其编码为 具有 VideoEncoderEncodedVideoChunk 对象。

在编码之前,需要为 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();

解码

<ph type="x-smartling-placeholder">
</ph> 从网络或存储空间到画布或 ImageBitmap 的路径。
从网络或存储空间到 CanvasImageBitmap 的路径。

设置 VideoDecoder 的操作与 VideoEncoder:在创建解码器时传递两个函数, 参数提供给 configure()

编解码器参数集因编解码器而异。例如 H.264 编解码器 可能需要一个二进制 blob AVCC 的编码,除非它采用所谓的附录 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
  • 分块的开始时间戳(以微秒为单位,分块中第一个编码帧的媒体时间)
  • 分块的类型,其中包括: <ph type="x-smartling-placeholder">
      </ph>
    • 如果数据块可独立于之前的分块进行解码,则为 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);
}

开发提示

使用媒体面板 ,以查看媒体日志和调试 WebCodecs。

<ph type="x-smartling-placeholder">
</ph> 用于调试 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,检测将失败。

反馈

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

向我们介绍 API 设计

API 是否有什么无法按预期运行?或者 是否缺少实现想法的方法或属性?在 对安全模型有疑问或意见?在 相应的 GitHub 代码库,或者添加 您对现有问题的想法

报告实现存在的问题

您在 Chrome 的实现过程中是否发现了错误?还是 与规范不同?在 new.crbug.com 上提交 bug。 请务必提供尽可能多的细节信息, 然后在组件框中输入 Blink>Media>WebCodecsGlitch 非常适用于分享轻松快速的重现问题。

表示对 API 的支持

您打算使用 WebCodecs API 吗?你的公开支持将帮助 让 Chrome 团队确定各项功能的优先级,并向其他浏览器供应商展示 而是为他们提供支持

发送电子邮件至 media-dev@chromium.org 或发推文 发送给 @ChromiumDev(使用 # 标签) #WebCodecs 并告诉我们您使用它的地点和方式。

主打图片,作者 丹尼斯·简斯 (在 Unsplash 网站上)