一、黑帧现象
在 Web 开发中,我们经常需要从视频中截取封面图。典型实现流程如下:
js
const video = document.createElement('video');
video.src = 'video.mp4';
video.currentTime = 1; // 定位到第1秒
video.addEventListener('seeked', () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
// 获取封面图...
});
然而,有时截取到的图像却是全黑画面,尽管在页面上播放时该时间点有正常画面,并且不管你使用 seek,onloadmeta,还是 canplay 等等,该现象依然会偶现。
二、视频编码 GOP
要理解为什么视频会出现黑帧,必须了解视频编码的基本原理:GOP。
GOP(Group of Pictures)代表一组连续的帧序列,该序列中有三种类型的帧:
- I 帧:完整保存画面信息的全图像帧,可独立解码
- P 帧:只存储与前帧差异的帧,依赖其他帧才能解码,属于预测帧
- B 帧:存储前后差异的帧,也属于预测帧。
GOP在编码的时候可以指定一个GOP的长度,比如下面就是一个长度为8的:

一个GOP的结构是:以 I 帧为开头,B 帧和 P 帧为后续填充,举个直观的例子:
- I 帧:你拍了一张全景照片
- P 帧:你记录了"人物往左移动5像素"
- B 帧:你记录了"人物正从左边走向右边的位置"
所以 I -> P -> B 的序列不是视觉上一帧一帧的画面,而是只保存了变化数据,需要参考帧+变化信息来重建真实画面,如果记录的信息如下:
text
I → P → B → B → P → I
t=0 t=1 t=2 t=3 t=4 t=5
那么你在 t=2 看到的画面,就是根据 t=1 的 P 帧和 t=4 的 P 帧推测重建出来的,而 t=1 也是 P 帧,所以 t=1 要先还原 t=0 的画面,而 t=4 也只能参考 t=0 的 I帧,因为 P 帧和 B 帧只能参考当前GOP范围内的帧。
⚠️ 注意:视频总帧数不等于 GOP 的长度。
视频的总帧数 = 帧率 x 时长,一个 10 秒的视频,帧率为30fps,总帧数就是 300 帧,假设 GOP 长度为 30,那么:
每 30 帧为一组(1 个 GOP),一共有 10 个 GOP。
text
帧编号: 0 1 2 3 4 ... 29 30 31 ... 59 60 ...
帧类型: I B B P B ... B I B ... B I ...
GOP编号: [GOP1] [GOP2] [GOP3]
以下图为例,多个画面展示的情况下,可能的GOP排列就是:

那么问题来了,如果截图截到了 P 帧或者 B 帧,而画面渲染需要找到对应的 I 帧 + 差异数据,而同时 canvas 做了绘制操作,黑帧是不是就出现了!
三、问题剖析和解决方案
当进入 video 的加载回调函数后,渲染其实就已经在找 I 帧并且计算渲染绘制了,但是目标真可能尚未完成解码,这个时候去使用 canvas 来 drawImage,获取的就是中间的解码状态,也就是黑帧。
其本质就是浏览器的 video 标签是用 GPU / Decode 直接渲染到画布上,不一定会把完整图像放到 CPU 供 canvas.drawImage() 使用,特别是在 GPU 解码路径中,帧画面在显存里,而不是在 CPU 能读取的位置,截图失败就会是黑的或者未渲染。
一句话:浏览器能播放不等于浏览器能把当前帧绘制到 canvas。

当然,这只是其中一种黑帧的原因,我们来整理一下 seek 成功但是黑帧的场景有哪些:
-
seek 到了 P / B 帧,但相关 I 帧还没来得及 decode
-
浏览器优化策略导致 readyState = 4,但是画面内容没有绘制出来
-
视频源使用了较长的 GOP,导致需要解析很多帧才能还原
-
网络慢,视频的缓冲流还没到那一帧
-
视频用了高级编码 HEVC / VP9,解码器延迟高
-
...
当然,最好的解决方式一定是在服务端做视频的解析(ffmpeg等),并且能和OSS高效配合的情况下(不用服务端再下载一次),而在纯前端能做的也只是如何把问题出现的比例降低,现在我们来针对不同的问题给出不同的解答方式:
- 通过读取 canvas 像素的方式来判定当前是否是黑帧
js
function isValidFrame(ctx, width, height) {
const imageData = ctx.getImageData(0, 0, width, height);
const data = imageData.data;
// 1. 检查纯黑帧
let darkPixels = 0;
// 2. 检查纯白帧
let lightPixels = 0;
const darkThreshold = 20; // RGB值小于20视为暗色
const lightThreshold = 235; // RGB值大于235视为亮色
// 采样检测(每4个像素检测1个)
for (let i = 0; i < data.length; i += 16) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// 纯黑检测
if (r < darkThreshold && g < darkThreshold && b < darkThreshold) {
darkPixels++;
}
// 纯白检测
else if (r > lightThreshold && g > lightThreshold && b > lightThreshold) {
lightPixels++;
}
}
const totalSampled = data.length / 16;
const darkRatio = darkPixels / totalSampled;
const lightRatio = lightPixels / totalSampled;
// 如果超过90%像素是纯黑或纯白,则认为无效
return darkRatio < 0.9 && lightRatio < 0.9;
}
- 引入重复截图重试机制(尝试3次)
js
const captureTimes = [0.1, 0.5, 1, 2];
let coverUrl = '';
let lastValidBlackBlob: Blob | null = null;
for (const time of captureTimes) {
try {
await seekToTime(video, time);
if (!ctx) throw new Error('Canvas context lost');
ctx.drawImage(video, 0, 0);
let blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/jpeg', 0.8),
);
if (!blob) {
const dataUrl = canvas.toDataURL('image/jpeg', 0.8);
blob = await (await fetch(dataUrl)).blob();
}
if (!blob) continue;
if (isValidFrame(ctx, canvas.width, canvas.height)) {
console.warn(`Black frame at ${time}s`);
lastValidBlackBlob = blob;
continue;
}
const file = new File([blob], 'cover.jpg', { type: 'image/jpeg' });
coverUrl = await uploadFile(file);
break;
} catch (err) {
console.warn(`Seek/capture failed at ${time}s`, err);
continue;
}
}
- Seek 函数可以用多个requestAnimationFrame 或者setTimeout来尽量让当前帧进入渲染管线。
js
const seekToTime = async (video: HTMLVideoElement, time: number): Promise<void> => {
video.currentTime = time;
await new Promise<void>((resolve, reject) => {
let attempts = 0;
const maxAttempts = 5;
const onError = () => {
cleanup();
reject(new Error(`Video error during seek to ${time}`));
};
const checkReady = () => {
if (video.readyState >= 3) {
cleanup();
resolve();
} else if (++attempts > maxAttempts) {
cleanup();
reject(new Error(`Frame not ready at ${time}`));
} else {
setTimeout(checkReady, 100);
}
};
const cleanup = () => {
video.removeEventListener('seeked', checkReady);
video.removeEventListener('error', onError);
};
video.addEventListener('seeked', checkReady, { once: true });
video.addEventListener('error', onError, { once: true });
});
};
- 使用 offscreenCanvas + WebCodecs,但是它有一定的兼容性问题,本质上和
<video> + canvas.drawImage()
差别不是很大,只是你可以有跳帧操作的能力了。 - 使用 video.play(),等50毫秒再video.pause(),此时再截帧(经验兜底而已)。