webp fix v2

This commit is contained in:
tamakyi
2026-10-16 09:39:30 +08:00
parent e4457e3da4
commit da7e76a7db
10 changed files with 903 additions and 60 deletions
+12 -3
View File
@@ -106,6 +106,16 @@ func tiffOrientation(tiff []byte) int {
return 0
}
// orientationSwapsDimensions 报告该 Orientation 是否会交换宽高。
// 2/3/4 只是镜像或 180 度旋转,宽高不变;5/6/7/8 会把横躺的像素转正,宽高互换。
func orientationSwapsDimensions(orientation int) bool {
switch orientation {
case exifOrientLeftTop, exifOrientRightTop, exifOrientRightBottom, exifOrientLeftBottom:
return true
}
return false
}
// orientedImage 把源图按 EXIF Orientation 重映射为目标坐标,供 draw.Draw
// 一次性拷贝,避免逐像素重复计算源矩形。
type orientedImage struct {
@@ -158,9 +168,8 @@ func applyJPEGOrientation(img image.Image, orientation int) image.Image {
return img
}
dstW, dstH := sw, sh
switch orientation {
case exifOrientLeftTop, exifOrientRightTop, exifOrientRightBottom, exifOrientLeftBottom:
dstW, dstH = sh, sw // 这四种会交换宽高
if orientationSwapsDimensions(orientation) {
dstW, dstH = sh, sw
}
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
+48 -1
View File
@@ -208,7 +208,7 @@ func TestConvertStaticImageToWebPAppliesEXIFOrientation(t *testing.T) {
t.Fatalf("raw jpeg dims = %dx%d, want 40x30", cfg.Width, cfg.Height)
}
out, err := ConvertStaticImageToWebP(bytes.NewReader(data), EncodeSpec{MaxSide: 1600, Quality: 80})
out, err := ConvertStaticImageToWebP(data, EncodeSpec{MaxSide: 1600, Quality: 80})
if err != nil {
t.Fatalf("ConvertStaticImageToWebP: %v", err)
}
@@ -220,3 +220,50 @@ func TestConvertStaticImageToWebPAppliesEXIFOrientation(t *testing.T) {
t.Fatalf("webp dims = %dx%d, want 30x40 (orientation 6 applied)", w, h)
}
}
// 回归:比例校验必须与转码时的 EXIF 归一用同一套方向。
//
// 手机竖拍照片存成 40x30 像素但 EXIF 声明 Orientation=6,肉眼看到的是 30x40 的竖图。
// 若比例校验只看原始像素宽高(30/40 的横躺比例),一张合规的方形头像会被判为
// 「比例不符」而拒——这正是线上验证曾发现的缺口。
func TestAspectCheckAppliesEXIFOrientation(t *testing.T) {
// 像素 40x30 + Orientation=6 → 归一后 30x40(竖图 3:4,不是 1:1)
if err := (&aspect{W: 1, H: 1}).check(image.Config{Width: 40, Height: 30}, exifOrientRightTop); err == nil {
t.Fatal("40x30 + ori6 归一后是 3:4 竖图,头像应判为比例不符")
}
// 像素 40x30 + Orientation=1 → 仍是 3:4 竖图,同样应拒
if err := (&aspect{W: 1, H: 1}).check(image.Config{Width: 40, Height: 30}, exifOrientTopLeft); err == nil {
t.Fatal("40x30 ori1 是 3:4 竖图,头像应判为比例不符")
}
// 像素 40x40 + Orientation=6 → 归一后仍 40x40,头像应放行
if err := (&aspect{W: 1, H: 1}).check(image.Config{Width: 40, Height: 40}, exifOrientRightTop); err != nil {
t.Fatalf("40x40 + ori6 应放行: %v", err)
}
// 背景 16:9:像素 160x90 + Orientation=6 → 归一后 90x160(9:16)应拒
if err := (&aspect{W: 16, H: 9}).check(image.Config{Width: 160, Height: 90}, exifOrientRightTop); err == nil {
t.Fatal("160x90 + ori6 归一后是 9:16 竖图,背景应判为比例不符")
}
// nil aspect 表示该用途不校验比例(如通用图)
if err := (*aspect)(nil).check(image.Config{Width: 4000, Height: 3}, exifOrientRightTop); err != nil {
t.Fatalf("nil aspect 不应校验: %v", err)
}
}
// 回归:EXIF 归一会交换宽高的四种方向,与不交换的三种。
func TestOrientationSwapsDimensions(t *testing.T) {
swaps := map[int]bool{
exifOrientTopLeft: false, exifOrientTopRight: false,
exifOrientBottomRight: false, exifOrientBottomLeft: false,
exifOrientLeftTop: true, exifOrientRightTop: true,
exifOrientRightBottom: true, exifOrientLeftBottom: true,
}
for ori, want := range swaps {
if got := orientationSwapsDimensions(ori); got != want {
t.Errorf("orientationSwapsDimensions(%d) = %v, want %v", ori, got, want)
}
}
}
+35 -34
View File
@@ -43,11 +43,19 @@ type aspect struct {
W, H float64
}
func (a *aspect) check(data []byte) error {
// check 校验给定尺寸是否满足本用途的宽高比约束。
// orientation 是 EXIF Orientation:手机竖拍照片解出的像素是横躺的(5/6/7/8 会
// 交换宽高),若直接用原始像素宽高比对,合规的竖图会被误判为比例不符而拒。
// 修正只交换宽高、不解码像素,大图内存不受影响。
func (a *aspect) check(cfg image.Config, orientation int) error {
if a == nil {
return nil
}
return imagecheck.CheckAspectRatio(bytes.NewReader(data), a.W, a.H, imagecheck.DefaultAspectTolerance)
w, h := cfg.Width, cfg.Height
if orientationSwapsDimensions(orientation) {
w, h = h, w
}
return imagecheck.CheckAspectRatioSize(w, h, a.W, a.H, imagecheck.DefaultAspectTolerance)
}
// EncodeSpec 描述一次上传的 WebP 编码档位。
@@ -107,31 +115,31 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as
return "", errors.New("unsupported file type: not a valid image")
}
// 2.5 用途宽高比校验(头像 1:1 / 背景 16:9),绕过前端裁剪的直传在此被拦截
if err := asp.check(fileData); err != nil {
return "", err
}
fileReader := bytes.NewReader(fileData)
// 3. 解码前先校验像素总量(防解压炸弹),再解码图片获取格式;解码失败则直接返回错误。
if err := imagecheck.CheckPixelLimit(bytes.NewReader(fileData)); err != nil {
return "", err
}
_, format, err := image.Decode(fileReader)
// 3. 只解析文件头:一次拿到格式与宽高,供像素上限校验、比例校验、
// WebP 透传判断共用。image.DecodeConfig 不解像素,因此这里不产生解码开销。
// (此前为拿 format 先 image.Decode 一遍、转换时再解一遍,同一张图解码两次,
// 4096×4096 上限下峰值内存翻倍,1C1G 机器并发上传有 OOM 风险。)
cfg, format, err := image.DecodeConfig(bytes.NewReader(fileData))
if err != nil {
return "", errors.Wrap(err, "image decode failed, file may be corrupted")
}
// 3.5 像素总量校验(防解压炸弹),必须在任何完整解码之前。
if cfg.Width > 0 && cfg.Height > 0 &&
int64(cfg.Width)*int64(cfg.Height) > imagecheck.MaxImagePixels {
return "", imagecheck.ErrImageTooLarge
}
// 3.6 用途宽高比校验(头像 1:1 / 背景 16:9),绕过前端裁剪的直传在此被拦截。
// 比例校验必须与转码时的 EXIF 归一保持同一套方向,否则手机竖拍照片
// (像素横躺 + Orientation=6)会被误判为比例不符。
if err := asp.check(cfg, jpegOrientation(fileData)); err != nil {
return "", err
}
var uploadData []byte
var contentType string
// 重置 reader,以便后续可能的 resize 操作
if _, err := fileReader.Seek(0, io.SeekStart); err != nil {
return "", errors.Wrap(err, "reset reader")
}
// 4. 按格式转换或直接使用原图
switch format {
case "gif":
@@ -146,13 +154,12 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as
case "webp":
// 已是 WebP:仅当超尺寸上限时才重编(重编会再丢一代画质),
// 尺寸合规则原样上传。
cfg, _, err := image.DecodeConfig(bytes.NewReader(fileData))
if err == nil && fitsWithin(cfg.Width, cfg.Height, spec.MaxSide) {
if fitsWithin(cfg.Width, cfg.Height, spec.MaxSide) {
uploadData = fileData
contentType = "image/webp"
break
}
webpData, err := ConvertStaticImageToWebP(fileReader, spec)
webpData, err := ConvertStaticImageToWebP(fileData, spec)
if err != nil || len(webpData) >= len(fileData) {
uploadData = fileData
contentType = mimeTypeFromFormat(format)
@@ -162,7 +169,7 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as
}
default:
// 静态图片(JPEG / PNG 等)转静态 WebP
webpData, err := ConvertStaticImageToWebP(fileReader, spec)
webpData, err := ConvertStaticImageToWebP(fileData, spec)
if err != nil || len(webpData) >= len(fileData) {
// 转换失败、或转换后并不比原图小(小图标/线框图/已优化 JPEG
// 上有损 WebP 可能反而更大)→ 一律沿用原图。
@@ -211,16 +218,10 @@ func uploadPictureToS3(file multipart.File, fileHeader *multipart.FileHeader, as
// ConvertStaticImageToWebP 将静态图片(JPEG/PNG 等)按给定档位转换为 WebP 格式。
// spec.MaxSide 同时约束宽与高(此前只限宽,竖图长边不受约束)。
// 调用方负责比较转换前后的体积,决定是否采用转换结果。
func ConvertStaticImageToWebP(r io.Reader, spec EncodeSpec) ([]byte, error) {
// 先整体读入并校验像素总量(防解压炸弹),再完整解码。
data, err := io.ReadAll(r)
if err != nil {
return nil, errors.Wrap(err, "read image")
}
if err := imagecheck.CheckPixelLimit(bytes.NewReader(data)); err != nil {
return nil, errors.Wrap(err, "check image pixel limit")
}
//
// 接收已读入的字节而非 io.Reader:调用方手里已有原始字节,避免再包一层
// bytes.Reader 重复持有整份数据。
func ConvertStaticImageToWebP(data []byte, spec EncodeSpec) ([]byte, error) {
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, errors.Wrap(err, "decode image")