GIF 制作工具,原本以为直接用现成的库就完事了,结果发现纯前端实现更有意思。这篇文章聊聊 GIF 格式的核心技术:LZW 压缩和 Median Cut 色彩量化。
为什么 GIF 这么难搞?
GIF 格式诞生于 1987 年,那时候的设计理念跟现在完全不同。最大的坑在于:GIF 只支持 256 色。现在的图片动辄几百万色,要塞进 256 色的框框里,还得保持画质,这就是色彩量化的难题。
另一个坑是 LZW 压缩。GIF 用的 LZW 算法是专利保护的(2003 年过期),但更重要的是,LZW 的实现细节很容易踩坑。比如码表溢出怎么办?Clear Code 什么时候发?这些细节文档里写得含糊,得靠实验摸索。
Median Cut:把几百万色砍到 256 色
Median Cut 是经典的色彩量化算法,核心思想很简单:把颜色空间切成 256 个方块,每个方块取中心点作为代表色。
算法步骤
function medianCut(pixels, maxColors) {
// 1. 统计所有颜色及其出现频率
const colorMap = new Map()
for (let i = 0; i < pixels.length; i += 4) {
const key = `${pixels[i]},${pixels[i+1]},${pixels[i+2]}`
colorMap.set(key, (colorMap.get(key) || 0) + 1)
}
// 2. 初始化:所有颜色放进一个桶
const buckets = [{
entries: Array.from(colorMap.entries()),
rMin: 0, rMax: 255,
gMin: 0, gMax: 255,
bMin: 0, bMax: 255
}]
// 3. 反复切分,直到桶数达到 maxColors
while (buckets.length < maxColors) {
// 找范围最大的桶
const bucket = findWidestBucket(buckets)
if (!bucket) break
// 沿最长边切分
const [left, right] = splitBucket(bucket)
buckets.splice(buckets.indexOf(bucket), 1, left, right)
}
// 4. 每个桶取加权平均色作为调色板
return buckets.map(b => computeAverage(b))
}
关键细节
为什么要按出现频率加权? 因为颜色出现的次数越多,对视觉影响越大。如果一个像素只出现一次,它被量化错了也无所谓;但背景色要是偏了,整张图都难看。
切分策略的选择:标准 Median Cut 按颜色数量均分,但更好的做法是按像素数量均分。这样可以避免一个桶里塞了几百万像素,另一个桶只有几个像素的情况。
LZW 压缩:GIF 的灵魂
LZW 是一种字典编码算法,核心思想是用短编码代替重复出现的字符串。GIF 的 LZW 有几个特殊点:
1. 变长编码
LZW 的编码长度是动态增长的。初始码长是 minCodeSize + 1,随着字典增大,码长逐步增加到 12 位。一旦字典满了(4096 项),就发 Clear Code 重置字典。
function packBits(codes, minCodeSize) {
const clearCode = 1 << minCodeSize
const endCode = clearCode + 1
let codeSize = minCodeSize + 1
let nextCode = endCode + 1
for (const code of codes) {
// 把编码塞进位缓冲区
bitBuffer |= (code << bitCount)
bitCount += codeSize
// 每凑够 8 位就输出一个字节
while (bitCount >= 8) {
bytes.push(bitBuffer & 0xFF)
bitBuffer >>>= 8
bitCount -= 8
}
// 字典满了,发 Clear Code
if (nextCode >= 4096) {
codes.push(clearCode)
nextCode = endCode + 1
codeSize = minCodeSize + 1
}
// 码长增长
else if (nextCode >= (1 << codeSize) && codeSize < 12) {
codeSize++
}
}
}
2. 字典构建
LZW 的字典是边压缩边构建的。每次输出一个编码,就把"当前编码 + 下一个像素"加入字典。
function lzwEncode(indexedPixels, minCodeSize) {
const dict = new Map()
let w = indexedPixels[0]
for (let i = 1; i < indexedPixels.length; i++) {
const k = indexedPixels[i]
// w+k 在字典里,继续扩展
if (dict.get(w)?.has(k)) {
w = dict.get(w).get(k)
}
// w+k 不在字典里,输出 w,把 w+k 加入字典
else {
codes.push(w)
if (!dict.has(w)) dict.set(w, new Map())
dict.get(w).set(k, nextCode++)
w = k
}
}
codes.push(w)
return codes
}
3. 性能优化
原始 LZW 实现用 w+k 作为字典键,字符串拼接很慢。优化方案是用嵌套 Map:第一层 Map 的键是前缀编码,第二层 Map 的键是后缀像素值。这样查找从 O(n) 降到 O(1)。
GIF 文件结构
GIF 文件是按块组织的,主要包含:
GIF89a Header
Logical Screen Descriptor
Netscape Application Extension (循环播放)
┌─ Graphics Control Extension (延迟、透明)
│ Image Descriptor
│ Local Color Table
│ LZW Image Data
└─ (重复每一帧)
GIF Trailer
延迟时间的坑
GIF 的延迟单位是 10 毫秒,不是 1 毫秒。而且很多播放器会强制最小延迟为 20ms(2 个单位),所以你设 10ms 实际播放可能是 20ms。
循环播放
GIF89a 标准本身不支持循环播放,是 Netscape 加的扩展。循环次数 0 表示无限循环,1 表示播放 1 次(总共播放 2 次)。这个坑我踩了好久。
实战经验
做 JsonKit 的 GIF 制作工具时,遇到几个性能问题:
色彩量化太慢:原始实现遍历所有像素找最近色,O(width × height × paletteSize)。优化方案是用 KD-Tree 或预先建立颜色查找表。
LZW 压缩太慢:字典查找是瓶颈。用嵌套 Map 替代字符串拼接后,速度提升了 10 倍。
内存爆炸:处理大图时,Canvas 的 getImageData 会返回巨大的数组。解决方案是分块处理,或者用 Web Worker 避免阻塞主线程。
最终效果
相关工具
总结
GIF 格式虽然古老,但背后的技术很有意思。LZW 压缩是早期无损压缩的经典算法,Median Cut 是色彩量化的基石。理解这些原理,不仅能写出更好的 GIF 工具,对理解现代图片格式(WebP、AVIF)也有帮助。
完整代码在 JsonKit 的 GIF Maker 工具里,欢迎试用。