# 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 的**编码耗时**未实测(这是决定是否采用的关键约束)。