一、黑帧现象
在 Web 开发中,我们经常需要从视频中截取封面图。典型实现流程如下:
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 的序列不是视觉上一帧一帧的画面,而是只保存了变化数据,需要参考帧+变化信息来重建真实画面,如果记录的信息如下:
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。
帧编号: 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 像素的方式来判定当前是否是黑帧
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次)
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 函数可以用 requestVideoAnimationFrame 来保证视频帧正常获取,非兼容情况下可以使用多个 requestAnimationFrame 或者setTimeout来尽量让当前帧进入渲染管线(但是要注意 requestVideoAnimationFrame 的调用时机,必须播放,或者有新的帧才会执行到它的回调函数)。
const seekToTime = async (video: HTMLVideoElement, time: number): Promise<void> => {
video.currentTime = time;
return new Promise<void>((resolve, reject) => {
let timeoutId: NodeJS.Timeout;
const onSeeked = async () => {
try {
clearTimeout(timeoutId);
video.removeEventListener('seeked', onSeeked);
video.removeEventListener('error', onError);
// 等待视频帧准备完成
await waitForFrame(video);
resolve();
} catch (err) {
reject(err);
}
};
const onError = () => {
clearTimeout(timeoutId);
video.removeEventListener('seeked', onSeeked);
video.removeEventListener('error', onError);
reject(new Error(`Video error during seek to ${time}`));
};
video.addEventListener('seeked', onSeeked, { once: true });
video.addEventListener('error', onError, { once: true });
// 超时处理
timeoutId = setTimeout(() => {
video.removeEventListener('seeked', onSeeked);
video.removeEventListener('error', onError);
reject(new Error(`Seek timeout at ${time}`));
}, 10000);
});
};
const requestVideoFrame = (
video: HTMLVideoElement,
callback: (now: number, metadata?: any) => void,
): number | null => {
if ('requestVideoFrameCallback' in video) {
return (video as any).requestVideoFrameCallback(callback);
}
// 降级到 requestAnimationFrame
return requestAnimationFrame(() => callback(performance.now()));
};
const cancelVideoFrame = (video: HTMLVideoElement, id: number): void => {
if ('cancelVideoFrameCallback' in video) {
(video as any).cancelVideoFrameCallback(id);
} else {
cancelAnimationFrame(id);
}
};
// 使用 requestVideoFrameCallback 等待帧准备
const waitForFrame = (video: HTMLVideoElement): Promise<void> => {
return new Promise((resolve, reject) => {
let frameId: number | null = null;
let timeoutId: NodeJS.Timeout;
const cleanup = () => {
if (frameId !== null) {
cancelVideoFrame(video, frameId);
}
if (timeoutId) {
clearTimeout(timeoutId);
}
};
const onFrame = (now: number, metadata?: any) => {
cleanup();
resolve();
};
const onTimeout = () => {
cleanup();
reject(new Error('Frame timeout'));
};
frameId = requestVideoFrame(video, onFrame);
timeoutId = setTimeout(onTimeout, 5000); // 5秒超时
});
};
- 使用 offscreenCanvas + WebCodecs,但是它有一定的兼容性问题,本质上和
<video> + canvas.drawImage()差别不是很大,只是你可以有跳帧操作的能力了。 - 使用 video.play(),等50毫秒再video.pause(),此时再截帧(经验兜底而已)。
下面给出一个比较完整的例子:
export const getVideoCoverUrl = async (source: string | File): Promise<string> => {
const video = document.createElement('video');
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
video.crossOrigin = 'anonymous';
video.src =
typeof videoSource === 'string' ? videoSource : URL.createObjectURL(videoSource);
const canvas = document.createElement('canvas');
let ctx = canvas.getContext('2d');
const cleanUp = () => {
video.remove();
if (typeof videoSource !== 'string') {
URL.revokeObjectURL(video.src);
}
canvas.width = 0;
canvas.height = 0;
ctx = null;
};
const isValidFrame = (ctx: CanvasRenderingContext2D, w: number, h: number): boolean => {
const frame = ctx.getImageData(0, 0, w, h);
const pixels = frame.data;
// 采样检测(最多检测1000个像素点)
const sampleSize = Math.min(1000, pixels.length / 4);
let darkPixels = 0; // 暗像素计数
let lightPixels = 0; // 亮像素计数
let colorVariance = 0; // 颜色方差(用于检测图像复杂度)
const darkThreshold = 10; // 暗像素阈值
const lightThreshold = 245; // 亮像素阈值
for (let i = 0; i < sampleSize; i++) {
const pixelIndex = Math.floor(Math.random() * (pixels.length / 4)) * 4;
const r = pixels[pixelIndex];
const g = pixels[pixelIndex + 1];
const b = pixels[pixelIndex + 2];
// 计算颜色方差(RGB与平均值的差异)
const avg = (r + g + b) / 3;
colorVariance += Math.abs(r - avg) + Math.abs(g - avg) + Math.abs(b - avg);
// 统计暗像素和亮像素
if (r < darkThreshold && g < darkThreshold && b < darkThreshold) {
darkPixels++;
} else if (r > lightThreshold && g > lightThreshold && b > lightThreshold) {
lightPixels++;
}
}
// 计算比例和平均方差
const avgVariance = colorVariance / sampleSize;
const darkRatio = darkPixels / sampleSize;
const lightRatio = lightPixels / sampleSize;
// 判断有效性:暗像素比例<98% AND 亮像素比例<98% AND 有足够的颜色变化
return darkRatio < 0.98 && lightRatio < 0.98 && avgVariance > 5;
};
const captureFrame = (): Promise<Blob | null> => {
return new Promise((resolve, reject) => {
let frameId: number | null = null;
let timeoutId: NodeJS.Timeout;
let startTime = Date.now();
const maxDuration = 5000;
const minCheckTime = 500;
const cleanup = () => {
if (frameId !== null) {
if ('cancelVideoFrameCallback' in video) {
(video as any).cancelVideoFrameCallback(frameId);
} else {
cancelAnimationFrame(frameId);
}
}
if (timeoutId) {
clearTimeout(timeoutId);
}
video.pause();
};
// 首先尝试截取静态的第一帧
const captureStaticFirstFrame = () => {
if (!ctx) {
return false;
}
try {
// 确保视频在第一帧位置
video.currentTime = 0;
// 等待一小段时间确保帧更新
setTimeout(() => {
// 检查画布上下文是否仍然有效
if (!ctx) {
startPlaybackCapture();
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(video, 0, 0);
// 检查是否为有效帧
if (isValidFrame(ctx, canvas.width, canvas.height)) {
cleanup();
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
return; // 修复:这里应该直接返回,不需要return true
}
// 如果第一帧是黑帧,则开始播放寻找有效帧
startPlaybackCapture();
}, 100);
return true;
} catch (error) {
console.warn('Static frame capture failed:', error);
return false;
}
};
// 播放模式的帧捕获
const startPlaybackCapture = () => {
let frameCheckCount = 0; // 添加帧检查计数器
const checkFrame = () => {
const elapsed = Date.now() - startTime;
const currentTime = video.currentTime;
frameCheckCount++;
if (!ctx) {
cleanup();
reject(new Error('Canvas context lost'));
return;
}
try {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(video, 0, 0);
if (isValidFrame(ctx, canvas.width, canvas.height)) {
console.log(
`🎉 找到有效帧!时间: ${currentTime.toFixed(2)}s, 总检测次数: ${frameCheckCount}, 总耗时: ${elapsed}ms`,
);
cleanup();
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
return;
}
if (elapsed >= maxDuration) {
console.warn(
`⏰ 检测超时(${elapsed}ms),使用当前帧 - 时间: ${currentTime.toFixed(2)}s, 检测次数: ${frameCheckCount}`,
);
cleanup();
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
return;
}
if (elapsed < minCheckTime) {
setTimeout(() => {
if ('requestVideoFrameCallback' in video) {
frameId = (video as any).requestVideoFrameCallback(checkFrame);
} else {
frameId = requestAnimationFrame(checkFrame);
}
}, 50);
} else {
if ('requestVideoFrameCallback' in video) {
frameId = (video as any).requestVideoFrameCallback(checkFrame);
} else {
frameId = requestAnimationFrame(checkFrame);
}
}
} catch (error) {
console.error('❌ 帧检测过程中出错:', error);
cleanup();
reject(error);
}
};
// 开始播放并截帧
video
.play()
.then(() => {
startTime = Date.now();
if ('requestVideoFrameCallback' in video) {
frameId = (video as any).requestVideoFrameCallback(checkFrame);
} else {
frameId = requestAnimationFrame(checkFrame);
}
timeoutId = setTimeout(() => {
console.warn(`⏰ 全局超时(${maxDuration + 1000}ms),强制使用当前帧`);
cleanup();
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(video, 0, 0);
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
} else {
reject(new Error('Capture timeout - no context'));
}
}, maxDuration + 1000);
})
.catch((playError) => {
console.warn('Video play failed, trying muted:', playError);
video.muted = true;
video
.play()
.then(() => {
startTime = Date.now();
if ('requestVideoFrameCallback' in video) {
frameId = (video as any).requestVideoFrameCallback(checkFrame);
} else {
frameId = requestAnimationFrame(checkFrame);
}
timeoutId = setTimeout(() => {
cleanup();
if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(video, 0, 0);
canvas.toBlob((blob) => resolve(blob), 'image/jpeg', 0.8);
} else {
reject(new Error('Capture timeout - no context'));
}
}, maxDuration + 1000);
})
.catch((mutedPlayError) => {
console.error('Muted play also failed:', mutedPlayError);
cleanup();
reject(new Error('Cannot play video'));
});
});
};
// 确保视频可以播放
if (video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) {
cleanup();
reject(new Error('Video not ready'));
return;
}
// 首先尝试截取静态第一帧
if (!captureStaticFirstFrame()) {
// 如果静态截取失败,直接开始播放模式
startPlaybackCapture();
}
});
};
try {
// 等待视频加载
await new Promise<void>((resolve, reject) => {
const onLoaded = () => {
video.removeEventListener('loadeddata', onLoaded);
video.removeEventListener('error', onError);
resolve();
};
const onError = () => {
video.removeEventListener('loadeddata', onLoaded);
video.removeEventListener('error', onError);
reject(new Error('Video load failed'));
};
video.addEventListener('loadeddata', onLoaded, { once: true });
video.addEventListener('error', onError, { once: true });
});
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// 直接截帧,不再 seek 到特定时间点
const blob = await captureFrame();
if (!blob) {
throw new Error('Failed to capture frame');
}
const file = new File([blob], 'cover.jpg', { type: 'image/jpeg' });
// 自行接入上传服务
const coverUrl = await uploadFile(file);
if (!coverUrl) {
throw new Error('Upload failed');
}
return coverUrl;
} catch (err) {
console.error('getVideoCoverUrl error:', err);
return '';
} finally {
cleanUp();
}
};