diff --git a/VideoCompress/.gitignore b/VideoCompress/.gitignore index 2e29888..293c674 100644 --- a/VideoCompress/.gitignore +++ b/VideoCompress/.gitignore @@ -4,4 +4,5 @@ config.json *.xml tmp build -dist \ No newline at end of file +dist +video_info.cache \ No newline at end of file diff --git a/VideoCompress/config_ui_2.py b/VideoCompress/config_ui_2.py new file mode 100644 index 0000000..76b6aac --- /dev/null +++ b/VideoCompress/config_ui_2.py @@ -0,0 +1,599 @@ +# -*- coding: utf-8 -*- +""" +Fluent Design 配置界面(PySide6 + QFluentWidgets) + +功能要点: +- 拆分「通用」与「高级」两页,符合需求 +- 内置 GPU 模板:CPU/libx264、NVIDIA(NVENC/cuda)、Intel(QSV)、AMD(AMF) +- 与 Pydantic Config 对接:即时校验,错误信息以 InfoBar 呈现 +- 生成/预览 ffmpeg 命令;可对单文件执行(subprocess.run) +- 可导入/导出配置(JSON) + +依赖: + pip install PySide6 qfluentwidgets pydantic + +注意: +- 按需在同目录准备 main.py,导出: + from pydantic import BaseModel + class Config(BaseModel): ... + def get_cmd(video_path: str | Path, output_file: str | Path) -> list[str]: ... +- 本 UI 会在运行前,把实例化后的配置对象挂到 main 模块:`main.config = cfg` + 若你的 get_cmd 内部使用全局 config,将能读到该对象。 +""" + +from __future__ import annotations +import json +import os +import re +import sys +import subprocess +from typing import Optional + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QApplication, QFileDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QTextEdit +) + +# QFluentWidgets +from qfluentwidgets import ( + FluentWindow, setTheme, Theme, setThemeColor, FluentIcon, + NavigationItemPosition, PrimaryPushButton, PushButton, LineEdit, + ComboBox, SpinBox, SwitchButton, InfoBar, InfoBarPosition, + CardWidget, NavigationPushButton +) + +# Pydantic +from pydantic import ValidationError + +# ---- 导入业务对象 ----------------------------------------------------------- +try: + import main as main_module # 用于挂载 config 实例 + from main import Config, get_cmd # 直接调用用户提供的方法 +except Exception as e: # 允许先运行 UI 草拟界面,不阻断 + main_module = None + Config = None # type: ignore + get_cmd = None # type: ignore + print("[警告] 无法从 main 导入 Config / get_cmd:", e) + + +# --------------------------- 工具函数 ---------------------------------------- + +def show_error(parent: QWidget, title: str, message: str): + InfoBar.error( + title=title, + content=message, + orient=Qt.Horizontal, + isClosable=True, + position=InfoBarPosition.TOP_RIGHT, + duration=5000, + parent=parent, + ) + + +def show_success(parent: QWidget, title: str, message: str): + InfoBar.success( + title=title, + content=message, + orient=Qt.Horizontal, + isClosable=True, + position=InfoBarPosition.TOP_RIGHT, + duration=3500, + parent=parent, + ) + + +# --------------------------- GPU 模板 ---------------------------------------- +GPU_TEMPLATES = { + "CPU (libx264)": { + "hwaccel": None, + "codec": "libx264", + "extra": ["-preset", "slow"], + "crf": 23, + "bitrate": None, + }, + "NVIDIA (NVENC/cuda)": { + "hwaccel": "cuda", + "codec": "h264_nvenc", + "extra": ["-preset", "p6", "-rc", "vbr"], + "crf": None, + "bitrate": "5M", + }, + "Intel (QSV)": { + "hwaccel": "qsv", + "codec": "h264_qsv", + "extra": ["-preset", "balanced"], + "crf": None, + "bitrate": "5M", + }, + "AMD (AMF)": { + "hwaccel": "amf", + "codec": "h264_amf", + "extra": ["-quality", "balanced"], + "crf": None, + "bitrate": "5M", + }, +} + +SAVE_TO_OPTIONS = ["single", "multi"] +HWACCEL_OPTIONS: list[Optional[str]] = [None, "cuda", "qsv", "amf"] + + +# --------------------------- 卡片基础 ---------------------------------------- +class GroupCard(CardWidget): + def __init__(self, title: str, subtitle: str = "", parent: QWidget | None = None): + super().__init__(parent) + self.setMinimumWidth(720) + self.v = QVBoxLayout(self) + self.v.setContentsMargins(16, 12, 16, 16) + t = QLabel(f"{title}") + t.setProperty("cssClass", "cardTitle") + s = QLabel(subtitle) + s.setProperty("cssClass", "cardSubTitle") + s.setStyleSheet("color: rgba(0,0,0,0.55);") + self.v.addWidget(t) + if subtitle: + self.v.addWidget(s) + self.body = QVBoxLayout() + self.body.setSpacing(10) + self.v.addLayout(self.body) + + def add_row(self, label: str, widget: QWidget): + row = QHBoxLayout() + lab = QLabel(label) + lab.setMinimumWidth(150) + row.addWidget(lab) + row.addWidget(widget, 1) + self.body.addLayout(row) + + +# --------------------------- 通用设置页 -------------------------------------- +class GeneralPage(QWidget): + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 16, 20, 20) + layout.setSpacing(12) + + # --- I/O 区 --- + io_card = GroupCard("输入/输出", "选择要压缩的视频与输出文件") + self.input_edit = LineEdit(self) + self.input_edit.setPlaceholderText("选择输入视频文件... (.mp4/.mkv 等)") + btn_in = PrimaryPushButton("选择文件") + btn_in.clicked.connect(self._pick_input) + io_row = QHBoxLayout() + io_row.addWidget(self.input_edit, 1) + io_row.addWidget(btn_in) + + self.output_edit = LineEdit(self) + self.output_edit.setPlaceholderText("选择输出文件路径,例如:/path/compressed.mp4") + btn_out = PushButton("选择输出") + btn_out.clicked.connect(self._pick_output) + oo_row = QHBoxLayout() + oo_row.addWidget(self.output_edit, 1) + oo_row.addWidget(btn_out) + + io_card.body.addLayout(io_row) + io_card.body.addLayout(oo_row) + + # --- 基础编码参数 --- + base_card = GroupCard("基础编码", "最常用的编码参数") + + self.save_to_combo = ComboBox() + self.save_to_combo.addItems(SAVE_TO_OPTIONS) + base_card.add_row("保存方式(save_to)", self.save_to_combo) + + self.codec_combo = ComboBox() + self.codec_combo.addItems(["h264", "libx264", "h264_nvenc", "h264_qsv", "h264_amf", "hevc", "hevc_nvenc", "hevc_qsv", "hevc_amf"]) + base_card.add_row("编码器(codec)", self.codec_combo) + + self.hwaccel_combo = ComboBox() + self.hwaccel_combo.addItems(["(无)", "cuda", "qsv", "amf"]) + base_card.add_row("GPU 加速(hwaccel)", self.hwaccel_combo) + + self.resolution_edit = LineEdit() + self.resolution_edit.setPlaceholderText("如 1920:1080 或 -1:1080,留空为不缩放") + base_card.add_row("分辨率(resolution)", self.resolution_edit) + + self.fps_spin = SpinBox() + self.fps_spin.setRange(0, 999) + self.fps_spin.setValue(30) + base_card.add_row("帧率(fps)", self.fps_spin) + + self.video_ext_edit = LineEdit() + self.video_ext_edit.setText(".mp4,.mkv") + base_card.add_row("视频后缀(video_ext)", self.video_ext_edit) + + self.compress_dir_edit = LineEdit() + self.compress_dir_edit.setText("compress") + base_card.add_row("压缩目录名(compress_dir_name)", self.compress_dir_edit) + + self.disable_hw_switch = SwitchButton("失败时禁用硬件加速(disable_hwaccel_when_fail)") + self.disable_hw_switch.setChecked(True) + base_card.body.addWidget(self.disable_hw_switch) + + # --- 模板 --- + tpl_card = GroupCard("GPU 模板", "一键应用推荐参数(会覆盖 codec/hwaccel/crf/bitrate/extra)") + self.tpl_combo = ComboBox() + self.tpl_combo.addItems(list(GPU_TEMPLATES.keys())) + btn_apply_tpl = PrimaryPushButton("应用模板") + btn_apply_tpl.clicked.connect(self.apply_template) + row_tpl = QHBoxLayout() + row_tpl.addWidget(self.tpl_combo, 1) + row_tpl.addWidget(btn_apply_tpl) + tpl_card.body.addLayout(row_tpl) + + # --- 运行/预览 --- + run_card = GroupCard("执行", "根据当前配置生成并运行 ffmpeg 命令") + self.preview_text = QTextEdit() + self.preview_text.setReadOnly(True) + btn_preview = PushButton("生成命令预览") + btn_preview.clicked.connect(self.on_preview) + btn_run = PrimaryPushButton("运行压缩 (subprocess.run)") + btn_run.clicked.connect(self.on_run) + rrow = QHBoxLayout() + rrow.addWidget(btn_preview) + rrow.addWidget(btn_run) + run_card.body.addWidget(self.preview_text) + run_card.body.addLayout(rrow) + + # 排版 + layout.addWidget(io_card) + layout.addWidget(base_card) + layout.addWidget(tpl_card) + layout.addWidget(run_card) + layout.addStretch(1) + + # 高级区联动:由 AdvancedPage 控制 + self.link_advanced: Optional[AdvancedPage] = None + + # ----- 文件选择 ----- + def _pick_input(self): + fn, _ = QFileDialog.getOpenFileName(self, "选择输入视频", "", "视频文件 (*.mp4 *.mkv *.mov *.avi *.*)") + if fn: + self.input_edit.setText(fn) + + def _pick_output(self): + fn, _ = QFileDialog.getSaveFileName(self, "选择输出文件", "compressed.mp4", "视频文件 (*.mp4 *.mkv)") + if fn: + self.output_edit.setText(fn) + + # ----- 模板应用 ----- + def apply_template(self): + name = self.tpl_combo.currentText() + t = GPU_TEMPLATES.get(name, {}) + # 覆盖通用字段 + codec = t.get("codec") + if codec: + self.codec_combo.setCurrentText(codec) + hw = t.get("hwaccel") + if hw is None: + self.hwaccel_combo.setCurrentIndex(0) + else: + self.hwaccel_combo.setCurrentText(str(hw)) + # 覆盖高级字段(若可用) + if self.link_advanced: + self.link_advanced.apply_template_from_general(t) + show_success(self, "已应用模板", f"{name} 参数已填充") + + # ----- 组装配置 ----- + def _collect_config_payload(self) -> dict: + # CRF / bitrate 值来自高级区 + adv = self.link_advanced + crf_val = adv.crf_spin.value() if adv else None + if adv and adv.mode_combo.currentText() == "CRF 模式": + bitrate_val = None + else: + bitrate_val = adv.bitrate_edit.text().strip() or None + + # hwaccel + _hw_text = self.hwaccel_combo.currentText() + hwaccel_val = None if _hw_text == "(无)" else _hw_text + + # video_ext 解析 + exts = [x.strip() for x in self.video_ext_edit.text().split(',') if x.strip()] + + payload = { + "save_to": self.save_to_combo.currentText(), + "crf": crf_val if (adv and adv.mode_combo.currentText() == "CRF 模式") else None, + "bitrate": bitrate_val if (adv and adv.mode_combo.currentText() == "比特率模式") else None, + "codec": self.codec_combo.currentText(), + "hwaccel": hwaccel_val, + "extra": adv.parse_list_field(adv.extra_edit.toPlainText()) if adv else None, + "ffmpeg": adv.ffmpeg_edit.text().strip() if adv else "ffmpeg", + "ffprobe": adv.ffprobe_edit.text().strip() if adv else "ffprobe", + "manual": adv.parse_list_field(adv.manual_edit.toPlainText()) if (adv and adv.manual_switch.isChecked()) else None, + "video_ext": exts or [".mp4", ".mkv"], + "compress_dir_name": self.compress_dir_edit.text().strip() or "compress", + "resolution": self.resolution_edit.text().strip() or None, + "fps": self.fps_spin.value(), + "test_video_resolution": adv.test_res_edit.text().strip() if adv else "1920x1080", + "test_video_fps": adv.test_fps_spin.value() if adv else 30, + "test_video_input": adv.test_in_edit.text().strip() if adv else "compress_video_test.mp4", + "test_video_output": adv.test_out_edit.text().strip() if adv else "compressed_video_test.mp4", + "disable_hwaccel_when_fail": self.disable_hw_switch.isChecked(), + } + return payload + + def build_config(self) -> Config: + if Config is None: + raise RuntimeError("未能导入 Config。请确认 main.py 可用且在同目录。") + payload = self._collect_config_payload() + try: + cfg = Config(**payload) + except ValidationError as e: + show_error(self, "配置校验失败", str(e)) + raise + return cfg + + # ----- 预览/运行 ----- + def on_preview(self): + if get_cmd is None: + show_error(self, "缺少 get_cmd", "未能导入 get_cmd,请检查 main.py") + return + in_path = self.input_edit.text().strip() + out_path = self.output_edit.text().strip() + if not in_path or not out_path: + show_error(self, "缺少路径", "请先选择输入与输出文件") + return + try: + cfg = self.build_config() + if main_module is not None: + setattr(main_module, "config", cfg) # 挂载到 main 供 get_cmd 读取 + cmd = get_cmd(in_path, out_path) + self.preview_text.setPlainText(" ".join([repr(c) if " " in c else c for c in cmd])) + show_success(self, "命令已生成", "如需执行请点击运行") + except Exception as e: + show_error(self, "生成失败", str(e)) + + def on_run(self): + if get_cmd is None: + show_error(self, "缺少 get_cmd", "未能导入 get_cmd,请检查 main.py") + return + in_path = self.input_edit.text().strip() + out_path = self.output_edit.text().strip() + if not in_path or not out_path: + show_error(self, "缺少路径", "请先选择输入与输出文件") + return + try: + cfg = self.build_config() + if main_module is not None: + setattr(main_module, "config", cfg) + cmd = get_cmd(in_path, out_path) + # 使用 subprocess.run 执行 + completed = subprocess.run(cmd, capture_output=True, text=True) + log = (completed.stdout or "") + "\n" + (completed.stderr or "") + self.preview_text.setPlainText(log) + if completed.returncode == 0: + show_success(self, "执行成功", "视频压缩完成") + else: + show_error(self, "执行失败", f"返回码 {completed.returncode}") + except Exception as e: + show_error(self, "执行异常", str(e)) + + +# --------------------------- 高级设置页 -------------------------------------- +class AdvancedPage(QWidget): + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 16, 20, 20) + layout.setSpacing(12) + + # 模式切换:CRF / Bitrate 互斥 + mode_card = GroupCard("编码模式", "CRF 与 比特率 二选一") + self.mode_combo = ComboBox() + self.mode_combo.addItems(["CRF 模式", "比特率模式"]) + self.mode_combo.currentIndexChanged.connect(self._on_mode_changed) + mode_card.add_row("选择模式", self.mode_combo) + + self.crf_spin = SpinBox() + self.crf_spin.setRange(0, 51) + self.crf_spin.setValue(23) + mode_card.add_row("CRF 值(0-51)", self.crf_spin) + + self.bitrate_edit = LineEdit() + self.bitrate_edit.setPlaceholderText("如 1000k / 2.5M / 1500B") + mode_card.add_row("比特率", self.bitrate_edit) + + # 其它高级参数 + adv_card = GroupCard("高级参数", "额外 ffmpeg 参数 / 手动命令 / 可执行路径") + + self.extra_edit = QTextEdit() + self.extra_edit.setPlaceholderText("每行一个参数;例如:\n-preset\np6\n-rc\nvbr") + adv_card.add_row("额外(extra)", self.extra_edit) + + self.manual_switch = SwitchButton("使用手动命令 (manual)") + self.manual_switch.setChecked(False) + adv_card.body.addWidget(self.manual_switch) + + self.manual_edit = QTextEdit() + self.manual_edit.setPlaceholderText("每行一个参数(会插入到 ffmpeg -i {input} {manual} {output} 中)") + self.manual_edit.setEnabled(False) + self.manual_switch.checkedChanged.connect(lambda c: self.manual_edit.setEnabled(c)) + adv_card.body.addWidget(self.manual_edit) + + self.ffmpeg_edit = LineEdit() + self.ffmpeg_edit.setText("ffmpeg") + adv_card.add_row("ffmpeg 路径", self.ffmpeg_edit) + self.ffprobe_edit = LineEdit() + self.ffprobe_edit.setText("ffprobe") + adv_card.add_row("ffprobe 路径", self.ffprobe_edit) + + # 测试参数 + test_card = GroupCard("测试", "用于快速验证配置是否能跑通") + self.test_res_edit = LineEdit(); self.test_res_edit.setText("1920x1080") + self.test_fps_spin = SpinBox(); self.test_fps_spin.setRange(0, 999); self.test_fps_spin.setValue(30) + self.test_in_edit = LineEdit(); self.test_in_edit.setText("compress_video_test.mp4") + self.test_out_edit = LineEdit(); self.test_out_edit.setText("compressed_video_test.mp4") + test_card.add_row("测试分辨率", self.test_res_edit) + test_card.add_row("测试 FPS", self.test_fps_spin) + test_card.add_row("测试输入文件名", self.test_in_edit) + test_card.add_row("测试输出文件名", self.test_out_edit) + + # backup_card = GroupCard("备份配置", "导入导出当前配置") + + layout.addWidget(mode_card) + layout.addWidget(adv_card) + layout.addWidget(test_card) + layout.addStretch(1) + + self._on_mode_changed(0) + + def _on_mode_changed(self, idx: int): + is_crf = (idx == 0) + self.crf_spin.setEnabled(is_crf) + self.bitrate_edit.setEnabled(not is_crf) + + # 解析多行 -> list[str] + @staticmethod + def parse_list_field(text: str) -> Optional[list[str]]: + lines = [ln.strip() for ln in text.splitlines() if ln.strip()] + return lines or None + + # 从通用模板带入高级字段 + def apply_template_from_general(self, tpl: dict): + if tpl.get("crf") is not None: + self.mode_combo.setCurrentText("CRF 模式") + self.crf_spin.setValue(int(tpl["crf"])) + self.bitrate_edit.clear() + else: + self.mode_combo.setCurrentText("比特率模式") + self.bitrate_edit.setText(str(tpl.get("bitrate", "5M"))) + self.extra_edit.setPlainText("\n".join(tpl.get("extra", []))) + + +# --------------------------- 主窗体 ------------------------------------------ +class MainWindow(FluentWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("视频压缩配置 - Fluent UI") + self.resize(980, 720) + + # 主题与主色 + setTheme(Theme.AUTO) + setThemeColor("#2563eb") # Tailwind 蓝-600 + + # 页面 + self.general = GeneralPage() + self.general.setObjectName("general_page") + self.advanced = AdvancedPage() + self.advanced.setObjectName("advanced_page") + self.general.link_advanced = self.advanced + + # 导航 + self.addSubInterface(self.general, FluentIcon.VIDEO, "通用 Config") + self.addSubInterface(self.advanced, FluentIcon.SETTING, "高级 Config") + + # 顶部右侧:操作按钮 + # self.navigationInterface.addWidget( # type: ignore + # routeKey="spacer", + # widget=QLabel(""), + # onClick=None, + # position=NavigationItemPosition.TOP + # ) + + # 导入/导出配置 + export_btn = NavigationPushButton(FluentIcon.SAVE, "导出配置", False, self.navigationInterface) + export_btn.clicked.connect(self.export_config) + import_btn = NavigationPushButton(FluentIcon.FOLDER, "导入配置", False, self.navigationInterface) + import_btn.clicked.connect(self.import_config) + self.titleBar.raise_() # 确保标题栏在顶层 + self.navigationInterface.addWidget( # type: ignore + routeKey="export", + widget=export_btn, + onClick=None, + position=NavigationItemPosition.BOTTOM + ) + self.navigationInterface.addWidget( # type: ignore + routeKey="import", + widget=import_btn, + onClick=None, + position=NavigationItemPosition.BOTTOM + ) + + # ---------------- 导入/导出 ----------------- + def export_config(self): + try: + cfg = self.general.build_config() + except ValidationError: + return + fn, _ = QFileDialog.getSaveFileName(self, "导出配置为 JSON", "config.json", "JSON (*.json)") + if not fn: + return + with open(fn, 'w', encoding='utf-8') as f: + json.dump(json.loads(cfg.model_dump_json()), f, ensure_ascii=False, indent=2) + show_success(self, "已导出", os.path.basename(fn)) + + def import_config(self): + fn, _ = QFileDialog.getOpenFileName(self, "导入配置 JSON", "", "JSON (*.json)") + if not fn: + return + try: + with open(fn, 'r', encoding='utf-8') as f: + data = json.load(f) + # 回填到界面(允许缺省) + self._fill_general(data) + self._fill_advanced(data) + show_success(self, "已导入", os.path.basename(fn)) + except Exception as e: + show_error(self, "导入失败", str(e)) + + def _fill_general(self, d: dict): + if v := d.get("save_to"): + self.general.save_to_combo.setCurrentText(v) + if v := d.get("codec"): + self.general.codec_combo.setCurrentText(v) + hw = d.get("hwaccel") + self.general.hwaccel_combo.setCurrentIndex(0 if hw in (None, "", "None") else max(1, self.general.hwaccel_combo.findText(str(hw)))) + if v := d.get("resolution"): + self.general.resolution_edit.setText(v) + if v := d.get("fps"): + self.general.fps_spin.setValue(int(v)) + if v := d.get("video_ext"): + if isinstance(v, list): + self.general.video_ext_edit.setText(",".join(v)) + else: + self.general.video_ext_edit.setText(str(v)) + if v := d.get("compress_dir_name"): + self.general.compress_dir_edit.setText(v) + if v := d.get("disable_hwaccel_when_fail") is not None: + self.general.disable_hw_switch.setChecked(bool(v)) + + def _fill_advanced(self, d: dict): + crf = d.get("crf") + bitrate = d.get("bitrate") + if crf is not None and bitrate is None: + self.advanced.mode_combo.setCurrentText("CRF 模式") + self.advanced.crf_spin.setValue(int(crf)) + self.advanced.bitrate_edit.clear() + else: + self.advanced.mode_combo.setCurrentText("比特率模式") + if bitrate is not None: + self.advanced.bitrate_edit.setText(str(bitrate)) + if v := d.get("extra"): + self.advanced.extra_edit.setPlainText("\n".join(v if isinstance(v, list) else [str(v)])) + if v := d.get("manual"): + self.advanced.manual_switch.setChecked(True) + self.advanced.manual_edit.setEnabled(True) + self.advanced.manual_edit.setPlainText("\n".join(v if isinstance(v, list) else [str(v)])) + if v := d.get("ffmpeg"): + self.advanced.ffmpeg_edit.setText(str(v)) + if v := d.get("ffprobe"): + self.advanced.ffprobe_edit.setText(str(v)) + if v := d.get("test_video_resolution"): + self.advanced.test_res_edit.setText(str(v)) + if v := d.get("test_video_fps"): + self.advanced.test_fps_spin.setValue(int(v)) + if v := d.get("test_video_input"): + self.advanced.test_in_edit.setText(str(v)) + if v := d.get("test_video_output"): + self.advanced.test_out_edit.setText(str(v)) + + +# --------------------------- 入口 -------------------------------------------- +if __name__ == "__main__": + app = QApplication(sys.argv) + win = MainWindow() + win.show() + sys.exit(app.exec()) diff --git a/VideoCompress/main.py b/VideoCompress/main.py index c1239e6..a4ab31d 100644 --- a/VideoCompress/main.py +++ b/VideoCompress/main.py @@ -12,7 +12,6 @@ from typing import Optional, Callable,Literal import atexit import re import get_frame -import pydantic as pyd from pydantic import BaseModel,Field,field_validator,model_validator class Config(BaseModel): @@ -80,7 +79,10 @@ class Config(BaseModel): root = None -CFG_FILE = Path(sys.path[0]) / "config.json" +if os.environ.get("INSTALL", "0") == "1": + CFG_FILE= Path(os.getenv("APPDATA", "C:/")) / "VideoCompress" / "config.json" +else: + CFG_FILE= Path(sys.path[0]) / "config.json" CFG = { "save_to": "single", "crf": "18", diff --git a/pdf_unlock/ui.py b/pdf_unlock/ui.py new file mode 100644 index 0000000..6c4fe46 --- /dev/null +++ b/pdf_unlock/ui.py @@ -0,0 +1,148 @@ +from __future__ import annotations +from pathlib import Path +import threading +import traceback +import tkinter as tk +from tkinter import filedialog, messagebox +import customtkinter as ctk + +from tkinterdnd2 import TkinterDnD, DND_FILES # type: ignore +DND_AVAILABLE = True + +from main import copy_pdf_pages # type: ignore + +APP_TITLE = "PDF 解锁(拖入即可)" +SUFFIX = "_decrypt" + + +def parse_dropped_paths(tcl_list: str, tk_root: tk.Misc) -> list[Path]: + """将 DND 的 raw 字符串解析为 Path 列表(兼容空格/花括号)。""" + return [Path(p) for p in tk_root.tk.splitlist(tcl_list)] + + +def gather_pdfs(paths: list[Path]) -> list[Path]: + """根据传入路径自动识别:单文件 / 多文件 / 目录(递归),提取所有 PDF。""" + out: list[Path] = [] + for p in paths: + if p.is_dir(): + out.extend([f for f in p.rglob("*.pdf") if f.is_file()]) + elif p.is_file() and p.suffix.lower() == ".pdf": + out.append(p) + # 去重并按路径排序 + uniq = sorted({f.resolve() for f in out}) + return list(uniq) + + +def output_path_for(in_path: Path) -> Path: + return in_path.with_name(f"{in_path.stem}{SUFFIX}.pdf") + + +class App: + def __init__(self, root: tk.Tk): + self.root = root + self.root.title(APP_TITLE) + self.root.geometry("720x360") + ctk.set_appearance_mode("System") + ctk.set_default_color_theme("blue") + + # ---- 单一可点击/可拖拽区域 ---- + self.drop = ctk.CTkFrame(self.root, height=240, corner_radius=12) + self.drop.pack(fill="both", expand=True, padx=20, pady=20) + + self.label = ctk.CTkLabel( + self.drop, + text=( + "将 PDF 文件或文件夹拖入此区域即可开始解锁\n" + "输出在原文件同目录,文件名加上 _decrypt 后缀" + + ("(拖拽可用)") + ), + font=("微软雅黑", 16), + justify="center", + ) + self.label.place(relx=0.5, rely=0.5, anchor="center") + + # 点击同样可选择(依然只有这一个控件) + self.drop.bind("", self._on_click_select) + self.label.bind("", self._on_click_select) + + if DND_AVAILABLE: + self.drop.drop_target_register(DND_FILES) # type: ignore + self.drop.dnd_bind("<>", self._on_drop) # type: ignore + + # ---- 事件 ---- + def _on_click_select(self, _evt=None): + # 仅一个简单文件选择器;若想选目录,可直接把目录拖进来 + files = filedialog.askopenfilenames( + title="选择 PDF 文件(可多选)", + filetypes=[("PDF 文件", "*.pdf"), ("所有文件", "*.*")], + ) + if files: + self._start_process([Path(f) for f in files]) + + def _on_drop(self, event): + try: + paths = parse_dropped_paths(event.data, self.root) + except Exception: + return + self._start_process(paths) + + # ---- 核心处理 ---- + def _start_process(self, raw_paths: list[Path]): + if copy_pdf_pages is None: + messagebox.showerror( + "错误", + "未能从 main.py 导入 copy_pdf_pages(input_path: Path, output_path: Path) -> bool", + ) + return + + pdfs = gather_pdfs(raw_paths) + if not pdfs: + messagebox.showwarning("提示", "未找到任何 PDF 文件。") + return + + # 后台线程,避免 UI 卡死 + self.label.configure(text=f"发现 {len(pdfs)} 个 PDF,开始处理…") + t = threading.Thread(target=self._worker, args=(pdfs,), daemon=True) + t.start() + + def _worker(self, pdfs: list[Path]): + ok, fail = 0, 0 + errors: list[str] = [] + for f in pdfs: + try: + out_path = output_path_for(f) + # 简化:若已存在,直接覆盖 + out_path.parent.mkdir(parents=True, exist_ok=True) + success = bool(copy_pdf_pages(f, out_path)) # type: ignore + if success: + ok += 1 + else: + fail += 1 + except Exception as e: + fail += 1 + errors.append(f"{f}: {e}{traceback.format_exc()}") + + summary = f"完成:成功 {ok},失败 {fail}。输出文件位于各自原目录。" + self._set_status(summary) + if errors: + # 仅在有错误时弹出详情 + messagebox.showerror("部分失败", summary + "\n" + "\n".join(errors[:3])) + else: + messagebox.showinfo("完成", summary) + + def _set_status(self, text: str): + self.label.configure(text=text) + + +def main(): + root: tk.Misc + if DND_AVAILABLE: + root = TkinterDnD.Tk() # type: ignore + else: + root = tk.Tk() + App(root) + root.mainloop() + + +if __name__ == "__main__": + main()