画面帧
业务场景:
- 1、视频封面:从视频文件中提取画面帧,从所有画面帧中选一个作为视频封面
- (全帧提取):可一次性或按间隔遍历视频时长,将每一帧绘制到
<canvas>上,并导出为图片数据 - (按秒数提取):直接将视频当前播放时间跳转到目标秒数,等待
seeked事件后捕获该帧;
- (全帧提取):可一次性或按间隔遍历视频时长,将每一帧绘制到
实现逻辑
-
通过
<video>元素的currentTime属性和seeked事件来按需跳转并捕获画面- seeked事件:表示音视频移动新的播放位置会触发事件
- 当设置了
currentTime, 就会进入seeking状态,当视频内部的渲染帧完成时,就会触发seeked事件,表示可以拿到当前视频的画面帧
-
将视频绘制到
<canvas>上,再通过drawImage()导出图片数据 -
获取视频的画面帧是异步的,因为获取视频的内容是需要一定的时间(如浏览器对视频的解析解码的过程),因此需要用promise来对异步的处理,当完成视频画面帧时,就resolve
获取画面帧
// src/composables/useVideoFrame.ts
import { ref, onMounted, onUnmounted } from 'vue';
export function useVideoFrame() {
// 1. 关联视频元素
const videoRef = ref<HTMLVideoElement | null>(null); // 挂载 <video ref="videoRef" />
// 2. 状态与存储
const frames = ref<string[]>([]); // Base64 URL 列表
const isCapturing = ref(false); // 抓帧中状态
let videoCallbackId: number | null = null; // requestVideoFrameCallback ID
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); // 2D 绘图上下文
// 3. 方法:按间隔批量抓帧(秒级)
function captureAllFrames(interval = 1): Promise<string[]> {
return new Promise((resolve, reject) => {
const video = videoRef.value;
if (!video) return reject('Video element not found');
frames.value = [];
isCapturing.value = true;
const duration = video.duration; // 视频总时长(秒)
let current = 0;
function onSeeked() {
// 同步画布大小与视频原始分辨率
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0); // 绘制当前帧
frames.value.push(canvas.toDataURL('image/png'));
current += interval;
if (current <= duration) {
video.currentTime = current; // 触发下一个 seek
} else {
video.removeEventListener('seeked', onSeeked);
isCapturing.value = false;
resolve(frames.value);
}
}
video.addEventListener('seeked', onSeeked); // 监听 seek 完成
video.currentTime = current; // 启动第一次跳转,触发seeked事件
});
}
// 4. 方法:获取指定秒数的单帧封面
function getFrameAt(time: number): Promise<string> {
return new Promise((resolve, reject) => {
const video = videoRef.value;
if (!video) return reject('Video element not found');
function onSeeked() {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0);
const dataUrl = canvas.toDataURL('image/png');
video.removeEventListener('seeked', onSeeked);
resolve(dataUrl);
}
video.addEventListener('seeked', onSeeked);
video.currentTime = time; // 跳转到目标时间并在 onSeeked 时捕获
});
}
// 5. 方法:使用 requestVideoFrameCallback API(高效逐帧回调)
function captureWithRVFC() {
const video = videoRef.value;
if (!video) return;
frames.value = [];
isCapturing.value = true;
function frameCallback(now: number, metadata: VideoFrameRequestCallbackMetadata) {
// metadata.width/height 即当前帧分辨率
canvas.width = metadata.width;
canvas.height = metadata.height;
ctx.drawImage(video, 0, 0);
frames.value.push(canvas.toDataURL('image/png'));
videoCallbackId = video.requestVideoFrameCallback(frameCallback);
}
videoCallbackId = video.requestVideoFrameCallback(frameCallback);
}
// 6. 清理:组件卸载时取消回调
onUnmounted(() => {
if (videoCallbackId !== null && videoRef.value) {
videoRef.value.cancelVideoFrameCallback(videoCallbackId);
}
});
return {
videoRef,
frames,
isCapturing,
captureAllFrames,
getFrameAt,
captureWithRVFC,
};
}
前端提取视频画面帧异常问题
https://juejin.cn/post/7527616691197198355#heading-2
原因:
-
视频流本身的 GOP(关键帧间隔)较大,导致某些时间点 seek 后无法精确定位到目标帧,浏览器会停在最近的关键帧。
-
部分时间点 seek 后,浏览器不会立即解码出画面
-
有些帧需要更长时间缓冲
-
异常的根本原因:浏览器视频 seek、解码、缓冲机制的异步性和不确定性,导致部分时间点无法立即获取到有效画面帧。
解决方案
- 增加重试次数和间隔
- seek 后多重等待
- 抓帧前后多重校验
- 抓帧前后都检查 videoWidth、videoHeight 是否为 0,readyState 是否满足,确保抓到的是真实画面。
- 通过“多次重试 + 多重等待 + 校验”机制,最大程度保证每一帧都能被正确获取
重试技巧:
- 利用循环:每次循环内,遇到失败条件(如 readyState 不足、尺寸为 0、画面未解码、drawImage 抛错),就会进行continue,下一个循环来进行尝试绘制
- 只要 continue 被执行,for 循环就会进入下一轮,重新尝试抓帧
性能优化
使用canvas来渲染,避免使用img
-
减少大量
<img>/DOM 节点后,滚动和播放头移动更稳定,掉帧更少 -
页面加载更快,页面节点数量显著下降,初始布局和样式计算压力更小
-
浏览器重排/重绘开销更低,避免大量节点带来的样式的 style/layout负担
-
每一帧的img图片,会导致网络请求增加吗?
- src 是同一个 URL,通常只请求一次,后续走缓存/复用
- img src 为 data: 或 blob: 时,图片加载本身不走网络请求