From 66faed75a2f5d51ba261dedd98ab4155f168b652 Mon Sep 17 00:00:00 2001 From: Odboy Date: Mon, 19 May 2025 21:15:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=20EXIF=20=E6=97=8B?= =?UTF-8?q?=E8=BD=AC=E5=9B=BE=E7=89=87=E8=87=AA=E5=8A=A8=E8=BD=AC=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 解析 JPEG EXIF Orientation,方向 5/6/7/8 的竖拍横存图不再误入横屏目录 - 配置 ffmpeg 时用 ffmpeg 物理转正像素并保留原 EXIF;无 ffmpeg 时按显示方向归类 - 新增 ffmpeg.path / organize.rotateImages 配置项 - 补充 EXIF 解析与旋转幂等测试 --- README.md | 2 + application.properties | 4 + internal/config/config.go | 17 ++- internal/organize/organize.go | 42 +++++- internal/probe/exif.go | 265 ++++++++++++++++++++++++++++++++++ internal/probe/exif_test.go | 103 +++++++++++++ internal/service/scan.go | 32 +++- 7 files changed, 459 insertions(+), 6 deletions(-) create mode 100644 internal/probe/exif.go create mode 100644 internal/probe/exif_test.go diff --git a/README.md b/README.md index ca3b388..b7dc785 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - **标准分辨率归类**:720P、1080P、2K、2.5K、影院 2K、DCI 4K、8K 及带鱼屏(UltraWide 2K/3K/4K)等标准规格单独建目录 - **非标分辨率归类**:按像素区间(每 1024px 一档)自动归入 1K ~ 19K / 普通 / 原图(原画质)目录 - **方向区分**:横屏、竖屏、等比(正方形)分开整理,GIF 单独处理 +- **EXIF 旋转修正**:手机/相机竖拍的图(文件里横着存、带 EXIF 旋转标记)自动识别并转正——找到 ffmpeg 时用 ffmpeg 把像素真正转正;找不到时至少按"显示方向"(宽高对调)归类,不会再误入横屏目录 - **明暗分类**:基于 ITU-R BT.601 亮度公式分析每张图片明暗程度,暗色(亮度 < 80)像素占比为 darkScore、亮色(亮度 > 175)像素占比为 lightScore;以两者差幅判定亮暗主导,在目录名末尾追加 `-dark_N` / `-mid` / `-bright_N`(N 为占比四舍五入到 10% 的档位) - **音频归类**:音频文件单独归入「音频」目录 - **异常兜底**:读取失败、不支持的格式自动归入「读取异常」「已忽略」目录 @@ -178,6 +179,7 @@ lightScore = 亮度 > 175 的像素占比 - 同一时间只允许一次扫描整理;重复点击会提示「正在运行中」 - GIF 图片单独归入「图片_GIF」;GIF 视频按方向归入「视频_*_GIF」 - ffprobe 内嵌(Windows),运行前释放到当前目录、结束后自动删除 +- EXIF 旋转转正需要 ffmpeg:配置 `ffmpeg.path` 指定路径,或让 ffmpeg 在 PATH 里;找不到 ffmpeg 时不转正像素,仍按显示方向归类。可用 `organize.rotateImages=false` 关闭 ## License diff --git a/application.properties b/application.properties index f08179b..93ac435 100644 --- a/application.properties +++ b/application.properties @@ -5,3 +5,7 @@ server.port=8080 server.autoOpen=true # 媒体信息数据库路径;留空或相对路径时相对服务启动目录(如 media-info.db) database.dsn=media-info.db +# ffmpeg 程序路径(用于把带 EXIF 旋转标记的图片转正);留空自动在 PATH 里找 +ffmpeg.path= +# 整理时是否自动转正带 EXIF 旋转标记的图片(true=是,默认;false=只按显示方向归类不转正) +organize.rotateImages=true diff --git a/internal/config/config.go b/internal/config/config.go index d60652d..7670cc3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) } diff --git a/internal/organize/organize.go b/internal/organize/organize.go index 5230769..cf3fab7 100644 --- a/internal/organize/organize.go +++ b/internal/organize/organize.go @@ -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) { // 统计结果,最后打一行总结给用户看 diff --git a/internal/probe/exif.go b/internal/probe/exif.go new file mode 100644 index 0000000..4083a3c --- /dev/null +++ b/internal/probe/exif.go @@ -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 +} diff --git a/internal/probe/exif_test.go b/internal/probe/exif_test.go new file mode 100644 index 0000000..cddbab9 --- /dev/null +++ b/internal/probe/exif_test.go @@ -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) + } + } +} diff --git a/internal/service/scan.go b/internal/service/scan.go index de97b03..467b656 100644 --- a/internal/service/scan.go +++ b/internal/service/scan.go @@ -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) // 整个"整理"分两个阶段,界面上共用一条进度条: // 归类阶段:图片、视频/音频分两批处理。进度 = 已处理文件数 / 全部待整理文件数