在写文章的过程中,经常都会有使用gif图的需求;比如一些动画,单纯使用图片无法很好的展示效果,用视频的话又太臃肿了,所以gif图是非常好的选择
市面上也有很多录制gif的软件,比如www.screentogif.com/ 。但都需要下载安装软件才能录制,那可不可以构建一个网页版的gif录制工具呢?
首先录制gif有两个步骤:
- 首先需要录制用户的屏幕(只需要画面即可)
- 生成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}
/>
之后简单编写一下页面,添加一个录制的按钮,点击之后执行我们上面写的代码,开启录制屏幕内容。
这样我们就实现了第一步,成功获取录制了屏幕并且获取到了视频数据
生成GIF
生成GIF有两个方案:
- 在前端调用ffmpeg的wasm包,直接生成GIF
- 使用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格式,并且没有视频时长的信息,所以我们在看视频的时候会发现没有进度条,只有当视频全部播放完毕之后才能展示进度条。
这时候有几种方法可以解决:
- 可以在录制和结束的时候计算时间差,用fix-webm-duration将时间数据添加到blob中。
- 给webm格式的视频一个非常大的当前时间,之后再跳回开头
video.currentTime = 1e101
video.addEventListener("timeupdate", () => {
console.log("after workaround:", video.duration)
video.currentTime = 0
}, { once: true })
3. 将视频转码之后获取视频长度
我这里采用第二种方法(以后可以优化这个步骤)
获取到录制的视频长度是用来计算需要获取的帧画面数量,根据gif的帧率就可以计算出需要多少帧的画面来生成gif。
之后再根据帧率就可以计算每一帧对应视频的时间点。
比如我想gif的帧率是
通过这个信息就可以获取帧画面了。
获取帧画面
取视频的某一帧画面有几种方法:
- 将视频渲染到canvas上,通过canvas导出当前画面数据(需要将需要的时间点都展示一遍,速度较慢)
- 使用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会很大
总结
- 我们使用了
MediaRecorder
来录制画面数据 - 使用渲染到canvas的方式获取视频帧画面
- 使用gif.js最终合成gif
这些步骤有很多优化项,比如
- 可以写视频编辑组件来控制生成gif的范围和画面范围
- 添加导出选项的表单,使用户可以控制gif的质量和大小
- 优化截取视频帧画面的方法和速度
等等,希望有空可以多优化一下,当做一个小工具发布出来