视频插帧
在 Canvas 上"画视频"看起来只是 ctx.drawImage(video, 0, 0) 一行代码,但要做到帧级精确同步、实时滤镜、补帧到高刷新率、慢动作回放,就要从视频解码原理开始重新理解。本篇从底到顶拆一遍。
一、为什么视频和 Canvas 这么难同步?
<video> 元素不是 Canvas 那种"立刻可得"的位图。它的数据流是:
MP4/WebM 文件
│
▼
视频解码器(硬件 / 软件)
│
▼
解码后的 YUV/I420 帧(GPU 纹理或 CPU buffer)
│
▼
浏览器合成器显示到屏幕
│
▼
你的 JS 代码(通过 drawImage 把视频"画"到 Canvas)
核心难点:异步 + 多线程
- 解码在另一个线程:视频解码由浏览器内部线程(甚至独立硬解码器)完成,JS 拿不到精确的"这一帧已就绪"信号。
- 屏幕刷新由 GPU 决定:视频最终是被 GPU 合成上去的,JS 里的
setTimeout(rAF)不是 GPU 真实刷新的时刻。- 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 + drawImage rVFC + 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)核心都是光流法:
- 计算两帧之间的逐像素运动向量(光流场)
- 假设中间时刻 t 时光流已经"走了一半"
- 用光流场把两帧的像素扭曲(Warp)到中间时刻
- 融合扭曲后的两帧
帧 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"高效得多。
抽帧性能优化
- 均匀分布 vs 关键帧分布:均匀抽会错过"有内容的瞬间"。
MediaSource+WebCodecs可以解析 MP4 拿到关键帧位置,精准抽帧。- 缩略图不必全分辨率:用
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 面试追问。