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

227 lines
14 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`(桩环境跳过),像素级映射由单测覆盖。
---
## 部署后线上验证(用户已部署 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 定位像素包围盒**。
- 排查时**必须先建立黄金标准**再判定「我实现错了」,否则会去改本来正确的代码。