12k
All articles

使用 WebCodecs API 实现实时视频处理

使用MediaStreamTrackProcessor、TransformStream和VideoTrackGenerator进行WebCodecs视频处理,并讲解frame.close、backpressure、worker和浏览器支持。

OpenReplay Team
OpenReplay Team
使用 WebCodecs API 实现实时视频处理

WebCodecs 视频管道由三个部分组成:MediaStreamTrackProcessor(将 MediaStreamTrack 转换为 ReadableStream<VideoFrame>)、TransformStream(用于对每一帧进行处理),以及 VideoTrackGenerator(将处理后的帧转换回 MediaStreamTrack,以便赋值给 <video> 元素)。VideoTrackGenerator 是当前规范中的命名;Chromium 的示例代码中仍沿用旧的非标准名称 MediaStreamTrackGenerator。以下是完整的管道代码:

const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const track = stream.getVideoTracks()[0];

const processor = new MediaStreamTrackProcessor({ track });
const generator = new VideoTrackGenerator();

const grayscale = new TransformStream({
  async transform(frame, controller) {
    try {
      const canvas = new OffscreenCanvas(frame.displayWidth, frame.displayHeight);
      const ctx = canvas.getContext('2d');
      ctx.filter = 'grayscale(1)';
      ctx.drawImage(frame, 0, 0);
      controller.enqueue(new VideoFrame(canvas, {
        timestamp: frame.timestamp,
        duration: frame.duration,
      }));
    } finally {
      frame.close();
    }
  },
});

processor.readable.pipeThrough(grayscale).pipeTo(generator.writable);
videoEl.srcObject = new MediaStream([generator.track]);

本文重点介绍的是现有教程普遍跳过的内容:故障模式。上面这段”正常路径”代码在大多数情况下可以运行,但一旦出现帧泄漏、变换处理跟不上速度、编码器进入关闭状态,或者代码中错误地假设 Safari 不支持该功能,问题就会随之而来。这些都是真实的生产环境故障,每一种都有其具体的原因和对应的解决方案,这正是本文后续内容所要讲解的。

核心要点

  • WebCodecs 管道的结构为 MediaStreamTrackProcessorTransformStreamVideoTrackGenerator;请使用规范构造函数 new MediaStreamTrackProcessor({ track }),而非已废弃的位置参数形式。
  • 忘记调用 frame.close() 会耗尽管道所依赖的有限媒体资源;一旦资源耗尽,帧的发射就会停滞,导致视频出现卡顿并最终冻结,而页面其余部分仍保持正常响应。
  • MediaStreamTrackProcessor 不会向上游传播背压——当变换处理落后时,处理器会静默丢弃最旧的帧,而不会抛出任何错误。
  • 所有 VideoFrame 操作应在同一个 Worker 中完成:帧在跨 Worker 边界传输后,发送方的引用会自动关闭,再次访问将抛出异常。
  • WebCodecs 的支持情况因接口而异——核心接口 VideoEncoder/VideoFrame 在 Chrome 94+、Firefox 130+ 和 Safari 16.4+ 中已可用,而 MediaStreamTrackProcessor/VideoTrackGenerator 的支持则相对滞后(Safari 18+ 支持,Firefox 暂不支持)。

WebCodecs 是什么,以及管道为何如此设计

WebCodecs 为 JavaScript 提供了对浏览器内置媒体编解码器(通常经过硬件加速)以及原始视频帧的直接访问能力。在此之前,MediaStream 是不透明的:你只能将其传入 <video> 元素,由浏览器处理从采集到显示的全部流程。WebCodecs 打破了这一封闭管道。VideoFrame 接口暴露了采集与编码之间的原始像素数据,这正是滤镜、虚拟背景或自定义编码器需要介入的位置。

管道采用 Streams 的原因在于:原始解码帧体积较大(每帧数兆字节),且到达速度很快(每秒 25 帧以上),因此需要流量控制和增量处理,而非将所有内容缓冲在内存中。WHATWG Streams API 正是为这种通过管道链进行原子块处理的场景而设计的。MediaStreamTrackProcessor 将实时轨道桥接到流中;TransformStream 是执行逐帧处理的地方;VideoTrackGenerator 则将处理结果桥接回平台其他组件(如 <video>RTCPeerConnection)所能理解的轨道格式。

WebCodecs 仅处理非容器化的流。如果需要读写 MP4/ISOBMFF 格式,则需自行实现容器逻辑。音频有对应的处理接口(AudioDataAudioEncoder),本文不作介绍;以下模式均针对视频场景。

一个可运行的摄像头 → 滤镜 → 显示管道

一个可运行的 WebCodecs 滤镜管道,通过 MediaStreamTrackProcessor 采集画面,在 TransformStream 内部直接使用 Canvas2D 对 VideoFrame 进行滤镜处理,并通过 VideoTrackGenerator 输出显示——即文章开头代码块所展示的结构。关键的效率优化在于 ctx.drawImage(frame, 0, 0)——drawImage 可直接接受 VideoFrame 作为源,因此无需手动将帧转换为 PNG 或创建中间 ImageBitmap 即可将帧绘制到画布上。

对于 Canvas2D 色彩滤镜,使用 ctx.filter 字符串是开销最低的方式。对于需要逐像素访问的场景——如色度键控、自定义卷积——则使用 getImageData/putImageData

const filter = new TransformStream({
  async transform(frame, controller) {
    try {
      const w = frame.displayWidth, h = frame.displayHeight;
      const canvas = new OffscreenCanvas(w, h);
      const ctx = canvas.getContext('2d', { willReadFrequently: true });
      ctx.drawImage(frame, 0, 0);

      const imageData = ctx.getImageData(0, 0, w, h);
      const px = imageData.data;
      for (let i = 0; i < px.length; i += 4) {
        const lum = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
        px[i] = px[i + 1] = px[i + 2] = lum;
      }
      ctx.putImageData(imageData, 0, 0);

      controller.enqueue(new VideoFrame(canvas, {
        timestamp: frame.timestamp,
        duration: frame.duration,
      }));
    } finally {
      frame.close();
    }
  },
});

从原始帧传递到新帧的有两个属性:timestampdurationtimestamp 是帧在整个管道中的唯一标识——它在编解码周期中得以保留,也是后续用于测量延迟的依据。丢失它会导致下游消费者失去帧的顺序信息。

对于全分辨率下较繁重的逐像素处理,getImageData 的读回操作往往是性能瓶颈;WebGL 或 WebGPU(通过 importExternalTexture)可将帧保留在 GPU 上,完全避免 CPU 读回。色彩变换和简单合成使用 Canvas2D;当逐像素处理开销占据帧预算的主要部分时,则应采用 GPU 路径。

VideoFrame 的生命周期:为何 frame.close() 是强制要求

忘记调用 frame.close() 不仅仅是普通的内存泄漏——它会耗尽管道所依赖的有限媒体资源。一旦这些资源耗尽,解码或帧发射就会停滞,因为无法再分配或发射新帧,其典型症状是:视频逐渐卡顿,最终完全冻结,而页面其余部分仍保持正常响应。VideoFrame.close() 释放帧所持有的底层媒体资源,WebCodecs 规范明确指出这些资源是有限的——由硬件缓冲区支撑的帧来自一个有限的资源池,当资源池满时,源端将无法发射新帧。

这就是为什么 close() 不是可以推迟到垃圾回收时处理的可选清理操作。垃圾回收器无法按自己的调度感知底层媒体资源的状态,而等到它运行时,资源池早已耗尽。从处理器读取的每一个 VideoFrame,以及你自行构造的每一个,都必须在使用完毕后恰好关闭一次。

容易被忽视的故障场景是错误路径。如果你的变换函数在读取帧之后、关闭帧之前抛出异常,该帧就会泄漏——而一个在某帧上抛出异常的变换函数,通常也会在下一帧上抛出,因此泄漏会迅速累积。解决方案是使用 try/finally

async transform(frame, controller) {
  try {
    // ...可能抛出异常的滤镜处理逻辑...
    controller.enqueue(newFrame);
  } finally {
    frame.close(); // 无论函数体是否抛出异常,都会执行
  }
}

finally 保证 frame.close() 在成功路径和错误路径上都会执行。这是 WebCodecs 管道中最重要的编码模式。

背压:为何慢速变换会静默丢帧

MediaStreamTrackProcessor 不会向上游传播背压。当你的 TransformStream 处理落后时,处理器会静默丢弃最旧的帧,而不会减慢摄像头的速度,且你永远不会看到任何错误——只会发现帧丢失。实际影响如下:在 30fps 源(33ms 预算)上,每帧处理耗时 50ms 的变换不会报错,也不会无限制地积压队列,而是会静默地以大约 20fps 运行,差值部分被丢弃。

你可以通过在变换内部监控可读端队列来检测这一情况。TransformStreamDefaultController.desiredSize 反映了可读端的背压状态——当其变为负值时,说明可读端已超过高水位线,消费者处于落后状态:

const filter = new TransformStream({
  async transform(frame, controller) {
    try {
      if (controller.desiredSize !== null && controller.desiredSize < 0) {
        // 消费者落后,主动丢弃当前帧,
        // 避免进一步落后。
        return;
      }
      // ...滤镜处理逻辑...
      controller.enqueue(newFrame);
    } finally {
      frame.close();
    }
  },
});

检测到背压后,你有两种调节手段。一是主动丢帧——如上所示跳过当前帧,以有意为之的丢帧节奏取代静默的随机丢帧。二是减少输入:通过 MediaTrackConstraintsgetUserMedia 请求更低的分辨率或帧率,或在运行时调用 track.applyConstraints() 动态降档。降低分辨率可直接减少每帧的像素处理量,通常是解决 CPU 密集型滤镜问题最有效的手段。

Worker:为何要在单一 Worker 中完成所有 VideoFrame 操作

所有 VideoFrame 操作应在同一个 Worker 中完成。当 VideoFrame 通过 postMessage 跨 Worker 边界传输时,发送方的引用会自动关闭,任何再次读取或关闭它的尝试都会抛出异常——这是一种几乎无法通过跨 Worker 消息队列进行调试的静默数据竞争。在已传输流中的帧会被序列化(即克隆),两端都需要显式关闭。混用两种方式会导致提前关闭的故障:

controller.enqueue(frame);
frame.close(); // 过早关闭——enqueue 是异步的,帧可能仍在传输中

由于 controller.enqueue() 相对于消费 Worker 是异步的,过早关闭发送方引用会导致序列化失败;而从不关闭则会引发前文所述的泄漏-冻结问题。将整个 MediaStreamTrackProcessorTransformStreamVideoTrackGenerator 链保持在同一个 Worker 中,可以完全避免所有权问题。(关于如何将编码块传输到设备外部——WebTransport、数据通道——请参阅 webrtcHacks 管道系列,这是一个独立的话题。)

当你确实需要将帧传递给 Worker 时——是为了向管道提供数据,而非拆分帧——请显式传输,并在发送方停止对其的一切访问:

// 主线程
worker.postMessage({ frame }, { transfer: [frame] });
// `frame` 在此处已被转移。不要在主线程上再读取或关闭它。

传输完成后,接收方 Worker 拥有该帧的所有权,并负责关闭它。发送方线程必须将自己的引用视为已失效。

用于传输或录制的编码

VideoEncoder 将原始 VideoFrame 对象压缩为 EncodedVideoChunk 对象,并通过输出回调传递,用于录制或传输。配置时需指定编解码器字符串、尺寸、码率和帧率:

const chunks = [];
const encoder = new VideoEncoder({
  output: (chunk, metadata) => {
    // chunk.type 为 'key' 或 'delta';chunk 包含 timestamp、duration、byteLength
    chunks.push(chunk);
  },
  error: (e) => console.error('encoder error', e),
});

encoder.configure({
  codec: 'vp8',          // 或如 'avc1.42001f' 表示 H.264 基准档
  width: 640,
  height: 480,
  bitrate: 1_000_000,
  framerate: 30,
});

output 回调提供一个 EncodedVideoChunk 及可选的元数据;该块携带其 type'key''delta')、timestampduration 以及编码后的字节数据。关于编解码器字符串,请参阅 WebCodecs 编解码器注册表MDN 编解码器指南,而不要凭猜测填写 AVC profile 字符串。

在需要帧内帧时(例如流开始时、跳转后或恢复点处),使用 encoder.encode(frame, { keyFrame: true })(注意 F 大写)请求关键帧——将每一帧都编码为关键帧会完全破坏帧间压缩,显著增加码率。该选项的拼写方式记录在 MDN 的 WebCodecs API 使用指南中。

从关闭的编码器中恢复

VideoEncoder 的错误回调触发且编码器转入 'closed' 状态时,该实例将无法再被复用。VideoEncoder.reset() 适用于非终止性场景,但从已关闭的编码器中恢复意味着需要构造一个新实例,并使用相同参数重新调用 configure()。在每次 encode() 调用前检查状态,并在关闭时重建编码器:

function encodeFrame(frame, keyFrame = false) {
  if (encoder.state === 'closed') {
    encoder = makeEncoder();   // 构造并配置一个新的 VideoEncoder
  }
  if (encoder.state === 'configured') {
    encoder.encode(frame, { keyFrame });
  }
}

encode() 前加入状态检查和重建逻辑,是保证长时间运行的会话能够在瞬时编解码器错误后继续运行的关键。

2026 年的浏览器支持情况

WebCodecs 的支持情况因接口而异,将其视为单一版本号是许多过时教程的常见错误。核心接口 VideoEncoder/VideoFrame 已得到广泛支持;而 Insertable Streams 相关接口——MediaStreamTrackProcessorVideoTrackGenerator——则遵循不同且更为滞后的时间线。

接口Chrome / EdgeFirefoxSafari
VideoEncoder / VideoFrame(核心 WebCodecs)94+130+16.4+
MediaStreamTrackProcessor94+不支持18+
VideoTrackGenerator不支持不支持18+
MediaStreamTrackGenerator(非标准)94+不支持不支持

数据来源:MDN 关于 VideoEncoder 的浏览器兼容性数据MediaStreamTrackProcessor。大多数教程中”Safari 不支持 WebCodecs”的笼统说法既已过时,也不够准确:Safari 自 16.4 起已支持核心 WebCodecs,并在 Safari 17.4 中扩展了编解码器支持(包括 HEVC)。Safari 和 Firefox 目前缺少的是 Insertable Streams 的采集/输出层——因此,上述摄像头 → 滤镜 → 显示管道目前可在 Chromium 中运行,在 Safari 18+ 中于专用 Worker 内运行,但在 Firefox 中,你只能编解码帧,需通过其他方式获取帧源。

实践建议:按接口进行特性检测,而非按浏览器。分别检查 window.MediaStreamTrackProcessorwindow.VideoEncoder,并在 Insertable Streams 相关接口缺失时,为采集层提供基于 Canvas/requestVideoFrameCallback 的降级方案。

调试检查清单

WebCodecs 管道中的三种故障模式——丢帧、内存失控和延迟飙升——各有其独特的症状和直接的诊断步骤。

症状可能原因诊断步骤
视频逐渐卡顿后冻结,页面其余部分正常响应某路径上缺少 frame.close() → 有限媒体资源耗尽审查每个被读取或构造的 VideoFrame,确保恰好调用一次 close();确认 try/finally 覆盖完整
帧丢失,控制台无报错变换处理过慢;处理器静默丢弃最旧的帧在变换内部记录 controller.desiredSize;若持续为负,说明消费者落后
延迟随时间持续增长每帧滤镜处理过慢,耗尽帧预算测量每步耗时;与帧率预算对比(30fps 下为 33ms)
编码器停止输出数据块VideoEncoder 在错误后进入 'closed' 状态在每次 encode() 前检查 encoder.state;在 'closed' 时重建编码器

卡顿-冻结的症状特征值得重点关注。基于 WebCodecs 功能的会话回放中,这一模式会稳定地呈现出来:视频先正常播放,随后开始出现明显的丢帧,最终完全冻结,而 UI 的其余部分仍保持交互响应。这正是未关闭帧导致有限媒体资源耗尽的可见特征——回放清晰地展示了症状,但若不知道要在管道代码中寻找未关闭的帧,原因将难以察觉。

要测量真实的端到端延迟——从摄像头采集到显示——可在管道前将帧的 timestamp 编码为像素叠加层,并通过 requestVideoFrameCallback 从渲染输出中解码。作为参考基准,webrtcHacks 管道基准测试(2023 年 3 月)报告了以下各步骤的每帧耗时:

步骤耗时
背景移除22ms
叠加层添加1ms
编码8ms
解码1ms
显示38ms

实际数值因硬件和滤镜复杂度而异。值得注意的是,显示环节本身就占约 38ms——是耗时最长的部分——这意味着一个在 30fps 预算内运行良好的滤镜,如果不考虑显示尾延迟,仍然可能让用户感到明显的卡顿。请测量整条路径,而不仅仅是你的变换步骤。

结语

WebCodecs 管道的结构——MediaStreamTrackProcessorTransformStreamVideoTrackGenerator——简洁到可以用一个代码块完整呈现,但演示代码与可交付产品之间的差距,完全在于对故障模式的处理:关闭每一个帧、检测静默背压、将整个链保持在同一个 Worker 中、从关闭的编码器中恢复,以及按接口而非按浏览器进行特性检测。从本文开头的 try/finally 示例出发,加入 desiredSize 检查和编码器状态守卫,你就拥有了一条能够应对”正常路径”教程从未触及的边界情况的健壮管道。

常见问题

在 WebCodecs 滤镜中,何时应使用 Canvas2D,何时应使用 WebGL 或 WebGPU?

对于色彩变换和简单合成,使用 Canvas2D,此时 ctx.filter 字符串或适量的 getImageData 循环可以满足帧预算需求。当逐像素处理开销占主导时,应使用 WebGL 或 WebGPU,因为它们通过 importExternalTexture 将帧保留在 GPU 上,避免了 getImageData 所强制的 CPU 读回。在全分辨率下,读回通常是性能瓶颈,因此对于色度键控等繁重的逐像素处理,GPU 路径是正确的解决方案。

为什么我的 VideoFrame 在跨 Worker 传递时会意外关闭?

通过 postMessage 跨 Worker 边界传输 VideoFrame 时,发送方的引用会自动关闭,因此发送方再次读取或关闭它都会抛出异常。这与已传输流中的帧不同——后者会被序列化和克隆,两端都需要显式关闭。为避免数据竞争,应将整个管道保持在同一个 Worker 中;若确实需要传输,传输完成后应将发送方的引用视为已失效,由接收方 Worker 负责拥有和关闭该帧。

摄像头-滤镜-显示管道在 Firefox 中能正常运行吗?

不能完全运行。Firefox 130 及更高版本支持核心接口 VideoEncoder 和 VideoFrame,但不支持 Insertable Streams 的采集和输出层,即 MediaStreamTrackProcessor 和 VideoTrackGenerator 不可用。在 Firefox 中你可以编解码帧,但必须通过其他方式获取帧源,例如结合 requestVideoFrameCallback 使用 Canvas。建议通过分别检查 window.MediaStreamTrackProcessor 和 window.VideoEncoder 来按接口进行特性检测,而非检测浏览器本身。

VideoEncoder.reset() 与重建编码器有什么区别?

VideoEncoder.reset() 适用于非终止性场景,可清除仍可使用的编码器上的待处理任务。它无法恢复因错误触发而转入 closed 状态的编码器,因为已关闭的编码器无法被重新配置或复用。从 closed 状态恢复意味着需要构造一个新的 VideoEncoder 实例,并使用相同参数重新调用 configure()。应在每次 encode() 前检查 encoder.state,并在其为 closed 时重建编码器。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.