mp4动画渲染引擎设计

354 阅读5分钟

前端有很多实现动画特效的技术方案,从早年的JS,CSS动画,gif图,到后来的帧动画,lottie动画等,但提到mp4动画,大家肯定会有个疑问,mp4也可以作为一种动画方案吗?

是的,mp4动画不仅能作为一种动画方案,它还存在很多优势:

动画实现原理

截屏2023-02-21 下午3.33.51.png

mp4动画的实现原理简单来讲就是使用 canvas 的 drawImage 方法将 video 容器解析后的视频画面逐帧绘制到 canvas画布上,我们还可以对动画的 FPS 进行干预。

使用 canvas 来作为渲染层还有以下优点:

  1. 将视频适配问题转化为图片适配问题,可针对各种不同屏幕进行小范围拉伸,实现全屏适配。 截屏2023-02-21 下午3.41.53.png

  2. 将 video 标签的 display 属性设置为none以后,video将不会被渲染,所以无法触发webview的一些默认行为,比如小窗口,播放器覆盖等

截屏2023-02-21 下午3.42.01.png

下面我们来对比一下mp4动画与其它动画的优缺点:

动画方案对比

截屏2023-02-21 下午3.29.27.png

  • 相比Webp, Apng动图方案,具有高压缩率(素材更小)、硬件解码(解码更快)的优点
  • 相比Lottie,能实现更复杂的动画效果(比如粒子特效)

mp4视频方案无论从效果、大小与解码性能上都是最优的,但H264的里存的是YUV数据,并没有带透明通道,即不支持透明动画。那有没有什么方案可以解决这个问题呢?

透明动画方案

(1)绿幕掩码替换方案

截屏2023-02-21 下午4.24.07.png

以片元着色器webGL shader形式干预渲染管线,充分发挥GPU并行计算的能力,将每一帧识别到的绿色像素点添加透明处理,利用了webGL 滤镜处理的能力。但是这种方案会导致抠像边缘锯齿化,大家仔细看的话会发现人物的边缘会有像素残留。

(2)透明遮罩方案

截屏2023-02-21 下午3.53.55.png

以左右同步的两块视频拼接而成,2个视频合成1个,左右分布,右边的视频用黑白来表示(黑白和通道蒙版是一个意思,让程序读取和识别且决定左边哪些显示,哪些不显示,从而实现透明,左边的视频保留原始的 RGB 信息,当 MP4 播放的时候分别读取左右两块视频进行拼接,得到完整了 RGBA 像素信息,然后绘制在目标画布上。

与绿幕掩码替换方式相比,左右拼接透明遮罩方案的效果更好:

image.png

mp4预加载

播放mp4动画的前提是要先加载mp4,然后再交给video解析,那有没有预加载视频的方案来减少动画启播的准备时间呢? 我们先来看下 video 常见的预加载方案

截屏2023-02-21 下午5.37.31.png

video 标签是对于开发者来讲是一个黑盒,且上述方案实际情况往往只会加载支持首帧可播放的数据,并不能将完整的mp4 buffer下载至内存备用。

截屏2023-02-21 下午4.10.13.png

虽然可以通过xhr或者fetch等方式将mp4视频流以buffer形式下载,但没办法将这些buffer直接输送给video,Media Source Extensions (MSE) 是一个新W3C标准,允许JavaScript动态构建,修改video媒体流,进而桥接给 video使用。

截屏2023-02-21 下午4.28.36.png

但是MSE只支持fmp4格式,所以我们需要实现一个将mp4 buffer 转化为 fmp4 buffer 的转封装器,关于fmp4的概念大家可以参考:www.zhihu.com/question/31…

实现预下载的技术方案:

截屏2023-02-21 下午4.31.57.png

相关代码及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 滤镜插件,实现多样化的渲染(透明滤镜,黑白滤镜,叠加绘制等)。

截屏2023-02-21 下午4.55.39.png

应用场景

H5活动页面动画,官网首页粒子动画,配合其它动画方案一起实现复杂渲染场景。

举个例子:

配合 Tensorflow.js 的 posenet 模型来实现人体姿态估计。

PoseNet 识别:人体姿态估计的原理是通过检测人体的关键点来估计人体的姿态。人体的关键点包括:头部、颈部、肩部、手臂、腰部、腿部等。人体的姿态包括:站立、坐着、躺着、跑步、跳跃等。

简单来讲,可以识别我们播放的mp4中人体部分,并通过训练好的模型将这些点找出来,将它们的点数据通过回调的形式返回开发者,开发者在原本绘制的canvas上二次绘制识别后的点数据。

体验demo:liangxin199045.github.io/animate-mp4…

(PS:出镜的是我本人)

截屏2023-02-21 下午4.51.11.png

截屏2023-02-21 下午4.51.26.png

延伸阅读:

  1. juejin.cn/post/696199…
  2. juejin.cn/post/694602…
  3. github.com/Tencent/vap…
  4. juejin.cn/post/717171…