纯前端也能实现视频转GIF

公众号:【可乐前端】,每天3分钟学习一个优秀的开源项目,分享web面试与实战知识。

前言

之前使用过FFMpeg来做视频转GIF,但是FFMpeg的体积还是太大了,前端加载一般要10M左右。后面发现了 Webcodecs 这个新的 Web API ,它提供了解码视频的能力。所以就沿着这个方向去使劲,也是实现了一个纯前端的在线的视频转 GIF 功能。

本文一共会按照以下三步去实现一个视频转 GIF 功能:

  • 解封装视频,从视频文件中获取视频帧
  • 解码视频帧,获取帧图像信息
  • 拼装帧图像信息,生成 GIF

视频解封装

视频解封装是从一个包含多种媒体数据的容器中提取出特定类型的媒体数据的过程。通过解封装,可以从容器中分离出视频轨道、音频轨道等各种媒体数据。

它的主要目的是获取原始的音频、视频等媒体数据,以便进行后续的处理,比如播放、编辑或者转码。解封装后的数据可以根据需要被送入相应的解码器进行解码。

这里使用到的是 mp4box.js 这个库去解码上传的视频文件,以获取视频轨道信息。首先定义一个获取文件 Buffer 的方法,我这里是上传文件然后去获取 ArrayBuffer

js 复制代码
const getFileArrayBuffer = (file) => {
  return new Promise((resolve) => {
    const reader = new FileReader();
    reader.onload = (e) => {
      resolve(e.target.result);
    };
    reader.readAsArrayBuffer(file);
  });
};

然后调用 mp4box 去解封装视频的轨道信息

js 复制代码
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 APIVideoDecoderEncodedVideoChunk ,它们的API兼容性如下:

  • VideoDecoder是一个较新的API,它可以让我们通过JS在浏览器中解码视频
  • EncodedVideoChunk是指表示视频编码数据块对象,用于表示已经编码的视频数据,这些数据可以通过网络传输并在接收端进行解码。

我们利用VideoDecodermp4box解封装后得到的轨道信息进一步解析成一帧一帧的图片,为我们后续的合成GIF做准备。

js 复制代码
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。实现代码如下:

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的帧图像以及压缩每一帧图像的体积。

所以接下来我们会做如下操作:

  1. new GIF的画布宽高缩小一半
  2. 逢两帧抽取一帧,每一帧的延时变成原来的2
  3. 对每一帧进行压缩

完整代码如下:

js 复制代码
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解码的速度非常快,希望等到它更加完善后,会铺开更多的使用场景。

如果你觉得有意思的话,点点关注点点赞吧~

参考

相关推荐
吃杠碰小鸡38 分钟前
commitlint校验git提交信息
前端
虾球xz1 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇1 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒1 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员2 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐2 小时前
前端图像处理(一)
前端
程序猿阿伟2 小时前
《智能指针频繁创建销毁:程序性能的“隐形杀手”》
java·开发语言·前端
疯狂的沙粒2 小时前
对 TypeScript 中函数如何更好的理解及使用?与 JavaScript 函数有哪些区别?
前端·javascript·typescript
瑞雨溪2 小时前
AJAX的基本使用
前端·javascript·ajax
力透键背2 小时前
display: none和visibility: hidden的区别
开发语言·前端·javascript