227 lines
14 KiB
Markdown
227 lines
14 KiB
Markdown
# 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 定位像素包围盒**。
|
||
- 排查时**必须先建立黄金标准**再判定「我实现错了」,否则会去改本来正确的代码。
|