Files

274 lines
7.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package probe
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"os"
"os/exec"
"time"
)
// ffmpegRotateTimeout 调 ffmpeg 转正图片的超时时间。超大图转起来慢,给足时间。
const ffmpegRotateTimeout = 120 * time.Second
// ReadExifOrientation 读取 JPEG 的 EXIF Orientation(0x0112)标记。
// 返回 1~8(标准 EXIF 方向值);文件不是 JPEG、没有该标记或读取失败时返回 0。
func ReadExifOrientation(filePath string) int {
app1, err := readExifAPP1(filePath)
if err != nil {
return 0
}
return exifOrientation(app1)
}
// NeedsDimensionSwap 判断该方向是否需要"宽高对调"(90°/270° 旋转)。
// EXIF 方向 5/6/7/8 是 90°/270° 旋转,横竖会互换;2/3/4 只是镜像/180°,横竖不变。
func NeedsDimensionSwap(orientation int) bool {
switch orientation {
case 5, 6, 7, 8:
return true
}
return false
}
// RotateImage 用 ffmpeg 把 EXIF 旋转"烧进"像素,把图片转正后写回原文件。
//
// 做法:
// 1. 先把原来的 EXIF APP1 段读出来(里面通常有拍摄日期、相机型号等,不能丢);
// 2. 调 ffmpeg 旋转像素(输出时清掉旋转标记,防止被二次旋转);
// 3. 把 EXIF 方向标记改成 1(正常方向)后回写进转正后的文件。
//
// ffmpegPath 为空时直接报错,调用方决定怎么兜底。
func RotateImage(ffmpegPath, filePath string) error {
if ffmpegPath == "" {
return errors.New("未配置 ffmpeg,无法转正图片")
}
app1, _ := readExifAPP1(filePath) // 读不到 EXIF 也不影响转正,忽略即可
tmp := filePath + ".rotate.tmp"
ctx, cancel := context.WithTimeout(context.Background(), ffmpegRotateTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, ffmpegPath, "-y", "-v", "error",
"-i", filePath,
"-f", "image2",
"-map_metadata", "0",
"-metadata:s:v", "rotate=0",
"-q:v", "2",
tmp)
if out, err := cmd.CombinedOutput(); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("ffmpeg 转正失败: %v: %s", err, string(out))
}
// 把原 EXIF 回写回去(方向标记改成 1,避免查看器再转一次)
if len(app1) > 0 {
setOrientationToOne(app1)
if err := injectExifAPP1(tmp, app1); err != nil {
fmt.Printf("=== 回写EXIF信息失败(不影响转正结果): %v\n", err)
}
}
// 用转正后的文件替换原文件:先把原文件改名成备份,再让转正结果顶上,最后删备份。
// 这样即使中途失败,原文件也还在(只是改了名),不会弄丢。
backup := filePath + ".rotate.bak"
if err := os.Rename(filePath, backup); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("备份原文件失败: %v", err)
}
if err := os.Rename(tmp, filePath); err != nil {
// 转正结果放不回去:把备份挪回来尽量恢复原状
_ = os.Rename(backup, filePath)
_ = os.Remove(tmp)
return fmt.Errorf("替换原文件失败: %v", err)
}
if err := os.Remove(backup); err != nil {
fmt.Printf("=== 删除原文件备份失败(可手动清理 %s): %v\n", backup, err)
}
return nil
}
// exifHeaderReadLimit 读 EXIF 只扫 JPEG 开头的这一小段。
// APP1 段永远在文件最前面(SOI 之后不久),读 64KB 足够定位。
const exifHeaderReadLimit = 64 * 1024
// readExifAPP1 从 JPEG 文件里读出第一个"Exif"的 APP1 段(FF E1 开头的整段)。
func readExifAPP1(filePath string) ([]byte, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
buf := make([]byte, exifHeaderReadLimit)
n, _ := io.ReadFull(f, buf)
buf = buf[:n]
if len(buf) < 2 || buf[0] != 0xFF || buf[1] != 0xD8 {
return nil, errors.New("不是 JPEG 文件")
}
pos := 2
for pos+4 <= len(buf) {
if buf[pos] != 0xFF {
break
}
marker := buf[pos+1]
// 无长度段的标记:跳过
if marker == 0xD8 || (marker >= 0xD0 && marker <= 0xD7) || marker == 0x01 {
pos += 2
continue
}
// 到图像数据(SOS)或文件尾了,后面没有 EXIF
if marker == 0xDA || marker == 0xD9 {
break
}
segLen := int(binary.BigEndian.Uint16(buf[pos+2 : pos+4]))
if segLen < 2 {
break
}
if marker == 0xE1 && segLen >= 8 && pos+8 <= len(buf) && string(buf[pos+4:pos+8]) == "Exif" {
end := pos + 2 + segLen
if end > len(buf) {
end = len(buf)
}
seg := make([]byte, end-pos)
copy(seg, buf[pos:end])
return seg, nil
}
pos += 2 + segLen
}
return nil, errors.New("没有 Exif APP1 段")
}
// exifOrientation 从 Exif 的 APP1 段里解析 Orientation(0x0112)。
// 布局:FF E1 [段长] "Exif\0\0" TIFF头 IFD0...
func exifOrientation(app1 []byte) int {
if len(app1) < 12 {
return 0
}
t := app1[10:] // TIFF 头起始("Exif\0\0" 占 6 字节)
if len(t) < 8 {
return 0
}
order := tiffByteOrder(t)
if order == nil {
return 0
}
if order.Uint16(t[2:4]) != 42 { // TIFF 魔数
return 0
}
ifd := int(order.Uint32(t[4:8]))
if ifd < 8 || ifd+2 > len(t) {
return 0
}
count := int(order.Uint16(t[ifd : ifd+2]))
for i := 0; i < count; i++ {
e := ifd + 2 + i*12
if e+12 > len(t) {
break
}
if order.Uint16(t[e:e+2]) == 0x0112 {
return int(order.Uint16(t[e+8 : e+10]))
}
}
return 0
}
// setOrientationToOne 把 EXIF 的 Orientation 标记改成 1(正常方向)。
func setOrientationToOne(app1 []byte) {
if len(app1) < 12 {
return
}
t := app1[10:]
if len(t) < 8 {
return
}
order := tiffByteOrder(t)
if order == nil {
return
}
ifd := int(order.Uint32(t[4:8]))
if ifd < 8 || ifd+2 > len(t) {
return
}
count := int(order.Uint16(t[ifd : ifd+2]))
for i := 0; i < count; i++ {
e := ifd + 2 + i*12
if e+12 > len(t) {
break
}
if order.Uint16(t[e:e+2]) == 0x0112 {
t[e+8] = 1
t[e+9] = 0
t[e+10] = 0
t[e+11] = 0
return
}
}
}
// tiffByteOrder 判断 TIFF 头的字节序,返回对应的 binary.ByteOrder(不认识返回 nil)。
func tiffByteOrder(t []byte) binary.ByteOrder {
switch {
case len(t) >= 2 && t[0] == 'I' && t[1] == 'I':
return binary.LittleEndian
case len(t) >= 2 && t[0] == 'M' && t[1] == 'M':
return binary.BigEndian
}
return nil
}
// injectExifAPP1 把 Exif APP1 段插到 JPEG 的 SOI 之后(覆盖式重写文件)。
// 文件里原本的 Exif APP1 会被去掉,只留新插入的这一份。
func injectExifAPP1(filePath string, app1 []byte) error {
data, err := os.ReadFile(filePath)
if err != nil {
return err
}
if len(data) < 2 || data[0] != 0xFF || data[1] != 0xD8 {
return errors.New("不是 JPEG 文件")
}
rest := stripExifAPP1(data[2:])
out := make([]byte, 0, 2+len(app1)+len(rest))
out = append(out, data[0], data[1])
out = append(out, app1...)
out = append(out, rest...)
return os.WriteFile(filePath, out, 0644)
}
// stripExifAPP1 从 JPEG 的 SOI 之后的内容里去掉所有"Exif"的 APP1 段,其余原样保留。
func stripExifAPP1(rest []byte) []byte {
pos := 0
out := make([]byte, 0, len(rest))
for pos+4 <= len(rest) {
if rest[pos] != 0xFF {
break
}
marker := rest[pos+1]
// SOS(图像数据开始)或 EOI:后面原样保留
if marker == 0xDA || marker == 0xD9 {
out = append(out, rest[pos:]...)
return out
}
if marker >= 0xD0 && marker <= 0xD7 || marker == 0x01 {
out = append(out, rest[pos], rest[pos+1])
pos += 2
continue
}
segLen := int(binary.BigEndian.Uint16(rest[pos+2 : pos+4]))
if segLen < 2 {
out = append(out, rest[pos:]...)
return out
}
isExif := marker == 0xE1 && segLen >= 8 && pos+8 <= len(rest) && string(rest[pos+4:pos+8]) == "Exif"
if !isExif {
out = append(out, rest[pos:pos+2+segLen]...)
}
pos += 2 + segLen
}
out = append(out, rest[pos:]...)
return out
}