import { applyPalette, GIFEncoder, quantize } from 'gifenc'; import { paintCrop, type Crop } from './avatarCrop'; export interface GifFrame { bitmap: ImageBitmap; delayMs: number } /** 编辑器里保留的帧:尽量保留全部。太长的动图才隔帧取样(时长不变),避免把几百张大图都放进内存 */ const MAX_FRAMES = 400; const MAX_PIXELS = 120_000_000; const MIN_DELAY_MS = 20; /** 服务端对头像 data URL 的上限(字符数),留一点余量 */ export const MAX_GIF_DATA_URL = 5_900_000; /** 浏览器是否能把 GIF 拆成帧(WebCodecs ImageDecoder)。不能就退回成静态头像 */ export const canDecodeGif = (): boolean => typeof ImageDecoder !== 'undefined'; /** 解出 GIF 的各帧;不是动图、不支持或解码失败时返回 undefined(调用方按静态图处理) */ export async function decodeGif(file: File): Promise { if (file.type !== 'image/gif' || !canDecodeGif()) return undefined; const frames: GifFrame[] = []; const decoder = new ImageDecoder({ data: await file.arrayBuffer(), type: 'image/gif' }); try { await decoder.tracks.ready; const total = decoder.tracks.selectedTrack?.frameCount ?? 1; const { displayWidth, displayHeight } = decoder.tracks.selectedTrack?.frameCount ? await sizeOf(decoder) : { displayWidth: 0, displayHeight: 0 }; const stride = Math.max(1, Math.ceil(total / MAX_FRAMES), Math.ceil((total * displayWidth * displayHeight) / MAX_PIXELS)); let pending = 0; for (let index = 0; index < total; index += 1) { const { image } = await decoder.decode({ frameIndex: index }); pending += Math.max(MIN_DELAY_MS, (image.duration ?? 100_000) / 1000); if (index % stride === 0 || index === total - 1) { frames.push({ bitmap: await createImageBitmap(image), delayMs: pending }); pending = 0; } image.close(); } } catch { for (const frame of frames) frame.bitmap.close(); return undefined; } finally { decoder.close(); } if (frames.length > 1) return frames; for (const frame of frames) frame.bitmap.close(); return undefined; } async function sizeOf(decoder: ImageDecoder): Promise<{ displayWidth: number; displayHeight: number }> { const { image } = await decoder.decode({ frameIndex: 0 }); const size = { displayWidth: image.displayWidth, displayHeight: image.displayHeight }; image.close(); return size; } function toDataUrl(bytes: Uint8Array): string { let binary = ''; for (let at = 0; at < bytes.length; at += 0x8000) binary += String.fromCharCode(...bytes.subarray(at, at + 0x8000)); return `data:image/gif;base64,${btoa(binary)}`; } function encode(frames: readonly GifFrame[], crop: Crop, from: number, size: number, step: number, colors = 256): string { const canvas = document.createElement('canvas'); canvas.width = canvas.height = size; const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) throw new Error('当前浏览器不支持图片处理'); const encoder = GIFEncoder(); let delay = 0; frames.forEach((frame, at) => { delay += frame.delayMs; if (at % step !== 0 && at !== frames.length - 1) return; paintCrop(ctx, frame.bitmap, crop, from, size); const { data } = ctx.getImageData(0, 0, size, size); const palette = quantize(data, colors, { format: 'rgba4444', oneBitAlpha: true }); const transparentIndex = palette.findIndex((color) => color.length > 3 && color[3] === 0); encoder.writeFrame(applyPalette(data, palette, 'rgba4444'), size, size, { palette, delay: Math.round(delay), transparent: transparentIndex >= 0, transparentIndex: Math.max(0, transparentIndex) }); delay = 0; }); encoder.finish(); return toDataUrl(encoder.bytes()); } /** 把每一帧按同样的裁剪导出成动图;默认 256 像素、保留每一帧;只有超过上限(约 4.5 MB)才逐步缩小尺寸、再隔帧 */ export async function exportGifAvatar(frames: readonly GifFrame[], crop: Crop, from: number, maxChars = MAX_GIF_DATA_URL): Promise { for (const [size, step] of [[256, 1], [192, 1], [128, 1], [192, 2], [128, 2], [96, 2]] as const) { // 让出线程,编辑器的「处理中」能先画出来 await new Promise((resolve) => setTimeout(resolve, 0)); const url = encode(frames, crop, from, size, step); if (url.length <= maxChars) return url; } throw new Error('动图压缩后仍然过大,请换一张更短或更小的 GIF'); } /** * 动图的有损缩略图:尺寸和颜色压小,帧数尽量保持和原图一样(隔帧是最后的手段,会丢掉流畅度)。 * 控制在服务端上限(80 万字符)以内。列表、聊天里默认用它 */ export async function exportGifThumb(frames: readonly GifFrame[], crop: Crop, from: number): Promise { for (const [size, step, colors] of [[80, 1, 64], [64, 1, 48], [64, 1, 32], [48, 1, 32], [64, 2, 32], [48, 2, 32], [40, 3, 16]] as const) { await new Promise((resolve) => setTimeout(resolve, 0)); const url = encode(frames, crop, from, size, step, colors); if (url.length <= 780_000) return url; } throw new Error('动图缩略图生成失败'); }