feat: 增加 EXIF 旋转图片自动转正
- 解析 JPEG EXIF Orientation,方向 5/6/7/8 的竖拍横存图不再误入横屏目录 - 配置 ffmpeg 时用 ffmpeg 物理转正像素并保留原 EXIF;无 ffmpeg 时按显示方向归类 - 新增 ffmpeg.path / organize.rotateImages 配置项 - 补充 EXIF 解析与旋转幂等测试
This commit is contained in:
@@ -12,10 +12,14 @@ import (
|
||||
// - Port:网页服务用哪个端口(默认 8080)
|
||||
// - Dsn:数据库文件叫什么(默认 media-info.db,放在程序目录下)
|
||||
// - AutoOpen:启动后要不要自动打开浏览器
|
||||
// - FFmpegPath:ffmpeg 程序路径(用于把带 EXIF 旋转标记的图片转正);留空自动在 PATH 里找
|
||||
// - RotateImages:整理时是否自动转正带 EXIF 旋转标记的图片(默认 true)
|
||||
type Config struct {
|
||||
Port int
|
||||
Dsn string
|
||||
AutoOpen bool
|
||||
Port int
|
||||
Dsn string
|
||||
AutoOpen bool
|
||||
FFmpegPath string
|
||||
RotateImages bool
|
||||
}
|
||||
|
||||
// appConfig 当前生效的配置(程序运行期间一直不变)。
|
||||
@@ -29,7 +33,7 @@ func GetAppConfig() Config {
|
||||
// LoadAppConfig 从 application.properties 读配置;文件不存在就用默认值。
|
||||
func LoadAppConfig() {
|
||||
// 先给一套默认值
|
||||
appConfig = Config{Port: 8080, Dsn: "media-info.db", AutoOpen: true}
|
||||
appConfig = Config{Port: 8080, Dsn: "media-info.db", AutoOpen: true, RotateImages: true}
|
||||
|
||||
p, err := properties.LoadFile("application.properties", properties.UTF8)
|
||||
if err != nil {
|
||||
@@ -43,4 +47,9 @@ func LoadAppConfig() {
|
||||
appConfig.Dsn = v
|
||||
}
|
||||
appConfig.AutoOpen = p.GetBool("server.autoOpen", true)
|
||||
// ffmpeg 路径:留空表示自动在 PATH 里找
|
||||
if v, ok := p.Get("ffmpeg.path"); ok && v != "" {
|
||||
appConfig.FFmpegPath = v
|
||||
}
|
||||
appConfig.RotateImages = p.GetBool("organize.rotateImages", true)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ const (
|
||||
type Organizer struct {
|
||||
pickerDir string // 整理后的文件都放到这个根目录下
|
||||
ffprobePath string // ffprobe 程序路径;为空表示没有可用解码器
|
||||
ffmpegPath string // ffmpeg 程序路径;为空表示没有转正 EXIF 旋转图片的能力
|
||||
store *store.Store // 数据库;为 nil 表示不记录媒体信息
|
||||
mover *mover.Mover // 负责实际搬文件的家伙
|
||||
buckets map[string][]string // 目标目录 → 里面该放哪些文件(只记源路径)
|
||||
@@ -65,10 +66,11 @@ type Organizer struct {
|
||||
}
|
||||
|
||||
// New 创建整理器。store 可以为 nil——数据库打不开时,整理照常进行,只是不记入库信息。
|
||||
func New(pickerDir, ffprobePath string, st *store.Store) *Organizer {
|
||||
func New(pickerDir, ffprobePath, ffmpegPath string, st *store.Store) *Organizer {
|
||||
return &Organizer{
|
||||
pickerDir: pickerDir,
|
||||
ffprobePath: ffprobePath,
|
||||
ffmpegPath: ffmpegPath,
|
||||
store: st,
|
||||
mover: &mover.Mover{},
|
||||
buckets: make(map[string][]string),
|
||||
@@ -295,6 +297,10 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
|
||||
return r
|
||||
}
|
||||
r.res = res
|
||||
// 先处理 EXIF 旋转:带旋转标记的图,要么用 ffmpeg 把像素转正(文件变了),
|
||||
// 要么退而求其次按"显示方向"归类(宽高对调)。
|
||||
// 必须在算 MD5 / 分析明暗之前做——转正会改文件内容,得让 MD5 对得上最终文件。
|
||||
o.correctImageRotation(f, r.res)
|
||||
// 算 MD5(顺便拿指纹,可能省掉全量计算)
|
||||
r.md5Str, r.headMd5, r.tailMd5, r.size = o.calcFileInfo(f)
|
||||
// 分析明暗(GIF 不做,动画图逐帧分析没意义)
|
||||
@@ -309,6 +315,40 @@ func (o *Organizer) probeImage(f scanner.File) imageProbeResult {
|
||||
return r
|
||||
}
|
||||
|
||||
// correctImageRotation 处理图片的 EXIF 旋转标记,保证分类方向正确。
|
||||
//
|
||||
// 手机/相机竖拍的图,文件里常横着存像素、再打一个 EXIF 旋转标记(Orientation=5~8),
|
||||
// 查看器会自动转正显示,但我们读到的宽高是"横着存"的,会把竖图错判成横图。
|
||||
//
|
||||
// 两种处理方式(按优先级):
|
||||
// 1. 配置了 ffmpeg 且允许转正:用 ffmpeg 把像素真正转正(烧进旋转),文件内容会变;
|
||||
// 2. 没有 ffmpeg:不改文件,只把宽高对调,按"显示方向"归类。
|
||||
//
|
||||
// 只有方向 5/6/7/8(90°/270° 旋转)才需要宽高对调;2/3/4 是镜像或 180°,横竖不变。
|
||||
func (o *Organizer) correctImageRotation(f scanner.File, res *probe.ProbeResult) {
|
||||
ori := probe.ReadExifOrientation(f.Path)
|
||||
if ori == 0 {
|
||||
return // 没有旋转标记,不用管
|
||||
}
|
||||
// 有 ffmpeg 且允许转正:真把图转过来
|
||||
if o.ffmpegPath != "" {
|
||||
if err := probe.RotateImage(o.ffmpegPath, f.Path); err != nil {
|
||||
fmt.Printf("=== 图片转正失败(按显示方向归类): %s, 错误: %v \n", f.Path, err)
|
||||
} else {
|
||||
// 转正后文件变了,重新读一遍尺寸,保证分类和实际文件一致
|
||||
if res2, err := probe.ProbeImage(f.Path, o.ffprobePath); err == nil {
|
||||
res.Width, res.Height = res2.Width, res2.Height
|
||||
}
|
||||
fmt.Printf("=== 已转正图片: %s (Orientation=%d)\n", f.Path, ori)
|
||||
return
|
||||
}
|
||||
}
|
||||
// 没有 ffmpeg(或转正失败):退而求其次,按显示方向对调宽高归类
|
||||
if probe.NeedsDimensionSwap(ori) {
|
||||
res.Width, res.Height = res.Height, res.Width
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessImages 处理全部图片:先并行体检,再按顺序一个个归类。
|
||||
func (o *Organizer) ProcessImages(images []scanner.File) {
|
||||
// 统计结果,最后打一行总结给用户看
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 用转正后的文件替换原文件
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return fmt.Errorf("替换原文件失败: %v", err)
|
||||
}
|
||||
if err := os.Rename(tmp, filePath); err != nil {
|
||||
return fmt.Errorf("替换原文件失败: %v", 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 && 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 && 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,14 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"kenaito-media-picker/internal/config"
|
||||
"kenaito-media-picker/internal/organize"
|
||||
"kenaito-media-picker/internal/probe"
|
||||
"kenaito-media-picker/internal/scanner"
|
||||
@@ -270,6 +272,23 @@ func resolveRootDir(dir string) string {
|
||||
return abs
|
||||
}
|
||||
|
||||
// resolveFFmpegPath 确定 ffmpeg 程序的路径:
|
||||
// - 配置里指定了就用配置的(不存在时退回下一步);
|
||||
// - 否则在 PATH 里找 ffmpeg。
|
||||
//
|
||||
// 找不到返回空串(调用方会退化为"不转正、按显示方向归类")。
|
||||
func resolveFFmpegPath(configured string) string {
|
||||
if configured != "" {
|
||||
if _, err := os.Stat(configured); err == nil {
|
||||
return configured
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ============================ 后台任务本体 ============================
|
||||
|
||||
// run 是后台干活线程。除了调用 runScan 真正干活外,它还要做一件特殊的事:
|
||||
@@ -410,7 +429,18 @@ func (s *ScanService) runScan(rootDir, destDir string) error {
|
||||
}
|
||||
|
||||
// ---------- 第四步:整理 ----------
|
||||
org := organize.New(pickerDir, ffprobePath, s.store)
|
||||
// ffmpeg 用于把带 EXIF 旋转标记的图片转正(竖拍的图文件里横着存,显示才竖起来)。
|
||||
// 找不到 ffmpeg 也没关系——图片照常处理,只是不转正、按显示方向归类。
|
||||
ffmpegPath := ""
|
||||
if config.GetAppConfig().RotateImages {
|
||||
ffmpegPath = resolveFFmpegPath(config.GetAppConfig().FFmpegPath)
|
||||
if ffmpegPath == "" {
|
||||
fmt.Println("=== 未找到 ffmpeg, 图片EXIF旋转将不做物理转正(仍按显示方向归类)")
|
||||
} else {
|
||||
fmt.Printf("=== 使用 ffmpeg 转正图片EXIF旋转: %s\n", ffmpegPath)
|
||||
}
|
||||
}
|
||||
org := organize.New(pickerDir, ffprobePath, ffmpegPath, s.store)
|
||||
|
||||
// 整个"整理"分两个阶段,界面上共用一条进度条:
|
||||
// 归类阶段:图片、视频/音频分两批处理。进度 = 已处理文件数 / 全部待整理文件数
|
||||
|
||||
Reference in New Issue
Block a user