WebCodecs实践——前端完成视频转GIF

背景

之前写博客的时候为了便于理解,会录一段操作视频然后转成gif放在文章里面,一般这个时候都是在网上随便找一个在线的转换网站或者直接用格式工厂,后来在开发过程中了解到了WebCodecs API,似乎可以对视频进行解码,于是便萌生了在前端进行视频转gif的想法。

视频封装与视频编解码

在进行具体的转换之前,我们首先得简单了解一下视频的组成与播放原理。

视频组成结构

通常我们会通过一个文件的后缀名来判断这个文件的类型,我们比较熟悉的视频文件后缀名有mp4、avi、mkv,这些后缀名其实就是视频的封装格式 ,也叫封装容器 。一个完整的视频文件由视频流和音频流组成,视频和音频采用不同的编码格式,例如视频编码格式h264,音频编码格式AAC,封装容器则是把视频与音频组合起来。

我们这里只关心视频文件中的视频流部分,也可以叫做视频轨道。我们常说视频多少帧,这里的帧就是指的视频帧 ,帧率越高,代表这个视频在同一时间内播放的画面越多,给人的感觉越流畅,一个视频轨道就是由许多个视频帧组成,然后通过如h264这样的视频编码格式进行编码压缩,我们要拿到视频帧数据对视频进行播放,就需要对视频流进行解码。

视频编解码与WebCodecs API

视频的播放依赖于视频解码器,视频能在浏览器中播放就意味着浏览器实际上是存在一个解码器的,但在稍早的时候,w3c标准并未开放浏览器的音视频编解码能力,前端想要在浏览器中进行视频的编解码需要依赖于WASMFFmpeg,这种方式在性能上是有着明显的缺陷的,于是随着技术的发展,w3c终于开放了浏览器的音视频编解码能力------WebCodecs API

Gif简介

gif是一种图像编码格式,但又与普通的静态图像不一样,具体的编码原理和算法这里不做介绍,我们只需要知道,一张gif图片实际上是由许多张帧组合在一起的,这一点与视频类似,我们可以把gif看成是一个压缩过的无音频视频。

Gif编解码

WebCodecs API中,提供了一个图像解码器ImageEncoder,可以完美地对包括gif在内的主流图片格式进行解码,但不知道为什么,浏览器目前还没有提供一个图像编码器,如果是jpg、png格式的静态图片的合成非常简单,有许多种方法,但是如果要合成gif,目前浏览器还没有提供原生的图像编码器 ,只能依赖于外部的gif编码器(当然有能力也可以自己写一个编码器),这里我们选取了gif.js作为我们的编码器。

视频流提取-MP4Box.js

尽管浏览器提供了针对视频流的解码方式,但是没有提供视频解包的工具,这里我选择了MP4Box.js作为视频解包工具。这里视频解封的原理就是读取视频文件的字节流进行解析,具体解析过程可以看一下源码,主要是一些格式上的解析。

技术路线

到此基本上已经能够确定这个工具的技术路线:

  1. MP4Box负责解封视频,提取视频流
  2. WebCodecs负责解码视频,提取视频帧
  3. gif.js负责封装产出gif图片

具体实现

代码仓库:github.com/ssyamv/Vide...

在线地址:Document (mayss.top)

引入MP4Box.js和gif.js

mp4box.js:MP4Box.js | mp4box.js (gpac.github.io)

gif.js:gif.js (jnordberg.github.io)

也可以直接使用我代码仓库中的js文件。

FileReader读取视频文件

ini 复制代码
const fr = new FileReader();
fr.readAsArrayBuffer(file) //获取视频字节流
fr.onloadend = (e) => {
	 fileBuffer = e.target.result;
}

mp4box只接受ArrayBuffer,所以我们需要使用FileReader将视频文件转换为ArrayBuffer

mp4box提取视频流

ini 复制代码
mp4box = MP4Box.createFile()
mp4box.appendBuffer(fileBuffer);

createFile方法创建一个ISO对象,然后使用appendBuffer方法读取视频字节,mp4box中内置了两个事件,onReadyonSamples,分别对应解析的不同阶段,具体可去官网阅读。

WebCodecs解析视频流

  1. onReady阶段我们将拿到视频的详细数据用于初始化VideoDecoder,同时通过通过mp4box中的setExtractionOptions方法让mp4box对视频流进行采样。
  2. 在onSamples阶段我们将对视频流采样的结果封装为WebCodecs中的EncodedVideoChunk对象,这个对象正是VideoDecoder可以解析的对象,然后使用decode方法将该对象排入解析队列中。
ini 复制代码
        mp4box.onReady = (info) => {
            console.log(info)
            videoTrack = info.videoTracks[0];
            mp4box.setExtractionOptions(info.videoTracks[0]?.id, 'video', {
                nbSamples: 100
            })
            // 视频的宽度和高度
            const videoW = videoTrack.track_width;
            const videoH = videoTrack.track_height;

            canvas.width = videoW * scale.value
            canvas.height = videoH * scale.value
            // 设置视频解码器

            videoDecoder = new VideoDecoder({
                output: (videoFrame) => {
                    createImageBitmap(videoFrame).then((img) => {
                        videoFrames.push({
                            img,
                            duration: videoFrame.duration,
                            timestamp: videoFrame.timestamp
                        });
                        if (videoFrames.length === nbSampleTotal) {
                            console.log(videoFrames)
                            e.data = videoFrames
                            window.dispatchEvent(e)
                        }
                        videoFrame.close();
                    });

                },
                error: (err) => {
                    console.error('videoDecoder错误:', err);
                }
            });
            nbSampleTotal = videoTrack.nb_samples;
            console.log(videoFrames, videoTrack)
            videoDecoder.configure({
                codec: videoTrack.codec,
                codedWidth: videoW,
                codedHeight: videoH,
                description: getExtraData(mp4box)
            });
            mp4box.start();
        }
        mp4box.onSamples = (trackId, ref, samples) => {
            // 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();
            }

        }
        fileBuffer.fileStart = 0;
        mp4box.appendBuffer(fileBuffer);
    }

Gif.js封装

注意我们在初始化VideoDecoder时的回调函数中做了判断,当视频解码完成时,触发gif封装,gif封装比较简单,我们只需将解码中保存下来的VideoFrame对象,也就是视频帧,按顺序绘制在Canavs中,然后依次调用gif.js中的addFrame方法添加即可,具体逻辑如下:

ini 复制代码
        gif = new GIF({
            workers: 2,
            quality: Number(quality.value),
            repeat: !repeat.checked,
            width: canvas.width,
            height: canvas.height,
            debug: debug.checked
        });
        gif.on('finished', function (blob) {
            console.log('complete', gif)
            gifUrl = URL.createObjectURL(blob)
            img.src = gifUrl
            finished = true
            parsing.innerText = '转换成功!'
        });
        ctx.drawImage(e.data[0].img, 0, 0, canvas.width, canvas.height)
        gif.addFrame(ctx, {copy: true, delay: 1000/Number(fps.value)})
        for (let i = 1; i < e.data.length; i = i + Number(space.value)) {
            ctx.drawImage(e.data[i].img, 0, 0, canvas.width, canvas.height)
            gif.addFrame(ctx, {copy: true, delay: 1000/Number(fps.value)})
        }
        gif.render()

效果展示

最终的转换时间取决于你生成的gif的帧率、宽高以及时长,如果是平时的小图gif,转换时间非常快,建议亲自体验一下。

原视频:SampleVideo_1280x720_20mb_哔哩哔哩_bilibili

转换参数:每2帧抽1帧,缩小为0.1倍,帧率为20

转换结果(如果看起来糊是因为博客样式放大了的原因,下载到本地大小恰好):

总结

这次尝试中主要使用了WebCodecs中的VideoDecoderEncodedVideoChunk两个api进行开发,实际上WebCodecs除了解码也能编码,除了视频还有音频,能做的绝不止这些,WebCodecs在性能上毫无疑问是完全碾压使用wasm集成的传统音视频处理库如ffmpeg,目前的主要问题在于兼容性不好,例如视频就不支持h265等编码格式,同时api也还不够完善,不过考虑到这是w3c的一个实验性技术,在未来可能会有更好的发展,从而取代目前的前端音视频处理方案。

参考资料

(如有侵权,请联系作者删除)

相关推荐
&白帝&22 分钟前
uniapp中使用picker-view选择时间
前端·uni-app
谢尔登29 分钟前
Babel
前端·react.js·node.js
ling1s29 分钟前
C#基础(13)结构体
前端·c#
卸任35 分钟前
使用高阶组件封装路由拦截逻辑
前端·react.js
lxcw1 小时前
npm ERR! code CERT_HAS_EXPIRED npm ERR! errno CERT_HAS_EXPIRED
前端·npm·node.js
秋沐1 小时前
vue中的slot插槽,彻底搞懂及使用
前端·javascript·vue.js
这个需求建议不做1 小时前
vue3打包配置 vite、router、nginx配置
前端·nginx·vue
QGC二次开发1 小时前
Vue3 : Pinia的性质与作用
前端·javascript·vue.js·typescript·前端框架·vue
云草桑1 小时前
逆向工程 反编译 C# net core
前端·c#·反编译·逆向工程
布丁椰奶冻1 小时前
解决使用nvm管理node版本时提示npm下载失败的问题
前端·npm·node.js