Skip to main content

视频插帧

在 Canvas 上"画视频"看起来只是 ctx.drawImage(video, 0, 0) 一行代码,但要做到帧级精确同步实时滤镜补帧到高刷新率慢动作回放,就要从视频解码原理开始重新理解。本篇从底到顶拆一遍。

一、为什么视频和 Canvas 这么难同步?

<video> 元素不是 Canvas 那种"立刻可得"的位图。它的数据流是:

   MP4/WebM 文件


视频解码器(硬件 / 软件)


解码后的 YUV/I420 帧(GPU 纹理或 CPU buffer)


浏览器合成器显示到屏幕


你的 JS 代码(通过 drawImage 把视频"画"到 Canvas)

核心难点:异步 + 多线程

  1. 解码在另一个线程:视频解码由浏览器内部线程(甚至独立硬解码器)完成,JS 拿不到精确的"这一帧已就绪"信号。
  2. 屏幕刷新由 GPU 决定:视频最终是被 GPU 合成上去的,JS 里的 setTimeout(rAF) 不是 GPU 真实刷新的时刻。
  3. P 帧 / B 帧的依赖关系:视频帧不是独立的图片,而是参考前后帧的差值。跳着取帧会失败。

结果就是:setInterval / requestAnimationFrame + video.currentTime 轮询,画面会抖、会有黑帧、会有"卡 1 秒"现象

二、第一性方案:<video> + requestVideoFrameCallback

requestVideoFrameCallback(简称 rVFC)是浏览器为这个难题专门设计的 API。当视频有新帧绘制到屏幕时回调你的 JS,回调参数里直接给你"这一帧的精确时间戳"。

video.requestVideoFrameCallback((now, metadata) => {
// now: 该帧被绘制到屏幕的精确时间(performance.now 时间轴)
// metadata.mediaTime: 该帧在视频流中的时间(秒)
// metadata.presentedFrames: 已呈现的帧序号
// metadata.expectedDisplayTime: 预期显示时间

ctx.drawImage(video, 0, 0);
video.requestVideoFrameCallback(arguments.callee); // 递归订阅下一帧
});

为什么 rVFC 比 rAF + drawImage 好?

维度rAF + drawImagerVFC + drawImage
帧时序屏幕准备刷新时回调视频帧真的画到屏幕时回调
与视频同步经常差 1 帧,画面"漂移"1:1 同步,无漂移
元数据直接拿到 mediaTime / presentedFrames
抗掉帧容易卡顿浏览器自动补齐回调

浏览器内部其实也是用 rVFC 来驱动 <video> 的渲染,我们只是订阅了它的"内部信号"

完整可运行的同步循环

async function playWithCanvas(video, canvas) {
const ctx = canvas.getContext("2d");
await video.play();

function tick(now, metadata) {
// 1. 把当前视频帧画到 Canvas
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

// 2. 执行业务逻辑(打时间戳、滤镜等)
drawTimestamp(ctx, metadata.mediaTime);

// 3. 订阅下一帧
video.requestVideoFrameCallback(tick);
}
video.requestVideoFrameCallback(tick);
}

踩坑点:视频要先 play() 才能 rVFC? 严格说不强制,paused 状态下也能拿到 metadata(用于抽帧预览)。但要让视频真正"动起来",必须先 play()如果你的业务是"在 Canvas 上合成视频 + 用户 UI",视频在另一个标签页/画中画播放也能正常 rVFC。

三、WebCodecs:现代解码方案

<video> 元素虽然方便,但有几个天花板:

  • 只能在主线程<video> 不能搬到 Worker 里)
  • 无法拿到原始 YUV 帧(drawImage 已经是 RGB 拷贝)
  • 无法精细控制解码时机(硬件解码器被浏览器独占调度)

WebCodecs API 直接把"视频解码器"暴露给 JS,绕开 <video> 元素

// 1. 创建解码器
const decoder = new VideoDecoder({
output: (frame) => {
// frame: VideoFrame 对象,封装了 GPU 纹理 + 元数据
// 可以直接画到 Canvas / OffscreenCanvas
ctx.drawImage(frame, 0, 0);
frame.close(); // 重要:必须手动释放,否则内存泄漏
},
error: (e) => console.error(e),
});

// 2. 配置解码参数
decoder.configure({
codec: "avc1.42E01E", // H.264 Baseline Level 3.0
codedWidth: 1920,
codedHeight: 1080,
});

// 3. 喂入压缩的视频数据(来自 fetch / WebSocket / 文件)
const chunk = new EncodedVideoChunk({
type: "key", // key frame / delta frame
timestamp: 0,
data: videoBuffer,
});
decoder.decode(chunk);

VideoFrame.close() 必须调

VideoFrame 内部封装了 GPU 资源,不会被 GC 自动释放(GPU 资源不在 JS 堆上)。忘记 close() 等于内存泄漏,1080p 60fps 视频 10 秒就会爆掉显存。

WebCodecs vs <video> 选型决策

  • 能用 <video> 就用 <video>:浏览器已经把硬件解码 + 音视频同步 + 字幕 + DRM 全部处理好了,不要重复造轮子。
  • 必须 WebCodecs 的场景
    • 视频流式处理:实时转码、AI 推理(逐帧跑模型)、流媒体推送
    • 极致性能:需要零拷贝拿到 GPU 纹理(比如 WebGL 直接消费 VideoFrame)
    • 自定义解码路径:从 WebSocket / WebTransport 拉流、特殊编码格式

四、补帧算法:从 30fps 到 60fps

补帧(Frame Interpolation)的本质:在两帧之间"猜"出中间帧

方案 1:帧混合(Frame Blending)—— 最简单

把两帧按透明度叠加。优点是 0 算法成本,缺点是运动物体会有重影

function blendFrames(ctx, video, alpha) {
ctx.globalAlpha = alpha;
ctx.drawImage(video, 0, 0);
ctx.globalAlpha = 1;
}

// 伪代码:在 30fps 视频的两次回调之间,插入一帧混合帧
// 假设 video 已经在 currentTime = 1.0,现在想渲染 1.0167 帧
// 思路:先 drawImage 画 1.0 帧,再画 0.0167 帧(用 video.currentTime 跳到 1.0167)
// 但 0.0167 帧会和 1.0 帧混合 → 重影

为什么帧混合有重影? 物体在 1.0→1.0167 之间移动了 5px,简单叠加会让 5px 范围内同时存在"老位置像素"和"新位置像素",视觉上是模糊的重影。仅在"画面几乎不动"的场景(比如慢镜头风景)才可接受。

方案 2:基于光流(Optical Flow)的运动补偿

工业级补帧(DAIN、RIFE、Adobe Frame Rate Converter)核心都是光流法

  1. 计算两帧之间的逐像素运动向量(光流场)
  2. 假设中间时刻 t 时光流已经"走了一半"
  3. 用光流场把两帧的像素扭曲(Warp)到中间时刻
  4. 融合扭曲后的两帧
  帧 t=0                 光流场                  帧 t=1
┌──────┐ ┌──────┐ ┌──────┐
│ ●→ │ │ →→→ │ │ ● │
│ │ 光流 │ │ warp │ │
│ ● │ ──────► │ ↓ │ ──────► │ ● │
└──────┘ └──────┘ └──────┘

中间帧 (t=0.5)
┌──────┐
│ ● ● │ ← 像素在中间位置
└──────┘

Canvas 端做光流? 纯 JS 光流的计算量极大(1080p 实时光流需要 ~50ms/帧),在 Canvas 2D 上不现实。常见方案:

  • 调 WebAssembly 库(如 OpenCV.js 提供的 calcOpticalFlowFarneback
  • 用 WebGL 跑简化光流算法(如 NVIDIA 提供的 NPP WebGL demo)
  • 调用云端 API(用深度学习模型如 RIFE / DAIN 在服务端补帧,前端只拉结果)

方案 3:Web 端"轻量"补帧的折中——重复帧

既然算不出光流,最简单的"补帧"就是把上一帧再画一次。30fps 视频在 60fps 屏幕上,每帧"播两遍",体感从 30fps 提升到 60fps。

听起来像偷懒,但对画面移动幅度小的视频(会议、直播、教学),效果意外地好。

let lastFrame = null;
function tick(now, metadata) {
if (lastFrame) {
// 用上一帧填充"中间帧"——视觉上是 60fps
ctx.drawImage(lastFrame, 0, 0);
}
// 等真实的新视频帧到了再画
ctx.drawImage(video, 0, 0);
lastFrame = captureCurrentFrame();
video.requestVideoFrameCallback(tick);
}

什么时候用重复帧?什么时候真做补帧?

场景推荐方案
会议、直播、教学(人物动作小)重复帧 + 简单混合
体育、舞蹈(运动剧烈)必须真光流补帧,否则重影明显
慢动作回放抽帧 + 混合 + AI 补帧
视频转码(云端)服务端用 RIFE / DAIN

五、抽帧与时间轴控制

另一个常见需求:把视频抽成关键帧序列(用于封面选择、缩略图条、AI 训练数据)。

async function extractFrames(video, count = 10) {
const frames = [];
const duration = video.duration;
for (let i = 0; i < count; i++) {
const t = (i / (count - 1)) * duration;
await seek(video, t);
// 注意:seek 后要等视频"准备好"才能画
const bitmap = await createImageBitmap(video);
frames.push(bitmap);
}
return frames;
}

function seek(video, time) {
return new Promise((resolve) => {
video.currentTime = time;
video.addEventListener("seeked", resolve, { once: true });
});
}

为什么用 createImageBitmap 而不是 drawImage 到 canvas? ImageBitmap 是 GPU 端的位图句柄,可以被 Worker、OffscreenCanvas、createPattern 自由使用,不绑定到具体 canvas 上下文。比"把帧画到一个 hidden canvas 再读 ImageData"高效得多。

抽帧性能优化

  1. 均匀分布 vs 关键帧分布:均匀抽会错过"有内容的瞬间"。MediaSource + WebCodecs 可以解析 MP4 拿到关键帧位置,精准抽帧。
  2. 缩略图不必全分辨率:用 createImageBitmap(video, { resizeWidth, resizeHeight }) 直接抽小图,省内存。

六、实战:Canvas 视频播放器骨架

把上面所有点串起来:

class CanvasVideoPlayer {
constructor(canvas, src) {
this.canvas = canvas;
this.ctx = canvas.getContext("2d", { alpha: false });
this.video = document.createElement("video");
this.video.src = src;
this.video.crossOrigin = "anonymous";
this.video.muted = true; // autoplay 需要静音
this.video.playsInline = true;
}

async play() {
await this.video.play();
this.video.requestVideoFrameCallback(this._tick);
}

_tick = (now, metadata) => {
// 1. 同步视频到 Canvas
this.ctx.drawImage(this.video, 0, 0, this.canvas.width, this.canvas.height);

// 2. 叠加 HUD(用 metadata 拿精确时间)
this._drawHud(metadata.mediaTime, metadata.presentedFrames);

// 3. 订阅下一帧
this.video.requestVideoFrameCallback(this._tick);
};

_drawHud(mediaTime, frameCount) {
this.ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
this.ctx.fillRect(8, 8, 200, 40);
this.ctx.fillStyle = "#fff";
this.ctx.font = "14px monospace";
this.ctx.fillText(`t=${mediaTime.toFixed(3)}s f=${frameCount}`, 16, 32);
}
}

// 用法
const player = new CanvasVideoPlayer(canvasEl, "demo.mp4");
player.play();

七、面试高频追问

Q:video.currentTime = 5 之后立即 drawImage 为什么经常是黑帧?

currentTime 是异步操作,赋值后浏览器要重新 seek、解码、出帧。drawImage 是在当前的解码结果上画,seek 完之前画的是旧的或空的帧。必须用 seeked 事件或 rVFC 来同步。

Q:rAF + drawImage 在很多 demo 里也能用,为什么不推荐?

表面上能跑,实际上 rAF 的回调时机和视频帧的呈现时机不是同一回事。视频帧在 GPU 渲染管线的某个阶段呈现,rAF 回调在合成前,两者之间有微小错位。长时间运行下来,画面会"漂移"(声音对不上嘴型、滚动条抖动)。rVFC 是从根源上解决。

Q:WebCodecs 兼容性如何?

Chrome / Edge / Safari / Firefox 都已支持。国内中低端 Android 浏览器可能没跟上,生产环境建议特性检测:

if ("VideoDecoder" in window) {
// 用 WebCodecs
} else {
// 降级到 <video> + rVFC
}

Q:补帧的延迟(latency)能到多少?

  • 重复帧方案:~16ms(1 帧)
  • 简单帧混合:~32ms(2 帧)
  • 光流法:~50~100ms(取决于算法)
  • AI 补帧(RIFE):~200~500ms

直播连麦、视频会议不能用高延迟方案——延迟 200ms 用户就开始说话了,体验崩。游戏 / 视频编辑可以接受高延迟

小结

视频与 Canvas 的协作链路:

   解码源选择:  <video> (简单)  vs  WebCodecs (高性能)


帧同步机制: rVFC (精确) > rAF + drawImage (有漂移)


帧消费: drawImage / VideoFrame / createImageBitmap


可选: 补帧算法 (重复帧 / 混合 / 光流 / AI)


输出: Canvas 像素 → 业务逻辑(滤镜、HUD、AI 推理)

掌握 rVFC + VideoFrame.close 两条核心规则,足够应对 90% 的视频×Canvas 面试追问。