webp fix v1
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
package storage
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFitsWithin(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
w, h, maxSide int
|
||||
want bool
|
||||
}{
|
||||
{"未设置上限时恒为真", 8000, 8000, 0, true},
|
||||
{"负上限等同于不限制", 8000, 8000, -1, true},
|
||||
{"宽高均在限内", 512, 512, 512, true},
|
||||
{"刚好等于上限", 1600, 1200, 1600, true},
|
||||
{"仅超宽", 1921, 1080, 1920, false},
|
||||
{"仅超高(竖图长边此前不受约束)", 1080, 10000, 1920, false},
|
||||
{"双向超限", 4000, 5000, 1600, false},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := fitsWithin(c.w, c.h, c.maxSide); got != c.want {
|
||||
t.Fatalf("fitsWithin(%d, %d, %d) = %v, want %v", c.w, c.h, c.maxSide, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 各用途档位必须与其展示场景相称:头像按前端 cropper 输出上限兜底,
|
||||
// 背景保留铺满宽度所需尺寸,通用图取折中值。
|
||||
func TestEncodeSpecTiers(t *testing.T) {
|
||||
if avatarEncodeSpec.MaxSide != 512 {
|
||||
t.Errorf("avatar MaxSide = %d, want 512", avatarEncodeSpec.MaxSide)
|
||||
}
|
||||
if backgroundEncodeSpec.MaxSide != 1920 {
|
||||
t.Errorf("background MaxSide = %d, want 1920", backgroundEncodeSpec.MaxSide)
|
||||
}
|
||||
if GeneralEncodeSpec.MaxSide != 1600 {
|
||||
t.Errorf("general MaxSide = %d, want 1600", GeneralEncodeSpec.MaxSide)
|
||||
}
|
||||
for name, s := range map[string]EncodeSpec{
|
||||
"avatar": avatarEncodeSpec, "background": backgroundEncodeSpec, "general": GeneralEncodeSpec,
|
||||
} {
|
||||
if s.Quality <= 0 || s.Quality > 100 {
|
||||
t.Errorf("%s Quality = %v, out of range (0,100]", name, s.Quality)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
)
|
||||
|
||||
// EXIF Orientation(TIFF tag 0x0112)的取值含义:描述「存储的像素」要如何变换
|
||||
// 才是用户肉眼看到的方向。手机竖拍照片几乎都是 6 或 8。
|
||||
const (
|
||||
exifOrientTopLeft = 1 // 正常
|
||||
exifOrientTopRight = 2 // 水平镜像
|
||||
exifOrientBottomRight = 3 // 旋转 180
|
||||
exifOrientBottomLeft = 4 // 垂直镜像
|
||||
exifOrientLeftTop = 5 // 转置
|
||||
exifOrientRightTop = 6 // 顺时针 90
|
||||
exifOrientRightBottom = 7 // 反转置
|
||||
exifOrientLeftBottom = 8 // 顺时针 270
|
||||
)
|
||||
|
||||
// jpegOrientation 解析 JPEG 的 EXIF Orientation,返回 1~8。
|
||||
//
|
||||
// Go 标准库 image.Decode 不读 EXIF(Pillow 默认也不读,需显式 exif_transpose),
|
||||
// 因此手机竖拍照片解出来的像素是横躺的。更严重的是转码成 WebP 时 EXIF 会被
|
||||
// 整体丢弃,方向信息随之永久消失、无法事后补救,故必须在转码前归一。
|
||||
//
|
||||
// 未找到 Orientation、格式异常或非 JPEG 时返回 1(identity),调用方无需区分
|
||||
// 「没有方向信息」和「解析失败」。
|
||||
func jpegOrientation(data []byte) int {
|
||||
if len(data) < 4 || data[0] != 0xFF || data[1] != 0xD8 || data[2] != 0xFF {
|
||||
return exifOrientTopLeft
|
||||
}
|
||||
for i := 2; i+4 <= len(data); {
|
||||
if data[i] != 0xFF {
|
||||
return exifOrientTopLeft // 段结构损坏,放弃解析
|
||||
}
|
||||
marker := data[i+1]
|
||||
// 0xFF 填充字节、SOI、Standalone 标记、RSTn 均无长度字段。
|
||||
if marker == 0xFF || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD9) {
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
segLen := int(binary.BigEndian.Uint16(data[i+2 : i+4]))
|
||||
if segLen < 2 || i+2+segLen > len(data) {
|
||||
return exifOrientTopLeft
|
||||
}
|
||||
// APP1 (0xE1),载荷形如 "Exif\x00\x00" + TIFF 块。
|
||||
if marker == 0xE1 && segLen >= 10 {
|
||||
payload := data[i+4 : i+2+segLen]
|
||||
if len(payload) >= 6 && string(payload[:6]) == "Exif\x00\x00" {
|
||||
if o := tiffOrientation(payload[6:]); o != 0 {
|
||||
return o
|
||||
}
|
||||
}
|
||||
}
|
||||
// SOS (0xDA) 之后是熵编码的像素数据,不再有元数据段。
|
||||
if marker == 0xDA {
|
||||
return exifOrientTopLeft
|
||||
}
|
||||
i += 2 + segLen
|
||||
}
|
||||
return exifOrientTopLeft
|
||||
}
|
||||
|
||||
// tiffOrientation 从 TIFF 块的 IFD0 中取出 Orientation;取不到返回 0。
|
||||
func tiffOrientation(tiff []byte) int {
|
||||
if len(tiff) < 8 {
|
||||
return 0
|
||||
}
|
||||
var bo binary.ByteOrder
|
||||
switch {
|
||||
case tiff[0] == 'I' && tiff[1] == 'I':
|
||||
bo = binary.LittleEndian
|
||||
case tiff[0] == 'M' && tiff[1] == 'M':
|
||||
bo = binary.BigEndian
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
if bo.Uint16(tiff[2:4]) != 42 { // 42 是 TIFF 魔数
|
||||
return 0
|
||||
}
|
||||
ifd := int(bo.Uint32(tiff[4:8]))
|
||||
if ifd < 8 || ifd+2 > len(tiff) {
|
||||
return 0
|
||||
}
|
||||
entries := int(bo.Uint16(tiff[ifd : ifd+2]))
|
||||
pos := ifd + 2
|
||||
for i := 0; i < entries; i++ {
|
||||
if pos+12 > len(tiff) {
|
||||
return 0
|
||||
}
|
||||
// SHORT(3) 且 count==1 时,值内联在 4 字节 value 字段的前 2 字节。
|
||||
if bo.Uint16(tiff[pos:pos+2]) == 0x0112 &&
|
||||
bo.Uint16(tiff[pos+2:pos+4]) == 3 &&
|
||||
bo.Uint32(tiff[pos+4:pos+8]) == 1 {
|
||||
v := int(bo.Uint16(tiff[pos+8 : pos+10]))
|
||||
if v >= exifOrientTopLeft && v <= exifOrientLeftBottom {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
pos += 12
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// orientedImage 把源图按 EXIF Orientation 重映射为目标坐标,供 draw.Draw
|
||||
// 一次性拷贝,避免逐像素重复计算源矩形。
|
||||
type orientedImage struct {
|
||||
src image.Image
|
||||
sx0, sy0 int // 源矩形左上角(源图 Bounds().Min 通常为 0,但不作假设)
|
||||
w, h int // 源宽高
|
||||
dstW, dstH int // 目标宽高
|
||||
ori int
|
||||
}
|
||||
|
||||
func (o *orientedImage) ColorModel() color.Model { return o.src.ColorModel() }
|
||||
|
||||
func (o *orientedImage) Bounds() image.Rectangle { return image.Rect(0, 0, o.dstW, o.dstH) }
|
||||
|
||||
// At 按 Orientation 反查源像素坐标。目标 (x,y) 命中源 (sx,sy)。
|
||||
func (o *orientedImage) At(x, y int) color.Color {
|
||||
sx, sy := 0, 0
|
||||
switch o.ori {
|
||||
case exifOrientTopRight: // 水平镜像
|
||||
sx, sy = o.w-1-x, y
|
||||
case exifOrientBottomRight: // 旋转 180
|
||||
sx, sy = o.w-1-x, o.h-1-y
|
||||
case exifOrientBottomLeft: // 垂直镜像
|
||||
sx, sy = x, o.h-1-y
|
||||
case exifOrientLeftTop: // 转置
|
||||
sx, sy = y, x
|
||||
case exifOrientRightTop: // 顺时针 90
|
||||
sx, sy = y, o.h-1-x
|
||||
case exifOrientRightBottom: // 反转置
|
||||
sx, sy = o.w-1-y, o.h-1-x
|
||||
case exifOrientLeftBottom: // 顺时针 270
|
||||
sx, sy = o.w-1-y, x
|
||||
default: // exifOrientTopLeft
|
||||
sx, sy = x, y
|
||||
}
|
||||
return o.src.At(o.sx0+sx, o.sy0+sy)
|
||||
}
|
||||
|
||||
// applyJPEGOrientation 按 EXIF Orientation 重绘像素;orientation 为 1 时原样返回。
|
||||
//
|
||||
// 归一后方向信息已烘进像素,尺寸在 5/6/7/8 时宽高互换——调用方必须在此之后再
|
||||
// 做尺寸上限判断,否则限边会作用在横躺的像素上。
|
||||
func applyJPEGOrientation(img image.Image, orientation int) image.Image {
|
||||
if orientation <= exifOrientTopLeft || orientation > exifOrientLeftBottom {
|
||||
return img
|
||||
}
|
||||
b := img.Bounds()
|
||||
sw, sh := b.Dx(), b.Dy()
|
||||
if sw == 0 || sh == 0 {
|
||||
return img
|
||||
}
|
||||
dstW, dstH := sw, sh
|
||||
switch orientation {
|
||||
case exifOrientLeftTop, exifOrientRightTop, exifOrientRightBottom, exifOrientLeftBottom:
|
||||
dstW, dstH = sh, sw // 这四种会交换宽高
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
draw.Draw(dst, dst.Bounds(), &orientedImage{
|
||||
src: img, sx0: b.Min.X, sy0: b.Min.Y,
|
||||
w: sw, h: sh, dstW: dstW, dstH: dstH,
|
||||
ori: orientation,
|
||||
}, image.Point{}, draw.Src)
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"strconv"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// itoa 避免为一个断言引入 strconv 到主逻辑。
|
||||
func itoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
// webpDimensions 直接解析 WebP 容器头(VP8/VP8L/VP8X 三种 chunk),
|
||||
// 避免依赖 libwebp 桩。
|
||||
func webpDimensions(t *testing.T, data []byte) (int, int) {
|
||||
t.Helper()
|
||||
if len(data) < 30 {
|
||||
t.Fatalf("webp too short: %d", len(data))
|
||||
}
|
||||
switch string(data[12:16]) {
|
||||
case "VP8 ":
|
||||
// 有损:帧头内含 0x9d012a 起始码,宽高各 14 位紧跟其后。
|
||||
b := data[23:26]
|
||||
if b[0] != 0x9d || b[1] != 0x01 || b[2] != 0x2a {
|
||||
t.Fatalf("unexpected VP8 keyframe signature %x", b)
|
||||
}
|
||||
return int(binary.LittleEndian.Uint16(data[26:28]) & 0x3FFF),
|
||||
int(binary.LittleEndian.Uint16(data[28:30]) & 0x3FFF)
|
||||
case "VP8L":
|
||||
// 无损:1 字节签名 + 14 位宽 - 1 + 14 位高 - 1
|
||||
if data[20] != 0x2f {
|
||||
t.Fatalf("unexpected VP8L signature %x", data[20])
|
||||
}
|
||||
bits := binary.LittleEndian.Uint32(data[21:25])
|
||||
return int(bits&0x3FFF) + 1, int((bits>>14)&0x3FFF) + 1
|
||||
case "VP8X":
|
||||
w := int(data[24]) | int(data[25])<<8 | int(data[26])<<16
|
||||
h := int(data[27]) | int(data[28])<<8 | int(data[29])<<16
|
||||
return w + 1, h + 1
|
||||
default:
|
||||
t.Fatalf("unknown webp chunk %q", data[12:16])
|
||||
return 0, 0
|
||||
}
|
||||
}
|
||||
|
||||
// buildJPEGWithOrientation 生成一张 JPEG,并在 SOI 之后插入带指定
|
||||
// Orientation 的 APP1/Exif 段。byteOrder 为 true 时用小端(II),false 用大端(MM)。
|
||||
func buildJPEGWithOrientation(t *testing.T, orientation int, littleEndian bool, w, h int) []byte {
|
||||
t.Helper()
|
||||
|
||||
var raw bytes.Buffer
|
||||
if err := jpeg.Encode(&raw, image.NewGray(image.Rect(0, 0, w, h)), nil); err != nil {
|
||||
t.Fatalf("encode base jpeg: %v", err)
|
||||
}
|
||||
body := raw.Bytes()
|
||||
|
||||
// TIFF: header(8) + IFD0(2 + 12 + 4)
|
||||
tiff := make([]byte, 0, 26)
|
||||
bo := binary.ByteOrder(binary.BigEndian)
|
||||
if littleEndian {
|
||||
tiff = append(tiff, 'I', 'I')
|
||||
bo = binary.LittleEndian
|
||||
} else {
|
||||
tiff = append(tiff, 'M', 'M')
|
||||
}
|
||||
magic := make([]byte, 2)
|
||||
bo.PutUint16(magic, 42)
|
||||
tiff = append(tiff, magic...)
|
||||
off := make([]byte, 4)
|
||||
bo.PutUint32(off, 8) // IFD0 紧跟 TIFF header
|
||||
tiff = append(tiff, off...)
|
||||
tiff = append(tiff, 0x01, 0x00) // IFD0 条目数 = 1
|
||||
|
||||
entry := make([]byte, 12)
|
||||
bo.PutUint16(entry[0:2], 0x0112) // Orientation
|
||||
bo.PutUint16(entry[2:4], 3) // SHORT
|
||||
bo.PutUint32(entry[4:8], 1) // count = 1 -> 值内联在前 2 字节
|
||||
bo.PutUint16(entry[8:10], uint16(orientation))
|
||||
// entry[10:12] 是 next IFD 偏移,保持为 0
|
||||
tiff = append(tiff, entry...)
|
||||
|
||||
payload := append([]byte("Exif\x00\x00"), tiff...)
|
||||
seg := []byte{0xFF, 0xE1, 0, 0}
|
||||
binary.BigEndian.PutUint16(seg[2:4], uint16(len(payload)+2))
|
||||
seg = append(seg, payload...)
|
||||
|
||||
out := make([]byte, 0, len(body)+len(seg))
|
||||
out = append(out, body[:2]...) // SOI
|
||||
out = append(out, seg...)
|
||||
out = append(out, body[2:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
func TestJPEGOrientation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want int
|
||||
}{
|
||||
{"无 EXIF 的裸 JPEG 回落到 1", mustJPEG(t, 4, 4), 1},
|
||||
{"小端 Orientation=6", buildJPEGWithOrientation(t, 6, true, 4, 4), 6},
|
||||
{"大端 Orientation=8", buildJPEGWithOrientation(t, 8, false, 4, 4), 8},
|
||||
{"Orientation=1 视为无需变换", buildJPEGWithOrientation(t, 1, true, 4, 4), 1},
|
||||
{"越界值不采信", buildJPEGWithOrientation(t, 99, true, 4, 4), 1},
|
||||
{"非 JPEG 数据", []byte("not an image at all"), 1},
|
||||
{"空数据", nil, 1},
|
||||
{"截断的 JPEG", []byte{0xFF, 0xD8, 0xFF}, 1},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := jpegOrientation(c.data); got != c.want {
|
||||
t.Fatalf("jpegOrientation() = %d, want %d", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustJPEG(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
if err := jpeg.Encode(&b, image.NewGray(image.Rect(0, 0, w, h)), nil); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// TestApplyJPEGOrientation 用 2x3 的彩色图逐像素校验 8 种方向的映射关系,
|
||||
// 并确认 5/6/7/8 会交换宽高。
|
||||
func TestApplyJPEGOrientation(t *testing.T) {
|
||||
// 源图 2 宽 3 高,标记为 (x,y) 的颜色值 = y*10+x,便于追踪。
|
||||
const sw, sh = 2, 3
|
||||
src := image.NewRGBA(image.Rect(0, 0, sw, sh))
|
||||
for y := 0; y < sh; y++ {
|
||||
for x := 0; x < sw; x++ {
|
||||
src.Set(x, y, color.RGBA{R: uint8(y*10 + x), A: 255})
|
||||
}
|
||||
}
|
||||
r := func(x, y int) uint8 { return uint8(y*10 + x) }
|
||||
|
||||
// 期望:目标 (dx,dy) 处的像素值
|
||||
cases := []struct {
|
||||
ori int
|
||||
dstW, dstH int
|
||||
expect func(dx, dy int) uint8
|
||||
}{
|
||||
{exifOrientTopLeft, sw, sh, func(dx, dy int) uint8 { return r(dx, dy) }},
|
||||
{exifOrientTopRight, sw, sh, func(dx, dy int) uint8 { return r(sw-1-dx, dy) }},
|
||||
{exifOrientBottomRight, sw, sh, func(dx, dy int) uint8 { return r(sw-1-dx, sh-1-dy) }},
|
||||
{exifOrientBottomLeft, sw, sh, func(dx, dy int) uint8 { return r(dx, sh-1-dy) }},
|
||||
{exifOrientLeftTop, sh, sw, func(dx, dy int) uint8 { return r(dy, dx) }},
|
||||
{exifOrientRightTop, sh, sw, func(dx, dy int) uint8 { return r(dy, sh-1-dx) }},
|
||||
{exifOrientRightBottom, sh, sw, func(dx, dy int) uint8 { return r(sw-1-dy, sh-1-dx) }},
|
||||
{exifOrientLeftBottom, sh, sw, func(dx, dy int) uint8 { return r(sw-1-dy, dx) }},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(itoa(c.ori), func(t *testing.T) {
|
||||
got := applyJPEGOrientation(src, c.ori)
|
||||
if b := got.Bounds(); b.Dx() != c.dstW || b.Dy() != c.dstH {
|
||||
t.Fatalf("bounds = %dx%d, want %dx%d", b.Dx(), b.Dy(), c.dstW, c.dstH)
|
||||
}
|
||||
for dy := 0; dy < c.dstH; dy++ {
|
||||
for dx := 0; dx < c.dstW; dx++ {
|
||||
want := color.RGBA{R: c.expect(dx, dy), A: 255}
|
||||
gotR, gotG, gotB, gotA := got.At(dx, dy).RGBA()
|
||||
wantR, wantG, wantB, wantA := want.RGBA()
|
||||
if gotR != wantR || gotG != wantG || gotB != wantB || gotA != wantA {
|
||||
t.Fatalf("ori=%d pixel(%d,%d) = RGBA(%d,%d,%d,%d), want R=%d",
|
||||
c.ori, dx, dy, gotR>>8, gotG>>8, gotB>>8, gotA>>8, c.expect(dx, dy))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Orientation=1 / 越界值必须原样返回同一对象,不做无谓的整图重绘。
|
||||
func TestApplyJPEGOrientationNoop(t *testing.T) {
|
||||
src := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||||
for _, ori := range []int{0, 1, 9, -1} {
|
||||
if got := applyJPEGOrientation(src, ori); got != src {
|
||||
t.Fatalf("orientation %d: expected same instance back", ori)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:带 Orientation=6 的 JPEG 走完整转换后,输出必须是竖图。
|
||||
//
|
||||
// 依赖真实 libwebp(cgo)。在无 libwebp 的环境(webp 被替换为编译桩)下
|
||||
// Encode 不产出字节,此时跳过——像素级映射由 TestApplyJPEGOrientation 覆盖。
|
||||
func TestConvertStaticImageToWebPAppliesEXIFOrientation(t *testing.T) {
|
||||
// 存成 40x30 像素但 EXIF 声明 Orientation=6,实际应显示为 30x40。
|
||||
data := buildJPEGWithOrientation(t, exifOrientRightTop, true, 40, 30)
|
||||
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeConfig: %v", err)
|
||||
}
|
||||
if format != "jpeg" {
|
||||
t.Fatalf("format = %s, want jpeg", format)
|
||||
}
|
||||
// 先确认前提:标准库读到的就是横躺的像素,不应用 EXIF。
|
||||
if cfg.Width != 40 || cfg.Height != 30 {
|
||||
t.Fatalf("raw jpeg dims = %dx%d, want 40x30", cfg.Width, cfg.Height)
|
||||
}
|
||||
|
||||
out, err := ConvertStaticImageToWebP(bytes.NewReader(data), EncodeSpec{MaxSide: 1600, Quality: 80})
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertStaticImageToWebP: %v", err)
|
||||
}
|
||||
if len(out) == 0 || !bytes.HasPrefix(out, []byte("RIFF")) {
|
||||
t.Skip("webp encoder is stubbed (no libwebp); pixel mapping is covered by TestApplyJPEGOrientation")
|
||||
}
|
||||
w, h := webpDimensions(t, out)
|
||||
if w != 30 || h != 40 {
|
||||
t.Fatalf("webp dims = %dx%d, want 30x40 (orientation 6 applied)", w, h)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/gif"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// buildGIF 生成指定帧数的动画 GIF。
|
||||
func buildGIF(t *testing.T, frames int) []byte {
|
||||
t.Helper()
|
||||
g := &gif.GIF{}
|
||||
for i := 0; i < frames; i++ {
|
||||
pal := color.Palette{color.Black, color.White}
|
||||
img := image.NewPaletted(image.Rect(0, 0, 4, 4), pal)
|
||||
if i%2 == 1 {
|
||||
for y := 0; y < 4; y++ {
|
||||
for x := 0; x < 4; x++ {
|
||||
img.SetColorIndex(x, y, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
g.Image = append(g.Image, img)
|
||||
g.Delay = append(g.Delay, 10)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := gif.EncodeAll(&b, g); err != nil {
|
||||
t.Fatalf("encode gif: %v", err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func TestGIFFrameCount(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
frames int
|
||||
}{
|
||||
{"单帧", 1},
|
||||
{"多帧", 12},
|
||||
{"恰好等于上限", maxGIFFrames},
|
||||
{"超过上限", maxGIFFrames + 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
data := buildGIF(t, c.frames)
|
||||
if got := gifFrameCount(data); got != c.frames {
|
||||
t.Fatalf("gifFrameCount = %d, want %d", got, c.frames)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 帧数统计必须能承受构造超大 GIF 的场景而不过度消耗内存。
|
||||
func TestGIFFrameCount_RejectsGarbage(t *testing.T) {
|
||||
cases := map[string][]byte{
|
||||
"空数据": nil,
|
||||
"非 GIF": []byte("not a gif at all, just some bytes"),
|
||||
"仅有文件头": []byte("GIF89a"),
|
||||
"截断的 LSD": []byte("GIF89a\x01\x00"),
|
||||
"截断的色表": append([]byte("GIF89a"), bytes.Repeat([]byte{0xFF}, 8)...),
|
||||
}
|
||||
for name, data := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if n := gifFrameCount(data); n < 0 || n > 3 {
|
||||
t.Fatalf("gifFrameCount = %d, out of sane range", n)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 转换入口在帧数超限时必须返回错误(而不是把超大 GIF 丢给 cwebp)。
|
||||
// 注意本机无 cwebp 时该测试只能覆盖「帧数检查发生在 LookPath 之后」这条路径,
|
||||
// 故直接针对判定逻辑断言,不依赖外部二进制。
|
||||
func TestGIFFrameLimitConstant(t *testing.T) {
|
||||
if maxGIFFrames <= 0 {
|
||||
t.Fatalf("maxGIFFrames = %d, must be positive", maxGIFFrames)
|
||||
}
|
||||
if gifFrameCount(buildGIF(t, maxGIFFrames+1)) <= maxGIFFrames {
|
||||
t.Fatal("oversized GIF should be detected as over limit")
|
||||
}
|
||||
if gifFrameCount(buildGIF(t, 3)) > maxGIFFrames {
|
||||
t.Fatal("small GIF should not be flagged as over limit")
|
||||
}
|
||||
}
|
||||
+154
-26
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user