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

8.6 KiB
Raw Blame History

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 只有三个字段:

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(需实测/依赖改动)

  1. method=6:先在 dev 环境用真实图测编码耗时,确认 1C1G 可接受再上; 路线倾向 (a) 统一走 cwebp(Dockerfile 需确认 webp 包,见下)。
  2. EXIF 方向归一(转码前重绘)。
  3. 统一 GIF 双实现。
  4. 合并重复解码降内存。

待核实(已全部核实完毕)

  • ✅ Dockerfile 用 Alpine apk,未装 webp → cwebp 线上不存在,GIF 转换是死代码。

五、当前无法验证的部分

  • 本机无 libwebp/GCC,未编译运行 Go 侧实测。体积数据来自 Pillow 绑定同一份 libwebp 1.4.0、同样 quality/method 组合,作为体积基线可信;但 imaging.Resize 的 Lanczos 与 Pillow LANCZOS 的输出差异未纳入实测,实际体积会有小幅出入。
  • method=6 的编码耗时未实测(这是决定是否采用的关键约束)。