纯前端抽帧实现
背景
最近在做业务的时候遇到了一个需要前端抽帧的需求,小小的研究了一下便记录了下来,以便复盘。 大概的需求呢就是说从页面A跳转到页面B后给你视频的url和一些时间节点,我们要抽取这个视频在这些时间节点的那一帧的图像,并且渲染出来。
// 也就是说我们要实现这么一个函数 给你下面这样一个数据结构,返回一个对应时间节点的图像url
type Parms = {
url:string;
startTime:number[];
}
function getFrames(parmas:Parmas):string[]{
}
用来干啥呢?简单的说我们是在做把一个长视频拆成多个短视频的事,大概就像下面这样,但是前端直接拆一个视频耗时太长,没办法能给用户实时看到拆出来的效果。所以选择软拆的策略,软拆也就是只进行逻辑上的拆分,提供需要拆分的视频片段的开始时间和截止时间,再抽取每个视频片段的首帧,播放的时候播放器播放对应时间节点的视频片段,真正拆分的工作让后台慢慢去做。另外本文只讲抽帧,怎么播放放在下一篇文章来讲吧。
解决方案
目前我调研到主要的解决方案有两种:1、video + canvas 2、ffmpeg + webassembly
优劣对比
- video + canvas
优势:video元素的属性做跳帧的操作,并利用canvas把当前帧绘制成blob图像,整个流程只涉及前端,简单易上手。
劣势:存在部分格式视频的兼容问题,属于浏览器的限制没法解决;对于长视频(1小时以上)会有卡顿现象。
- ffmpeg + webassembly
优势:不存在格式问题
劣势:需要wasm,ffmpeg相关知识储备,有一定上手难度
业务限制了上传视频格式只能是mp4或者mov,所以我们这里选择方案一;关于第二种方案可以参考下这篇文章:基于 ffmpeg+Webassembly 实现视频帧提取
一些优化点
-
准备低分辨率视频
抽帧这事儿本身是为了让用户得到实时的体验,并不是最后视频拆分的结果,所以这里视频的分辨率要求不高,甚至可以说一定得低,经测试400多M的视频分辨率降至480P后只有67M,因为视频越大抽帧越慢,这个应该比较容易想到。我们在业务中是用的480P的视频,这里选择一个基本能看清楚的分辨率就好了。
-
预抽帧
这里预加载并不是有啥难度的事,仅仅是字面意思:在用户没有感知的情况下偷偷做抽帧的行为,用户点开后,直接展示已经抽好了的帧,剩下没有抽完的接着抽。那么问题来了,用户没有感知是一件很微妙的事,不同的业务都可能有所不同,总之是在用户做查看抽帧的图像这个行为之前都可以算作在预加载。
-
图片填充
这里还有一个问题需要考虑,在抽帧没有完成前应该要为那些所有图像预留他们的位置,一个是防止滚动条不停的抖动;还有多少也能减少一点重排吧,这里是图片懒加载常用到的优化手段,大家应该蛮熟悉了。
抽帧具体实现
-
抽帧原理
抽视频片段首帧实际上我们在干什么呢?我们要做的事就相当于打开一个播放器,把进度条拉到你需要截取的那一帧然后暂停,截图保存下来,重复以上操作;不过这里我们用代码来代替了这样的动作。这里其实也解释了为啥我们需要更小的视频,拉动 进度条 也就是改变video.currentTime时浏览器的视频解码器会搜索最接近指定时间位置的关键帧,分辨率越小视频存储的信息量也就越小,搜索也就越快。
-
初始化
let video = document.createElement('video'); let canvas = document.createElement('canvas'); let ctx = canvas.getContext('2d'); video.setAttribute('crossOrigin','Anonymous');//对此元素的 CORS 请求将不设置凭据标志。 video.src = 'xxxx'; video.onloadeddata = async()=>{ xxx }
loadeddata事件在媒体当前播放位置的视频帧(通常是第一帧)加载完成后触发。
-
抽帧
video.onloadeddata = async () => {
video.currentTime = 2; // 相当于把进度条拉到2s
let { videoWidth, videoHeight } = video;
await new Promise((resolve, reject) => {
video.onseeked = () => {
resolve("seek resolve!"); //拉动进度条会触发seeked事件
};
});
canvas.width = videoWidth; //将画布的宽高和video的统一,否则会截不全
canvas.height = videoHeight;
await new Promise((resolve, reject) => {
ctx.drawImage(video, 0, 0, videoWidth, videoHeight);
canvas.toBlob((blob) => { //方法名说明了一切 toBlob
let url = URL.createObjectURL(blob); // 获取可以使用的url
console.log("url", url);
resolve(void 0);
});
});
};
//记得释放
video = null;
canvas = null;
这里我们以抽第二秒的帧为例,大家可以找一个视频试试,看看打印出来的url是否符合预期,关于createObjectURL这个函数需要注意一下他产生的URL是绑定于document的生命周期的,也就是说刷新会掉。。。
正好我们还有用户返回到这个页面刷新保持数据的需求,所以这里还需要在刷新的时候校验图片可用性以及生成新的图片也就是重新抽帧。这里我们简单说说如何校验一张 "blob:http://xxx" 格式的图片是否可用吧。
-
校验
分两步,首先校验url是否是blob开头,第二步,创建一个img标签,把url作为src属性给他添加上看他触发的是onload事件还是onerror事件。
async function checkImgAvailble(url) { const isAvailble = async() => { return new Promise((s, r) => { let img = new Image(); img.src = url; img.onload = () => { s(true); img = null; }; img.onerror = () => { s(false); img = null; }; }); }; return /^blob:/.test(url) && (await isAvailble()); }
待尝试
在解决长视频抽帧速度慢的问题时,发现一种修改关键帧间距的办法来优化;这个问题的关键是因为浏览器搜索关键帧的时间,如果关键帧间隔过长会导致搜索时间变长,而我们这里又是一个频繁搜索的场景,所以我们可以通过降低关键帧间距来达到减少搜索时间的目的;具体来说可以通过FFMPEGS -g标志来控制关键帧间距,详情见:# 配置用于寻求性能的视频流。
转载自:https://juejin.cn/post/7155110617983451172