Refactor code structure for improved readability and maintainability

This commit is contained in:
Nymiro
2025-10-21 13:29:10 +13:00
parent 3c41c57084
commit f0a03e8376
3 changed files with 172 additions and 40 deletions
+20
View File
@@ -25,6 +25,7 @@
| 组件 | 作用 | | 组件 | 作用 |
| --- | --- | | --- | --- |
| `scripts/samsung_live_photo.py` | 命令行转换脚本,输出苹果兼容的 HEIC + MOV | | `scripts/samsung_live_photo.py` | 命令行转换脚本,输出苹果兼容的 HEIC + MOV |
| `scripts/batch_media_manager.py` | 图形化批处理工具:扫描目录、转换 Motion Photo、匹配 Live Photo 配对、清理由 MotionPhoto_Data 标记的冗余视频,并可归档原始素材 |
| `pyheic_struct.HEICFile` | 解析 HEIC 盒结构、重建主图、提取 Motion Photo 等 | | `pyheic_struct.HEICFile` | 解析 HEIC 盒结构、重建主图、提取 Motion Photo 等 |
| `pyheic_struct.HEICBuilder` | 在修改元数据后重新写出 HEIC,确保偏移正确 | | `pyheic_struct.HEICBuilder` | 在修改元数据后重新写出 HEIC,确保偏移正确 |
| `pyheic_struct.convert_motion_photo` | 通用转换入口,可指定厂商与目标适配器 | | `pyheic_struct.convert_motion_photo` | 通用转换入口,可指定厂商与目标适配器 |
@@ -107,6 +108,25 @@ HEICBuilder(rebuilt).write("converted/customized.HEIC")
--- ---
### 4. 图形化批处理工具(GUI)
`scripts/batch_media_manager.py` 提供完整的桌面界面,便于批量巡检目录:
- 递归扫描 HEIC/MOV/MP4 等媒体,并以进度条与日志反馈处理情况;
- 检测 Live Photo 配对,自动移动缺失伴侣的孤立视频到安全子目录;
- 将三星 Motion Photo 转换为 Apple 兼容的 HEIC + MOV,并补写 `ContentIdentifier`
- 清理带 `MotionPhoto_Data` 标签、与 Motion Photo 重复的冗余 MP4
- 可选地把成功转换的原始素材按照原路径层级移动到 `归档/`(或 `Archive/`)并打包 zip
- 内置“停止”按钮和窗口关闭钩子,可安全取消长时间任务。
运行示例:
```bash
python3 scripts/batch_media_manager.py
```
---
## Live Photo 转换管线解读 ## Live Photo 转换管线解读
1. **解析原始 HEIC**:读取 `ftyp``meta``iloc` 等盒;通过 handler 自动识别三星文件并查找 `mpvd` 盒。 1. **解析原始 HEIC**:读取 `ftyp``meta``iloc` 等盒;通过 handler 自动识别三星文件并查找 `mpvd` 盒。
+126 -14
View File
@@ -5,13 +5,14 @@ GUI batch utility for inspecting Live Photo / Motion Photo media trees.
from __future__ import annotations from __future__ import annotations
import json
import os import os
import queue import queue
import shutil import shutil
import subprocess import subprocess
import threading import threading
import datetime import datetime
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Callable, Dict, List, Tuple from typing import Callable, Dict, List, Tuple
@@ -20,10 +21,6 @@ import tkinter as tk
from tkinter import filedialog, messagebox, ttk from tkinter import filedialog, messagebox, ttk
# 假设 pyheic_struct 在你的 Python 路径中 # 假设 pyheic_struct 在你的 Python 路径中
# from pyheic_struct import AppleTargetAdapter, HEICFile, convert_motion_photo
'''
# --- Mocking pyheic_struct for testing if not installed ---
# 如果你没有安装 pyheic_struct,请取消注释下面的部分来模拟它
try: try:
from pyheic_struct import AppleTargetAdapter, HEICFile, convert_motion_photo from pyheic_struct import AppleTargetAdapter, HEICFile, convert_motion_photo
except ImportError: except ImportError:
@@ -48,7 +45,6 @@ except ImportError:
AppleTargetAdapter = MockAppleTargetAdapter AppleTargetAdapter = MockAppleTargetAdapter
convert_motion_photo = mock_convert_motion_photo convert_motion_photo = mock_convert_motion_photo
# --- End of Mocking --- # --- End of Mocking ---
'''
STILL_SUFFIXES = {".heic", ".heif"} STILL_SUFFIXES = {".heic", ".heif"}
VIDEO_SUFFIXES = {".mov", ".mp4", ".m4v", ".qt", ".3gp"} VIDEO_SUFFIXES = {".mov", ".mp4", ".m4v", ".qt", ".3gp"}
@@ -87,6 +83,10 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
"log_archiving": "正在归档原始照片...", "log_archiving": "正在归档原始照片...",
"log_archive_complete": "归档完成: {zip_path}", "log_archive_complete": "归档完成: {zip_path}",
"log_archive_failed": "归档失败: {error}", "log_archive_failed": "归档失败: {error}",
"log_archived_path": "已归档: {src} -> {dest}",
"log_cancel_requested": "已请求中止,正在等待正在运行的任务完成…",
"log_cancelled": "处理已中止。",
"archive_dir_name": "归档",
"task_orphan_video": "孤立视频: {path}", # (此翻译保留,但逻辑已更改) "task_orphan_video": "孤立视频: {path}", # (此翻译保留,但逻辑已更改)
"task_motion_convert": "Motion Photo 转 Live Photo: {path}", "task_motion_convert": "Motion Photo 转 Live Photo: {path}",
"task_cleanup_mp4": "清理 MP4: {path}", # (此翻译保留,但逻辑已更改) "task_cleanup_mp4": "清理 MP4: {path}", # (此翻译保留,但逻辑已更改)
@@ -98,7 +98,9 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
"result_cleanup_kept": "保留 (非目标视频)", # 修改 "result_cleanup_kept": "保留 (非目标视频)", # 修改
"result_cleanup_skipped": "跳过 (缺少 exiftool)", "result_cleanup_skipped": "跳过 (缺少 exiftool)",
"result_failed": "失败: {error}", "result_failed": "失败: {error}",
"result_cancelled": "已取消",
"result_archived": "已归档原始文件", "result_archived": "已归档原始文件",
"button_stop": "停止",
}, },
"en": { "en": {
"app_title": "Live / Motion Photo Batch Manager", "app_title": "Live / Motion Photo Batch Manager",
@@ -133,6 +135,10 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
"log_archiving": "Archiving original photos...", "log_archiving": "Archiving original photos...",
"log_archive_complete": "Archive complete: {zip_path}", "log_archive_complete": "Archive complete: {zip_path}",
"log_archive_failed": "Archive failed: {error}", "log_archive_failed": "Archive failed: {error}",
"log_archived_path": "Archived: {src} -> {dest}",
"log_cancel_requested": "Cancellation requested. Waiting for running tasks…",
"log_cancelled": "Processing cancelled.",
"archive_dir_name": "Archive",
"task_orphan_video": "Orphan video: {path}", # (Retained, logic changed) "task_orphan_video": "Orphan video: {path}", # (Retained, logic changed)
"task_motion_convert": "Motion Photo → Live Photo: {path}", "task_motion_convert": "Motion Photo → Live Photo: {path}",
"task_cleanup_mp4": "Cleanup MP4: {path}", # (Retained, logic changed) "task_cleanup_mp4": "Cleanup MP4: {path}", # (Retained, logic changed)
@@ -144,7 +150,9 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = {
"result_cleanup_kept": "Kept (Not a target video)", # Modified "result_cleanup_kept": "Kept (Not a target video)", # Modified
"result_cleanup_skipped": "Skipped (exiftool unavailable)", "result_cleanup_skipped": "Skipped (exiftool unavailable)",
"result_failed": "Failed: {error}", "result_failed": "Failed: {error}",
"result_cancelled": "Cancelled",
"result_archived": "Archived original file", "result_archived": "Archived original file",
"button_stop": "Stop",
}, },
} }
@@ -157,6 +165,7 @@ class ProcessingOptions:
remove_motion_mp4: bool remove_motion_mp4: bool
archive_originals: bool archive_originals: bool
max_workers: int max_workers: int
stop_event: threading.Event
@dataclass @dataclass
@@ -188,7 +197,15 @@ def _move_orphan_video(mov_path: Path, root_dir: Path, tr: Callable[[str], str])
return {"status": "MOVED", "message": tr("result_moved", dest=str(destination))} return {"status": "MOVED", "message": tr("result_moved", dest=str(destination))}
def _convert_motion_photo(heic_path: Path, inject_mov_tag: bool, tr: Callable[[str], str]) -> dict: def _convert_motion_photo(
heic_path: Path,
inject_mov_tag: bool,
options: ProcessingOptions,
tr: Callable[[str], str],
) -> dict:
if options.stop_event.is_set():
return {"status": "CANCELLED", "message": tr("result_cancelled")}
output_still = heic_path.with_name(f"{heic_path.stem}_apple_compatible.HEIC") output_still = heic_path.with_name(f"{heic_path.stem}_apple_compatible.HEIC")
output_video = heic_path.with_name(f"{heic_path.stem}_apple_compatible.MOV") output_video = heic_path.with_name(f"{heic_path.stem}_apple_compatible.MOV")
@@ -222,6 +239,9 @@ def _process_video(
实现新的优先级逻辑。 实现新的优先级逻辑。
""" """
if options.stop_event.is_set():
return {"status": "CANCELLED", "message": tr("result_cancelled")}
# 如果两个选项都没选,或者没有 exiftool,我们只能执行旧的“孤立”逻辑 # 如果两个选项都没选,或者没有 exiftool,我们只能执行旧的“孤立”逻辑
if not has_exiftool: if not has_exiftool:
if options.handle_live_pairs and not has_still_pair: if options.handle_live_pairs and not has_still_pair:
@@ -236,18 +256,41 @@ def _process_video(
try: try:
cmd = [ cmd = [
"exiftool", "exiftool",
"-s", "-j",
"-s",
"-s",
"-EmbeddedVideoType", "-EmbeddedVideoType",
"-ContentIdentifier", "-ContentIdentifier",
str(video_path), str(video_path),
] ]
result = subprocess.run(cmd, capture_output=True, text=True, check=False) result = subprocess.run(cmd, capture_output=True, text=True, check=False)
output = result.stdout.strip() output = result.stdout.strip()
if result.returncode != 0 and not output:
raise RuntimeError(result.stderr.strip() or "exiftool invocation failed")
has_motion_data = "MotionPhoto_Data" in output metadata: Dict[str, str] = {}
has_content_id = "ContentIdentifier" in output # 注意:exiftool 找到标签就会返回 True if output:
try:
data = json.loads(output)
if isinstance(data, list) and data:
first_record = data[0]
if isinstance(first_record, dict):
metadata = {
str(k): str(v).strip() if isinstance(v, str) else str(v)
for k, v in first_record.items()
}
except json.JSONDecodeError:
for line in output.splitlines():
if ":" not in line:
continue
key, value = line.split(":", 1)
metadata[key.strip()] = value.strip()
if not metadata:
metadata = {}
embedded_type = str(metadata.get("EmbeddedVideoType", ""))
content_identifier = str(metadata.get("ContentIdentifier", "")).strip()
has_motion_data = "MotionPhoto_Data" in embedded_type
has_content_id = bool(content_identifier)
# 优先级 1:检查是否为多余的 MotionPhoto_Data (如果选项开启) # 优先级 1:检查是否为多余的 MotionPhoto_Data (如果选项开启)
if options.remove_motion_mp4 and has_motion_data: if options.remove_motion_mp4 and has_motion_data:
@@ -283,6 +326,8 @@ def _gather_tasks(
log(tr("log_scanning")) log(tr("log_scanning"))
for path in options.root_dir.rglob("*"): for path in options.root_dir.rglob("*"):
if options.stop_event.is_set():
break
if not path.is_file(): if not path.is_file():
continue continue
suffix = path.suffix.lower() suffix = path.suffix.lower()
@@ -302,6 +347,8 @@ def _gather_tasks(
if options.convert_motion: if options.convert_motion:
inject_mov_tag = has_exiftool inject_mov_tag = has_exiftool
for heic_path in heic_files: for heic_path in heic_files:
if options.stop_event.is_set():
break
key = (heic_path.parent, heic_path.stem.lower()) key = (heic_path.parent, heic_path.stem.lower())
entry = base_map.get(key, {}) entry = base_map.get(key, {})
videos = entry.get("videos") or [] videos = entry.get("videos") or []
@@ -312,7 +359,9 @@ def _gather_tasks(
continue continue
tasks.append( tasks.append(
Task( Task(
func=lambda p=heic_path, tr=tr: _convert_motion_photo(p, inject_mov_tag, tr), func=lambda p=heic_path, tr=tr: _convert_motion_photo(
p, inject_mov_tag, options, tr
),
description=tr("task_motion_convert", path=str(heic_path)), description=tr("task_motion_convert", path=str(heic_path)),
) )
) )
@@ -320,6 +369,8 @@ def _gather_tasks(
# 2. (新增) 统一的视频处理任务 (替代旧的 MP4 清理和孤立视频移动) # 2. (新增) 统一的视频处理任务 (替代旧的 MP4 清理和孤立视频移动)
if options.handle_live_pairs or options.remove_motion_mp4: if options.handle_live_pairs or options.remove_motion_mp4:
for video_path in all_video_files: for video_path in all_video_files:
if options.stop_event.is_set():
break
key = (video_path.parent, video_path.stem.lower()) key = (video_path.parent, video_path.stem.lower())
has_still = "still" in base_map.get(key, {}) has_still = "still" in base_map.get(key, {})
@@ -367,6 +418,7 @@ def _worker(options: ProcessingOptions, message_queue: queue.Queue, language: st
emit_log(tr("log_executing", total=total, workers=max_workers)) emit_log(tr("log_executing", total=total, workers=max_workers))
completed = 0 completed = 0
cancelled = False
with ThreadPoolExecutor(max_workers=max_workers) as executor: with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {executor.submit(task.func): task for task in tasks} future_map = {executor.submit(task.func): task for task in tasks}
for future in as_completed(future_map): for future in as_completed(future_map):
@@ -383,15 +435,28 @@ def _worker(options: ProcessingOptions, message_queue: queue.Queue, language: st
files_to_archive.append(outcome["original_path"]) files_to_archive.append(outcome["original_path"])
result_text += f" ({tr('result_archived')})" result_text += f" ({tr('result_archived')})"
except CancelledError:
result_text = tr("result_cancelled")
cancelled = True
except Exception as exc: except Exception as exc:
result_text = tr("result_failed", error=exc) result_text = tr("result_failed", error=exc)
emit_log(tr("log_task_entry", task=task.description, result=result_text)) emit_log(tr("log_task_entry", task=task.description, result=result_text))
completed += 1 completed += 1
message_queue.put(("progress", completed)) message_queue.put(("progress", completed))
if options.stop_event.is_set():
cancelled = True
for pending_future in future_map:
if not pending_future.done():
pending_future.cancel()
break
if cancelled or options.stop_event.is_set():
emit_log(tr("log_cancelled"))
else:
if options.archive_originals and files_to_archive: if options.archive_originals and files_to_archive:
emit_log(tr("log_archiving")) emit_log(tr("log_archiving"))
archive_dir_name = "三星原始照片" archive_dir_name = tr("archive_dir_name")
archive_base_dir = options.root_dir / archive_dir_name archive_base_dir = options.root_dir / archive_dir_name
try: try:
@@ -400,6 +465,13 @@ def _worker(options: ProcessingOptions, message_queue: queue.Queue, language: st
archive_dest = archive_base_dir / relative_path archive_dest = archive_base_dir / relative_path
archive_dest.parent.mkdir(parents=True, exist_ok=True) archive_dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(original_path), str(archive_dest)) shutil.move(str(original_path), str(archive_dest))
emit_log(
tr(
"log_archived_path",
src=str(relative_path),
dest=str(archive_dest.relative_to(options.root_dir)),
)
)
datestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") datestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
zip_name_base = f"{archive_dir_name}_{datestamp}" zip_name_base = f"{archive_dir_name}_{datestamp}"
@@ -431,11 +503,13 @@ class BatchManagerGUI:
self.root = tk.Tk() self.root = tk.Tk()
# 标题与所有文案通过 _update_ui_text 统一设置 # 标题与所有文案通过 _update_ui_text 统一设置
self.root.geometry("780x520") self.root.geometry("780x520")
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
self.lang = tk.StringVar(value="zh") self.lang = tk.StringVar(value="zh")
self.queue: queue.Queue = queue.Queue() self.queue: queue.Queue = queue.Queue()
self.worker_thread: threading.Thread | None = None self.worker_thread: threading.Thread | None = None
self.stop_event = threading.Event()
self.path_var = tk.StringVar() self.path_var = tk.StringVar()
self.handle_live_var = tk.BooleanVar(value=True) self.handle_live_var = tk.BooleanVar(value=True)
@@ -459,6 +533,7 @@ class BatchManagerGUI:
self.label_workers = None self.label_workers = None
self.worker_spin = None self.worker_spin = None
self.start_button = None self.start_button = None
self.stop_button = None
self.progress_bar = None self.progress_bar = None
self.progress_label = None self.progress_label = None
self.log_frame = None self.log_frame = None
@@ -574,6 +649,9 @@ class BatchManagerGUI:
control_frame.pack(fill="x", **padding) control_frame.pack(fill="x", **padding)
self.start_button = ttk.Button(control_frame, text=self._t("button_start"), command=self._start_processing) self.start_button = ttk.Button(control_frame, text=self._t("button_start"), command=self._start_processing)
self.start_button.pack(side="left") self.start_button.pack(side="left")
self.stop_button = ttk.Button(control_frame, text=self._t("button_stop"), command=self._stop_processing)
self.stop_button.pack(side="left", padx=(8, 0))
self.stop_button.config(state="disabled")
self.progress_bar = ttk.Progressbar(self.root, mode="determinate") self.progress_bar = ttk.Progressbar(self.root, mode="determinate")
self.progress_bar.pack(fill="x", padx=10, pady=(4, 0)) self.progress_bar.pack(fill="x", padx=10, pady=(4, 0))
@@ -622,6 +700,8 @@ class BatchManagerGUI:
self.label_workers.config(text=self._t("label_workers")) self.label_workers.config(text=self._t("label_workers"))
if self.start_button is not None: if self.start_button is not None:
self.start_button.config(text=self._t("button_start")) self.start_button.config(text=self._t("button_start"))
if self.stop_button is not None:
self.stop_button.config(text=self._t("button_stop"))
# 进度文本 # 进度文本
if self.progress_label is not None: if self.progress_label is not None:
@@ -648,6 +728,8 @@ class BatchManagerGUI:
messagebox.showerror(self._t("error_invalid_dir_title"), self._t("error_invalid_dir_body")) messagebox.showerror(self._t("error_invalid_dir_title"), self._t("error_invalid_dir_body"))
return return
self.stop_event.clear()
options = ProcessingOptions( options = ProcessingOptions(
root_dir=directory, root_dir=directory,
handle_live_pairs=self.handle_live_var.get(), handle_live_pairs=self.handle_live_var.get(),
@@ -655,11 +737,14 @@ class BatchManagerGUI:
remove_motion_mp4=self.remove_mp4_var.get(), remove_motion_mp4=self.remove_mp4_var.get(),
archive_originals=self.archive_originals_var.get(), archive_originals=self.archive_originals_var.get(),
max_workers=self.worker_count_var.get(), max_workers=self.worker_count_var.get(),
stop_event=self.stop_event,
) )
self._reset_progress() self._reset_progress()
self.start_button.config(state="disabled") self.start_button.config(state="disabled")
self.worker_spin.config(state="disabled") self.worker_spin.config(state="disabled")
if self.stop_button is not None:
self.stop_button.config(state="normal")
self.worker_thread = threading.Thread( self.worker_thread = threading.Thread(
target=_worker, target=_worker,
@@ -668,6 +753,15 @@ class BatchManagerGUI:
) )
self.worker_thread.start() self.worker_thread.start()
def _stop_processing(self) -> None:
if not (self.worker_thread and self.worker_thread.is_alive()):
return
if not self.stop_event.is_set():
self.stop_event.set()
self.queue.put(("log", self._t("log_cancel_requested")))
if self.stop_button is not None:
self.stop_button.config(state="disabled")
def _reset_progress(self) -> None: def _reset_progress(self) -> None:
self.progress_total = 0 self.progress_total = 0
self.progress_done = 0 self.progress_done = 0
@@ -675,6 +769,8 @@ class BatchManagerGUI:
self.progress_bar["maximum"] = 1 self.progress_bar["maximum"] = 1
self.progress_label.config(text=self._t("progress_status").format(done=0, total=0)) self.progress_label.config(text=self._t("progress_status").format(done=0, total=0))
self.log_text.delete("1.0", tk.END) self.log_text.delete("1.0", tk.END)
if self.stop_button is not None:
self.stop_button.config(state="disabled")
def _poll_queue(self) -> None: def _poll_queue(self) -> None:
try: try:
@@ -706,9 +802,25 @@ class BatchManagerGUI:
elif kind == "finished": elif kind == "finished":
self.start_button.config(state="normal") self.start_button.config(state="normal")
self.worker_spin.config(state="normal") self.worker_spin.config(state="normal")
if self.stop_button is not None:
self.stop_button.config(state="disabled")
self.stop_event.clear()
self.log_text.insert(tk.END, self._t("log_processing_done") + "\n") self.log_text.insert(tk.END, self._t("log_processing_done") + "\n")
self.log_text.see(tk.END) self.log_text.see(tk.END)
def _on_close(self) -> None:
if self.worker_thread and self.worker_thread.is_alive():
self._stop_processing()
self.root.after(100, self._wait_for_worker_and_close)
else:
self.root.destroy()
def _wait_for_worker_and_close(self) -> None:
if self.worker_thread and self.worker_thread.is_alive():
self.root.after(100, self._wait_for_worker_and_close)
else:
self.root.destroy()
def main() -> None: def main() -> None:
gui = BatchManagerGUI() gui = BatchManagerGUI()