
大多数人说起 GIF第一反应是「不就是个会动的图片吗」。但当你真的要亲手生成一个.gif文件时会发现它是一套非常具体、而且极易写错的小标准变长码 LZW、LSB-first 位打包、4096 字典上限、块顺序错一处就拒播。我给自己定的规矩是每个工具都必须是一个单文件、零依赖、可离线打开的 HTML 文件——代码你看得懂、改得动、也拥有它。GIFForge 就是在这条规矩下从零手写出来的一个离线 GIF 制作器。这一篇不罗列功能只讲我最较劲的一件事手写编码器最大的风险不是写不出来而是它「看起来能播」其实「根本不对」——以及我怎么用一套自证闭环逼它正确。一、为什么要在「单文件工具」里做 GIF一句话背景GIFForge 是 nano-tools 矩阵里 118 个工具之一。这个矩阵的全部工具都遵守三条铁律——单文件整个工具就是一个.html可下载、可邮件、可丢 U 盘零依赖不引任何外部 CDN / 框架 / 字体断网也能跑本地优先数据不出本机没有后端、没有埋点、没有账号。在这三条约束下做 GIF等于主动放弃所有现成轮子不能用 Canvas 的 GIF 导出不能引任何编码库。唯一能依赖的是你自己写的字节。二、GIF89a 不是像素流是块流一个 GIF89a 文件从头到尾是这些块按顺序拼起来的图 1GIF89a 由一串「块」拼成顺序或长度字段错一处播放器就直接拒播。Header GIF89a 6 字节 LSD 逻辑屏幕描述符宽高、调色板标志 7 字节 GCT 全局调色板RGB×NN 为 2 的幂 NETSCAPE2.0 循环扩展让动图永远循环 GCE 图形控制扩展延迟、透明色、处置法 Image Desc. 图像描述符帧位置、尺寸 LZW 数据子块 真正压缩后的像素按 255 字节分块 ... 每帧重复 GCE Image Desc. LZW Trailer 0x3B 结尾标记理解这一点很关键GIF 不是「像素流」而是「块流」。只要每一块的长度字段和字节序都对播放器才认。这也决定了我的内核gfBuildGif(opts)本质上就是一个「按规范把字节 push 进数组」的组装器。三、LZW 变长码我改到第三天才信它GIF 的压缩用的是LZWLempel–Ziv–Welch而且是 GIF 定制的变长码版本。入口函数是function gfLzwEncode(indices, minCodeSize) { if (minCodeSize 2) minCodeSize 2; var CLEAR 1 minCodeSize, EOI CLEAR 1; var codeSize minCodeSize 1, next EOI 1; var dict Object.create(null); var out [], acc 0, nbits 0; function emit(code) { acc | code nbits; nbits codeSize; while (nbits 8) { out.push(acc 255); acc 8; nbits - 8; } } emit(CLEAR); // ...每遇到新前缀就 emit 当前码再决定要不要升位、要不要清表 }三个必须盯死、而且我每个都踩过坑的点1. 码宽升位的时机是坑。初始码宽minCodeSize 1字典从EOI 1填起。每往字典加一个新词条如果「下一个要写入的码」已经放不进当前码宽就把码宽 1if (next (1 codeSize) codeSize 12) codeSize; dict[key] next;GIF 的编码器是「先 emit 当前前缀再决定是否升位」而不是「升位后再 emit」。我第一版升位早了一拍——自己写的参考解码器直接抛bad LZW code因为解码端按规范在「写入词条之后」才升位两边错位一个码整条流就废了。2. 位打包是 LSB-first低位在前。这和绝大多数格式的 MSB-first 相反acc | code nbits把码拼到右移窗口的低位攒满 8 位吐一个字节。顺序搞反解码端读出来的全是错码。3. 字典满 4096 必须清表重置。否则码宽会突破 12 位上限。这三点单独看都不难难的是它们叠在一起时任何一处差一拍结果都是「文件能生成、某些播放器能播、但另一些直接黑屏」——这正是手写编码器最阴险的地方。四、颜色量化中位切分不是玄学真彩图不能直接进 GIF——每帧最多 256 色所以要先量化。gfQuantize(pixels, maxColors)用的是经典的中位切分median cut先对像素去重颜色本来就少时直接保真再在「通道极差最大」的通道上按中位数切开最后每个盒子取均值。target.sort(function(a, c){ return a[chn] - c[chn]; }); var mid target.length 1; boxes.splice(bi, 1, target.slice(0, mid), target.slice(mid));为了让色带不那么难看索引阶段还加了4×4 Bayer 有序抖动做轻微误差扰动——不开抖动时干净开了更有胶片感。五、怎么证明它对这是全文的重点手写编码器最大的风险不是写不出来而是「看起来能播」其实是个巧合。我的做法是永远先写一个独立的 oracle参考实现再用它对拍。在_test.js里我没有依赖任何外部 GIF 库而是手写了两个东西一个独立的参考 LZW 解码器lzwDecode(bytes, minCodeSize)——它走的是规范里「解码端」那条顺序专门给编码器当裁判一个极简 GIF 结构解析器parseGif(bytes)——只认块结构把每帧的 LZW 子块和 GCE 抽出来。然后做encode → decode round-trip断言并且刻意覆盖了边界用例目的空流 / 单像素最小边界[0,1,2]/[0,1,2,3]码宽从 2→3 切换的临界点[1,1,1,…]KwKwK经典 LZW 重复串20000 像素伪随机强制字典触发 4096 重置10000 个相同值验证压缩率应远小于原大小function roundTrip(indices, minCode, name) { const enc GF.gfLzwEncode(indices, minCode); const dec lzwDecode(enc, Math.max(2, minCode)); arrEq(dec, indices, name round-trip); }但这还不够「真」。round-trip 只能证明「我的编码器和我自己写的解码器自洽」不能证明「Chrome / ffmpeg / 图像库认它」。所以我又做了交叉验证用sharp/libvips真实解码gfBuildGif导出的 GIF确认主流工具能正常读、能取到正确帧数与调色板。这一套自证闭环也是整个 nano-tools 矩阵「2437 个断言、0 失败」里的一部分。单个工具的可信度是靠这种「先有 oracle再对拍最后交叉验证」的纪律堆出来的而不是靠肉眼看它动没动。六、它的边界诚实地说round-trip 证明的是「自洽」不是「所有播放器都认」——所以我才加sharp/libvips交叉验证来补这一环GIF 单帧仍受256 色调色板和4096 字典上限约束超大真彩图会损失细节它解决的是「生成合法 GIF」不解决「怎么把视频优雅地降采样成好看的调色板」——那是另一回事。七、收尾GIFForge 现在是矩阵里 118 个工具之一是一个 0 依赖、可离线、代码全在你手里的单文件工具。它没什么黑科技就是把一个 30 多年前的老格式老老实实按规范实现了一遍并给它配了一套能自证的测试。完整内核含上面所有函数的逐行实现和测试开源在 GitHub名字就叫GIFForgeGitHub - wangzifan396-wzf/GIFForge: Offline GIF maker: drop images or slice a sprite sheet into frames, reorder, set delays and looping, export via a pure-JS GIF89a encoder (LZW median-cut). Single file, zero dependencies, local-first PWA. · GitHub