From f0a03e83767a232e92590379965f3a188a9c4d9b Mon Sep 17 00:00:00 2001 From: Nymiro Date: Tue, 21 Oct 2025 13:29:10 +1300 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- README.md | 20 ++ .../batch_media_manager.cpython-314.pyc | Bin 0 -> 48902 bytes scripts/batch_media_manager.py | 192 ++++++++++++++---- 3 files changed, 172 insertions(+), 40 deletions(-) create mode 100644 scripts/__pycache__/batch_media_manager.cpython-314.pyc diff --git a/README.md b/README.md index 7a923a6..bc138b1 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ | 组件 | 作用 | | --- | --- | | `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.HEICBuilder` | 在修改元数据后重新写出 HEIC,确保偏移正确 | | `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 转换管线解读 1. **解析原始 HEIC**:读取 `ftyp`、`meta`、`iloc` 等盒;通过 handler 自动识别三星文件并查找 `mpvd` 盒。 diff --git a/scripts/__pycache__/batch_media_manager.cpython-314.pyc b/scripts/__pycache__/batch_media_manager.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..05e6ec72a1d5322163a7e58c223f7d37c3672c8d GIT binary patch literal 48902 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 literal 0 HcmV?d00001 diff --git a/scripts/batch_media_manager.py b/scripts/batch_media_manager.py index 7917fb2..40e0415 100644 --- a/scripts/batch_media_manager.py +++ b/scripts/batch_media_manager.py @@ -5,13 +5,14 @@ GUI batch utility for inspecting Live Photo / Motion Photo media trees. from __future__ import annotations +import json import os import queue import shutil import subprocess import threading import datetime -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor, as_completed, CancelledError from dataclasses import dataclass from pathlib import Path from typing import Callable, Dict, List, Tuple @@ -20,10 +21,6 @@ import tkinter as tk from tkinter import filedialog, messagebox, ttk # 假设 pyheic_struct 在你的 Python 路径中 -# from pyheic_struct import AppleTargetAdapter, HEICFile, convert_motion_photo -''' -# --- Mocking pyheic_struct for testing if not installed --- -# 如果你没有安装 pyheic_struct,请取消注释下面的部分来模拟它 try: from pyheic_struct import AppleTargetAdapter, HEICFile, convert_motion_photo except ImportError: @@ -48,7 +45,6 @@ except ImportError: AppleTargetAdapter = MockAppleTargetAdapter convert_motion_photo = mock_convert_motion_photo # --- End of Mocking --- -''' STILL_SUFFIXES = {".heic", ".heif"} VIDEO_SUFFIXES = {".mov", ".mp4", ".m4v", ".qt", ".3gp"} @@ -87,6 +83,10 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = { "log_archiving": "正在归档原始照片...", "log_archive_complete": "归档完成: {zip_path}", "log_archive_failed": "归档失败: {error}", + "log_archived_path": "已归档: {src} -> {dest}", + "log_cancel_requested": "已请求中止,正在等待正在运行的任务完成…", + "log_cancelled": "处理已中止。", + "archive_dir_name": "归档", "task_orphan_video": "孤立视频: {path}", # (此翻译保留,但逻辑已更改) "task_motion_convert": "Motion Photo 转 Live Photo: {path}", "task_cleanup_mp4": "清理 MP4: {path}", # (此翻译保留,但逻辑已更改) @@ -98,7 +98,9 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = { "result_cleanup_kept": "保留 (非目标视频)", # 修改 "result_cleanup_skipped": "跳过 (缺少 exiftool)", "result_failed": "失败: {error}", + "result_cancelled": "已取消", "result_archived": "已归档原始文件", + "button_stop": "停止", }, "en": { "app_title": "Live / Motion Photo Batch Manager", @@ -133,6 +135,10 @@ TRANSLATIONS: Dict[str, Dict[str, str]] = { "log_archiving": "Archiving original photos...", "log_archive_complete": "Archive complete: {zip_path}", "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_motion_convert": "Motion Photo → Live Photo: {path}", "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_skipped": "Skipped (exiftool unavailable)", "result_failed": "Failed: {error}", + "result_cancelled": "Cancelled", "result_archived": "Archived original file", + "button_stop": "Stop", }, } @@ -157,6 +165,7 @@ class ProcessingOptions: remove_motion_mp4: bool archive_originals: bool max_workers: int + stop_event: threading.Event @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))} -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_video = heic_path.with_name(f"{heic_path.stem}_apple_compatible.MOV") @@ -221,7 +238,10 @@ def _process_video( 根据元数据和用户选项处理单个视频文件(MP4, MOV 等)。 实现新的优先级逻辑。 """ - + + if options.stop_event.is_set(): + return {"status": "CANCELLED", "message": tr("result_cancelled")} + # 如果两个选项都没选,或者没有 exiftool,我们只能执行旧的“孤立”逻辑 if not has_exiftool: if options.handle_live_pairs and not has_still_pair: @@ -236,18 +256,41 @@ def _process_video( try: cmd = [ "exiftool", - "-s", - "-s", - "-s", + "-j", "-EmbeddedVideoType", "-ContentIdentifier", str(video_path), ] result = subprocess.run(cmd, capture_output=True, text=True, check=False) output = result.stdout.strip() + if result.returncode != 0 and not output: + raise RuntimeError(result.stderr.strip() or "exiftool invocation failed") + + metadata: Dict[str, str] = {} + 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 = {} - has_motion_data = "MotionPhoto_Data" in output - has_content_id = "ContentIdentifier" in output # 注意:exiftool 找到标签就会返回 True + 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 (如果选项开启) if options.remove_motion_mp4 and has_motion_data: @@ -283,6 +326,8 @@ def _gather_tasks( log(tr("log_scanning")) for path in options.root_dir.rglob("*"): + if options.stop_event.is_set(): + break if not path.is_file(): continue suffix = path.suffix.lower() @@ -302,6 +347,8 @@ def _gather_tasks( if options.convert_motion: inject_mov_tag = has_exiftool for heic_path in heic_files: + if options.stop_event.is_set(): + break key = (heic_path.parent, heic_path.stem.lower()) entry = base_map.get(key, {}) videos = entry.get("videos") or [] @@ -312,7 +359,9 @@ def _gather_tasks( continue tasks.append( 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)), ) ) @@ -320,6 +369,8 @@ def _gather_tasks( # 2. (新增) 统一的视频处理任务 (替代旧的 MP4 清理和孤立视频移动) if options.handle_live_pairs or options.remove_motion_mp4: for video_path in all_video_files: + if options.stop_event.is_set(): + break key = (video_path.parent, video_path.stem.lower()) 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)) completed = 0 + cancelled = False with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = {executor.submit(task.func): task for task in tasks} for future in as_completed(future_map): @@ -383,42 +435,62 @@ def _worker(options: ProcessingOptions, message_queue: queue.Queue, language: st files_to_archive.append(outcome["original_path"]) result_text += f" ({tr('result_archived')})" + except CancelledError: + result_text = tr("result_cancelled") + cancelled = True except Exception as exc: result_text = tr("result_failed", error=exc) emit_log(tr("log_task_entry", task=task.description, result=result_text)) completed += 1 message_queue.put(("progress", completed)) - if options.archive_originals and files_to_archive: - emit_log(tr("log_archiving")) - archive_dir_name = "三星原始照片" - archive_base_dir = options.root_dir / archive_dir_name - - try: - for original_path in files_to_archive: - relative_path = original_path.relative_to(options.root_dir) - archive_dest = archive_base_dir / relative_path - archive_dest.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(original_path), str(archive_dest)) + if options.stop_event.is_set(): + cancelled = True + for pending_future in future_map: + if not pending_future.done(): + pending_future.cancel() + break - datestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - zip_name_base = f"{archive_dir_name}_{datestamp}" + if cancelled or options.stop_event.is_set(): + emit_log(tr("log_cancelled")) + else: + if options.archive_originals and files_to_archive: + emit_log(tr("log_archiving")) + archive_dir_name = tr("archive_dir_name") + archive_base_dir = options.root_dir / archive_dir_name - zip_path_str = shutil.make_archive( - base_name=str(options.root_dir / zip_name_base), - format="zip", - root_dir=options.root_dir, - base_dir=archive_dir_name, - ) - - # shutil.rmtree(archive_base_dir) # 归档后删除原文件夹 + try: + for original_path in files_to_archive: + relative_path = original_path.relative_to(options.root_dir) + archive_dest = archive_base_dir / relative_path + archive_dest.parent.mkdir(parents=True, exist_ok=True) + 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)), + ) + ) - emit_log(tr("log_archive_complete", zip_path=Path(zip_path_str).name)) + datestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + zip_name_base = f"{archive_dir_name}_{datestamp}" + + zip_path_str = shutil.make_archive( + base_name=str(options.root_dir / zip_name_base), + format="zip", + root_dir=options.root_dir, + base_dir=archive_dir_name, + ) + + # shutil.rmtree(archive_base_dir) # 归档后删除原文件夹 - except Exception as e: - emit_log(tr("log_archive_failed", error=e)) + emit_log(tr("log_archive_complete", zip_path=Path(zip_path_str).name)) - emit_log(tr("log_finished")) + except Exception as e: + emit_log(tr("log_archive_failed", error=e)) + + emit_log(tr("log_finished")) except Exception as e: emit_log(tr("result_failed", error=e)) finally: @@ -431,11 +503,13 @@ class BatchManagerGUI: self.root = tk.Tk() # 标题与所有文案通过 _update_ui_text 统一设置 self.root.geometry("780x520") + self.root.protocol("WM_DELETE_WINDOW", self._on_close) self.lang = tk.StringVar(value="zh") self.queue: queue.Queue = queue.Queue() self.worker_thread: threading.Thread | None = None + self.stop_event = threading.Event() self.path_var = tk.StringVar() self.handle_live_var = tk.BooleanVar(value=True) @@ -459,6 +533,7 @@ class BatchManagerGUI: self.label_workers = None self.worker_spin = None self.start_button = None + self.stop_button = None self.progress_bar = None self.progress_label = None self.log_frame = None @@ -574,6 +649,9 @@ class BatchManagerGUI: 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.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.pack(fill="x", padx=10, pady=(4, 0)) @@ -622,6 +700,8 @@ class BatchManagerGUI: self.label_workers.config(text=self._t("label_workers")) if self.start_button is not None: 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: @@ -648,6 +728,8 @@ class BatchManagerGUI: messagebox.showerror(self._t("error_invalid_dir_title"), self._t("error_invalid_dir_body")) return + self.stop_event.clear() + options = ProcessingOptions( root_dir=directory, handle_live_pairs=self.handle_live_var.get(), @@ -655,11 +737,14 @@ class BatchManagerGUI: remove_motion_mp4=self.remove_mp4_var.get(), archive_originals=self.archive_originals_var.get(), max_workers=self.worker_count_var.get(), + stop_event=self.stop_event, ) self._reset_progress() self.start_button.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( target=_worker, @@ -668,6 +753,15 @@ class BatchManagerGUI: ) 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: self.progress_total = 0 self.progress_done = 0 @@ -675,6 +769,8 @@ class BatchManagerGUI: self.progress_bar["maximum"] = 1 self.progress_label.config(text=self._t("progress_status").format(done=0, total=0)) 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: try: @@ -706,9 +802,25 @@ class BatchManagerGUI: elif kind == "finished": self.start_button.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.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: gui = BatchManagerGUI() @@ -716,4 +828,4 @@ def main() -> None: if __name__ == "__main__": # pragma: no cover - main() \ No newline at end of file + main()