纯前端如何实现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 重新进行渲染。期间也就也可以加上暂停、倍速、滤镜的功能。

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

相关推荐
树上有只程序猿2 分钟前
后端思维之高并发处理方案
前端
庸俗今天不摸鱼37 分钟前
【万字总结】前端全方位性能优化指南(十)——自适应优化系统、遗传算法调参、Service Worker智能降级方案
前端·性能优化·webassembly
黄毛火烧雪下44 分钟前
React Context API 用于在组件树中共享全局状态
前端·javascript·react.js
Apifox1 小时前
如何在 Apifox 中通过 CLI 运行包含云端数据库连接配置的测试场景
前端·后端·程序员
一张假钞1 小时前
Firefox默认在新标签页打开收藏栏链接
前端·firefox
高达可以过山车不行1 小时前
Firefox账号同步书签不一致(火狐浏览器书签同步不一致)
前端·firefox
m0_593758101 小时前
firefox 136.0.4版本离线安装MarkDown插件
前端·firefox
掘金一周1 小时前
金石焕新程 >> 瓜分万元现金大奖征文活动即将回归 | 掘金一周 4.3
前端·人工智能·后端
三翼鸟数字化技术团队1 小时前
Vue自定义指令最佳实践教程
前端·vue.js
Jasmin Tin Wei2 小时前
蓝桥杯 web 学海无涯(axios、ecahrts)版本二
前端·蓝桥杯