webp fix v1

This commit is contained in:
tamakyi
2026-10-15 11:41:19 +08:00
parent 6ffc87e4a8
commit e4457e3da4
15 changed files with 1297 additions and 39 deletions
+30
View File
@@ -0,0 +1,30 @@
# 2026-09-27 工作日志
## Serverless 迁移可行性评估(只读,未改源码)
用户问:TamaBox-v4 能否像 OpenListTeam/OpenList-Worker 那样改造成 TypeScript + Cloudflare Workers + D1。
**产出**:`outputs/serverless-migration-feasibility-2026-09-27.md`
**结论:技术可行,但工程性价比低——是「整体重写」不是「改造」。**
关键判据(两个项目形态根本不同):
- OpenList = **无状态文件列表 + 反代**(无会话、无可变状态、存储可降级 map/KV、无图像、无服务端模板)→ 边缘天然匹配。
- TamaBox = **有状态多用户 Web 应用**(Session/CSRF/Redis cache、PG 关系模型、cgo 图像管线、42 个服务端模板)。
标定数据:
- TamaBox:34,664 行 Go(route 7645 / db 6950 / i18n 3689 / security 2675 / internal-route 1653 / template 825)。
- OpenList 原版 9.8MB Go / 24.8k star;OpenList-Worker 21.7MB TS / 416 个 .ts(约 2.6MB 源码)/ 官方团队 / 2025-08-23 立项 → 约一年,且**代码量反超原版**。
P0 硬阻断(11 项)要点:
1. `internal/storage/s3.go` cgo WebP(chai2010/webp)+ `cwebp` 子进程 + `os.CreateTemp` → Workers 无 cgo/无子进程/无磁盘。
2. `route/question/share.go` freetype + `os.ReadFile(font_path)` 渲染中文分享图 → 需整体重写,且 CPU 密集易超 Workers 限额。
3. Session/缓存/限流/自动封禁/去重/统计全在进程内(`internal/cache`、`autoban`、`shared/duplicate.go`、`ratelimit.go`)→ 必须迁 Durable Objects。
4. `internal/route/route.go:386-395` cache 中间件**无条件硬编码 Redis**(不判空配置)。
5. D1=SQLite:无 PG 序列(`db.go:211-234`)、无 `FOR UPDATE`(`follow_ups.go:68`)、无 JSONB(`datatypes.JSON`)。
6. SMTP(`internal/mail/mail.go:129-147`)Workers 禁裸 TCP → 换 HTTP 邮件 API。
7. OTel/Uptrace gRPC 上报不可用。
顺带确认(对后续有用):`internal/template/minify.go` 当前**未被路由装配使用**(`route.go:99` 用的是 flamego template.EmbedFS),属死代码。`internal/security/ipinfo` 是离线签名、零 DNS。
建议方案:A 不迁移(推荐);B 只把静态资源+只读接口下沉边缘(EdgeOne/Workers),写路径与图像处理留 Go 源站——项目已有 EdgeOne 回源白名单脚本,该形态已在用;C 完全重写(需 2-3 个月专职 + 功能裁剪,优先 EdgeOne 因其有大陆节点)。
+13
View File
@@ -0,0 +1,13 @@
# 2026-09-28
本会话的工作实际发生在**另一个仓库** `C:/OtherFiles/Github/rond-with-you`(用户指定的 data 文件夹与该备份都在那边),
TamaBox 仓库这次没动。
- 任务:用真机新导出的 `data/20260928_183908.rondbackup` 在本地测试环境(`conf/app.ini`)实测「rondbackup 合并」。
- 结论:主链路生效(新增 70 地点 / 1 活动 / 134 到访,953 条到访 UUID 全命中、时间零误差,孤儿位移 0);
查出 3 个问题(重复地点不幂等、到访冲突被活动改名放大且不收敛、21 个长沙地点坐标系差 ~660m)。
- 笔记与报告落在该仓库:`.workbuddy/memory/2026-09-28.md`、`.workbuddy/memory/MEMORY.md`、
`storage/.mergetest/报告-rondbackup合并实测-20260928.md`。
- 随后按用户要求**回档数据库 → 修复前两个问题 → 复测**:改 `internal/rond/diff.go`(`DiffPlaces` 多值索引、
到访活动按 `ZUID_` 身份比)与 `internal/web/backup_diff.go`(多取活动 UUID),共 5 文件 +172/−22。
复测:到访冲突 253→6,地点新增 70→67 且合并后归零,重复合并不再产生重复地点。细节见该仓库记忆。
+9
View File
@@ -0,0 +1,9 @@
# 2026-09-29
工作仍发生在 `C:/OtherFiles/Github/rond-with-you`(TamaBox 仓库未动)。
- 站长手动合并后追问差异页「地点 X → X」为何算冲突;复现定性:`地点` 比的是**坐标**、页面只印**名称**,
同名不同坐标(连锁店重名 236.8km/8.3km,坐标系 750m)就渲染成看似没变化的一行。
- 顺带发现默认「用实机」已把站内 `中国体育彩票`(src_pk=71) 从贺州搬到佛山(236.8km);
提出 3 处待修方案(名称兜底加距离上限、地点字段补坐标、mergePlaces 的单槽位映射),等批准。
- 细节见该仓库 `.workbuddy/memory/2026-09-29.md`。
+134
View File
@@ -0,0 +1,134 @@
# 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`(桩环境跳过),像素级映射由单测覆盖。
+25
View File
@@ -25,6 +25,31 @@
编辑前用 Python 数 `b'\r\n'` 确认(`grep -c $'\r'` 不可靠)。`gofmt -l` 会把 CRLF 文件误报为未格式化。
格式化统一走 `dev/gofmt_keep_crlf.py <files>`。
- `internal/security/censor/censor.go`、`text_test.go` 有升级前就存在的空格缩进,别顺手 gofmt。
- ⚠️ **`go build -overlay` 不能替换 GOMODCACHE 下的文件**(Go 直接报错拒绝)。
要在本机类型检查依赖 cgo 的真实代码(如 `internal/storage/s3.go`),唯一可行方案:
**`tar` 复制项目到临时目录 → 在副本里 `go mod edit -replace <cgo依赖>=../本地stub` → 编译/测试**。
副本约 15MB、编译约 2~7 分钟。**别用 `s3stub` overlay 检查 s3.go 本身**——overlay 会把 s3.go
换成桩,等于根本没检查它(只能检查调用方)。见 2026-10-15 的批次 A 落地记录。
- 行尾确认:`internal/storage/s3.go` = LF,`route/question/page.go` = CRLF。
- ⚠️ **依赖真实编码器的端到端断言在本机不成立**(webp 被 replace 成桩,Encode 不产出字节)。
这类测试要写成「检测到桩就 `t.Skip`」,否则 CI/本机表现不一致。
## 图片上传与编码(`internal/storage/s3.go`)
- 三档编码规格 `EncodeSpec{MaxSide, Quality}`:头像 512/q78、背景 1920/q80、通用 1600/q80。
头像依据:模板最大展示 120px(`.tb-frame-avatar`),前端 cropper 输出上限 512。
⚠️ 档位只作用于**新上传**,线上已有图片不会被重压。
- 所有转换分支都有 `len(webp) >= len(原图)` 回退——线框图/小图标上有损 WebP 反而更大
(实测 200x200 线框图 PNG 652B vs WebP 908B),不加兜底会越转越大。
- **EXIF 方向必须在转码前归一**(`exif.go` 手工解析 APP1,无外部依赖)。
`image.Decode` 与 Pillow 默认都不读 EXIF;**转成 WebP 后 EXIF 被丢弃、方向永久丢失**。
顺序铁律:**先归一方向 → 再限边缩放**(归一会交换宽高)。
- GIF 帧数上限 `maxGIFFrames = 200`:`CheckPixelLimit` 只看逻辑屏尺寸,
几百帧的大 GIF 体积小但能让 `cwebp` 长时间吃 CPU。
- **method=6 已评估并否决**:实测耗时只增 1.2~1.9x(不是原以为的 5~10x),
但体积只省 ~3%,1C1G 上不划算。
- `cwebp` 缺失时的 GIF 静默回退已加 `logrus.Warn`;生产机(裸 Linux)已确认装有 `cwebp`。
## 内容审核