Files
tamabox/.workbuddy/memory/2026-10-15.md
T
2026-10-18 21:14:59 +08:00

14 KiB
Raw Blame History

2026-10-15

WebP 压缩链路评审(只读,未改代码)

产出:outputs/webp-compression-review-2026-10-15.md

关键事实(已实证)

  • internal/storage/s3.go:186 的 maxWidth=1920 + Quality:80 对所有用途一刀切; 前端 profile.html:1390 头像 cropper 只出 512,服务端却按1920 存 → 头像实测多传 ~7倍。
  • chai2010/webp v1.4.0 的 Options 只有 Lossless/Quality/Exact; C 层是裸 WebPEncodeRGB/RGBA(internal/src/webp.c:194-215)= cwebp method4 默认档。 → method/sns/filter/alpha_q/near_lossless 全都不可控。实测 method6 再省 3~6%。
  • 🔴 Dockerfile:16 用 Alpine apk 但未装 webp 包 → 容器内无 cwebp → s3.go:201 LookPath 必失败 → GIF 动画 WebP 分支是死代码,线上一直原图 GIF,且无日志。
  • 🔴 s3.go:99 / page.go:707 用 image.Decode 不读 EXIF Orientation; templates/question/item.html:206,238 的 accept="image/*" 是原图直传(无 canvas 归一) → 手机竖拍照片转 WebP 后横躺。
  • 缺 len(webp) >= len(orig) 回退兜底(小图/线框图可能越转越大)。
  • format=="webp" 会二次有损重编(实测 160KB→140KB,体积收益不确定但画质丢一代)。
  • s3.go 对同一张图解码两遍(99 行取format + 181 行完整解码),4096² 下峰值 ~64MB/张。
  • route/question/page.go:740 PutObject 缺 CacheControl(s3.go:158 有 immutable)。
  • s3.go:187 只判 Dx()>1920,不限制长边 → 竖图 1080×10000(10.8M 像素 < 16.7M 上限)不缩放。

实测方法

本机无 libwebp/GCC,Go 侧未编译。体积基线用 Pillow(绑定同一份 libwebp 1.4.0) 对 outputs/*.png 真实截图测 m4/m6、q75/80/85、lossless。 imaging.Lanczos vs Pillow LANCZOS 的差异未纳入。 method=6 的编码耗时未实测 —— 这是决定是否采用的关键约束(m6 约为 m4 的 5~10 倍耗时,1C1G 需实测)。

方案分批

  • 批次 A(低风险):按用途分档(头像 512/q78、背景 1920/q80、通用 1600/q80)、 加大小回退、webp 透传、限长边、补 CacheControl、GIF 缺 cwebp 补 Warn。
  • 批次 B:method=6(需先Dockerfile 装 webp + 实测耗时)、EXIF 归一、统一 GIF 实现、降内存。

批次 A 已落地(同日)

用户确认不使用 Docker(webp 包/cwebp 相关影响可忽略),故批次 A 全部走 Go 侧实现。

改动文件

  1. internal/storage/s3.go(LF)
    • 新增导出 EncodeSpec{MaxSide, Quality} + 档位 avatarEncodeSpec(512/78)、 backgroundEncodeSpec(1920/80)、导出 GeneralEncodeSpec(1600/80) (导出是因为 route/question/page.go 要传参给 ConvertStaticImageToWebP)。
    • uploadPictureToS3 新增第 4 参 spec EncodeSpec;3 个 Upload* 入口各自传档位。
    • ConvertStaticImageToWebP(r, spec):签名变了,用 imaging.Fit 同时限宽高 (原 Resize(img, maxWidth, 0, ...) 只限宽,1080×10000 竖图不缩放)。
    • 新增 case "webp":已是 WebP 且尺寸合规 → 原样透传,不再二次有损。
    • 所有转换分支加 len(webpData) >= len(原图) 回退兜底。
    • cwebp 缺失时 logrus.Warn(原先完全静默);新增 sirupsen/logrus 导入。
  2. route/question/page.go(CRLF,792→794 行)
    • 同款 webp 透传 + 体积兜底;用 image.DecodeConfig 拿尺寸避免多余全量解码。
    • PutObject 补 CacheControl: public, max-age=31536000, immutable。
  3. dev/s3stub/s3_stub.go、dev/webpstub/webp.go:桩签名同步。
  4. 新增 internal/storage/encode_spec_test.go(fitsWithin 边界 + 档位值断言)。

验证结果(全部实测)

  • 编译:本机无 libwebp/GCC,用临时副本 + go mod edit -replace github.com/chai2010/webp=../webpstub 让真实 s3.go 参与编译(比 s3stub overlay 强,后者会把 s3.go 换成桩、等于没检查它)。 go build ./... EXIT=0,go vet ./... EXIT=0。
  • 测试:go test ./internal/...(带 PG)全绿,含 internal/storage。
  • 兜底逻辑有效性实测(Pillow,同 libwebp 1.4.0):
    • 头像真实路径(前端 cropper 出 512×512 PNG,quality:null 无损)→ WebP 10,486 vs PNG 103,054,用 WebP。
    • 线框图 200×200 → WebP 908 vs PNG 652,回退 PNG(兜底生效,这就是不加兜底会踩的坑)。
    • 纯色 1600×900 → WebP 2,672 vs PNG 6,390,用 WebP。

关键坑

  • go build -overlay 不能替换 GOMODCACHE 下的文件(Go 明确报错)。 唯一可行的本机方案是复制项目到临时目录 + go mod edit -replace 到本地 stub 模块。
  • 前端头像 cropper 输出 PNG(profile.html:1391 fileType:'image/png', quality:null), 背景是 jpeg q0.92 —— 服务端转 WebP 的收益主要来自这里。
  • 站点模板实际展示尺寸:头像 120px(.tb-frame-avatar),背景铺满宽度, 私聊图 max-width:75%,提问图 100×100 缩略。→ 头像档 512 有据可依。

批次 A 未覆盖

  • method 仍锁在 4(chai2010/webp 只暴露 Quality/Lossless/Exact)。
  • EXIF 方向未处理、GIF 双实现未统一、同一张图仍解码两遍 —— 见下方批次 B。

批次 B1+B2 已落地(同日,用户批准)

用户澄清:生产是裸 Linux(不用 Docker),且能确保部署机装了 cwebp。

🔴 重要:原报告的 method=6 结论被实测推翻

原报告写「m6 耗时是 m4 的 5~10 倍」——未实测,错的。实测(取中位数、颠倒顺序防干扰):

场景 m4 m6 倍数 体积差
1600x4830 (7.7MP) 1867ms 2229ms 1.2x -2.9%
1920x1080 背景 464ms 655ms 1.4x -3.3%
1200x800 配图 253ms 492ms 1.9x -3.0%
512x512 头像 60ms 81ms 1.4x -3.5%

结论:代价远小于预估,但收益只有 3%——1.2~1.9x CPU 换 3% 不划算,决定不做 method=6。 (另注:Pillow 绑的是 libwebp 1.6.0,项目是 1.4.0,耗时数值仅供相对比较。)

B1 EXIF 方向归一

  • 新增 internal/storage/exif.go:jpegOrientation(手工解析 APP1/Exif + TIFF IFD0, 不引新依赖,项目原本无 EXIF 库)、applyJPEGOrientation(走 orientedImage + draw.Draw 一次性拷贝,支持全部 8 种方向)。
  • 接入 ConvertStaticImageToWebP,顺序必须是「先归一方向,再限边缩放」—— 归一会交换宽高,顺序反了会按错误的边长做缩放判断。
  • 为何不可事后修:实测转码成 WebP 后 EXIF 被整体丢弃(字节流无 Exif 标记), 方向信息永久消失。且 image.Decode 与 Pillow 默认都不读 EXIF,已实证。

B2 GIF 统一

  • convertGifToAnimatedWebP 导出为 ConvertGifToAnimatedWebP,page.go 的 GIF 分支 改为复用同一实现(此前 page.go 完全跳过转换)。
  • 新增 maxGIFFrames = 200 + gifFrameCount(遍历数据块统计 Image Descriptor, 不解码像素,常数级开销)。CheckPixelLimit 只看 GIF 逻辑屏尺寸, 500 帧 2000x2000 的 GIF 体积小但会让 cwebp 长时间吃 CPU。
  • cwebp 参数补 -metadata none。
  • 两处 GIF 分支都加 len(webp) >= len(原图) 兜底。

验证

  • go build ./... EXIT=0、go vet ./... EXIT=0、 go test ./internal/... ./route/...(带 PG)全绿。
  • 新增测试 exif_test.go(JPEG 手工构造 APP1 段,覆盖大小端/越界/截断/非 JPEG; 8 种方向逐像素校验)、gif_test.go(1/12/200/201 帧 + 垃圾输入)。

踩坑记录(都是我自己写错的)

  1. gifFrameCount 恒返回 0:漏跳 7 字节 Logical Screen Descriptor,

    pos 停在 LSD 首字节(宽度低位)→ 首轮命中 default 返回 0。 LSD 的 packed 在 data[10](=pos+4),必须无条件 pos += 7 再按 flag 加色表。

  2. buildJPEGWithOrientation panic:entry[10:12] 只有 2 字节却调 PutUint32。 IFD 条目固定 12 字节,next-IFD 偏移占最后 2 字节,保持 0 即可。

  3. image.Config 没有 Dx()/Dy(),是 Width/Height 字段。

  4. color.RGBA().RGBA() 返回 alpha 预乘值,不能简单和 v<<8 比较, 要四个通道一起比。

  5. WebP 桩不产出字节 → 依赖真实编码器的端到端断言在本机无法成立, 已改为 t.Skip(桩环境跳过),像素级映射由单测覆盖。


部署后线上验证(用户已部署 box-dev-api)

新增 dev/devcheck-avatar.py(头像链路,可复跑)。A 分档线上确认生效: 上传 1600x1600 JPEG(124,941 B)→ 存储为 512x512 WebP, 1,496 B。

🔴 线上验证暴露的真实缺口(待修)

EXIF 归一的位置太晚,比例校验看不到归一后的方向。

  • s3.go:110 asp.check() 只用 image.DecodeConfig 拿宽高,不读 EXIF;
  • EXIF 归一在 ConvertStaticImageToWebP 内部(第 240+ 行),晚于比例校验。
  • 后果:手机竖拍照片(像素 4032x3024 + Orientation=6,实际显示 3024x4032) 直传头像会被判「比例不符 1:1(偏差 0.333)」而拒绝——尽管用户看到的是方形。 实测线上确实如此:传 1536x512+EXIF=6 被拒,头像 URL 未变。
  • 前端走 cropper(canvas 会归一方向)不受影响,故只影响绕过前端的直传。
  • 修法方向:asp.check 先读 jpegOrientation,对 5/6/7/8 交换宽高再比比例。 别为一次校验就完整解码(大图内存),只做 EXIF 段解析 + 宽高交换即可。

验证脚本踩坑

  • 提问成功页不渲染图片(templates/question/success.html 无图片区), 图片只在 question.go:50 的详情页渲染;且提问图 key 不带 picture/ 前缀 (是 <年>/<月>/<纳秒>.<ext>),头像才带 picture/。→ 断言正则要分开写。
  • /user/profile/update 是完整资料表单,UpdateProfile 有 12+ 个 required 字段 (internal/form/user.go:7)。只发 _csrf 会被挡在 Bind 阶段、头像根本不上传。 做法:从 profile 页抓取所有 input/select/textarea 的 name=value 回填。
  • 头像上传成功是 302,头像 URL 在跳转后的页面里(不是响应体)。

补完 B1 + B3(2026-10-15 夜)

B1 补完:EXIF 比例校验缺口

  • aspect.check 签名改为 (cfg image.Config, orientation int) error——不再自己DecodeConfig, 由调用方传入已解析的宽高,顺带消除一次重复的文件头读取。
  • 内部:orientationSwapsDimensions(orientation) 为真(5/6/7/8)则交换 w/h 再比比例。
  • imagecheck 新增 CheckAspectRatioSize(w, h, wantW, wantH, tol); CheckAspectRatio(r,...) 改为薄封装(内部走 DecodeConfig + 调Size 版),行为不变。
  • exif.go 抽出 orientationSwapsDimensions,applyJPEGOrientation 里的 switch 改为调它(去重)。

B3:合并重复解码(峰值内存减半)

  • 根因:uploadPictureToS3 里 image.Decode 为拿 format 解一遍 → ConvertStaticImageToWebP 内再解一遍。
  • 改法:image.DecodeConfig 一次同时拿 format + 宽高(不解像素), 供「像素上限校验 / 比例校验 / WebP 透传判断」三处共用;完整解码只发生在真正要转码时。
  • ConvertStaticImageToWebP 签名 (io.Reader, spec) → ([]byte, spec): 调用方手里已有原始字节,不必再包一层 bytes.Reader(也不必内部 io.ReadAll 复制)。 内部随之删掉重复的 CheckPixelLimit(调用方已在解码前校验过)。
  • route/question/page.go 同步:删掉 image.Decode取 format 与 DecodeConfig取 cfg 的两次调用, 改为一次 DecodeConfig;新增本地 fitsWithinSide(cfg, maxSide)。
  • dev/s3stub/s3_stub.go 同步签名(去掉 io import)。
  • 实测堆峰值增量(runtime.MemStats,含保留引用强制不回收):
    输入 一次解码 两次解码
    JPEG 4000×3000 (2.3MB) 18.1 MB 36.1 MB
    PNG 3000×3000 (33KB) 36.1 MB 72.1 MB
    → 正好 2 倍。1C1G 上并发上传大图时这是决定性的。

线上验证通过(2026-10-16 上午)

脚本:dev/devcheck-upload.py(取代 dev/devcheck-avatar.py),12/12 全通过。

⚠️ 重要纠正:头像路径测不出 EXIF 比例校验缺口

头像要求 1:1 是对称比例 —— EXIF 归一(5/6/7/8 交换宽高)后比例不变。 所以修没修缺口,头像路径表现完全一样。之前 devcheck-avatar.py 那个 「1536x512 + ori6」用例根本测不到点上(3:1 和 1:3 都不是 1:1,两边都会被拒)。 能触发缺口的是背景 16:9(非对称)。

核心用例(背景 16:9 + EXIF Orientation=6)

像素 1080x1920(竖,比例 0.5625)+ ori6 → 肉眼看到 1920x1080(16:9)

  • 修复前:按像素判比例,偏差 |0.5625 - 1.7778| = 1.215 >> 0.01 → 被误拒
  • 修复后:先交换宽高,偏差 0 → 放行

线上实测数据

用例 输入 存盘结果
A 分档头像 1600×1600 JPEG 125,865 B webp (512,512) 1,542 B
背景正向对照 1920×1080 JPEG 93,484 B webp (1920,1080) 7,582 B
背景 EXIF ori6 1080×1920 JPEG 138,842 B webp (1920,1080) 9,900 B
反向用例 1080×1920 无 EXIF 被 16:9 校验拒绝 ✅ 未把校验放宽

像素级方向验证(尺寸对≠ 方向对)

与 Pillow ImageOps.exif_transpose 作黄金标准比「红色标记条包围盒」:

黄金标准(Pillow exif_transpose)  红条bbox=(0, 0, 1916, 88) -> 顶部
线上存盘(本项目 WebP)            红条bbox=(0, 0, 1916, 88) -> 顶部

完全一致 → 方向归一正确。这条已固化进脚本(redness_bbox() + 一条断言)。

踩坑

  • 用「上下左右取平均亮度」判断红条位置不可靠:采样带(H//10=108px)比红条本身 (w//12=90px)还宽,红色被稀释成背景色 → 误判「方向未归一」。 正确做法是按颜色阈值 r>150&&g<90&&b<90 定位像素包围盒。
  • 排查时必须先建立黄金标准再判定「我实现错了」,否则会去改本来正确的代码。