跳到主要内容

画面帧

业务场景:​

  • 1、视频封面:从视频文件中提取画面帧,从所有画面帧中选一个作为视频封面
    • (全帧提取):可一次性或按间隔遍历视频时长,将每一帧绘制到 <canvas> 上,并导出为图片数据
    • (按秒数提取):直接将视频当前播放时间跳转到目标秒数,等待 seeked 事件后捕获该帧;

实现逻辑​

  • 通过 <video> 元素的 currentTime 属性和 seeked 事件来按需跳转并捕获画面

    • seeked事件:表示音视频移动新的播放位置会触发事件
    • 当设置了currentTime, 就会进入seeking状态,当视频内部的渲染帧完成时,就会触发seeked事件,表示可以拿到当前视频的画面帧
  • 将视频绘制到 <canvas> 上,再通过 drawImage() 导出图片数据

  • 获取视频的画面帧是异步的,因为获取视频的内容是需要一定的时间(如浏览器对视频的解析解码的过程),因此需要用promise来对异步的处理,当完成视频画面帧时,就resolve

获取画面帧​

js
// 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: 时,图片加载本身不走网络请求