纯前端如何实现Gif暂停、倍速播放

前言

GIF 我相信大家都不会陌生,由于它被广泛的支持,所以我们一般用它来做一些简单的动画效果。一般就是设计师弄好了之后,把文件发给我们。然后我们就直接这样使用:

html 复制代码
<img src="xxx.gif"/>

这样就能播放一个 GIF ,不知道大家有没有思考过一个问题?在播放 GIF 的时候,可以把这个 GIF 暂停/停止播放吗?可以把这个 GIF 倍速播放吗?听起来是很离谱的需求,你为啥不直接给我一个视频呢?

anyway,那我们今天就一起来尝试实现一下上述的一些功能在 GIF 的实现。

ImageDecoder

首先先来了解一下 WebCodecs API ,它旨在浏览器提供原生的音视频处理能力。 WebCodecs API 的核心包含两大部分:编码器( Encoder )和解码器( Decoder )。编码器把原始的媒体数据(如音频或视频)进行编码,转换成特定的文件格式(如 mp3mp4 等)。解码器则是进行逆向操作,把特定格式的文件解码为原始的媒体数据。

使用 WebCodecs API ,我们可以对原始媒体数据进行更细粒度的操作,如进行合成、剪辑等,然后把操作后的数据进行编码,保存成新的媒体文件。

不过需要注意的是 WebCodecs API 还属于实验性阶段,并未在所有浏览器中支持。

ImageDecoderWebCodecs API 的一部分,它可以让我们解码图片,获取到图片的元数据。

假设我们这样导入一个 GIF

js 复制代码
import Flower from "./flower.gif";

导入之后,通过 ImageDecoder 解码 GIF 获取到每一帧的关键信息:如图像信息、每一帧的持续时长等。获取到这些信息之后,再通过 canvas+定时器 把这个 GIF 在画图中绘制出来,下面一起来看看具体操作:

  useEffect(() => {
    const run = async () => {
      const res = await fetch(Flower);
      const clone = res.clone();
      const blob = await res.blob();
      const { width, height } = await getDimensions(blob);
      canvas.current.width = width;
      canvas.current.height = height;
      offscreenCanvas.current = new OffscreenCanvas(width, height);
      //@ts-ignore
      decodeImage(clone.body);
    };
    run();
  }, []);

顺带说一下 html 结构,十分简单:

html 复制代码
    <div className="container">
      <div>原始gif</div>
      {init && <img src={Flower} />}
      <div>canvas渲染的gif</div>
      <canvas ref={canvas} />
    </div>

首先通过 fetch 获取到 GIF 图的元数据,这里有一个 getDimensions 方法,它是获取 GIF 图的原始宽高信息的:

ts 复制代码
  const getDimensions = (blob): any => {
    return new Promise((resolve) => {
      const img = document.createElement("img");
      img.addEventListener("load", (e) => {
        URL.revokeObjectURL(blob);
        return resolve({ width: img.naturalWidth, height: img.naturalHeight });
      });
      img.src = URL.createObjectURL(blob);
    });
  };

获取到宽高信息后,对 canvas 元素赋值宽高,并且定义一个离屏 canvas 对象,后续用它来操作像素,同时也对他赋值宽高。

然后就可以调用 decodeImage 来解码 GIF

ts 复制代码
  const decodeImage = async (imageByteStream) => {
    //@ts-ignore
    imageDecoder.current = new ImageDecoder({
      data: imageByteStream,
      type: "image/gif",
    });
    const imageFrame = await imageDecoder.current.decode({
      frameIndex: imageIndex.current, // imageIndex从0开始
    });
    const track = imageDecoder.current.tracks.selectedTrack;
    await renderImage(imageFrame, track);
  };

这里的 imageIndex0 开始, imageFrame 表示第 imageIndex 帧的图像信息,拿到图像信息和轨道之后,就可以把图像渲染出来。

js 复制代码
 const renderImage = async (imageFrame, track) => {
    const offscreenCtx = offscreenCanvas.current.getContext("2d");
    offscreenCtx.drawImage(imageFrame.image, 0, 0);
    const temp = offscreenCtx.getImageData(
      0,
      0,
      offscreenCanvas.current.width,
      offscreenCanvas.current.height
    );
    const ctx = canvas.current.getContext("2d");
    ctx.putImageData(temp, 0, 0);
    setInit(true);
    if (track.frameCount === 1) {
      return;
    }
    if (imageIndex.current + 1 >= track.frameCount) {
      imageIndex.current = 0;
    }
    const nextImageFrame = await imageDecoder.current.decode({
      frameIndex: ++imageIndex.current,
    });
    window.setTimeout(() => {
      renderImage(nextImageFrame, track);
    }, (imageFrame.image.duration / 1000) * factor.current);
  };

imageFrame.image 中就可以获取到当前帧的图像信息,然后就可以把它绘制到画布中。其中 track.frameCount 表示当前 GIF 有多少帧,当到达最后一帧时,将 imageIndex 归零,实现循环播放。

其中 factor.current 表示倍速,后续会提到,这里先默认看作 1

一起来看看效果:

暂停/播放

既然我们能把 GIF 的图像信息每一帧都提取出来放到 canvas 中重新绘制成一个动图,那么实现暂停/播放功能也不是什么难事了。

下面的展示我会把原 GIF 去掉,只留下我们用 canvas 绘制的动图。

用一个按钮表示暂停开始状态:

jsx 复制代码
  const [playing, setPlaying] = useState(true);
  const playingRef = useRef(true);
  useEffect(() => {
    playingRef.current = playing;
  }, [playing]);
  // ....
      <div>
        <Button onClick={() => setPlaying((prev) => !prev)}>
          {playing ? "暂停" : "开始"}
        </Button>
      </div>

然后在 renderImage 方法中,如果当前状态是暂停,则停止渲染。

js 复制代码
  const renderImage = async (imageFrame, track) => {
    const offscreenCtx = offscreenCanvas.current.getContext("2d");
    offscreenCtx.drawImage(imageFrame.image, 0, 0);
    const temp = offscreenCtx.getImageData(
      0,
      0,
      offscreenCanvas.current.width,
      offscreenCanvas.current.height
    );
    const ctx = canvas.current.getContext("2d");
    // 根据状态判断是否渲染
    if (playingRef.current) {
      ctx.putImageData(temp, 0, 0);
    }
    setInit(true);
    if (track.frameCount === 1) {
      return;
    }
    if (imageIndex.current + 1 >= track.frameCount) {
      imageIndex.current = 0;
    }
    const nextImageFrame = await imageDecoder.current.decode({
      frameIndex: playingRef.current
        ? ++imageIndex.current
        : imageIndex.current, // 根据状态判断是否要渲染下一帧
    });
    window.setTimeout(() => {
      renderImage(nextImageFrame, track);
    }, (imageFrame.image.duration / 1000) * factor.current);
  };

一起来看看效果:

倍速

再来回顾一下渲染下一帧的逻辑:

js 复制代码
    window.setTimeout(() => {
      renderImage(nextImageFrame, track);
    }, (imageFrame.image.duration / 1000) * factor.current);

这里获取到每一帧原本的持续时长之后,乘以一个 factor ,我们只要改变这个 factor ,就可以实现各种倍速。

这里用一个下拉框,实现 0.5/1/2 倍速:

js 复制代码
  const [speed, setSpeed] = useState(1);
  const factor = useRef(1);
  useEffect(() => {
    factor.current = speed;
  }, [speed]);
  
  
  // ....
        <Select
          value={speed}
          onChange={(e) => setSpeed(e)}
          options={[
            {
              label: "0.5X",
              value: 2,
            },
            {
              label: "1X",
              value: 1,
            },
            {
              label: "2X",
              value: 0.5,
            },
          ]}
        ></Select>

一起来看看效果:

滤镜

既然我们是拿到每一帧图像的信息到 canvas 中进行渲染的,那么我们也就可以对 canvas 做一些滤镜操作。以常见的灰度滤镜、黑白滤镜为例:

js 复制代码
  const [filter, setFilter] = useState(0);
  const filterRef = useRef(0);
  
      <Select
      value={filter}
      onChange={(e) => setFilter(e)}
      options={[
        {
          label: "无滤镜",
          value: 0,
        },
        {
          label: "灰度",
          value: 1,
        },
        {
          label: "黑白",
          value: 2,
        },
      ]}
    ></Select>

同样的,用一个下拉框来表示所选择的滤镜,然后我们实现一个函数,对 temp 进行像素变换

像素变换如下,更多的像素变换可以参考我的这篇文章------这10种图像滤镜是否让你想起一位故人

js 复制代码
  const doFilter = (imageData) => {
    if (filterRef.current === 1) {
      const data = imageData.data;
      const threshold = 128;
      for (let i = 0; i < data.length; i += 4) {
        const gray = (data[i] + data[i + 1] + data[i + 2]) / 3;
        const binaryValue = gray < threshold ? 0 : 255;
        data[i] = binaryValue;
        data[i + 1] = binaryValue;
        data[i + 2] = binaryValue;
      }
    }
    if (filterRef.current === 2) {
      const data = imageData.data;
      for (let i = 0; i < data.length; i += 4) {
        const red = data[i];
        const green = data[i + 1];
        const blue = data[i + 2];
        const gray = 0.299 * red + 0.587 * green + 0.114 * blue;
        data[i] = gray;
        data[i + 1] = gray;
        data[i + 2] = gray;
      }
    }
    return imageData;
  };

一起来看看效果:

最后

以上就是本文的全部内容,主要介绍了 ImageDecoder 解码 GIF 图像之后,再利用 canvas 重新进行渲染。期间也就也可以加上暂停、倍速、滤镜的功能。

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

相关推荐
喵叔哟28 分钟前
重构代码之取消临时字段
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