使用 requestVideoFrameCallback() 对视频执行高效的逐视频帧操作

了解如何使用 requestVideoFrameCallback() 在浏览器中更高效地处理视频。

HTMLVideoElement.requestVideoFrameCallback() 方法允许 Web 作者注册一个回调,该回调在新的视频帧发送到合成器时在渲染步骤中运行。这允许开发者对视频执行高效的逐视频帧操作,例如视频处理和绘制到画布、视频分析或与外部音频源同步。

与 requestAnimationFrame() 的区别

通过此 API 进行的,例如使用 drawImage() 将视频帧绘制到画布等操作,将尽最大努力与屏幕上播放的视频的帧率同步。与通常每秒触发约 60 次的 window.requestAnimationFrame() 不同,requestVideoFrameCallback() 绑定到实际的视频帧率,但有一个重要的例外

回调运行的有效速率是视频速率和浏览器速率之间的较小速率。这意味着在以 60Hz 绘制的浏览器中播放 25fps 视频将以 25Hz 触发回调。在同一 60Hz 浏览器中播放 120fps 视频将以 60Hz 触发回调。

名称的由来?

由于其与 window.requestAnimationFrame() 的相似性,该方法最初提议为 video.requestAnimationFrame(),并重命名为 requestVideoFrameCallback(),这是在长时间讨论后达成一致的。

功能检测

if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) {
  // The API is supported!
}

浏览器支持

浏览器支持

  • Chrome: 83.
  • Edge: 83.
  • Firefox: 132.
  • Safari: 15.4.

来源

Polyfill

基于 Window.requestAnimationFrame()HTMLVideoElement.getVideoPlaybackQuality()requestVideoFrameCallback() 方法的 polyfill 可用。在使用此 polyfill 之前,请注意 README 中提到的限制

使用 requestVideoFrameCallback() 方法

如果您曾经使用过 requestAnimationFrame() 方法,您会立即对 requestVideoFrameCallback() 方法感到熟悉。您注册一次初始回调,然后在每次回调触发时重新注册。

const doSomethingWithTheFrame = (now, metadata) => {
  // Do something with the frame.
  console.log(now, metadata);
  // Re-register the callback to be notified about the next frame.
  video.requestVideoFrameCallback(doSomethingWithTheFrame);
};
// Initially register the callback to be notified about the first frame.
video.requestVideoFrameCallback(doSomethingWithTheFrame);

在回调中,now 是一个 DOMHighResTimeStampmetadata 是一个 VideoFrameMetadata 字典,具有以下属性:

  • presentationTime,类型为 DOMHighResTimeStamp:用户代理提交帧以进行合成的时间。
  • expectedDisplayTime,类型为 DOMHighResTimeStamp:用户代理预计帧可见的时间。
  • width,类型为 unsigned long:视频帧的宽度,以媒体像素为单位。
  • height,类型为 unsigned long:视频帧的高度,以媒体像素为单位。
  • mediaTime,类型为 double:呈现的帧的媒体呈现时间戳 (PTS),以秒为单位(例如,其在 video.currentTime 时间线上的时间戳)。
  • presentedFrames,类型为 unsigned long:提交以进行合成的帧数的计数。允许客户端确定在 VideoFrameRequestCallback 的实例之间是否错过了帧。
  • processingDuration,类型为 double:从提交具有与此帧相同呈现时间戳 (PTS) 的编码数据包(例如,与 mediaTime 相同)到解码器,直到解码帧准备好呈现为止的经过时间,以秒为单位。

对于 WebRTC 应用程序,可能会出现其他属性:

  • captureTime,类型为 DOMHighResTimeStamp:对于来自本地或远程源的视频帧,这是相机捕获帧的时间。对于远程源,捕获时间是使用时钟同步和 RTCP 发送方报告估算的,以将 RTP 时间戳转换为捕获时间。
  • receiveTime,类型为 DOMHighResTimeStamp:对于来自远程源的视频帧,这是平台接收到编码帧的时间,即通过网络接收到属于此帧的最后一个数据包的时间。
  • rtpTimestamp,类型为 unsigned long:与此视频帧关联的 RTP 时间戳。

在此列表中,特别令人感兴趣的是 mediaTimemediaTime 直接由帧的 presentationTimestamp 填充,而 Chromium 的实现使用音频时钟作为支持 video.currentTime 的时间源。如果您想以可重现的方式准确识别帧,包括准确识别您错过的帧,则应使用 mediaTime

如果事情看起来偏离一帧…

垂直同步(或仅 vsync)是一种图形技术,可同步视频的帧率和显示器的刷新率。由于 requestVideoFrameCallback() 在主线程上运行,但实际上,视频合成发生在合成器线程上,因此来自此 API 的一切都是尽最大努力,并且浏览器不提供任何严格的保证。可能发生的情况是,API 可能比视频帧渲染的时间晚一个 vsync。通过 API 对网页所做的更改需要一个 vsync 才能显示在屏幕上(与 window.requestAnimationFrame() 相同)。因此,如果您不断更新网页上的 mediaTime 或帧号,并将其与编号的视频帧进行比较,最终视频看起来会超前一帧。

真正发生的情况是,帧在 vsync x 准备就绪,回调被触发,帧在 vsync x+1 渲染,回调中所做的更改在 vsync x+2 渲染。您可以通过检查 metadata.expectedDisplayTime 是否大约为 now 或未来一个 vsync 来检查回调是否晚一个 vsync(并且帧已在屏幕上渲染)。如果它在 now 的大约五到十微秒内,则帧已渲染;如果 expectedDisplayTime 大约在未来 16 毫秒(假设您的浏览器/屏幕以 60Hz 的频率刷新),那么您与帧同步。

演示

我创建了一个小的 Glitch 上的演示,展示了如何在画布上以视频的精确帧率绘制帧,以及在其中记录帧元数据以用于调试目的。

let paintCount = 0;
let startTime = 0.0;

const updateCanvas = (now, metadata) => {
  if (startTime === 0.0) {
    startTime = now;
  }

  ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

  const elapsed = (now - startTime) / 1000.0;
  const fps = (++paintCount / elapsed).toFixed(3);
  fpsInfo.innerText = `video fps: ${fps}`;
  metadataInfo.innerText = JSON.stringify(metadata, null, 2);

  video.requestVideoFrameCallback(updateCanvas);
};

video.requestVideoFrameCallback(updateCanvas);

结论

人们长期以来一直在进行帧级处理,但无法访问实际帧,仅基于 video.currentTimerequestVideoFrameCallback() 方法在此解决方法的基础上进行了极大的改进。

致谢

requestVideoFrameCallback API 由 Thomas Guilbert 规范和实现。这篇文章由 Joe MedleyKayce Basques 审阅。题图Denise Jans 在 Unsplash 上提供。