前言
之前使用过FFMpeg
来做视频转GIF
,但是FFMpeg
的体积还是太大了,前端加载一般要10M
左右。后面发现了 Webcodecs
这个新的 Web API
,它提供了解码视频的能力。所以就沿着这个方向去使劲,也是实现了一个纯前端的在线的视频转 GIF
功能。
本文一共会按照以下三步去实现一个视频转 GIF
功能:
- 解封装视频,从视频文件中获取视频帧
- 解码视频帧,获取帧图像信息
- 拼装帧图像信息,生成
GIF
视频解封装
视频解封装是从一个包含多种媒体数据的容器中提取出特定类型的媒体数据的过程。通过解封装,可以从容器中分离出视频轨道、音频轨道等各种媒体数据。
它的主要目的是获取原始的音频、视频等媒体数据,以便进行后续的处理,比如播放、编辑或者转码。解封装后的数据可以根据需要被送入相应的解码器进行解码。
这里使用到的是 mp4box.js
这个库去解码上传的视频文件,以获取视频轨道信息。首先定义一个获取文件 Buffer
的方法,我这里是上传文件然后去获取 ArrayBuffer
:
const getFileArrayBuffer = (file) => { return new Promise((resolve) => { const reader = new FileReader(); reader.onload = (e) => { resolve(e.target.result); }; reader.readAsArrayBuffer(file); }); };
然后调用 mp4box
去解封装视频的轨道信息
const data = await getFileArrayBuffer(file); data.fileStart = 0; const getVideoInfo = async (data) => { return new Promise((resolve, rejcet) => { const mp4boxfile = MP4Box.createFile(); mp4boxfile.onError = function (e) { console.log("e", e); rejcet(e); }; mp4boxfile.onReady = (info) => { resolve({ mp4boxfile, info, }); }; mp4boxfile.appendBuffer(data); mp4boxfile.flush(); }); }; const { mp4boxfile, info } = await getVideoInfo(data); console.log(info); const videoTrack = info.tracks.find((track) => track.type === "video"); const timescale = videoTrack.timescale; const duration = videoTrack.duration / timescale; const nbSamples = videoTrack.nb_samples; const fps = Math.round(nbSamples / duration);
以下大概是一个视频轨道的字段:
这里如果我们想获取视频的时长,帧率等信息,需要做一些小小的转换。nb_samples
是视频总帧数; movie_timescale
我理解是视频的一个采样单位,拿 movie_duration/movie_timescale
才是我们视频的长度,这里大概是 18.2
秒。帧率就是总帧数/视频时长,这里大概是 15FPS
。
获取视频帧
获取视频帧这里用到的是一个较新的 Web API
, VideoDecoder
和 EncodedVideoChunk
,它们的API兼容性如下:
VideoDecoder
是一个较新的API
,它可以让我们通过JS
在浏览器中解码视频EncodedVideoChunk
是指表示视频编码数据块对象,用于表示已经编码的视频数据,这些数据可以通过网络传输并在接收端进行解码。
我们利用VideoDecoder
将mp4box
解封装后得到的轨道信息进一步解析成一帧一帧的图片,为我们后续的合成GIF
做准备。
const videoFrames = []; const initDecoder = () => { const getExtradata = () => { // 生成VideoDecoder.configure需要的description信息 const entry = mp4boxfile.moov.traks[0].mdia.minf.stbl.stsd.entries[0]; const box = entry.avcC ?? entry.hvcC ?? entry.vpcC; if (box != null) { const stream = new MP4Box.DataStream( undefined, 0, MP4Box.DataStream.BIG_ENDIAN ); box.write(stream); // slice()方法的作用是移除moov box的header信息 return new Uint8Array(stream.buffer.slice(8)); } }; // 初始化 VideoDecoder const decoder = new VideoDecoder({ output: (videoFrame) => { createImageBitmap(videoFrame).then((img) => { videoFrames.push({ img, duration: videoFrame.duration, timestamp: videoFrame.timestamp, }); videoFrame.close(); if (videoFrames.length === nbSamples) { const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); const img = videoFrames[0].img; console.log(img); ctx.drawImage(img, 0, 0, img.width, img.height); } }); }, error: (err) => { console.error("videoDecoder错误:", err); }, }); const config = { codec: videoTrack.codec, codedWidth: videoTrack.video.width, codedHeight: videoTrack.video.height, description: getExtradata(), }; decoder.configure(config); return decoder; }; let decoder = initDecoder(); const getChunkList = () => { const track = mp4boxfile.getTrackById(videoTrack.id); console.log(track.samples.length); const chunkList = track.samples.map((_, index) => { const sample = mp4boxfile.getSample(track, index); const type = sample.is_sync ? "key" : "delta"; const chunk = new EncodedVideoChunk({ type, timestamp: sample.cts, duration: sample.duration, data: sample.data, }); return chunk; }); return chunkList; }; const chunkList = getChunkList(); chunkList.forEach((chunk) => decoder.decode(chunk));
大概解释一下上面的代码:
initDecoder
中我们初始化了一个VideoDecoder
,它接收到数据之后就会响应output
回调,在output
回调中我们把videoFrame
转成了一个ImageBitmap
对象(即帧图像信息),然后收集起来。- 然后我们实现了一个
getChunkList
函数来收集解封装后的视频数据,把所有的chunk
收集起来供decoder
调用 - 两者配合起来,我们就可以拿到这段视频轨道的所有视频帧图像
合成GIF
当所有的视频帧处理完成之后,docoder
会触发一个flush
方法,我们可以在这里进行GIF
的合成。这里我GIF
合成使用的库是gif.js
。实现代码如下:
decoder.flush().then(() => { const width = videoFrames[0].img.width; const height = videoFrames[0].img.height; var gif = new GIF({ workers: 4, quality: 10, width, height, }); console.log("开始"); videoFrames .map((frame) => frame.img) .forEach((imageBitmap) => { var offscreenCanvas = new OffscreenCanvas( imageBitmap.width, imageBitmap.height ); var offscreenContext = offscreenCanvas.getContext("2d"); offscreenContext.drawImage(imageBitmap, 0, 0); var imageData = offscreenContext.getImageData( 0, 0, imageBitmap.width, imageBitmap.height ); gif.addFrame(imageData, { delay: 1000 / fps }); }); gif.on("finished", function (blob) { var link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "animated.gif"; document.body.appendChild(link); link.click(); document.body.removeChild(link); }); // 开始生成 GIF gif.render(); });
简单解释一下上面的代码:
- 由于生成的
imageBitmap
并不能直接喂给gif.addFrame
调用,所以这里使用了一个离屏Canvas
去转换一下 gif.addFrame(imageData, { delay: 1000 / fps });
这里的delay
参数就是每一帧图片持续的时长,默认是500ms
,我们用1秒
除于帧率,来换算出实际的时长- 合成完毕之后,通过一个
a
标签把GIF
下载下来
通过这样的方式,一个1M多
的MP4
生成出来的GIF
居然有30M
,我滴妈呀。虽然质量跟流畅度还是挺好的,但这个体积也太吓人了。
所以我们最好对GIF
进行一个压缩,这个场景下压缩主要是减少合成GIF
的帧图像以及压缩每一帧图像的体积。
所以接下来我们会做如下操作:
new GIF
的画布宽高缩小一半- 逢两帧抽取一帧,每一帧的延时变成原来的
2
倍 - 对每一帧进行压缩
完整代码如下:
decoder.flush().then(() => { const width = videoFrames[0].img.width / 2; const height = videoFrames[0].img.height / 2; const gif = new GIF({ workers: 4, quality: 10, width, height, }); const halfFrames = videoFrames.filter((frame, index) => index % 2 === 0); halfFrames .map((frame) => frame.img) .forEach((imageBitmap) => { const originalWidth = imageBitmap.width; const originalHeight = imageBitmap.height; var offscreenCanvas = new OffscreenCanvas( imageBitmap.width / 2, imageBitmap.height / 2 ); var offscreenContext = offscreenCanvas.getContext("2d"); // 在新Canvas上绘制原始ImageBitmap,并缩小一半 offscreenContext.drawImage( imageBitmap, 0, 0, originalWidth, originalHeight, 0, 0, offscreenCanvas.width, offscreenCanvas.height ); const compressedImageData = offscreenContext.getImageData( 0, 0, offscreenCanvas.width, offscreenCanvas.height ); gif.addFrame(compressedImageData, { delay: (1000 / fps) * 2 }); }); gif.on("finished", function (blob) { // 创建一个虚拟的下载链接并触发点击 const link = document.createElement("a"); link.href = URL.createObjectURL(blob); link.download = "animated.gif"; document.body.appendChild(link); link.click(); document.body.removeChild(link); }); // 开始生成 GIF gif.render(); });
下面是生成的GIF
图,大小在5M
左右
最后
decoder.configure(config);
中有一个description
字段,搞了好久都没搞定,最后还是拜读了张鑫旭大佬的文章,才把这个demo
跑通。
跑通这个demo
的时候是十分开心的,前端能做的事情越来越多了,而且Webcodecs
解码的速度非常快,希望等到它更加完善后,会铺开更多的使用场景。
如果你觉得有意思的话,点点关注点点赞吧~