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
+222
View File
@@ -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)
}
}