前端有很多实现动画特效的技术方案,从早年的JS,CSS动画,gif图,到后来的帧动画,lottie动画等,但提到mp4动画,大家肯定会有个疑问,mp4也可以作为一种动画方案吗?
是的,mp4动画不仅能作为一种动画方案,它还存在很多优势:
动画实现原理
mp4动画的实现原理简单来讲就是使用 canvas 的 drawImage 方法将 video 容器解析后的视频画面逐帧绘制到 canvas画布上,我们还可以对动画的 FPS 进行干预。
使用 canvas 来作为渲染层还有以下优点:
-
将视频适配问题转化为图片适配问题,可针对各种不同屏幕进行小范围拉伸,实现全屏适配。
-
将 video 标签的 display 属性设置为none以后,video将不会被渲染,所以无法触发webview的一些默认行为,比如小窗口,播放器覆盖等
下面我们来对比一下mp4动画与其它动画的优缺点:
动画方案对比
- 相比Webp, Apng动图方案,具有高压缩率(素材更小)、硬件解码(解码更快)的优点
- 相比Lottie,能实现更复杂的动画效果(比如粒子特效)
mp4视频方案无论从效果、大小与解码性能上都是最优的,但H264的里存的是YUV数据,并没有带透明通道,即不支持透明动画。那有没有什么方案可以解决这个问题呢?
透明动画方案
(1)绿幕掩码替换方案
以片元着色器webGL shader形式干预渲染管线,充分发挥GPU并行计算的能力,将每一帧识别到的绿色像素点添加透明处理,利用了webGL 滤镜处理的能力。但是这种方案会导致抠像边缘锯齿化,大家仔细看的话会发现人物的边缘会有像素残留。
(2)透明遮罩方案
以左右同步的两块视频拼接而成,2个视频合成1个,左右分布,右边的视频用黑白来表示(黑白和通道蒙版是一个意思,让程序读取和识别且决定左边哪些显示,哪些不显示,从而实现透明,左边的视频保留原始的 RGB 信息,当 MP4 播放的时候分别读取左右两块视频进行拼接,得到完整了 RGBA 像素信息,然后绘制在目标画布上。
与绿幕掩码替换方式相比,左右拼接透明遮罩方案的效果更好:
mp4预加载
播放mp4动画的前提是要先加载mp4,然后再交给video解析,那有没有预加载视频的方案来减少动画启播的准备时间呢? 我们先来看下 video 常见的预加载方案
video 标签是对于开发者来讲是一个黑盒,且上述方案实际情况往往只会加载支持首帧可播放的数据,并不能将完整的mp4 buffer下载至内存备用。
虽然可以通过xhr或者fetch等方式将mp4视频流以buffer形式下载,但没办法将这些buffer直接输送给video,Media Source Extensions (MSE) 是一个新W3C标准,允许JavaScript动态构建,修改video媒体流,进而桥接给 video使用。
但是MSE只支持fmp4格式,所以我们需要实现一个将mp4 buffer 转化为 fmp4 buffer 的转封装器,关于fmp4的概念大家可以参考:www.zhihu.com/question/31…
实现预下载的技术方案:
相关代码及demo演示:
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0,maximum-scale=1.0,user-scalable=no,viewport-fit=cover"/>
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-itunes-app" content="app-id=444934666">
<meta name="format-detection" content="telephone=no">
<title></title>
<style type="text/css">
html,body, div, p, ol, ul, li, table, tbody, tr, td, textarea,
form, input, h1, h2, h3, h4, h5, dl, dt, dd, img, iframe, header, nav,
section, article, footer, figure, figcaption, menu, a, p,button {padding: 0;margin: 0; -webkit-user-select: none; -moz-user-select: none; -webkit-text-size-adjust: none;-webkit-touch-callout: none;}
html ,body{ width: 100%; height: 100%;}
body { font-size: 62.5%; font: 16px "Helvetica Neue", Helvetica, STHeiTi, "\5FAE\8F6F\96C5\9ED1", sans-serif; min-width: 320px; margin: 0 auto;}
em{ font-style: normal;}
a, span { text-decoration: none;display: inline-block;}
a:link, a:visited{ color: #fff; text-decoration:none;}
a,button{outline: none; -webkit-tap-highlight-color:rgba(0,0,0,0);}
button{border:none; background: transparent;}
li {list-style: none;}
</style>
</head>
<body>
<video id="j-video" x-webkit-airplay="true" webkit-playsinline="true" preload="auto" style="width:100%;margin-top:50px;"></video>
<div id="pros0" style="width:100%;margin-top:30px;">视频加载进度:</div>
<button id="btn" onclick="play()" style="margin-top:20px;font-size:24px;color:blueviolet;">点我播放</button>
<div id="pros1" style="width:100%;margin-top:30px;"></div>
<div id="pros2" style="width:100%;margin-top:30px;"></div>
<div id="pros3" style="width:100%;margin-top:30px;"></div>
<div id="noTips"></div>
<!--业务js脚步区域-->
<script src="./mp4box.all.js"></script>
<script type="text/javascript">
var time1 =0;
var time2 =0;
var time3 =0;
var time4 =0;
var fetchTime1 =0;
var fetchTime2 =0;
var flag =false;
var video = document.getElementById('j-video');
var sourceBufferArr =[];
var assetURL = './mov_bbb.mp4';
//http加载模块
function fetchAB(url, cb) {
fetchTime1 =new Date().getTime();
var xhr = new XMLHttpRequest;
xhr.open('get', url);
xhr.responseType = 'arraybuffer';
xhr.onprogress = function (event) {
if (event.lengthComputable) {
var loaded = parseInt(event.loaded / event.total * 100) + "%";
console.log(event.loaded)
console.log(event.total)
if(event.loaded ==event.total){
}
document.getElementById('pros0').innerText = 'demo视频加载进度:'+loaded;
}
}
xhr.onload = function () {
fetchTime2 =new Date().getTime();
document.getElementById('pros2').innerText='下载耗时:'+(fetchTime2-fetchTime1)/1000;
cb(xhr.response);
};
xhr.send();
};
//buffer解析模块
function dealBuffer(url){
var mp4box = new MP4Box();
mp4box.onReady = function (info) {
console.log(info)
//mediaSource.duration = Math.floor(info.duration / 1000);
for (var i = 0; i < info.tracks.length; i++) {
var track = info.tracks[i];
var mime = 'video/mp4; codecs=\"' + track.codec + '\"';
console.log(mime)
if (MediaSource.isTypeSupported(mime) && track.type=='video') {
try{
var sb = mediaSource.addSourceBuffer(mime);
sourceBufferArr.push(sb)
mp4box.setSegmentOptions(track.id, sb, { nbSamples: 4000 });
}catch(e){
document.getElementById('pros0').style.display = 'none';
document.getElementById('btn').style.display = 'none';
document.getElementById('noTips').innerText = '不支持MSE';
}
}
}
var initSegs = mp4box.initializeSegmentation();
var pendingInits = 0;
for (var i = 0; i < initSegs.length; i++) {
var sb = initSegs[i].user;
sb.addEventListener("updateend", function (e) {
pendingInits--;
if (pendingInits === 0) {
mp4box.start();
}
});
sb.appendBuffer(initSegs[i].buffer);
pendingInits++;
}
}
var b=null;
mp4box.onSegment = function (id, sb, buffer, sampleNum) { //生成一个片都会触发一次
console.log(sb+'>>>'+id+'>>>'+sampleNum);
time4 =new Date().getTime();
var timeInfo =(time4-time3)/1000;
console.log('解封装时间:'+timeInfo);
document.getElementById('pros3').innerText='转封装耗时:'+(time4-time3)/1000;
sb.appendBuffer(buffer);
}
fetchAB(url, function (buffer) {
buffer.fileStart = 0
mp4box.appendBuffer(buffer)
mp4box.flush();
time3 =new Date().getTime();
});
}
//关联MSE到video
if(window.MediaSource){
window.mediaSource = new MediaSource();
video.src = window.URL.createObjectURL(mediaSource);
dealBuffer(assetURL);
}else{
document.getElementById('pros0').style.display = 'none';
document.getElementById('btn').style.display ='none';
document.getElementById('noTips').innerText='不支持MSE';
}
function play(){
time1 =new Date().getTime();
video.addEventListener('timeupdate',function(){
if(video.currentTime>0 && !flag){
time2 =new Date().getTime();
flag =true;
document.getElementById('pros1').innerText='启动播放耗时:'+(time2-time1)/1000;
}
})
video.currentTime =0;
video.play();
}
</script>
</body>
</html>
转封装会用到 mp4box 这个库
demo 地址:liangxin199045.github.io/animate-mp4…
mp4动画渲染引擎设计
如果我们将上面这些针对mp4动画零碎的知识整理组织起来,就可以形成一套绘制mp4动画通用的解决方案,或者说mp4的动画渲染引擎库。
整体架构可以分为 4 层:
1. I/O层: 负责加载mp4视频资源,可以加载多段 mp4 buffer 流,通过转封装器处理为 fmp4 buffer 后放入调度队列中备用,整个过程属于耗时操作,所以我们把相关的逻辑放入web worker。
2. 核心控制层: 这一层主要封装 MSE 以及 Source Buffer 相关的处理逻辑,属于核心调度层,可以将 I/O 层提供的备用数据桥接到下面的 video 解码播放层,通过Source Buffer 可以动态可控的拼接多段 mp4 实现多视频动态绘制,可以调整绘制顺序。
3. 解码播放层: video 容器仅仅用于 fmp4 文件解码的容器,而非渲染容器,会将其display 属性设为 none。封装 video 的play,pause等方法用于控制动画开始,暂停,销毁等。
4. 渲染层: 通过 drawImage 方法将 video 解码后的视频帧绘制在 画布上,绘制过程可以穿插指定的 webGL 滤镜插件,实现多样化的渲染(透明滤镜,黑白滤镜,叠加绘制等)。
应用场景
H5活动页面动画,官网首页粒子动画,配合其它动画方案一起实现复杂渲染场景。
举个例子:
配合 Tensorflow.js
的 posenet
模型来实现人体姿态估计。
PoseNet 识别:人体姿态估计的原理是通过检测人体的关键点来估计人体的姿态。人体的关键点包括:头部、颈部、肩部、手臂、腰部、腿部等。人体的姿态包括:站立、坐着、躺着、跑步、跳跃等。
简单来讲,可以识别我们播放的mp4中人体部分,并通过训练好的模型将这些点找出来,将它们的点数据通过回调的形式返回开发者,开发者在原本绘制的canvas上二次绘制识别后的点数据。
体验demo:liangxin199045.github.io/animate-mp4…
(PS:出镜的是我本人)
延伸阅读: