纯前端也能实现视频转GIF

简介: 纯前端也能实现视频转GIF

前言

之前使用过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);

以下大概是一个视频轨道的字段:

image.png

这里如果我们想获取视频的时长,帧率等信息,需要做一些小小的转换。nb_samples是视频总帧数; movie_timescale 我理解是视频的一个采样单位,拿 movie_duration/movie_timescale 才是我们视频的长度,这里大概是 18.2 秒。帧率就是总帧数/视频时长,这里大概是 15FPS

获取视频帧

获取视频帧这里用到的是一个较新的 Web APIVideoDecoder  和  EncodedVideoChunk ,它们的API兼容性如下:

image.png

image.png

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

我们利用VideoDecodermp4box解封装后得到的轨道信息进一步解析成一帧一帧的图片,为我们后续的合成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的帧图像以及压缩每一帧图像的体积。

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

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

完整代码如下:

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左右

image.png

最后

decoder.configure(config);中有一个description字段,搞了好久都没搞定,最后还是拜读了张鑫旭大佬的文章,才把这个demo跑通。

跑通这个demo的时候是十分开心的,前端能做的事情越来越多了,而且Webcodecs解码的速度非常快,希望等到它更加完善后,会铺开更多的使用场景。

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

相关文章
|
Web App开发 移动开发 JavaScript
【前端用法】HTML5 Video标签如何屏蔽右键视频另存为的js代码以及如何禁用浏览器控件,Video 禁止鼠标右键下载
【前端用法】HTML5 Video标签如何屏蔽右键视频另存为的js代码以及如何禁用浏览器控件,Video 禁止鼠标右键下载
368 0
|
2月前
|
前端开发 JavaScript 编译器
不走弯路,纯前端如何把图片导出成视频!
【10月更文挑战第3天】不走弯路,纯前端如何把图片导出成视频!
59 3
|
7月前
|
前端开发
前端input上传文件获取视频或音频的时长
前端input上传文件获取视频或音频的时长
241 0
|
5月前
|
前端开发
ElementPlus卡片如何能够一行呈四,黑马UI前端布局视频资料,element样式具体的细节无法修改,F12找到那个位置,可能在其他组件写了错误,找到那个位置,围绕着位置解决问题最快了,卡片下边
ElementPlus卡片如何能够一行呈四,黑马UI前端布局视频资料,element样式具体的细节无法修改,F12找到那个位置,可能在其他组件写了错误,找到那个位置,围绕着位置解决问题最快了,卡片下边
|
5月前
|
前端开发 PHP 数据格式
【附带效果视频】php接口给前端返回流式数据,php使用event-stream进行数据推送,循环一次输出一次
【附带效果视频】php接口给前端返回流式数据,php使用event-stream进行数据推送,循环一次输出一次
191 0
|
7月前
|
前端开发
前端使用多张图片生成 Gif 效果(支持循环、不循环、完成回调)
前端使用多张图片生成 Gif 效果(支持循环、不循环、完成回调)
422 0
|
7月前
|
编解码 Rust 前端开发
纯前端也能实现在线GIF压缩
纯前端也能实现在线GIF压缩
|
7月前
|
XML JSON 前端开发
【Flutter前端技术开发专栏】Flutter中的图片、视频与网络资源加载
【4月更文挑战第30天】Flutter是谷歌的开源前端框架,因其高性能、流畅UI和多端运行能力受开发者喜爱。本文聚焦于Flutter中的资源加载:使用`Image`组件加载静态、网络和本地图片;通过`video_player`库加载和播放视频;利用`http`包进行网络资源请求。掌握这些技巧将有助于提升Flutter应用的开发效率和质量。
51 0
【Flutter前端技术开发专栏】Flutter中的图片、视频与网络资源加载
|
前端开发 JavaScript
【前端用法】前端JS获取视频时长的写法
【前端用法】前端JS获取视频时长的写法
135 0
|
7月前
|
资源调度 前端开发 JavaScript
推荐一款可以自动创建视频的前端Ract框架
推荐一款可以自动创建视频的前端Ract框架