feat: 增加 EXIF 旋转图片自动转正

- 解析 JPEG EXIF Orientation,方向 5/6/7/8 的竖拍横存图不再误入横屏目录
- 配置 ffmpeg 时用 ffmpeg 物理转正像素并保留原 EXIF;无 ffmpeg 时按显示方向归类
- 新增 ffmpeg.path / organize.rotateImages 配置项
- 补充 EXIF 解析与旋转幂等测试
This commit is contained in:
2025-05-19 21:15:58 +08:00
parent 0c96a7bb63
commit 66faed75a2
7 changed files with 459 additions and 6 deletions
+103
View File
@@ -0,0 +1,103 @@
package probe
import (
"encoding/binary"
"os"
"path/filepath"
"testing"
)
// buildJpegWithOrientation 拼一个带 Exif 方向标记的最小 JPEG:
// SOI + APP1(Exif) + EOI,APP1 里只放 TIFF 头 + IFD0 一条 Orientation。
func buildJpegWithOrientation(t *testing.T, orientation uint16, bigEndian bool) []byte {
t.Helper()
var order binary.ByteOrder = binary.LittleEndian
if bigEndian {
order = binary.BigEndian
}
bom := []byte("II")
if bigEndian {
bom = []byte("MM")
}
// TIFF 头(8) + IFD0 计数(2) + 一条目(12)
tiff := make([]byte, 0, 8+2+12)
tiff = append(tiff, bom...)
magic := make([]byte, 2)
order.PutUint16(magic, 42)
tiff = append(tiff, magic...)
ifdOff := make([]byte, 4)
order.PutUint32(ifdOff, 8)
tiff = append(tiff, ifdOff...)
count := make([]byte, 2)
order.PutUint16(count, 1)
tiff = append(tiff, count...)
// 一条目:tag=0x0112 type=SHORT count=1 value=orientation
entry := make([]byte, 12)
order.PutUint16(entry[0:2], 0x0112)
order.PutUint16(entry[2:4], 3) // SHORT
order.PutUint32(entry[4:8], 1) // count
order.PutUint16(entry[8:10], orientation)
tiff = append(tiff, entry...)
app1Data := append([]byte("Exif\x00\x00"), tiff...)
segLen := len(app1Data) + 2
out := []byte{0xFF, 0xD8} // SOI
out = append(out, 0xFF, 0xE1)
lenB := make([]byte, 2)
binary.BigEndian.PutUint16(lenB, uint16(segLen))
out = append(out, lenB...)
out = append(out, app1Data...)
out = append(out, 0xFF, 0xD9) // EOI
return out
}
func TestReadExifOrientation(t *testing.T) {
dir := t.TempDir()
cases := []struct {
name string
ori uint16
bigEnd bool
want int
}{
{"le6", 6, false, 6},
{"be8", 8, true, 8},
{"le1", 1, false, 1},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
p := filepath.Join(dir, c.name+".jpg")
if err := os.WriteFile(p, buildJpegWithOrientation(t, c.ori, c.bigEnd), 0644); err != nil {
t.Fatal(err)
}
if got := ReadExifOrientation(p); got != c.want {
t.Fatalf("ReadExifOrientation = %d, want %d", got, c.want)
}
})
}
// 非 JPEG 返回 0
p := filepath.Join(dir, "notjpeg.txt")
if err := os.WriteFile(p, []byte("hello"), 0644); err != nil {
t.Fatal(err)
}
if got := ReadExifOrientation(p); got != 0 {
t.Fatalf("non-jpeg ReadExifOrientation = %d, want 0", got)
}
// 不存在的文件返回 0
if got := ReadExifOrientation(filepath.Join(dir, "missing.jpg")); got != 0 {
t.Fatalf("missing ReadExifOrientation = %d, want 0", got)
}
}
func TestNeedsDimensionSwap(t *testing.T) {
cases := map[int]bool{
1: false, 2: false, 3: false, 4: false,
5: true, 6: true, 7: true, 8: true,
0: false, 9: false,
}
for ori, want := range cases {
if got := NeedsDimensionSwap(ori); got != want {
t.Fatalf("NeedsDimensionSwap(%d) = %v, want %v", ori, got, want)
}
}
}