一个 1987 年的格式,还在承载今天的情绪

GIF 诞生于 1987 年,比万维网还早。图像格式换了一代又一代:JPEG 压出了照片,PNG 修了色深与透明的短板,WebP 和 AVIF 又把压缩率翻了几番——但表情包和客服对话里的动图,十有八九还是 GIF。原因不难猜:它简单、宽容、无处不在,一个只认字节的块结构,任何年代的解析器都能读,任何年代的编码器都能写。

但「无处不在」的另一面是:只要你想对动图动手——抽帧导出、把图片序列合成 GIF、把 GIF 转成视频——前提都是先读懂它的字节。这篇文章把 GIF 从第一个字节拆到帧合成,重点讲清三件事:LZW 变长码怎么解、帧与帧之间怎么叠、交织行序为什么存在。

从头六个字节读起

GIF 的开头是六字节的签名:GIF87a 或 GIF89a。两个版本的关键差别在 89a 补上了图形控制扩展与循环扩展——今天所有会动的 GIF,几乎都是 89a。紧随其后的是逻辑屏幕描述符(Logical Screen Descriptor):宽、高各占 2 字节小端整数,定义整幅画布的尺寸;然后是 1 个打包字节,它把几件事压进 8 个位——最高位是全局色表标志,低 3 位 N 给出全局色表的颜色数 2^(N+1),比如 N=7 就是 256 色;再往后是背景色索引和像素宽高比各 1 字节。如果全局色表标志为 1,文件在这里紧跟色表本体:每个颜色 RGB 三字节,256 色就是 768 字节。

1
GIF89a | 宽u16 高u16 | 打包字节 | 背景色 | 宽高比 | [全局色表] | 数据块流… | 0x3B

之后的文件体就是一串数据块:0x21 开头的扩展块(图形控制、应用扩展、注释等),0x2C 开头的图像描述符,直到 0x3B 文件结束符。GIF 没有中央索引,解码器就是在这个块流上顺序走一遍。

LZW:一场变长码的精巧游戏

图像数据本身经过 LZW 压缩。LZW 的思想是边解码边重建字典:编码器把重复出现的像素串编成越来越短的码,解码器按同样的规则把字典长回来,全程不需要在文件里存字典。

GIF 版的 LZW 有一层自己的包装。图像描述符之后有一个「最小码长」字节,通常取色表的位深——256 色表就是 8。真正读码时,起始码长是最小码长加 1:8 位码长表达不了那么大的字典,所以从 9 位起步。清理码与结束码是保留值——256 色下即 256 和 257,前者把码表重置回初始状态,后者宣告数据结束;新条目从 258 开始增长。码长也不是固定的:当新条目编号增长到 2^codeSize(现有码长刚好表达不了下一个码)时,码长加 1;涨到 12 位后封顶——字典上限 4096 项——此后码长不再增长,继续用 12 位解码,等清理码到来才重置。高频动图突然「卡」一下重新渐进,多半就是流里出现了一个清理码。

还有两个容易踩的坑。其一是 KwKwK 特例:解码器偶尔会读到一个恰好等于「下一个未定义码」的码,此时字典里还没这个词。规则是先输出上一个字符串、再把它追加进字典——上一个字符串加上它自己的首字符,正是编码器此刻刚定义的新词。先输出、后入表,顺序不能反。其二是位流方向:GIF 的码按 LSB 优先打包进字节,先到的码占低有效位,这与许多实现默认的 MSB 优先相反,读错了解出的就是乱码。

1
2
3
4
5
6
7
8
codeSize = minCodeSize + 1            // 起始码长 = 最小码长 + 1,位流 LSB 优先
loop 读 code:
  if code == 清理码: 码表重置, codeSize 复位; continue
  if code == 结束码: break
  str = 码表[code] ?? 码表[prev] + 首(码表[prev])   // 后半即 KwKwK 特例
  输出 str 的像素; 码表[nextCode++] = 码表[prev] + 首(str)
  if nextCode == 2 ** codeSize && codeSize < 12: codeSize++   // 12 位封顶
  prev = code

帧不是全幅画面:合成与 disposal

很多人以为 GIF 每帧都是完整图片,其实多数 GIF 只有第一帧是全幅,后面每帧只带一小块增量矩形。图像描述符里除了宽高,还有本帧在画布上的 left/top 偏移;解码器的工作就是把这一小块按规则叠到整幅画布上。

叠加规则由图形控制扩展(GCE)约定,它携带三个关键字段:延时、透明索引和 disposal 方法。延时字段以 1/100 秒为单位,写 5 就是 50 毫秒——有意思的是浏览器对 0 或 1 这类过小值会钳制到约 100 毫秒再播,这是早期低端机播不动快 GIF 留下的兼容惯例,如今成了所有渲染器的默契。透明索引指向色表中的某个下标,绘制时遇到这个下标的像素就跳过,让画布上已有的内容透出来——GIF 的「镂空」动画帧全靠它。

disposal 描述本帧展示完之后画布怎么处理:0 和 1 什么都不做,增量直接叠在上一帧上;2 是恢复背景色,把本帧矩形清回透明,适合每帧独立、互不叠加的动画;3 是恢复上一帧,绘制前先把画布存一份快照,展示完再还原——适合「局部反复重画、背景保持不动」的场景。三种 disposal 的取舍,直接决定动画是正常接力还是糊成一团、鬼影重重。

局部色表是另一处容易忽略的细节:每帧可以自带色表覆盖全局色表,讲究的 GIF 用这招给不同帧配不同调色板。而交织(interlace)则是拨号时代的遗产:开启后像素行不按 0,1,2 顺序存,而是分四遍扫描——第 0,8,16… 行、第 4,12,20… 行、第 2,6,10… 行、最后第 1,3,5… 行。下载到四分之一数据就能看到全图轮廓,之后逐遍变清晰。今天带宽充裕,交织只剩兼容价值,但解码器必须把行序还原回来,否则画面会被抽成筛子。

循环:一个浏览器的既成事实

GIF89a 规范里其实没有「循环播放」这个概念。循环是 Netscape 浏览器用应用扩展(0x21 0xFF,标识 NETSCAPE2.0)私自加上的,后来成了事实标准:扩展子块里带一个循环次数,0 表示无限循环,没写这个扩展的 GIF 只播一遍。今天你在网上看到的几乎每个动图都带着 NETSCAPE2.0——一个浏览器厂商的名字,在格式里活到了现在。

不用现成解码库的理由

站点的 🔧 GIF抽帧 把上面全部逻辑——LZW 解码、帧合成、disposal、交织还原、循环扩展——装在一个几百行的零依赖纯 JS 文件里,浏览器和 node 通用。为什么不用现成的解码库?因为工具链的另一半在测试:纯函数不碰 DOM、不碰 canvas,才能在 node 里直接跑单测——目前的用例共 79 项断言,其中关键一组用编码器做 roundtrip 验证:把索引像素编码成 GIF 再解回来,逐像素比对是否还原。这种测试依赖「输入输出都是纯数据」,引一个绑定浏览器 API 的库反而做不到。

从字节到成品

读懂格式是为了改造它。想把图片序列变成动图,🔧 GIF制作工具 在浏览器里完成调帧率、排顺序与合成;想让 GIF 变小,方向不是继续折腾 GIF 本身,而是转视频——调色板加 LZW 这套组合对付全彩视频是灾难:256 色的量化损失加上不针对时域的压缩,文件又大又糊;而 H.264 的帧间压缩只要参考帧不动、后续帧几乎只存差异,体积往往小一个量级。🔧 GIF转视频 做的就是这件事,浏览器本地逐帧解码重录。

从抽帧、合成到转视频,动图处理的整条线都收在视频创作工具箱专题里。