webp fix v2
This commit is contained in:
@@ -63,17 +63,24 @@ const DefaultAspectTolerance = 0.01
|
||||
// 与 CheckPixelLimit 一致:只做 DecodeConfig 级轻量检查,不解码全图。
|
||||
// 注意:该方法会读取(消耗)传入的 reader,调用方需自行重建/复位 reader。
|
||||
func CheckAspectRatio(r io.Reader, wantW, wantH float64, tolerance float64) error {
|
||||
if wantW <= 0 || wantH <= 0 {
|
||||
return nil
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return CheckAspectRatioSize(cfg.Width, cfg.Height, wantW, wantH, tolerance)
|
||||
}
|
||||
|
||||
// CheckAspectRatioSize 是 CheckAspectRatio 的「宽高已知」版本。
|
||||
// 调用方若已从文件头拿到尺寸(或需要先按 EXIF Orientation 修正宽高后再比对),
|
||||
// 用本函数可避免重复读取文件头。
|
||||
func CheckAspectRatioSize(w, h int, wantW, wantH float64, tolerance float64) error {
|
||||
if wantW <= 0 || wantH <= 0 {
|
||||
return nil
|
||||
}
|
||||
actual := float64(cfg.Width) / float64(cfg.Height)
|
||||
if w <= 0 || h <= 0 {
|
||||
return nil
|
||||
}
|
||||
actual := float64(w) / float64(h)
|
||||
if math.Abs(actual-wantW/wantH) > tolerance {
|
||||
return ErrBadAspectRatio
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user