Files
tamabox/.workbuddy/memory/2026-10-15.md
T
2026-10-15 11:41:19 +08:00

135 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`(桩环境跳过),像素级映射由单测覆盖。