前言

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

 1<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

 1import Flower from "./flower.gif";

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

 1  useEffect(() => {
 2    const run = async () => {
 3      const res = await fetch(Flower)
 4      const clone = res.clone()
 5      const blob = await res.blob()
 6      const { width, height } = await getDimensions(blob)
 7      canvas.current.width = width
 8      canvas.current.height = height
 9      offscreenCanvas.current = new OffscreenCanvas(width, height)
10      //@ts-ignore
11      decodeImage(clone.body)
12    }
13    run()
14  }, [])

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

 1    <div className="container">
 2      <div>原始gif</div>
 3      {init && <img src={Flower} />}
 4      <div>canvas渲染的gif</div>
 5      <canvas ref={canvas} />
 6    </div>

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

 1  const getDimensions = (blob): any => {
 2    return new Promise((resolve) => {
 3      const img = document.createElement("img");
 4      img.addEventListener("load", (e) => {
 5        URL.revokeObjectURL(blob);
 6        return resolve({ width: img.naturalWidth, height: img.naturalHeight });
 7      });
 8      img.src = URL.createObjectURL(blob);
 9    });
10  };

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

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

 1  const decodeImage = async (imageByteStream) => {
 2    
 3    imageDecoder.current = new ImageDecoder({
 4      data: imageByteStream,
 5      type: "image/gif",
 6    });
 7    const imageFrame = await imageDecoder.current.decode({
 8      frameIndex: imageIndex.current, 
 9    });
10    const track = imageDecoder.current.tracks.selectedTrack;
11    await renderImage(imageFrame, track);
12  };

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

 1 const renderImage = async (imageFrame, track) => {
 2    const offscreenCtx = offscreenCanvas.current.getContext("2d");
 3    offscreenCtx.drawImage(imageFrame.image, 0, 0);
 4    const temp = offscreenCtx.getImageData(
 5      0,
 6      0,
 7      offscreenCanvas.current.width,
 8      offscreenCanvas.current.height
 9    );
10    const ctx = canvas.current.getContext("2d");
11    ctx.putImageData(temp, 0, 0);
12    setInit(true);
13    if (track.frameCount === 1) {
14      return;
15    }
16    if (imageIndex.current + 1 >= track.frameCount) {
17      imageIndex.current = 0;
18    }
19    const nextImageFrame = await imageDecoder.current.decode({
20      frameIndex: ++imageIndex.current,
21    });
22    window.setTimeout(() => {
23      renderImage(nextImageFrame, track);
24    }, (imageFrame.image.duration / 1000) * factor.current);
25  };

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

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

一起来看看效果:

暂停/播放

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

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

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

 1  const [playing, setPlaying] = useState(true);
 2  const playingRef = useRef(true);
 3  useEffect(() => {
 4    playingRef.current = playing;
 5  }, [playing]);
 6  
 7      <div>
 8        <Button onClick={() => setPlaying((prev) => !prev)}>
 9          {playing ? "暂停" : "开始"}
10        </Button>
11      </div>

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

 1  const renderImage = async (imageFrame, track) => {
 2    const offscreenCtx = offscreenCanvas.current.getContext("2d");
 3    offscreenCtx.drawImage(imageFrame.image, 0, 0);
 4    const temp = offscreenCtx.getImageData(
 5      0,
 6      0,
 7      offscreenCanvas.current.width,
 8      offscreenCanvas.current.height
 9    );
10    const ctx = canvas.current.getContext("2d");
11    
12    if (playingRef.current) {
13      ctx.putImageData(temp, 0, 0);
14    }
15    setInit(true);
16    if (track.frameCount === 1) {
17      return;
18    }
19    if (imageIndex.current + 1 >= track.frameCount) {
20      imageIndex.current = 0;
21    }
22    const nextImageFrame = await imageDecoder.current.decode({
23      frameIndex: playingRef.current
24        ? ++imageIndex.current
25        : imageIndex.current, 
26    });
27    window.setTimeout(() => {
28      renderImage(nextImageFrame, track);
29    }, (imageFrame.image.duration / 1000) * factor.current);
30  };

一起来看看效果:

倍速

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

 1    window.setTimeout(() => {
 2      renderImage(nextImageFrame, track);
 3    }, (imageFrame.image.duration / 1000) * factor.current);

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

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

 1  const [speed, setSpeed] = useState(1);
 2  const factor = useRef(1);
 3  useEffect(() => {
 4    factor.current = speed;
 5  }, [speed]);
 6  
 7  
 8  
 9        <Select
10          value={speed}
11          onChange={(e) => setSpeed(e)}
12          options={[
13            {
14              label: "0.5X",
15              value: 2,
16            },
17            {
18              label: "1X",
19              value: 1,
20            },
21            {
22              label: "2X",
23              value: 0.5,
24            },
25          ]}
26        ></Select>

一起来看看效果:

滤镜

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

 1  const [filter, setFilter] = useState(0);
 2  const filterRef = useRef(0);
 3  
 4      <Select
 5      value={filter}
 6      onChange={(e) => setFilter(e)}
 7      options={[
 8        {
 9          label: "无滤镜",
10          value: 0,
11        },
12        {
13          label: "灰度",
14          value: 1,
15        },
16        {
17          label: "黑白",
18          value: 2,
19        },
20      ]}
21    ></Select>

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

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

 1  const doFilter = (imageData) => {
 2    if (filterRef.current === 1) {
 3      const data = imageData.data;
 4      const threshold = 128;
 5      for (let i = 0; i < data.length; i += 4) {
 6        const gray = (data[i] + data[i + 1] + data[i + 2]) / 3;
 7        const binaryValue = gray < threshold ? 0 : 255;
 8        data[i] = binaryValue;
 9        data[i + 1] = binaryValue;
10        data[i + 2] = binaryValue;
11      }
12    }
13    if (filterRef.current === 2) {
14      const data = imageData.data;
15      for (let i = 0; i < data.length; i += 4) {
16        const red = data[i];
17        const green = data[i + 1];
18        const blue = data[i + 2];
19        const gray = 0.299 * red + 0.587 * green + 0.114 * blue;
20        data[i] = gray;
21        data[i + 1] = gray;
22        data[i + 2] = gray;
23      }
24    }
25    return imageData;
26  };

一起来看看效果:

最后

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

个人笔记记录 2021 ~ 2025