为了方便写文章,写一个网页版的录制GIF工具吧

314 阅读5分钟

在写文章的过程中,经常都会有使用gif图的需求;比如一些动画,单纯使用图片无法很好的展示效果,用视频的话又太臃肿了,所以gif图是非常好的选择

市面上也有很多录制gif的软件,比如www.screentogif.com/ 。但都需要下载安装软件才能录制,那可不可以构建一个网页版的gif录制工具呢?

首先录制gif有两个步骤:

  1. 首先需要录制用户的屏幕(只需要画面即可)
  2. 生成gif(生成的时候可以设置需要生成gif的片段、参数等)

我们来一步一步实现一个简单的录制GIF网页工具

录制屏幕

使用MediaRecorder可以录制用户的页面,包括整个屏幕或者单个tab页

我们获取到用户的设备流

const stream = await navigator.mediaDevices
  .getDisplayMedia({
    video: true,
    audio: false, // 录制gif不需要音频
  })
  .catch(e => {
    console.error(e)
  })

之后就可以使用MediaRecorder来读取流,并将流存储起来,MediaRecorder返回的是webm格式的视频

const mediaRecorder = new MediaRecorder(stream, {
  mimeType: mime,
})
const chunks: Blob[] = []

mediaRecorder.addEventListener('dataavailable', function (e) {
  chunks.push(e.data)
})

mediaRecorder.addEventListener('stop', async () => {
  setIsStart(false)
  const blob = new Blob(chunks, { type: chunks[0].type })
  
  // 之后可以用这个数据展示视频或者进行其他处理
})

完整代码:

const stream = await navigator.mediaDevices
  .getDisplayMedia({
    video: true,
    audio: false, // 录制gif不需要音频
  })
  .catch(e => {
    console.error(e)
  })

// 判断 MediaRecorder 支持的文件类型
const mime = MediaRecorder.isTypeSupported('video/webm;codecs=h264')
  ? 'video/webm;codecs=h264'
  : 'video/webm'
if (!stream) {
  return
}

const mediaRecorder = new MediaRecorder(stream, {
  mimeType: mime,
})
const chunks: Blob[] = []

mediaRecorder.addEventListener('dataavailable', function (e) {
  chunks.push(e.data)
})

mediaRecorder.addEventListener('stop', async () => {
  setIsStart(false)
  const blob = new Blob(chunks, { type: chunks[0].type })
  
  // 之后可以用这个数据展示视频或者进行其他处理
})
// 开始记录数据
mediaRecorder.start()

获取到录制的数据后,就可以将blob数据用URL.createObjectURL将其转换为blobUrl,使用video将录制的视频播放出来

const blob = new Blob(chunks, { type: chunks[0].type })
const url = URL.createObjectURL(blob)

setUrl(url)

// ...

<video
  ref={videoRef}
  controls
  className="w-[800px]"
  src={url}
/>

之后简单编写一下页面,添加一个录制的按钮,点击之后执行我们上面写的代码,开启录制屏幕内容。

image.png

这样我们就实现了第一步,成功获取录制了屏幕并且获取到了视频数据

生成GIF

生成GIF有两个方案:

  1. 在前端调用ffmpeg的wasm包,直接生成GIF
  2. 使用gif.js生成

我这里为了简单,使用的是gif.js生成。

首先安装gif.js包,以及其types包

pnpm add gif.js
pnpm add @types/gif.js -D

之后去gif.js的仓库下载gif.worker.js

因为我开发的时候使用的是Nextjs,所以可以新建public目录,将gif.worker.js放到public目录下,这样就可以通过/gif.worker.js来读取到文件。

之后封装一下生成GIF的工具类

import GIF from 'gif.js'

export interface ImageWithDelay {
  id: string
  base64: string
  delay: number
}

interface GifOptions {
  width?: number
  height?: number
  quality?: number
  workerScript?: string
}

interface ExtendedGIF extends Omit<GIF, 'on'> {
  on(event: 'start' | 'abort', listener: () => void): this
  on(event: 'finished', listener: (blob: Blob, data: Uint8Array) => void): this
  on(event: 'progress', listener: (percent: number) => void): this
  on(event: 'error', listener: (error: Error) => void): this
}

class GifCreationError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'GifCreationError'
  }
}

async function loadImage(base64: string): Promise<HTMLImageElement> {
  return new Promise((resolve, reject) => {
    const img = new Image()
    img.onload = () => resolve(img)
    img.onerror = () => reject(new Error('Failed to load image'))
    img.src = base64
  })
}

function resizeImage(
  img: HTMLImageElement,
  maxWidth: number,
  maxHeight: number
): HTMLCanvasElement {
  const canvas = document.createElement('canvas')
  const ctx = canvas.getContext('2d')!

  let width = img.width
  let height = img.height

  if (width > maxWidth) {
    height *= maxWidth / width
    width = maxWidth
  }

  if (height > maxHeight) {
    width *= maxHeight / height
    height = maxHeight
  }

  canvas.width = width
  canvas.height = height

  ctx.drawImage(img, 0, 0, width, height)
  return canvas
}

export async function createGif({
  images,
  onProgress,
  options = {},
  abortSignal,
}: {
  images: ImageWithDelay[]
  onProgress?: (percent: number) => void
  options?: GifOptions
  abortSignal?: AbortSignal
}): Promise<Blob> {
  if (images.length === 0) {
    throw new GifCreationError('No images provided')
  }

  const {
    width = 500,
    height = 500,
    quality = 10,
    workerScript = typeof window !== 'undefined' ? '/gif.worker.js' : undefined,
  } = options

  const gif = new GIF({
    workers: 2,
    quality,
    width,
    height,
    workerScript,
  }) as ExtendedGIF

  let aborted = false

  abortSignal?.addEventListener('abort', () => {
    aborted = true
    gif.abort()
  })

  return new Promise((resolve, reject) => {
    gif.on('progress', (percent: number) => {
      if (onProgress) {
        onProgress(percent)
      }
    })

    gif.on('finished', (blob: Blob) => {
      console.log('GIF creation completed')
      resolve(blob)
    })

    gif.on('error', error => {
      console.error('GIF generation error:', error)
      reject(new GifCreationError(`GIF generation error: ${error.message}`))
    })

    gif.on('abort', () => {
      console.warn('GIF generation aborted')
      reject(new GifCreationError('GIF generation aborted'))
    })

    const addFrames = async () => {
      for (let i = 0; i < images.length; i++) {
        if (aborted) {
          return
        }

        const { base64, delay } = images[i]
        try {
          console.log(`Processing image ${i + 1}/${images.length}`)
          const img = await loadImage(base64)
          const canvas = resizeImage(img, width, height)
          gif.addFrame(canvas, { delay })
        } catch (error) {
          console.error(`Error processing image ${i + 1}/${images.length}:`, error)
          reject(
            new GifCreationError(
              `Error processing image ${i + 1}/${images.length}: ${(error as Error).message}`
            )
          )
          return
        }
      }

      console.log('All frames added, rendering GIF...')
      gif.render()
    }

    addFrames().catch(error => {
      console.error('Error in addFrames:', error)
      reject(error)
    })
  })
}

获取帧画面

计算帧画面时间点

接下来我们需要获取视频的帧画面

由于MediaRecorder录制的是webm格式,并且没有视频时长的信息,所以我们在看视频的时候会发现没有进度条,只有当视频全部播放完毕之后才能展示进度条。

这时候有几种方法可以解决:

  1. 可以在录制和结束的时候计算时间差,用fix-webm-duration将时间数据添加到blob中。
  2. 给webm格式的视频一个非常大的当前时间,之后再跳回开头
video.currentTime = 1e101
video.addEventListener("timeupdate", () => {
  console.log("after workaround:", video.duration)
  video.currentTime = 0
}, { once: true })

3. 将视频转码之后获取视频长度

我这里采用第二种方法(以后可以优化这个步骤)

获取到录制的视频长度是用来计算需要获取的帧画面数量,根据gif的帧率就可以计算出需要多少帧的画面来生成gif。

之后再根据帧率就可以计算每一帧对应视频的时间点。

比如我想gif的帧率是

通过这个信息就可以获取帧画面了。

获取帧画面

取视频的某一帧画面有几种方法:

  1. 将视频渲染到canvas上,通过canvas导出当前画面数据(需要将需要的时间点都展示一遍,速度较慢)
  2. 使用ffmpeg将视频转码之后直接获取某一帧画面

这里采用第一种方法

const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const context = canvas.getContext('2d')
context?.drawImage(video, 0, 0, canvas.width, canvas.height)
const imageData = canvas.toDataURL()
images.push({
  id: i.toString(),
  base64: imageData,
  delay: (1 / 24) * 1000,
})

合成

之后就可以使用images里的数据用我们封装的合成gif方法来生成gif

const ratio = 2
const width = video.videoWidth
const height = video.videoHeight
const blob = await createGif({
  images: images,
  options: { quality: 50, width: width / ratio, height: height / ratio },
})
const url = URL.createObjectURL(blob)
setPreviewUrl(url)

为了控制最终生成的gif大小,可以

  • 修改quality
  • 控制缩放大小,即上面的ratio
  • 控制gif帧率,即减少截取图片的数量
  • 限制帧画面的区域,后者压缩图片

否则最终生成的gif会很大

总结

  1. 我们使用了MediaRecorder来录制画面数据
  2. 使用渲染到canvas的方式获取视频帧画面
  3. 使用gif.js最终合成gif

这些步骤有很多优化项,比如

  • 可以写视频编辑组件来控制生成gif的范围和画面范围
  • 添加导出选项的表单,使用户可以控制gif的质量和大小
  • 优化截取视频帧画面的方法和速度

等等,希望有空可以多优化一下,当做一个小工具发布出来