Files
tamabox/outputs/webp-compression-review-2026-10-15.md
T
2026-10-15 11:41:19 +08:00

186 lines
8.6 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.
# WebP 压缩链路评审(2026-10-15)
范围:`internal/storage/s3.go`(上传管线)、`route/question/page.go`(提问/回答图片)、
依赖 `github.com/chai2010/webp v1.4.0` + `disintegration/imaging v1.6.2`。
结论:**提升空间很大,且主要不在「调 quality」上,而在「按用途分档 + 修方向 + 兜底回退 + 编码器能力上限」四件事。**
最肥的一块:头像服务端不缩到实际展示尺寸,实测多传 ~7 倍字节。
---
## 一、现状事实(已核对代码)
| 位置 | 现状 |
|---|---|
| `s3.go:186-195` | `maxWidth=1920` 对**所有**用途统一生效;`webp.Options{Quality: 80}`,无 `Lossless`、无 `Exact` |
| `s3.go:113-133` | GIF 走 `cwebp -q 80 -mixed -mt`;静态走 `ConvertStaticImageToWebP`;失败回退原图 |
| `s3.go:200-203` | `cwebp` 不在 PATH 时静默回退原始 GIF(无日志) |
| `route/question/page.go:713-734` | GIF **完全不转换**,直接原样上传 —— 与 `s3.go` 行为不一致 |
| `route/question/page.go:740-748` | 该分支 PutObject **未设 CacheControl**(`s3.go` 有 immutable) |
| `route/question/page.go:721` | 提问图片与头像共用同一函数,同样 1920/80 |
`chai2010/webp` 的 `Options` 只有三个字段:
```go
type Options struct { Lossless bool; Quality float32; Exact bool }
```
即 **method(0-6)、sns_strength、filter_strength、alpha_quality、near_lossless 全部不可控**。
C 层是裸 `WebPEncodeRGB` / `WebPEncodeRGBA`(`internal/src/webp.c:194-215`),等价于 cwebp 默认档
(method=4)。
---
## 二、体积实测(本机 Pillow 绑定同一份libwebp 1.4.0,样本取自 outputs/ 真实截图)
### 2.1 method 的影响(q80 固定)
| 样本 | 原 PNG | m4 q80(=现状) | m6 q80 | m6 q75 | m6 q85 |
|---|---|---|---|---|---|
| tb-prod-test-qr.png | 343,674 | 31,856 | **29,916** (-6.1%) | 24,274 (-23.8%) | 37,448 |
| prod-tamakyi-snrat-check.png | 635,502 | 151,888 | **147,498** (-2.9%) | 126,766 (-16.5%) | 172,434 |
| admin6-cdn-check.png | 375,873 | 51,202 | **48,664** (-5.0%) | 41,122 (-19.7%) | 59,222 |
→ **method=6 平均再省 3~6%**,纯 CPU 换体积,无画质损失。
### 2.2 按用途分档的收益(当前完全没分档)
模拟一张典型手机照片:
| 用途 | 现状(1920/q80/m4) | 建议档位 | 建议档实测 | 节省 |
|---|---|---|---|---|
| **头像**(前端 cropper 输出512) | 83,366 | 512 / q78 / m6 | 11,390 | **-86%** |
| 头像(列表缩略) | — | 320 / q78 | 4,966 | -94% |
| 背景16:9 | 26,222 | 1920 / q80 | 26,222 | — |
| 正文配图 | — | 1600 / q80 | — | -20~25% |
注意:前端 `profile.html:1390` 头像 `maxWidth:512`,但**服务端把 1920 传给编码器**,
意味着直传(绕过前端 cropper)的大图会被以 1920 全尺寸存下,而头像展示最大也就 256px。
### 2.3 有损 vs 无损(透明图)
| 类型 | 体积 |
|---|---|
| 带 alpha 图 lossy q80 | 49,636 |
| 带 alpha 图 lossless | 253,128 |
→ 无脑lossless 是灾难(5x),**不要**走这条路。但存在反向问题:
### 2.4 「编码后不一定更小」
对已经压得很狠的小 PNG/图标,WebP 有损可能**比原图还大**(WebP 对纯色/线框图有损效率差)。
**当前代码没有任何 `len(webpData) > len(fileData)` 的兜底判断** —— 转换失败会回退,但转换「成功却更大」不会回退。
### 2.5 重复编码(代际损失)
已是 WebP 的图再走一次管线 → 二次有损。实测 160,896 → 139,866(体积反而小了 13%,
因为 q82→q80),**画质白白损失一代,体积收益不确定**。
---
## 三、问题清单
### P0 🔴 EXIF 方向未处理 —— 手机竖拍 JPEG 会横躺
`image.Decode`(`s3.go:99`、`page.go:707`)**不读 EXIF Orientation**。
前端 cropper 走 canvas 会正确应用方向,但 `templates/question/item.html:206,238` 的
`accept="image/*"` 是**原始文件直传**,没有 canvas 归一化 → 手机竖拍照片转 WebP 后躺倒。
这不是压缩问题,但和转换管线同源,且用户可见。修EXIF 时顺带可在转码前
`image.NewRGBA` 重绘归一,比让 libwebp 处理更可控。
### P1 🟠 按用途分档缺失(最大体积收益)
`s3.go:186` 的 `maxWidth=1920` + `Quality:80` 一刀切。已有 `aspect` 参数做比例分档的先例
(`UploadAvatarToS3` / `UploadBackgroundToS3`),扩展成 `{maxW, maxH, quality}` 成本极低。
预期头像场景存储与流量降 **~85%**。
### P1 🟠 method 不可控,白丢 3~6%
`chai2010/webp` 不暴露 method。三条路:
- (a) `cwebp -m 6`(GIF 分支已经在用 `cwebp`,静态图也可统一走 CLI,还能顺带拿到
`-alpha_q`/`-sns`/`-near_lossless`/`-pass`)—— 需保证 Dockerfile 装 webp 包(**待核**);
- (b) 换 `gen2brain/go-webp`,API 直接暴露完整 `WebPConfig`;
- (c) 维持现状。
⚠️ method=6 编码耗时约为 method=4 的 **5~10 倍**。1C1G 生产机上批量上传场景
需实测编码耗时再定,不能只看体积。
### P1 🟠 无「转换后更大」的兜底
`s3.go:125-132`、`page.go:721-733` 都缺`if len(webp) >= len(orig) { 用原图 }`。
小图标/线框图/已优化 JPEG 场景会越转越大。
### P1 🟠 已是 WebP 还再编一代
`format == "webp"` 走 default 分支二次有损。建议:已是 WebP 且宽度合规 → 直接透传,
只在超宽时才 resize 后重编。
### P2 🟠 GIF 转换在生产上从未生效(已实证)
`Dockerfile:16` 是 Alpine `apk add tzdata`,**未安装 `webp` 包** → 生产容器内没有 `cwebp`。
`s3.go:201` 的 `exec.LookPath("cwebp")` 必然失败 → GIF 全部走 `uploadData = fileData` 原图上传,
且**无任何日志**。即`s3.go:115` 的动画 WebP 分支是死代码,线上动画图一直是原 GIF。
(另外 `page.go:714-718` GIF 不转,与 `s3.go` 不一致;但因为上述原因,两条路实际殊途同归。)
若要启用:Dockerfile 加 `RUN apk add --no-cache webp`(Alpine 包名即 `webp`,含 `cwebp`/`img2webp`/`gif2webp`),
并在 `LookPath` 失败时补 Warn。
### P2 🟠 GIF 分支其余问题
`s3.go:223` `cwebp -q 80 -mixed -mt` 缺 `-m 6`、`-lossless`、`-metadata none`,
也缺 GIF 帧数/总像素上限(`imagecheck.CheckPixelLimit` 只看逻辑屏幕宽高,
500 帧 2000×2000 的 GIF 不会被拦)。
### P2 走 cwebp CLI 需先改 Dockerfile
若采纳批次 B 的 method=6 方案 (a)(静态图统一走 CLI),必须先在 Dockerfile 装 webp 包,
否则会把「能用但慢」变成「不能用的静默回退」。
### P2 🟠 峰值内存
`s3.go:95-99` 解码一次拿format,`s3.go:181` 又完整解码一次,**同一张图解码两遍**;
`imaging.Resize` 再全量分配。4096×4096 上限下 RGBA 峰值约 64MB/张,
并发上传时 1C1G 有 OOM 风险。可合并为「一次解码 → 校验 → resize → 编码」。
### P2 提问图片分支缺 CacheControl
`page.go:740-748` 未设 `CacheControl`,与 `s3.go:158` 不一致(后者 immutable 一年)。
### P2 maxWidth 只限宽不限高
`s3.go:187` 只判 `Dx() > 1920`。竖图 1080×10000(像素总量在4096² 内?1080*10000
= 10.8M < 16.7M,通过)→ 不缩放,长边 10000px 下发,首屏极慢。
---
## 四、建议的落地方案(待批准,未动代码)
### 批次 A(低风险,先做)
1. `ConvertStaticImageToWebP` 增加参数:`maxWidth int, quality float32`;
`uploadPictureToS3` 按 `aspect` 传入不同档位(头像 512/q78、背景 1920/q80、通用 1600/q80)。
2. 加 `len(webp) >= len(fileData) && format != "gif"` 回退原图。
3. `format == "webp"` 且尺寸合规 → 直接透传,不重编。
4. maxWidth 改为同时约束长边(`maxSide`)。
5. `page.go` PutObject 补 `CacheControl`。
6. GIF 分支加`cwebp` 缺失时的 Warn 日志。
### 批次 B(需实测/依赖改动)
7. method=6:先在 dev 环境用真实图测编码耗时,确认 1C1G 可接受再上;
路线倾向 (a) 统一走 `cwebp`(Dockerfile 需确认 webp 包,见下)。
8. EXIF 方向归一(转码前重绘)。
9. 统一 GIF 双实现。
10. 合并重复解码降内存。
### 待核实(已全部核实完毕)
- ✅ `Dockerfile` 用 Alpine apk,**未装 `webp`** → `cwebp` 线上不存在,GIF 转换是死代码。
---
## 五、当前无法验证的部分
- 本机无 libwebp/GCC,**未编译运行 Go 侧实测**。体积数据来自 Pillow 绑定同一份
libwebp 1.4.0、同样 quality/method 组合,作为体积基线可信;但
**`imaging.Resize` 的 Lanczos 与 Pillow LANCZOS 的输出差异未纳入实测**,实际体积会有小幅出入。
- method=6 的**编码耗时**未实测(这是决定是否采用的关键约束)。