3

mp4box.js加WebCodecs 解码MP4视频帧并渲染

 6 months ago
source link: https://www.zhangxinxu.com/wordpress/2023/11/mp4box-js-webcodecs-mp4-canvas/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

by zhangxinxu from https://www.zhangxinxu.com/wordpress/?p=11043 鑫空间-鑫生活
本文欢迎分享与聚合,全文转载就不必了,尊重版权,圈子就这么大,若急用可以联系授权。

封面图 风景 熊,树木,高山,湖

一、已有的学习资源

WebCodecs API解码GIF动图之前已经撰文介绍过了,访问“使用ImageDecoder API让GIF图片暂停播放”。

然后使用Webcodecs API编码带音频的MP4文件在这篇文章中有介绍过了,演示页面可以狠狠地点击这里:canvas序列+MP3音频实现mp4视频demo

并且基于上面的实现原理,我把之前开发的APNG在线生成工具稍微扩展了下,同时支持生成MP4文件并下载,有兴趣可以狠击这里体验:APNG/MP4在线合成下载工具

OK,好,最近又遇到新需求,需要对MP4视频进行解码。

关于MP4视频解码,之前有介绍过JSMpeg和Broadway两个项目,不过自己demo并没有跑通,就没有深究。

这一回,由于有了webcodecs API,我确信一定可以解码,因此,就花半天时间研究了下,算是跑通了整个流程,可以说是市面上最简洁,依赖最少的实现代码了。

二、先看需求和效果

需要用到视频解码的需求很多,例如:

  • 纯JS前端实现视频的拼接或剪裁
  • 视频添加水印变成新视频
  • 视频格式的滤镜应用在webGL特效上
  • 视频转为canvas播放以规避Android下Video元素顶层问题

这里,我就拿第三个需求,也就是MP4格式的氛围视频作为特效滤镜的需求举例,看看如何实现MP4解码效果。

注意:如果仅仅是只需要效果,而不需要最终的效果再次合成视频,直接使用CSS混合模式就可以了,这个4年前就有介绍过

您可以狠狠地点击这里:MP4视频素材解析并作为特效渲染demo

默认情况下只绘制了背景图,效果为:

景物素材图

然后这是下雨特效的视频素材:

当点击图片下方的按钮后,就可以看到下雨的特效了,如下截图所示:

截图效果

此时所见的效果并不是某个video元素覆盖在图片上,而是合二为一的canvas画布。

canvas实现示意

三、原理简述和实现代码

我看了下,无论是webcodecs官方项目,还是私有的使用Webcodecs API的项目,凡是需要解码MP4视频的,都用到了一个工具,MP4Box.js

所以,我就先去了解了下MP4Box.js这个项目,原来这个JS可以将一个MP4文件分析得体无完肤,什么信息都可以弄到,自然也包括时间里面的画面轨道数据和音轨数据。

在我这个例子中,只需要视频轨道数据,有了数据,就可以使用Webcodecs API中的VideoDecoder方法进行解码了。

JavaScript代码实现参考如下,全网最精简版本。

// 下面是视频解码的处理逻辑,使用mp4box.js获取视频信息
// 使用 Webcodecs API 进行解码
const mp4url = './rains-s.mp4';
const mp4box = MP4Box.createFile();

// 这个是额外的处理方法,不需要关心里面的细节
const getExtradata = () => {
    // 生成VideoDecoder.configure需要的description信息
    const entry = mp4box.moov.traks[0].mdia.minf.stbl.stsd.entries[0];

    const box = entry.avcC ?? entry.hvcC ?? entry.vpcC;
    if (box != null) {
        const stream = new DataStream(
            undefined,
            0,
            DataStream.BIG_ENDIAN
        )
        box.write(stream)
         // slice()方法的作用是移除moov box的header信息
        return new Uint8Array(stream.buffer.slice(8))
    }
};

// 视频轨道,解码用
let videoTrack = null;
let videoDecoder = null;
// 这个就是最终解码出来的视频画面序列文件
const videoFrames = [];

let nbSampleTotal = 0;
let countSample = 0;

mp4box.onReady = function (info) {
    // 记住视频轨道信息,onSamples匹配的时候需要
    videoTrack = info.videoTracks[0];

    if (videoTrack != null) {
        mp4box.setExtractionOptions(videoTrack.id, 'video', { 
            nbSamples: 100 
        })
    }

    // 视频的宽度和高度
    const videoW = videoTrack.track_width;
    const videoH = videoTrack.track_height;

    // 设置视频解码器
    videoDecoder = new VideoDecoder({
        output: (videoFrame) => {
            createImageBitmap(videoFrame).then((img) => {
                videoFrames.push({
                    img,
                    duration: videoFrame.duration,
                    timestamp: videoFrame.timestamp
                });
                videoFrame.close();
            });
        },
        error: (err) => {
            console.error('videoDecoder错误:', err);
        }
    });

    nbSampleTotal = videoTrack.nb_samples;

    videoDecoder.configure({
        codec: videoTrack.codec,
        codedWidth: videoW,
        codedHeight: videoH,
        description: getExtradata()
    });

    mp4box.start();
};

mp4box.onSamples = function (trackId, ref, samples) {
    // samples其实就是采用数据了
    if (videoTrack.id === trackId) {
        mp4box.stop();

        countSample += samples.length;

        for (const sample of samples) {
            const type = sample.is_sync ? 'key' : 'delta';

            const chunk = new EncodedVideoChunk({
                type,
                timestamp: sample.cts,
                duration: sample.duration,
                data: sample.data
            });

            videoDecoder.decode(chunk);
        }

        if (countSample === nbSampleTotal) {
            videoDecoder.flush();
        }
    }
};

// 获取视频的arraybuffer数据
fetch(mp4url).then(res => res.arrayBuffer()).then(buffer => {
    // 因为文件较小,所以直接一次性写入
    // 如果文件较大,则需要res.body.getReader()创建reader对象,每次读取一部分数据
    // reader.read().then(({ done, value })
    buffer.fileStart = 0;
    mp4box.appendBuffer(buffer);
    mp4box.flush();
});

其中的常量videoFrames就是最终解码出来的视频的每一帧图像,有了图像序列,事情就好办了,想干嘛就可以干嘛了。

例如这里作为特效图片显示,只需要设置绘制的混合模式为滤色screen就好了。

具体代码不展示,有兴趣可以访问demo页面,里面有完整代码。

不过demo页面中的绘制使用的是pixijs绘制的,有些人可能不熟。

使用pixijs演示是为了下一篇文章服务的,如果只是简单的混合模式图像绘制,传统的2d canvas绘制就可以了,使用参考(源码中的draw()方法可以换成这个):

const draw = () => {
    const { img, timestamp, duration } = videoFrames[index];

    // 清除画布
    context.clearRect(0, 0, canvas.width, canvas.height);
    // 混合模式设为正常
    context.globalCompositeOperation = 'source-over';
    // 绘制图片,bgImg是背景图
    context.drawImage(bgImg, 0, 0, canvas.width, canvas.height);
    // 使用 screen 混合模式
    context.globalCompositeOperation = 'screen';
    context.drawImage(img, 0, 0, canvas.width, canvas.height);

    // 开始下一帧绘制
    index++;

    if (index === videoFrames.length) {
        // 重新开始
        index = 0;
    }

    setTimeout(draw, duration);
}

四、积跬步,参考实现与结语

使用上层工具完成一个需求,也需要技术,但这个技术门槛不高。

基于原始的API,尽可能用最简洁的底层代码实现,这个才是真正的学习与积累,虽然过程痛苦,但是却能和其他人拉开差距,提高自己的竞争力。

且随着相关的积累越来越多,你会逐渐成为这个领域的大咖,也就自然成为团队的中流砥柱。

一开始谁都不懂的,就像音视频处理,放到七八年前,只会使用audio和video元素,现在呢,基本上前端能够实现的能力在脑中都有数了,再配合canvas、SVG等其他与视觉表现相关的积累,可以做的事情就多了。

成长就是这样,一步一个脚印慢慢起来了,千万不要好高骛远。

本文的MP4解码并没有音频部分,如果你有这方面的需求,下面两个资源你可以参考下:

OK,就这么多,又是一篇其他地方难觅的优质文章,感谢阅读,欢迎。

如果你比较害羞,不愿意分享,也可以购买我写的书籍,或者小册以表支持,嘿嘿~

😋 😛 😝 😜 🤪

(本篇完)1f44d.svg 是不是学到了很多?可以分享到微信
1f44a.svg 有话要说?点击这里


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK