首页/文章/javascript

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

2024-07-15
12445 分钟
...

前言

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

<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

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 结构,十分简单:

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

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

  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

  const decodeImage = async (imageByteStream) => {
    
    imageDecoder.current = new ImageDecoder({
      data: imageByteStream,
      type: "image/gif",
    });
    const imageFrame = await imageDecoder.current.decode({
      frameIndex: imageIndex.current, 
    });
    const track = imageDecoder.current.tracks.selectedTrack;
    await renderImage(imageFrame, track);
  };

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

 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 绘制的动图。

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

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

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

  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);
  };

一起来看看效果:

倍速

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

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

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

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

  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 做一些滤镜操作。以常见的灰度滤镜、黑白滤镜为例:

  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种图像滤镜是否让你想起一位故人

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

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
javascript2026-02-24
navigator.sendBeacon全指南
在前端开发中,埋点系统是必不可少的一环。我们经常需要在用户 关闭页面 、 刷新 或 跳转路由 时,向服务器发送最后一条统计数据(比如用户停留时长、页面跳出率)。 但这看似简单的需求,在实现时却危机四伏:请求发不出去?页面跳转卡顿?今天我们就来聊聊这个问题的终极解决方案 —— 。 一、 痛点与传统方案的挣扎 场景还原 当用户点击关闭按钮时,浏览器会触发生命周期事件( 或 )。如果我们直接使用普通的异步 AJAX ( 或 ) 发送请求,浏览...
学习
javascript2025-11-02
理解浏览器事件系统,从用户点击到事件对象的完整旅程
深入理解浏览器事件系统:从用户点击到事件对象的完整旅程 “当我点击页面按钮时,背后发生了什么?为什么回调函数能收到一个包含丰富信息的event对象?今天,让我们一起揭开浏览器事件系统的神秘面纱。” 一个令人困惑的现象 作为前端开发者,我们每天都在写这样的代码: 这段代码如此熟悉,以至于我们很少停下来思考:​ ​这个 对象到底从哪里来?它为什么能知道点击的精确坐标?为什么能识别是哪个元素被点击了?​ ​ 更神奇的是,当我们手动创建事件时:...
学习
javascript2025-10-01
实现大文件上传全流程详解
在日常开发中,大文件上传是个绕不开的坎——动辄几百 MB 甚至 GB 级的文件,直接上传不仅容易超时,还会让用户体验大打折扣。最近我用 Vue+Express 实现了一套完整的大文件上传方案,支持分片上传、断点续传、秒传和手动中。 一、先看效果:我们要实现什么? 先上核心功能清单,确保大家明确目标,知道我们要解决哪些实际问题: 大文件分片上传 :将文件切成固定大小的小片段分批上传,避免单次请求超时 秒传 :服务器已存在完整文件时,直接返...
学习
javascript2025-09-18
JavaScript 的多线程能力:Worker
如果你写过一些计算量稍大的 JavaScript 代码,比如图像处理、大量数据排序或者复杂的算法,你几乎肯定遇到过浏览器“卡死”的现象。点击页面没反应,动画也停了,就像整个世界都静止了。 这就是主线程被阻塞的典型后果。因为主线程既要负责执行 JavaScript,又要负责渲染页面、响应用户操作,一旦它被繁重的计算任务占满,就无暇顾及其他,用户体验便直线下降。 这个问题的根源,正是“主线程是单线程的”。那么,如何解决呢? 答案很简单:把这...
学习面试
javascript2025-09-15
一张 8K 海报差点把首屏拖垮
你给后台管理系统加了一个「企业风采」模块,运营同学一口气上传了 200 张 8K 宣传海报。首屏直接飙到 8.3 s,LCP 红得发紫。 老板一句「能不能像朋友圈那样滑到哪看到哪?」——于是你把懒加载重新翻出来折腾了一轮。 解决方案:三条技术路线,你全踩了一遍 1\. 最偷懒:原生 一行代码就能跑,浏览器帮你搞定。 🔍 关键决策点 2020 年后现代浏览器全覆盖,IE 全军覆没。 必须写死 ,否则 CLS 会抖成 PPT。 适用场景...
学习
javascript2025-09-10
🚀 Web Worker让你的应用丝滑
🌟 引言 在日常的前端开发中,你是否遇到过这样的困扰: 大数据处理时页面卡死 :处理几万条数据时,页面直接卡成PPT,用户点击毫无反应 复杂计算阻塞UI :图片处理、数据分析等计算密集型任务让整个应用假死 文件上传/下载卡顿 :大文件操作时,其他功能完全无法使用 实时数据处理性能差 :WebSocket接收大量数据时,页面渲染严重滞后 今天分享6个Web Worker的核心技巧,让你的应用告别卡顿,用户体验丝滑如德芙! 💡 核心技巧...
学习

评论

请登录后发表评论

去登录
加载评论中...

目录