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
+154 -26
View File
@@ -23,6 +23,7 @@ import (
"github.com/chai2010/webp"
"github.com/disintegration/imaging"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/thanhpk/randstr"
"github.com/wuhan005/gadget"
@@ -49,25 +50,47 @@ func (a *aspect) check(data []byte) error {
return imagecheck.CheckAspectRatio(bytes.NewReader(data), a.W, a.H, imagecheck.DefaultAspectTolerance)
}
// EncodeSpec 描述一次上传的 WebP 编码档位。
//
// 此前所有用途共用「最长边 1920 + q80」,头像这种实际只展示 120px 的用途
// 也要按 1920 存盘,实测体积差约 7 倍(83KB vs 11KB)。故按用途分档:
// 头像跟随前端 cropper 的输出上限,背景铺满宽度,通用图取折中值。
type EncodeSpec struct {
// MaxSide 是允许的最大边长(宽高均不超过),<=0 表示不缩放。
MaxSide int
// Quality 是有损质量(0~100)。
Quality float32
}
var (
// avatarEncodeSpec 头像 1:1:模板中最大展示 120px,
// 前端 cropper 输出上限为 512,服务端取同值兜底直传的大图。
avatarEncodeSpec = EncodeSpec{MaxSide: 512, Quality: 78}
// backgroundEncodeSpec 页面背景 16:9:作为整页头部铺满宽度,保留 1920。
backgroundEncodeSpec = EncodeSpec{MaxSide: 1920, Quality: 80}
// GeneralEncodeSpec 通用图(提问配图、私聊图片):正文内展示,取 1600 折中。
GeneralEncodeSpec = EncodeSpec{MaxSide: 1600, Quality: 80}
)
// UploadAvatarToS3 上传头像:除通用校验/转换外强制 1:1 宽高比。
func UploadAvatarToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
return uploadPictureToS3(file, fileHeader, &aspect{W: 1, H: 1})
return uploadPictureToS3(file, fileHeader, &aspect{W: 1, H: 1}, avatarEncodeSpec)
}
// UploadBackgroundToS3 上传页面背景:除通用校验/转换外强制 16:9 宽高比。
func UploadBackgroundToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
return uploadPictureToS3(file, fileHeader, &aspect{W: 16, H: 9})
return uploadPictureToS3(file, fileHeader, &aspect{W: 16, H: 9}, backgroundEncodeSpec)
}
// UploadPictureToS3 上传通用图片到 Cloudflare R2,自动转换为 WebP(动画 GIF 可选转换)
func UploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader) (string, error) {
return uploadPictureToS3(file, fileHeader, nil)
return uploadPictureToS3(file, fileHeader, nil, GeneralEncodeSpec)
}
// uploadPictureToS3 是上传管线实现:读入 → 魔数 → (可选)比例校验 → 像素总量校验
// → 解码 → WebP 转换 → 落盘 R2。avatar/background 的固定比例校验复用同一管线,
// 仅通过 aspect 参数区分,避免为一种用途复制一套上传代码。
func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, asp *aspect) (string, error) {
// → 解码 → WebP 转换 → 落盘 R2。avatar/background 的固定比例校验与各自的
// 编码档位复用同一管线,仅通过 asp/spec 参数区分,避免为一种用途复制一套上传代码。
func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, asp *aspect, spec EncodeSpec) (string, error) {
// 1. 限制读取大小,防止超大上传撑爆内存(DoS)。
// 先用 LimitReader 截断到 MaxUploadBytes+1 字节;若实际读到的字节数超过上限则直接拒绝。
limited := io.LimitReader(file, MaxUploadBytes+1)
@@ -112,19 +135,38 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as
// 4. 按格式转换或直接使用原图
switch format {
case "gif":
webpData, err := convertGifToAnimatedWebP(fileData)
if err != nil {
uploadData = fileData // 转换失败回退原始 GIF
webpData, err := ConvertGifToAnimatedWebP(fileData)
if err != nil || len(webpData) >= len(fileData) {
uploadData = fileData // 转换失败、或转换后并不更小 → 回退原始 GIF
contentType = "image/gif"
} else {
uploadData = webpData
contentType = "image/webp"
}
case "webp":
// 已是 WebP:仅当超尺寸上限时才重编(重编会再丢一代画质),
// 尺寸合规则原样上传。
cfg, _, err := image.DecodeConfig(bytes.NewReader(fileData))
if err == nil && fitsWithin(cfg.Width, cfg.Height, spec.MaxSide) {
uploadData = fileData
contentType = "image/webp"
break
}
webpData, err := ConvertStaticImageToWebP(fileReader, spec)
if err != nil || len(webpData) >= len(fileData) {
uploadData = fileData
contentType = mimeTypeFromFormat(format)
} else {
uploadData = webpData
contentType = "image/webp"
}
default:
// 静态图片(JPEG / PNG / WebP 等)转静态 WebP
webpData, err := ConvertStaticImageToWebP(fileReader)
if err != nil {
uploadData = fileData // 转换失败回退原图
// 静态图片(JPEG / PNG 等)转静态 WebP
webpData, err := ConvertStaticImageToWebP(fileReader, spec)
if err != nil || len(webpData) >= len(fileData) {
// 转换失败、或转换后并不比原图小(小图标/线框图/已优化 JPEG
// 上有损 WebP 可能反而更大)→ 一律沿用原图。
uploadData = fileData
contentType = mimeTypeFromFormat(format)
} else {
uploadData = webpData
@@ -166,9 +208,10 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as
return fmt.Sprintf("https://%s/%s", conf.Upload.ImageBucketCDNHost, key), nil
}
// ConvertStaticImageToWebP 将静态图片(JPEG/PNG等)转换为 WebP 格式
// 限制最大宽度为 1920px,保持比例
func ConvertStaticImageToWebP(r io.Reader) ([]byte, error) {
// ConvertStaticImageToWebP 将静态图片(JPEG/PNG 等)按给定档位转换为 WebP 格式。
// spec.MaxSide 同时约束宽与高(此前只限宽,竖图长边不受约束)。
// 调用方负责比较转换前后的体积,决定是否采用转换结果。
func ConvertStaticImageToWebP(r io.Reader, spec EncodeSpec) ([]byte, error) {
// 先整体读入并校验像素总量(防解压炸弹),再完整解码。
data, err := io.ReadAll(r)
if err != nil {
@@ -183,26 +226,49 @@ func ConvertStaticImageToWebP(r io.Reader) ([]byte, error) {
return nil, errors.Wrap(err, "decode image")
}
maxWidth := 1920
if img.Bounds().Dx() > maxWidth {
img = imaging.Resize(img, maxWidth, 0, imaging.Lanczos)
// 必须先按 EXIF 归一方向再限边缩放:手机竖拍照片解出来的像素是横躺的,
// 归一过程会交换宽高,顺序反了就会按错误的边长做缩放判断。
img = applyJPEGOrientation(img, jpegOrientation(data))
b := img.Bounds()
if !fitsWithin(b.Dx(), b.Dy(), spec.MaxSide) {
img = imaging.Fit(img, spec.MaxSide, spec.MaxSide, imaging.Lanczos)
}
buf := new(bytes.Buffer)
if err := webp.Encode(buf, img, &webp.Options{Quality: 80}); err != nil {
if err := webp.Encode(buf, img, &webp.Options{Quality: spec.Quality}); err != nil {
return nil, errors.Wrap(err, "webp encode")
}
return buf.Bytes(), nil
}
// convertGifToAnimatedWebP 将 GIF 文件数据转换为动画 WebP(需要系统安装 cwebp 工具)
// 如果 cwebp 不可用,返回错误,调用方应回退到原始 GIF
func convertGifToAnimatedWebP(gifData []byte) ([]byte, error) {
_, err := exec.LookPath("cwebp")
if err != nil {
// fitsWithin 判断 w×h 是否已在 maxSide 的边长限制内(maxSide <= 0 时恒为 true)。
func fitsWithin(w, h, maxSide int) bool {
if maxSide <= 0 {
return true
}
return w <= maxSide && h <= maxSide
}
// maxGIFFrames 是允许转换的 GIF 最大帧数。imagecheck.CheckPixelLimit 只看 GIF 的
// 逻辑屏幕尺寸(不解码帧),一个 2000×2000、500 帧的 GIF 体积可能很小却会让
// cwebp 长时间占用 CPU 与内存,故在此显式设限。
const maxGIFFrames = 200
// ConvertGifToAnimatedWebP 将 GIF 转换为动画 WebP(需要系统安装 cwebp)。
// cwebp 不可用、GIF 损坏或帧数超限时返回错误,调用方应回退到原始 GIF。
func ConvertGifToAnimatedWebP(gifData []byte) ([]byte, error) {
if _, err := exec.LookPath("cwebp"); err != nil {
// 回退是静默的(线上表现为动画图一直以原 GIF 存盘、体积异常偏高),
// 故在此显式告警,否则线上无法察觉 cwebp 缺失。
logrus.Warn("cwebp not found in PATH, animated GIF will be stored as-is (install the webp package to enable animated WebP)")
return nil, errors.New("cwebp not found in PATH")
}
if n := gifFrameCount(gifData); n > maxGIFFrames {
return nil, errors.Errorf("too many GIF frames: %d > %d", n, maxGIFFrames)
}
tmpGif, err := os.CreateTemp("", "input_*.gif")
if err != nil {
return nil, errors.Wrap(err, "create temp gif")
@@ -220,7 +286,10 @@ func convertGifToAnimatedWebP(gifData []byte) ([]byte, error) {
defer os.Remove(tmpWebp.Name())
tmpWebp.Close()
cmd := exec.Command("cwebp", "-q", "80", "-mixed", "-mt", "-quiet", tmpGif.Name(), "-o", tmpWebp.Name())
// -mixed:对透明帧用无损、其余有损,是动画的推荐组合。
// -metadata none:剥离调色板外的元数据,进一步减小体积。
cmd := exec.Command("cwebp", "-q", "80", "-mixed", "-metadata", "none",
"-mt", "-quiet", tmpGif.Name(), "-o", tmpWebp.Name())
if err := cmd.Run(); err != nil {
return nil, errors.Wrap(err, "run cwebp")
}
@@ -232,6 +301,65 @@ func convertGifToAnimatedWebP(gifData []byte) ([]byte, error) {
return webpData, nil
}
// gifFrameCount 通过遍历 GIF 数据块统计 Image Descriptor 数量,
// 不解码像素,因此对超大 GIF 也是常数级开销。
func gifFrameCount(data []byte) int {
if len(data) < 13 || string(data[:3]) != "GIF" {
return 0
}
pos := 6
// Logical Screen Descriptor 固定 7 字节,其 packed 字段在 data[10](即 pos+4)。
// 无条件跳过这 7 字节后再按 Global Color Table Flag 追加调色板长度——
// 漏跳会让 pos 停在宽度低字节上,块遍历立即失效。
if pos+7 > len(data) {
return 0
}
packed := data[pos+4]
pos += 7
if packed&0x80 != 0 {
pos += 3 * (1 << ((packed & 0x07) + 1))
}
frames := 0
for pos < len(data) {
switch data[pos] {
case 0x21: // Extension Introducer:跳过标签与子块
pos += 2
pos = skipSubBlocks(data, pos)
case 0x2C: // Image Descriptor:计一帧,再跳过局部色表与 LZW 子块
if pos+10 > len(data) {
return frames
}
frames++
if data[pos+9]&0x80 != 0 { // Local Color Table Flag
pos += 3 * (1 << ((data[pos+9] & 0x07) + 1))
}
pos += 10 // Image Descriptor 固定 10 字节
if pos < len(data) {
pos++ // LZW minimum code size
pos = skipSubBlocks(data, pos)
}
case 0x3B: // Trailer
return frames
default:
return frames
}
}
return frames
}
// skipSubBlocks 返回子块链结束后的偏移(terminator 0x00 之后的位置)。
func skipSubBlocks(data []byte, pos int) int {
for pos < len(data) {
size := int(data[pos])
pos++
if size == 0 {
return pos
}
pos += size
}
return pos
}
// mimeTypeFromFormat 根据图片格式返回 MIME 类型
func mimeTypeFromFormat(format string) string {
switch format {