Files
tools/VideoCompress/config_ui_2.py
2025-11-01 00:12:29 +08:00

600 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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"<b>{title}</b>")
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())