纯前端也能实现视频转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解码的速度非常快,希望等到它更加完善后,会铺开更多的使用场景。

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

参考

相关推荐
喵叔哟7 分钟前
重构代码之取消临时字段
java·前端·重构
还是大剑师兰特1 小时前
D3的竞品有哪些,D3的优势,D3和echarts的对比
前端·javascript·echarts
王解1 小时前
【深度解析】CSS工程化全攻略(1)
前端·css
一只小白菜~1 小时前
web浏览器环境下使用window.open()打开PDF文件不是预览,而是下载文件?
前端·javascript·pdf·windowopen预览pdf
方才coding1 小时前
1小时构建Vue3知识体系之vue的生命周期函数
前端·javascript·vue.js
阿征学IT1 小时前
vue过滤器初步使用
前端·javascript·vue.js
王哲晓1 小时前
第四十五章 Vue之Vuex模块化创建(module)
前端·javascript·vue.js
丶21361 小时前
【WEB】深入理解 CORS(跨域资源共享):原理、配置与常见问题
前端·架构·web
发现你走远了1 小时前
『VUE』25. 组件事件与v-model(详细图文注释)
前端·javascript·vue.js
Mr.咕咕1 小时前
Django 搭建数据管理web——商品管理
前端·python·django