Skip to content

实现 GIF 制作 工具

分类:general | 标签:GIF、动图、合成 多图合成为 GIF 动图

2.1 功能说明

多图合成为 GIF 动图

核心能力

  • 多图合成 GIF
  • 图片
  • 每帧延时
  • 尺寸
  • GIF 拆帧
  • GIF 文件
  • 播放速度
  • 当前帧
  • 缩放
  • 启用裁剪
  • 起点
  • 宽高

2.1.1 使用指南

多图合成 GIF

把多张图片按序合成为 GIF 动图,可设置每帧延时与统一尺寸(表情包、教程动图常用)。

  • 至少选择 2 张图片;尺寸留空则按首图自动。
  • 合成在浏览器本地完成,图片不会上传服务器。

GIF 拆帧

把一个 GIF 动图反向拆解成一张张静态画面,用于提取表情包素材、逐帧分析动画、截取某一瞬间。

  1. 在「GIF 拆帧」页选择一个 .gif 文件,工具会自动解码出全部帧。
  2. 解码时会按 GIF 规范还原每帧的完整画面:GIF 内部只存储与上一帧的差异区域(patch), 并带有「处置方式」标记(保留上一帧 / 清空该区域 / 恢复到更早画面),本工具已逐帧还原, 因此导出的每张 PNG 都是完整可用的图片,而不是残缺的差异块。
  3. 用播放条可以调速(0.1×–4×)与逆序播放,拖动滑块或点击下方缩略图可跳到任意一帧。
  4. 右侧可设置缩放比例与裁剪区域,设置会同时作用于「导出帧」和「重新合成 GIF」。

导出说明

  • 导出当前帧 PNG:只保存当前正在预览的那一帧。
  • 导出全部帧 PNG:逐张触发下载,浏览器可能会询问「是否允许下载多个文件」, 请选择允许;帧数较多时耗时较长,请勿中途切换页面。
  • 重新合成 GIF:把裁剪/缩放后的帧重新编码成一个新的 GIF,原始每帧延时会被保留。
  • 透明背景的 GIF 导出 PNG 时会保留透明通道;重新合成时透明区域会被填充为白色(GIF 编码限制)。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • onFiles()
  • loadImage()
  • generate()
  • download()
  • resetCrop()
  • releaseFrames()
  • onGifFile()
  • canvasToBlob()
  • loadImageFromUrl()
  • decodeGif()
  • drawFrame()
  • selectFrame()
  • onSeek()
  • stepFrame()
  • tick()
  • startPlay()
  • stopPlay()
  • togglePlay()
  • saveBlob()
  • renderOutCanvas()
  • baseName()
  • exportCurrentFrame()
  • exportAllFrames()
  • rebuildGif()
  • downloadRebuilt()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, computed, nextTick, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { VideoPlay, VideoPause } from '@element-plus/icons-vue'
import ToolPageShell from '@/components/ToolPageShell.vue'
import GIF from 'gif.js'
import { parseGIF, decompressFrames } from '@/vendor/gifuct/index.mjs'

const innerTab = ref('make')

const files = ref([])
const delay = ref(500)
const width = ref(null)
const height = ref(null)
const error = ref('')
const rendering = ref(false)
const progress = ref(0)
const url = ref('')

function onFiles(e) {
  error.value = ''
  const list = Array.from(e.target.files || [])
  if (list.length < 2) {
    error.value = '请至少选择 2 张图片'
    return
  }
  files.value = list
}

function loadImage(file) {
  return new Promise((resolve, reject) => {
    const img = new Image()
    img.onload = () => resolve(img)
    img.onerror = reject
    img.src = URL.createObjectURL(file)
  })
}

function generate() {
  error.value = ''
  if (files.value.length < 2) {
    error.value = '请至少选择 2 张图片'
    return
  }
  rendering.value = true
  progress.value = 0
  if (url.value) URL.revokeObjectURL(url.value)

  Promise.all(files.value.map(loadImage))
    .then((imgs) => {
      const W = width.value || imgs[0].naturalWidth
      const H = height.value || imgs[0].naturalHeight
      const gif = new GIF({
        workers: 2,
        quality: 10,
        workerScript: '/gif.worker.js',
        width: W,
        height: H,
        background: '#000'
      })
      imgs.forEach((img) => {
        const c = document.createElement('canvas')
        c.width = W
        c.height = H
        const ctx = c.getContext('2d')
        ctx.drawImage(img, 0, 0, W, H)
        gif.addFrame(c, { delay: delay.value, copy: true })
      })
      gif.on('progress', (p) => {
        progress.value = Math.round(p * 100)
      })
      gif.on('finished', (blob) => {
        url.value = URL.createObjectURL(blob)
        rendering.value = false
      })
      gif.render()
    })
    .catch((e) => {
      error.value = '生成失败:' + (e && e.message ? e.message : String(e))
      rendering.value = false
    })
}

function download() {
  if (!url.value) return
  const a = document.createElement('a')
  a.href = url.value
  a.download = 'output.gif'
  a.click()
}

/* ================== GIF 拆帧 ================== */

const gifInput = ref(null)
const playCanvas = ref(null)
const splitError = ref('')
const splitInfo = ref(null)
const frames = ref([]) // { url, delay, image }
const decoding = ref(false)
const decodeProgress = ref(0)
const currentIndex = ref(0)
const playing = ref(false)
const reverse = ref(false)
const speed = ref(1)

const scalePct = ref(100)
const cropOn = ref(false)
const cropX = ref(0)
const cropY = ref(0)
const cropW = ref(1)
const cropH = ref(1)

const exportingAll = ref(false)
const exportProgress = ref(0)
const rebuilding = ref(false)
const rebuildProgress = ref(0)
const rebuiltUrl = ref('')

let rafId = 0
let lastTs = 0
let acc = 0

const totalDuration = computed(() => frames.value.reduce((s, f) => s + (f.delay || 0), 0))

// 实际参与输出的源区域(裁剪后)
const srcRect = computed(() => {
  const W = splitInfo.value ? splitInfo.value.width : 0
  const H = splitInfo.value ? splitInfo.value.height : 0
  if (!cropOn.value) return { x: 0, y: 0, w: W, h: H }
  const x = Math.min(Math.max(0, cropX.value), Math.max(0, W - 1))
  const y = Math.min(Math.max(0, cropY.value), Math.max(0, H - 1))
  return {
    x,
    y,
    w: Math.max(1, Math.min(cropW.value, W - x)),
    h: Math.max(1, Math.min(cropH.value, H - y))
  }
})

const outSize = computed(() => {
  const r = srcRect.value
  const k = scalePct.value / 100
  return { w: Math.max(1, Math.round(r.w * k)), h: Math.max(1, Math.round(r.h * k)) }
})

function resetCrop() {
  if (!splitInfo.value) return
  cropX.value = 0
  cropY.value = 0
  cropW.value = splitInfo.value.width
  cropH.value = splitInfo.value.height
}

function releaseFrames() {
  for (const f of frames.value) {
    if (f.url) URL.revokeObjectURL(f.url)
  }
  frames.value = []
}

function onGifFile(e) {
  const file = (e.target.files || [])[0]
  if (file) decodeGif(file)
}

function canvasToBlob(canvas, type, quality) {
  return new Promise((resolve, reject) => {
    canvas.toBlob(
      (b) => (b ? resolve(b) : reject(new Error('画布导出失败'))),
      type || 'image/png',
      quality
    )
  })
}

function loadImageFromUrl(src) {
  return new Promise((resolve, reject) => {
    const img = new Image()
    img.onload = () => resolve(img)
    img.onerror = () => reject(new Error('帧图片加载失败'))
    img.src = src
  })
}

/**
 * 解码 GIF:GIF 每帧只存差异区域(patch)与处置方式(disposalType),
 * 需要按规范逐帧叠加还原成完整画面。
 * disposalType: 0/1=保留当前画面,2=把本帧区域清空为背景,3=恢复到本帧绘制前的画面
 */
async function decodeGif(file) {
  stopPlay()
  splitError.value = ''
  releaseFrames()
  splitInfo.value = null
  decoding.value = true
  decodeProgress.value = 0
  currentIndex.value = 0
  if (rebuiltUrl.value) {
    URL.revokeObjectURL(rebuiltUrl.value)
    rebuiltUrl.value = ''
  }

  try {
    const buf = await file.arrayBuffer()
    const gif = parseGIF(buf)
    const raw = decompressFrames(gif, true)
    if (!raw.length) throw new Error('未解析到任何帧')

    const W = gif.lsd.width
    const H = gif.lsd.height

    // 主画布:累积完整画面
    const main = document.createElement('canvas')
    main.width = W
    main.height = H
    const mctx = main.getContext('2d')

    // 补丁画布:放置单帧差异区域
    const patchCanvas = document.createElement('canvas')
    const pctx = patchCanvas.getContext('2d')

    const list = []
    let restore = null // disposalType===3 时保存的还原点

    for (let i = 0; i < raw.length; i++) {
      const f = raw[i]
      const d = f.dims

      if (f.disposalType === 3) {
        restore = mctx.getImageData(0, 0, W, H)
      }

      patchCanvas.width = d.width
      patchCanvas.height = d.height
      pctx.putImageData(new ImageData(f.patch, d.width, d.height), 0, 0)
      // 用 drawImage 而非 putImageData,才能让透明像素与底层画面正确合成
      mctx.drawImage(patchCanvas, d.left, d.top)

      const blob = await canvasToBlob(main, 'image/png')
      const objUrl = URL.createObjectURL(blob)
      const image = await loadImageFromUrl(objUrl)
      list.push({
        url: objUrl,
        image,
        // GIF 的 delay 单位为 1/100 秒,gifuct 已换算为毫秒;0 按浏览器习惯兜底为 100ms
        delay: f.delay && f.delay > 0 ? f.delay : 100,
        dims: d,
        disposalType: f.disposalType
      })

      // 处置:为下一帧准备画面
      if (f.disposalType === 2) {
        mctx.clearRect(d.left, d.top, d.width, d.height)
      } else if (f.disposalType === 3 && restore) {
        mctx.putImageData(restore, 0, 0)
      }

      decodeProgress.value = Math.round(((i + 1) / raw.length) * 100)
      // 让出主线程,避免长 GIF 卡死界面
      if (i % 8 === 7) await new Promise((r) => setTimeout(r, 0))
    }

    frames.value = list
    splitInfo.value = { name: file.name, width: W, height: H }
    cropX.value = 0
    cropY.value = 0
    cropW.value = W
    cropH.value = H
    decoding.value = false
    await nextTick()
    drawFrame(0)
    ElMessage.success(`解码完成,共 ${list.length} 帧`)
  } catch (err) {
    decoding.value = false
    splitError.value = '解析失败:' + (err && err.message ? err.message : String(err))
  }
}

/** 把第 i 帧按裁剪+缩放画到预览画布 */
function drawFrame(i) {
  const f = frames.value[i]
  const cvs = playCanvas.value
  if (!f || !cvs) return
  const r = srcRect.value
  const o = outSize.value
  cvs.width = o.w
  cvs.height = o.h
  const ctx = cvs.getContext('2d')
  ctx.clearRect(0, 0, o.w, o.h)
  ctx.imageSmoothingEnabled = true
  ctx.imageSmoothingQuality = 'high'
  ctx.drawImage(f.image, r.x, r.y, r.w, r.h, 0, 0, o.w, o.h)
}

function selectFrame(i) {
  stopPlay()
  currentIndex.value = i
  drawFrame(i)
}

function onSeek(v) {
  currentIndex.value = v
  drawFrame(v)
}

function stepFrame(dir) {
  stopPlay()
  const n = frames.value.length
  if (!n) return
  currentIndex.value = (currentIndex.value + dir + n) % n
  drawFrame(currentIndex.value)
}

function tick(ts) {
  if (!playing.value) return
  if (!lastTs) lastTs = ts
  const dt = ts - lastTs
  lastTs = ts
  acc += dt * speed.value

  const cur = frames.value[currentIndex.value]
  const need = cur ? cur.delay : 100
  if (acc >= need) {
    acc -= need
    const n = frames.value.length
    currentIndex.value = reverse.value
      ? (currentIndex.value - 1 + n) % n
      : (currentIndex.value + 1) % n
    drawFrame(currentIndex.value)
  }
  rafId = requestAnimationFrame(tick)
}

function startPlay() {
  if (!frames.value.length) return
  playing.value = true
  lastTs = 0
  acc = 0
  rafId = requestAnimationFrame(tick)
}

function stopPlay() {
  playing.value = false
  if (rafId) cancelAnimationFrame(rafId)
  rafId = 0
}

function togglePlay() {
  if (playing.value) stopPlay()
  else startPlay()
}

function saveBlob(blob, name) {
  const objUrl = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = objUrl
  a.download = name
  a.click()
  URL.revokeObjectURL(objUrl)
}

/** 生成指定帧的输出画布(应用裁剪+缩放) */
function renderOutCanvas(i, fillWhite) {
  const f = frames.value[i]
  const r = srcRect.value
  const o = outSize.value
  const c = document.createElement('canvas')
  c.width = o.w
  c.height = o.h
  const ctx = c.getContext('2d')
  if (fillWhite) {
    ctx.fillStyle = '#ffffff'
    ctx.fillRect(0, 0, o.w, o.h)
  }
  ctx.imageSmoothingEnabled = true
  ctx.imageSmoothingQuality = 'high'
  ctx.drawImage(f.image, r.x, r.y, r.w, r.h, 0, 0, o.w, o.h)
  return c
}

function baseName() {
  const n = splitInfo.value ? splitInfo.value.name : 'frame'
  return n.replace(/\.[^.]+$/, '')
}

async function exportCurrentFrame() {
  if (!frames.value.length) return
  const c = renderOutCanvas(currentIndex.value, false)
  const blob = await canvasToBlob(c, 'image/png')
  saveBlob(blob, `${baseName()}-frame-${String(currentIndex.value + 1).padStart(3, '0')}.png`)
}

async function exportAllFrames() {
  if (!frames.value.length) return
  stopPlay()
  exportingAll.value = true
  exportProgress.value = 0
  try {
    for (let i = 0; i < frames.value.length; i++) {
      const c = renderOutCanvas(i, false)
      const blob = await canvasToBlob(c, 'image/png')
      saveBlob(blob, `${baseName()}-frame-${String(i + 1).padStart(3, '0')}.png`)
      exportProgress.value = Math.round(((i + 1) / frames.value.length) * 100)
      // 间隔触发,避免浏览器拦截连续下载
      await new Promise((r) => setTimeout(r, 120))
    }
    ElMessage.success('全部帧已导出')
  } catch (e) {
    splitError.value = '导出失败:' + (e && e.message ? e.message : String(e))
  } finally {
    exportingAll.value = false
  }
}

/** 用当前裁剪/缩放设置重新编码成 GIF(保留原每帧延时,支持逆序) */
function rebuildGif() {
  if (!frames.value.length) return
  stopPlay()
  rebuilding.value = true
  rebuildProgress.value = 0
  if (rebuiltUrl.value) {
    URL.revokeObjectURL(rebuiltUrl.value)
    rebuiltUrl.value = ''
  }
  try {
    const o = outSize.value
    const gif = new GIF({
      workers: 2,
      quality: 10,
      workerScript: '/gif.worker.js',
      width: o.w,
      height: o.h,
      background: '#fff'
    })
    const order = frames.value.map((_, i) => i)
    if (reverse.value) order.reverse()
    for (const i of order) {
      // GIF 不支持半透明,透明区域填白后再编码
      gif.addFrame(renderOutCanvas(i, true), {
        delay: Math.max(20, Math.round(frames.value[i].delay / speed.value)),
        copy: true
      })
    }
    gif.on('progress', (p) => {
      rebuildProgress.value = Math.round(p * 100)
    })
    gif.on('finished', (blob) => {
      rebuiltUrl.value = URL.createObjectURL(blob)
      rebuilding.value = false
      ElMessage.success('重新合成完成')
    })
    gif.render()
  } catch (e) {
    rebuilding.value = false
    splitError.value = '合成失败:' + (e && e.message ? e.message : String(e))
  }
}

function downloadRebuilt() {
  if (!rebuiltUrl.value) return
  const a = document.createElement('a')
  a.href = rebuiltUrl.value
  a.download = `${baseName()}-edited.gif`
  a.click()
}

onBeforeUnmount(() => {
  if (url.value) URL.revokeObjectURL(url.value)
  stopPlay()
  releaseFrames()
  if (rebuiltUrl.value) URL.revokeObjectURL(rebuiltUrl.value)
})

2.3 效果截图

GIF 制作 效果截图

工具访问地址:https://www.i91tools.com/tools/gif-maker