之前看短剧,看1-2分钟就要缓冲一下,实在是受不了,干脆借助Trea加小米送的词元,做了这个合并器,反正自己测试了可以用。不排除还有bug,提供源码,可以自己去进一步完善。因为pyqt5安装挺麻烦的,所以干脆使用了PySide6,优先支持Linux国产系统,兼容Windows。若是源码运行时提示你缺少第三方库,自己安装即可。现在有AI写个软件真的太轻松了。

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
短剧 MP4 快速合并工具 v1.4.1(PySide6 便携版)
所有日志、配置均存储在程序所在目录下,无需写入系统目录。
功能:
- 启动时检测 ffmpeg/ffprobe 是否可用
- 子线程扫描文件信息,避免 UI 假死
- 拖拽排序、批量导入视频文件
- ffmpeg concat 合并,带进度反馈
- 未捕获异常写入日志文件
- 支持 PyInstaller 打包
- 自然排序(1.mp4 → 2.mp4 → 10.mp4)
"""
import sys
import os
import re
import json
import queue
import traceback
import subprocess
import platform
import shutil
import tempfile
import threading
from datetime import datetime
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QListWidget, QListWidgetItem, QFileDialog,
QMessageBox, QProgressBar, QAbstractItemView, QStyle,
QGroupBox, QTextEdit, QCheckBox, QComboBox
)
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QColor, QFont, QIcon
# ──────────────────────────────────────────────
# 常量
# ──────────────────────────────────────────────
APP_NAME = "短剧 MP4 合并工具"
APP_VERSION = "1.4.1"
SUPPORTED_EXTENSIONS = {".mp4", ".mkv", ".avi", ".mov", ".ts", ".flv", ".wmv"}
MOVFLAGS_FORMATS = {".mp4", ".mov"} # 仅这些容器需要 -movflags +faststart
IS_WINDOWS = platform.system() == "Windows"
# ──────────────────────────────────────────────
# 自然排序(Natural Sort)
# 1.mp4 → 2.mp4 → 10.mp4,而非 1.mp4 → 10.mp4 → 2.mp4
# ──────────────────────────────────────────────
def _natural_sort_key(s):
"""将字符串拆分为 [文本, 数字, 文本, 数字, ...] 用于自然排序"""
return [int(part) if part.isdigit() else part.lower()
for part in re.split(r'(\d+)', str(s))]
def natural_sort(paths):
"""对路径列表按文件名做自然排序"""
return sorted(paths, key=lambda p: _natural_sort_key(os.path.basename(p)))
# ──────────────────────────────────────────────
# 便携版路径:所有数据都在程序目录下
# ──────────────────────────────────────────────
def app_base_dir():
"""程序所在目录(打包后为 exe 所在目录,开发时为 .py 所在目录)"""
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__))
def ensure_dir(path):
os.makedirs(path, exist_ok=True)
return path
def log_dir():
"""日志目录:程序目录/logs/"""
return ensure_dir(os.path.join(app_base_dir(), "logs"))
def crash_log_path():
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return os.path.join(log_dir(), f"crash_{stamp}.log")
def resource_path(*parts):
"""PyInstaller 打包资源路径,未打包时回退到程序目录"""
if getattr(sys, "_MEIPASS", None):
return os.path.join(sys._MEIPASS, *parts)
return os.path.join(app_base_dir(), *parts)
# ──────────────────────────────────────────────
# 全局异常捕获 → 写日志文件
# ──────────────────────────────────────────────
def write_crash_log(exc_type, exc_value, exc_tb):
log_path = crash_log_path()
content = [
f"App: {APP_NAME}",
f"Version: {APP_VERSION}",
f"Time: {datetime.now().isoformat()}",
f"Platform: {platform.platform()}",
f"Python: {sys.version}",
"",
"Traceback:",
"".join(traceback.format_exception(exc_type, exc_value, exc_tb)),
]
try:
with open(log_path, "w", encoding="utf-8") as f:
f.write("\n".join(content))
except OSError:
log_path = "(写入失败)"
return log_path
def exception_hook(exc_type, exc_value, exc_tb):
log_path = write_crash_log(exc_type, exc_value, exc_tb)
traceback.print_exception(exc_type, exc_value, exc_tb)
try:
QMessageBox.critical(
None, "程序异常",
f"程序发生未处理异常。\n\n错误日志已写入:\n{log_path}"
)
except Exception:
pass
# ──────────────────────────────────────────────
# 跨平台 subprocess 参数
# ──────────────────────────────────────────────
def subprocess_run_kwargs(timeout=None):
"""subprocess.run 的额外参数,Windows 下隐藏控制台窗口,统一 UTF-8 编码"""
kwargs = {
"capture_output": True,
"text": True,
"encoding": "utf-8",
"errors": "replace", # 遇到非法字节用 ? 替代,避免崩溃
}
if timeout is not None:
kwargs["timeout"] = timeout
if IS_WINDOWS:
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
kwargs["startupinfo"] = si
return kwargs
def subprocess_popen_kwargs():
"""subprocess.Popen 的额外参数,Windows 下隐藏控制台窗口,统一 UTF-8 编码"""
kwargs = {
"stdout": subprocess.PIPE,
"stderr": subprocess.PIPE,
"text": True,
"encoding": "utf-8",
"errors": "replace",
}
if IS_WINDOWS:
si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
kwargs["startupinfo"] = si
return kwargs
# ──────────────────────────────────────────────
# ffmpeg / ffprobe 检测
# ──────────────────────────────────────────────
def check_ffmpeg():
return shutil.which("ffmpeg") is not None
def check_ffprobe():
return shutil.which("ffprobe") is not None
def get_install_hint():
os_name = platform.system().lower()
if "linux" in os_name:
return (
"请通过包管理器安装 FFmpeg:\n\n"
" Ubuntu/Debian:\n sudo apt update && sudo apt install ffmpeg\n\n"
" Fedora:\n sudo dnf install ffmpeg\n\n"
" Arch Linux:\n sudo pacman -S ffmpeg\n\n"
" CentOS/RHEL(需 EPEL):\n sudo yum install epel-release\n sudo yum install ffmpeg\n"
)
if "windows" in os_name:
return (
"请安装 FFmpeg 并添加到系统 PATH:\n\n"
" 1. 访问 https://www.gyan.dev/ffmpeg/builds/\n"
" 下载 ffmpeg-release-essentials.zip\n\n"
" 2. 解压到目录,例如 C:\\ffmpeg\n\n"
" 3. 将 C:\\ffmpeg\\bin 添加到系统环境变量 PATH:\n"
" 右键「此电脑」→ 属性 → 高级系统设置\n"
" → 环境变量 → 系统变量 → Path → 编辑 → 新建\n\n"
" 4. 重启终端/应用使 PATH 生效\n"
)
return "请安装 FFmpeg:\n https://ffmpeg.org/download.html\n"
def show_ffmpeg_missing_dialog(parent=None):
hint = get_install_hint()
msg = QMessageBox(parent)
msg.setIcon(QMessageBox.Icon.Critical)
msg.setWindowTitle("FFmpeg 未安装")
msg.setText(
"本程序依赖 FFmpeg 进行视频合并,但未检测到 ffmpeg / ffprobe 命令。\n\n"
"请先安装 FFmpeg 后再运行本程序。"
)
msg.setDetailedText(hint)
msg.setInformativeText(
"官方文档:https://ffmpeg.org/documentation.html\n"
"安装完成后请重新启动本程序。"
)
msg.setStandardButtons(QMessageBox.StandardButton.Ok)
msg.exec()
# ──────────────────────────────────────────────
# 文件信息数据类
# ──────────────────────────────────────────────
class MediaInfo:
def __init__(self, filepath):
self.filepath = filepath
self.filename = os.path.basename(filepath)
self.duration = 0.0
self.size_bytes = 0
self.codec = ""
self.resolution = ""
self.fps = ""
self.bitrate = ""
self.valid = False
self.error = ""
self.scanning = False # 正在扫描中
@property
def size_display(self):
if self.size_bytes < 1024:
return f"{self.size_bytes} B"
if self.size_bytes < 1024 ** 2:
return f"{self.size_bytes / 1024:.1f} KB"
if self.size_bytes < 1024 ** 3:
return f"{self.size_bytes / 1024 ** 2:.1f} MB"
return f"{self.size_bytes / 1024 ** 3:.2f} GB"
@property
def duration_display(self):
if self.duration <= 0:
return "--:--"
m, s = divmod(int(self.duration), 60)
h, m = divmod(m, 60)
if h > 0:
return f"{h:d}:{m:02d}:{s:02d}"
return f"{m:d}:{s:02d}"
def summary(self):
parts = [self.filename]
if self.scanning:
parts.append("[扫描中...]")
elif self.valid:
parts.append(self.duration_display)
if self.resolution:
parts.append(self.resolution)
if self.codec:
parts.append(self.codec)
parts.append(self.size_display)
elif self.error:
parts.append(f"[错误: {self.error}]")
return " | ".join(parts)
# ──────────────────────────────────────────────
# concat 路径转义(符合 ffmpeg concat demuxer 规范)
# ──────────────────────────────────────────────
def concat_escape_path(filepath):
"""
转义路径写入 ffmpeg concat 列表。
规则:包裹在单引号内,单引号用 '\\'' 转义。
反斜杠不需要额外转义(-safe 0 模式下 ffmpeg 不解释反斜杠)。
"""
return "'" + filepath.replace("'", "'\\''") + "'"
# ──────────────────────────────────────────────
# 工作线程:ffprobe 扫描
# ──────────────────────────────────────────────
class ScanWorker(QThread):
file_scanned = Signal(str, object) # (filepath, MediaInfo)
scan_finished = Signal(int)
def __init__(self, file_paths, parent=None):
super().__init__(parent)
self.file_paths = list(file_paths)
self._cancelled = False
def cancel(self):
self._cancelled = True
def run(self):
processed = 0
for filepath in self.file_paths:
if self._cancelled:
break
info = self._probe_file(filepath)
if self._cancelled:
break
self.file_scanned.emit(filepath, info)
processed += 1
self.scan_finished.emit(processed)
@staticmethod
def _probe_file(filepath):
info = MediaInfo(filepath)
try:
info.size_bytes = os.path.getsize(filepath)
except OSError:
info.size_bytes = 0
try:
cmd = [
"ffprobe", "-v", "quiet",
"-print_format", "json",
"-show_format", "-show_streams",
filepath
]
result = subprocess.run(cmd, **subprocess_run_kwargs(timeout=30))
if result.returncode != 0:
info.error = result.stderr.strip()[:120] or "ffprobe 返回错误"
return info
data = json.loads(result.stdout)
fmt = data.get("format", {})
info.duration = float(fmt.get("duration", 0) or 0)
for stream in data.get("streams", []):
if stream.get("codec_type") != "video":
continue
info.codec = stream.get("codec_name", "unknown")
w, h = stream.get("width", 0), stream.get("height", 0)
if w and h:
info.resolution = f"{w}x{h}"
r_frame_rate = str(stream.get("r_frame_rate", ""))
if "/" in r_frame_rate:
num, den = r_frame_rate.split("/", 1)
if float(den) > 0:
info.fps = f"{float(num) / float(den):.2f} fps"
break
bit_rate = fmt.get("bit_rate")
if bit_rate:
info.bitrate = f"{int(bit_rate) / 1000:.0f} kbps"
info.valid = True
except FileNotFoundError:
info.error = "ffprobe 未找到"
except subprocess.TimeoutExpired:
info.error = "扫描超时"
except json.JSONDecodeError:
info.error = "ffprobe 输出解析失败"
except Exception as exc:
info.error = str(exc)[:120]
return info
# ──────────────────────────────────────────────
# 工作线程:ffmpeg 合并
# ──────────────────────────────────────────────
class MergeWorker(QThread):
"""
使用 ffmpeg concat 协议合并文件。
进度:-progress pipe:1 -nostats,从 stdout 读取 out_time_us。
错误:stderr 通过 daemon 线程收集,避免 stdout/stderr 交替 readline 的死锁风险。
"""
progress_updated = Signal(int)
merge_finished = Signal(bool, str)
log_message = Signal(str)
def __init__(self, file_paths, output_path, reencode=False,
output_ext=".mp4", parent=None):
super().__init__(parent)
self.file_paths = list(file_paths)
self.output_path = output_path
self.reencode = reencode
self.output_ext = output_ext.lower()
self._process = None
self._cancelled = False
def cancel(self):
self._cancelled = True
if self._process and self._process.poll() is None:
try:
self._process.terminate()
except Exception:
pass
def run(self):
try:
self._do_merge()
except Exception as exc:
self.merge_finished.emit(False, f"合并失败:{exc}")
def _probe_duration(self, filepath):
cmd = [
"ffprobe", "-v", "quiet",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
filepath
]
try:
result = subprocess.run(cmd, **subprocess_run_kwargs(timeout=15))
if result.returncode != 0:
return 0.0
return float(result.stdout.strip() or 0)
except Exception:
return 0.0
def _do_merge(self):
total_duration = sum(self._probe_duration(p) for p in self.file_paths)
self.log_message.emit(f"总时长:{total_duration:.1f} 秒")
concat_file_path = None
stderr_lines = []
try:
# 创建 concat 列表文件
concat_file_path = self._write_concat_file()
self.log_message.emit("concat 列表已创建")
# 构建命令
cmd = self._build_command(concat_file_path)
self.log_message.emit("执行 ffmpeg...")
# 启动进程
self._process = subprocess.Popen(cmd, **subprocess_popen_kwargs())
# stderr 通过 daemon 线程收集
def _collect_stderr():
for line in self._process.stderr:
stripped = line.strip()
if stripped:
stderr_lines.append(stripped)
stderr_thread = threading.Thread(target=_collect_stderr, daemon=True)
stderr_thread.start()
# stdout 通过 daemon 线程写入队列,主线程用超时读取
# 解决网络路径下 ffmpeg I/O 阻塞导致 stdout 不关闭的问题
stdout_queue = queue.Queue()
_EOF = object() # 哨兵值,标记 EOF
def _enqueue_stdout():
for line in self._process.stdout:
stdout_queue.put(line)
stdout_queue.put(_EOF)
stdout_thread = threading.Thread(target=_enqueue_stdout, daemon=True)
stdout_thread.start()
# 从队列读取进度,每行最多等 30 秒
# 超时说明 ffmpeg 可能卡在网络 I/O 上(局域网写入慢)
# 但如果进度已接近完成(>=95%),可以容忍更长等待
PROGRESS_LINE_TIMEOUT = 30 # 正常每行超时(秒)
NETWORK_FLUSH_TIMEOUT = 120 # 进度接近完成时的超时(秒)
last_pct = 0
reached_end = False
while True:
if self._cancelled:
self._process.terminate()
self.merge_finished.emit(False, "用户取消操作")
return
# 根据当前进度选择超时:接近完成时给更多时间等网络 flush
timeout = (NETWORK_FLUSH_TIMEOUT if last_pct >= 95
else PROGRESS_LINE_TIMEOUT)
try:
line = stdout_queue.get(timeout=timeout)
except queue.Empty:
# 超时:ffmpeg 可能卡在网络 I/O
if last_pct >= 95 and self._process.poll() is not None:
# 进程已退出但没收到 end 信号,算成功
self.log_message.emit("网络写入超时,但进程已完成,检查输出文件...")
reached_end = True
break
elif self._process.poll() is not None:
# 进程已退出但进度很低,算失败
break
else:
# 进程还在跑,继续等
self.log_message.emit(f"等待 ffmpeg 响应...(当前进度 {last_pct}%)")
continue
if line is _EOF:
break # stdout 正常关闭
line = line.strip()
if not line:
continue
if line.startswith("out_time_us="):
try:
us = int(line.split("=", 1)[1])
if total_duration > 0:
pct = min(int(us / 1_000_000.0 / total_duration * 100), 100)
last_pct = pct
self.progress_updated.emit(pct)
except (ValueError, IndexError):
pass
elif line.startswith("out_time_ms="):
try:
ms = int(line.split("=", 1)[1])
if total_duration > 0:
pct = min(int(ms / 1_000_000.0 / total_duration * 100), 100)
last_pct = pct
self.progress_updated.emit(pct)
except (ValueError, IndexError):
pass
elif line.startswith("progress=") and line.endswith("end"):
reached_end = True
self.progress_updated.emit(100)
# 等待进程退出(给网络 flush 额外时间)
try:
self._process.wait(timeout=30)
except subprocess.TimeoutExpired:
self.log_message.emit("ffmpeg 进程退出超时,强制终止...")
self._process.kill()
self._process.wait(timeout=5)
stderr_thread.join(timeout=3)
return_code = self._process.returncode
# 判断结果
if self._cancelled:
self.merge_finished.emit(False, "用户取消操作")
elif return_code == 0 or reached_end:
# 成功:return_code==0 正常结束,或进度已到 100%/超时但进程已退出
self.progress_updated.emit(100)
try:
output_size = os.path.getsize(self.output_path)
size_mb = output_size / (1024 * 1024)
size_info = f"文件大小:{size_mb:.1f} MB"
except OSError:
size_info = "文件大小:未知(网络路径可能需要稍等)"
self.merge_finished.emit(
True,
f"合并完成!\n输出文件:{self.output_path}\n{size_info}"
)
else:
err_tail = "\n".join(stderr_lines[-20:]) if stderr_lines else "无详细错误输出"
self.merge_finished.emit(
False,
f"ffmpeg 返回错误码 {return_code}:\n{err_tail}"
)
finally:
if concat_file_path:
try:
os.unlink(concat_file_path)
except OSError:
pass
def _write_concat_file(self):
"""写入 concat 列表文件,使用正确的转义规则"""
f = tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False, encoding="utf-8", newline="\n"
)
try:
for filepath in self.file_paths:
f.write(f"file {concat_escape_path(filepath)}\n")
finally:
f.close()
return f.name
def _build_command(self, concat_file_path):
cmd = [
"ffmpeg", "-y", "-hide_banner",
"-progress", "pipe:1",
"-nostats",
"-f", "concat", "-safe", "0",
"-i", concat_file_path,
]
if self.reencode:
cmd.extend(["-c:v", "libx264", "-c:a", "aac"])
else:
cmd.extend(["-c", "copy"])
if self.output_ext in MOVFLAGS_FORMATS:
cmd.extend(["-movflags", "+faststart"])
cmd.append(self.output_path)
return cmd
# ──────────────────────────────────────────────
# 可拖拽排序的列表控件
# ──────────────────────────────────────────────
class DragDropListWidget(QListWidget):
files_dropped = Signal(list)
order_changed = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.setAcceptDrops(True)
self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
self.setDefaultDropAction(Qt.DropAction.MoveAction)
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.setAlternatingRowColors(True)
self.setMinimumHeight(200)
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
return
super().dragEnterEvent(event)
def dragMoveEvent(self, event):
if event.mimeData().hasUrls():
event.acceptProposedAction()
return
super().dragMoveEvent(event)
def dropEvent(self, event):
if event.mimeData().hasUrls():
paths = []
for url in event.mimeData().urls():
path = url.toLocalFile()
if os.path.isfile(path):
ext = os.path.splitext(path)[1].lower()
if ext in SUPPORTED_EXTENSIONS:
paths.append(path)
elif os.path.isdir(path):
dir_files = []
for name in os.listdir(path):
full = os.path.join(path, name)
if os.path.isfile(full):
ext = os.path.splitext(name)[1].lower()
if ext in SUPPORTED_EXTENSIONS:
dir_files.append(full)
paths.extend(natural_sort(dir_files))
if paths:
self.files_dropped.emit(paths)
event.acceptProposedAction()
return
super().dropEvent(event)
self.order_changed.emit()
# ──────────────────────────────────────────────
# 主窗口
# ──────────────────────────────────────────────
# 数据模型:
# QListWidgetItem.Qt.ItemDataRole.UserRole → filepath(绝对路径)
# self.media_info_map[filepath] → MediaInfo
# 列表控件的顺序即合并顺序,不维护额外索引列表
# ──────────────────────────────────────────────
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.media_info_map = {} # {filepath: MediaInfo}
self.scan_worker = None
self.merge_worker = None
self._scan_generation = 0 # 计数器,丢弃过期扫描回调
self.setWindowTitle(f"{APP_NAME} v{APP_VERSION}")
self.setMinimumSize(800, 600)
self.resize(900, 700)
self._load_icon()
self._init_ui()
self._apply_style()
def _load_icon(self):
for name in ("app_icon.ico", "app_icon.png"):
p = resource_path("assets", name)
if os.path.exists(p):
icon = QIcon(p)
self.setWindowIcon(icon)
QApplication.instance().setWindowIcon(icon)
return
def _init_ui(self):
central = QWidget()
self.setCentralWidget(central)
main_layout = QVBoxLayout(central)
main_layout.setSpacing(10)
main_layout.setContentsMargins(12, 12, 12, 12)
# 标题
title_label = QLabel(f"\U0001f3ac {APP_NAME}")
title_label.setFont(QFont("Microsoft YaHei", 16, QFont.Weight.Bold))
title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
main_layout.addWidget(title_label)
subtitle = QLabel("将多个短剧视频文件按顺序合并为一个完整视频")
subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
subtitle.setStyleSheet("color: #888; font-size: 12px;")
main_layout.addWidget(subtitle)
# 工具栏
btn_layout = QHBoxLayout()
self.btn_add = QPushButton(" 添加文件")
self.btn_add.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_FileDialogStart))
self.btn_add.setMinimumHeight(36)
self.btn_add.clicked.connect(self._add_files)
self.btn_add_dir = QPushButton(" 添加目录")
self.btn_add_dir.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DirOpenIcon))
self.btn_add_dir.setMinimumHeight(36)
self.btn_add_dir.clicked.connect(self._add_directory)
self.btn_remove = QPushButton(" 移除选中")
self.btn_remove.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_TrashIcon))
self.btn_remove.setMinimumHeight(36)
self.btn_remove.clicked.connect(self._remove_selected)
self.btn_clear = QPushButton(" 清空列表")
self.btn_clear.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_DialogResetButton))
self.btn_clear.setMinimumHeight(36)
self.btn_clear.clicked.connect(self._clear_list)
self.btn_move_up = QPushButton(" 上移 \u2191")
self.btn_move_up.setMinimumHeight(36)
self.btn_move_up.clicked.connect(self._move_up)
self.btn_move_down = QPushButton(" 下移 \u2193")
self.btn_move_down.setMinimumHeight(36)
self.btn_move_down.clicked.connect(self._move_down)
for btn in [self.btn_add, self.btn_add_dir, self.btn_remove,
self.btn_clear, self.btn_move_up, self.btn_move_down]:
btn_layout.addWidget(btn)
main_layout.addLayout(btn_layout)
# 文件列表
list_group = QGroupBox("文件列表(可拖拽排序,也可从文件管理器拖入)")
list_layout = QVBoxLayout(list_group)
self.file_list = DragDropListWidget()
self.file_list.files_dropped.connect(self._on_files_dropped)
self.file_list.order_changed.connect(self._update_count_label)
self.file_list.model().rowsInserted.connect(self._update_count_label)
self.file_list.model().rowsRemoved.connect(self._update_count_label)
list_layout.addWidget(self.file_list)
self.lbl_count = QLabel("共 0 个文件")
self.lbl_count.setStyleSheet("color: #666; font-size: 11px;")
list_layout.addWidget(self.lbl_count)
main_layout.addWidget(list_group, stretch=1)
# 选项
opt_layout = QHBoxLayout()
self.chk_reencode = QCheckBox("重新编码(兼容性更好,但速度较慢)")
self.chk_reencode.setToolTip(
"勾选后会使用 libx264 重新编码视频,\n"
"适用于源文件编码格式不一致的情况。\n"
"不勾选则使用流复制,速度快但要求所有文件编码格式一致。"
)
opt_layout.addWidget(self.chk_reencode)
opt_layout.addStretch()
lbl_format = QLabel("输出格式:")
self.combo_format = QComboBox()
self.combo_format.addItems([".mp4", ".mkv"])
self.combo_format.setFixedWidth(80)
opt_layout.addWidget(lbl_format)
opt_layout.addWidget(self.combo_format)
main_layout.addLayout(opt_layout)
# 合并按钮 + 进度
merge_layout = QHBoxLayout()
self.btn_merge = QPushButton(" 开始合并")
self.btn_merge.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_MediaPlay))
self.btn_merge.setMinimumHeight(44)
self.btn_merge.setFont(QFont("Microsoft YaHei", 12, QFont.Weight.Bold))
self.btn_merge.setStyleSheet("""
QPushButton {
background-color: #2196F3; color: white;
border: none; border-radius: 6px; padding: 8px 24px;
}
QPushButton:hover { background-color: #1976D2; }
QPushButton:pressed { background-color: #1565C0; }
QPushButton:disabled { background-color: #BDBDBD; color: #757575; }
""")
self.btn_merge.clicked.connect(self._start_merge)
self.btn_cancel = QPushButton(" 取消")
self.btn_cancel.setMinimumHeight(44)
self.btn_cancel.setEnabled(False)
self.btn_cancel.clicked.connect(self._cancel_merge)
self.btn_cancel.setStyleSheet("""
QPushButton {
background-color: #f44336; color: white;
border: none; border-radius: 6px; padding: 8px 24px;
}
QPushButton:hover { background-color: #d32f2f; }
QPushButton:disabled { background-color: #BDBDBD; color: #757575; }
""")
merge_layout.addWidget(self.btn_merge)
merge_layout.addWidget(self.btn_cancel)
main_layout.addLayout(merge_layout)
self.progress = QProgressBar()
self.progress.setRange(0, 100)
self.progress.setValue(0)
self.progress.setTextVisible(True)
self.progress.setFormat("%p%")
self.progress.setMinimumHeight(22)
main_layout.addWidget(self.progress)
# 日志
log_group = QGroupBox("运行日志")
log_layout = QVBoxLayout(log_group)
self.log_text = QTextEdit()
self.log_text.setReadOnly(True)
self.log_text.setMaximumHeight(160)
self.log_text.setFont(QFont("Consolas", 9))
self.log_text.setStyleSheet("background-color: #1e1e1e; color: #d4d4d4;")
log_layout.addWidget(self.log_text)
btn_clear_log = QPushButton("清空日志")
btn_clear_log.setFixedWidth(80)
btn_clear_log.clicked.connect(self.log_text.clear)
log_layout.addWidget(btn_clear_log, alignment=Qt.AlignmentFlag.AlignRight)
main_layout.addWidget(log_group)
self.statusBar().showMessage("就绪 - 请添加要合并的视频文件")
def _apply_style(self):
self.setStyleSheet("""
QMainWindow { background-color: #fafafa; }
QGroupBox {
font-weight: bold; border: 1px solid #ddd;
border-radius: 6px; margin-top: 8px; padding-top: 16px;
}
QGroupBox::title {
subcontrol-origin: margin; subcontrol-position: top left;
padding: 2px 8px; color: #555;
}
QListWidget {
border: 2px dashed #ccc; border-radius: 6px;
padding: 4px; font-size: 12px;
}
QListWidget::item { padding: 6px; border-bottom: 1px solid #eee; }
QListWidget::item:selected { background-color: #bbdefb; color: #0d47a1; }
QListWidget::item:hover { background-color: #e3f2fd; }
QProgressBar {
border: 1px solid #bbb; border-radius: 4px;
text-align: center; background-color: #e0e0e0;
}
QProgressBar::chunk { background-color: #4CAF50; border-radius: 3px; }
""")
# ── 日志 ──
def _log(self, message):
timestamp = datetime.now().strftime("%H:%M:%S")
self.log_text.append(f"[{timestamp}] {message}")
# ── 列表项辅助 ──
def _create_item(self, info):
item = QListWidgetItem(info.summary())
item.setData(Qt.ItemDataRole.UserRole, info.filepath)
self._apply_item_style(item, info)
return item
def _apply_item_style(self, item, info):
if info.scanning:
item.setForeground(QColor("#555555"))
elif info.valid:
item.setForeground(QColor("#2e7d32"))
else:
item.setForeground(QColor("#c62828"))
def _refresh_item_for_path(self, filepath):
"""按 filepath 查找列表项并刷新显示"""
for i in range(self.file_list.count()):
item = self.file_list.item(i)
if item and item.data(Qt.ItemDataRole.UserRole) == filepath:
info = self.media_info_map.get(filepath)
if info:
item.setText(info.summary())
self._apply_item_style(item, info)
return
def _get_all_filepaths_in_order(self):
"""从列表控件当前顺序获取所有 filepath(唯一真源)"""
paths = []
for i in range(self.file_list.count()):
item = self.file_list.item(i)
if item:
fp = item.data(Qt.ItemDataRole.UserRole)
if fp:
paths.append(fp)
return paths
def _get_media_info_in_order(self):
"""按列表顺序返回 MediaInfo 列表"""
return [self.media_info_map[fp]
for fp in self._get_all_filepaths_in_order()
if fp in self.media_info_map]
# ── 文件添加 ──
def _add_files(self):
filepaths, _ = QFileDialog.getOpenFileNames(
self, "选择视频文件", "",
"视频文件 (*.mp4 *.mkv *.avi *.mov *.ts *.flv *.wmv);;所有文件 (*)"
)
if filepaths:
self._add_paths(filepaths)
def _add_directory(self):
dir_path = QFileDialog.getExistingDirectory(self, "选择包含视频的目录")
if not dir_path:
return
files = []
for name in os.listdir(dir_path):
full = os.path.join(dir_path, name)
if os.path.isfile(full):
if os.path.splitext(name)[1].lower() in SUPPORTED_EXTENSIONS:
files.append(full)
files = natural_sort(files)
if files:
self._add_paths(files)
else:
QMessageBox.information(self, "提示", "该目录下没有找到支持的视频文件。")
def _on_files_dropped(self, paths):
self._add_paths(paths)
def _add_paths(self, paths):
existing = set(self._get_all_filepaths_in_order())
new_paths = []
for p in paths:
normalized = os.path.abspath(p)
if normalized not in existing and os.path.isfile(normalized):
existing.add(normalized)
new_paths.append(normalized)
if not new_paths:
return
for p in new_paths:
info = MediaInfo(p)
info.scanning = True
self.media_info_map[p] = info
self.file_list.addItem(self._create_item(info))
self._log(f"添加 {len(new_paths)} 个文件,开始扫描")
self.statusBar().showMessage(f"正在扫描 {len(new_paths)} 个文件...")
self._update_count_label()
self._start_scan(new_paths)
def _start_scan(self, paths):
if self.scan_worker and self.scan_worker.isRunning():
self.scan_worker.cancel()
self.scan_worker.wait(1000)
self._scan_generation += 1
gen = self._scan_generation
self.scan_worker = ScanWorker(paths)
self.scan_worker.file_scanned.connect(
lambda fp, info: self._on_file_scanned(fp, info, gen)
)
self.scan_worker.scan_finished.connect(
lambda n: self._on_scan_finished(n, gen)
)
self.scan_worker.start()
def _on_file_scanned(self, filepath, info, generation):
if generation != self._scan_generation:
return # 旧批次,丢弃
if filepath not in self.media_info_map:
return
info.scanning = False
self.media_info_map[filepath] = info
self._refresh_item_for_path(filepath)
self._update_count_label()
def _on_scan_finished(self, count, generation):
if generation != self._scan_generation:
return
self._log(f"扫描完成,共处理 {count} 个文件")
self.statusBar().showMessage("扫描完成")
self._update_count_label()
# ── 列表操作 ──
def _remove_selected(self):
selected = self.file_list.selectedItems()
if not selected:
return
rows = sorted((self.file_list.row(it) for it in selected), reverse=True)
for row in rows:
item = self.file_list.takeItem(row)
if item:
fp = item.data(Qt.ItemDataRole.UserRole)
self.media_info_map.pop(fp, None)
self._update_count_label()
def _clear_list(self):
if self.scan_worker and self.scan_worker.isRunning():
self.scan_worker.cancel()
self._scan_generation += 1
self.file_list.clear()
self.media_info_map.clear()
self._update_count_label()
self._log("列表已清空")
def _move_up(self):
row = self.file_list.currentRow()
if row <= 0:
return
item = self.file_list.takeItem(row)
self.file_list.insertItem(row - 1, item)
self.file_list.setCurrentItem(item)
self._update_count_label()
def _move_down(self):
row = self.file_list.currentRow()
if row < 0 or row >= self.file_list.count() - 1:
return
item = self.file_list.takeItem(row)
self.file_list.insertItem(row + 1, item)
self.file_list.setCurrentItem(item)
self._update_count_label()
def _update_count_label(self, *_args):
infos = self._get_media_info_in_order()
total_dur = sum(info.duration for info in infos if info.valid)
count = len(infos)
m, s = divmod(int(total_dur), 60)
h, m = divmod(m, 60)
if h > 0:
dur_text = f"总时长 {h:d}:{m:02d}:{s:02d}"
else:
dur_text = f"总时长 {m:d}:{s:02d}"
self.lbl_count.setText(f"共 {count} 个文件 | {dur_text}")
# ── 合并操作 ──
def _start_merge(self):
infos = self._get_media_info_in_order()
if len(infos) < 2:
QMessageBox.warning(self, "提示", "请至少添加 2 个文件才能合并。")
return
invalid = [info.filename for info in infos if not info.valid]
if invalid:
reply = QMessageBox.question(
self, "提示",
f"以下 {len(invalid)} 个文件扫描失败,将被跳过:\n\n"
f"{chr(10).join(invalid[:10])}"
f"{'...' if len(invalid) > 10 else ''}\n\n"
f"是否继续合并剩余文件?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.No:
return
file_paths = [info.filepath for info in infos if info.valid]
if len(file_paths) < 2:
QMessageBox.warning(self, "错误", "有效文件不足 2 个,无法合并。")
return
ext = self.combo_format.currentText()
default_name = f"merged_output{ext}"
first_dir = os.path.dirname(file_paths[0])
output_path, _ = QFileDialog.getSaveFileName(
self, "保存合并后的文件",
os.path.join(first_dir, default_name),
f"视频文件 (*{ext})"
)
if not output_path:
return
# 确保扩展名正确
if not output_path.lower().endswith(ext):
output_path += ext
# 校验输出路径不与输入冲突
abs_output = os.path.abspath(output_path)
abs_inputs = {os.path.abspath(fp) for fp in file_paths}
if abs_output in abs_inputs:
QMessageBox.critical(
self, "错误",
"输出文件路径与某个输入文件相同,会导致源文件被覆盖!\n"
"请选择其他输出路径。"
)
return
# 确认
total_size = sum(info.size_bytes for info in infos if info.valid)
size_mb = total_size / (1024 * 1024)
mode_str = "重新编码" if self.chk_reencode.isChecked() else "流复制(快速)"
reply = QMessageBox.question(
self, "确认合并",
f"即将合并 {len(file_paths)} 个文件\n"
f"总大小约 {size_mb:.1f} MB\n"
f"输出格式:{ext}\n"
f"模式:{mode_str}\n"
f"输出路径:{output_path}\n\n"
f"是否开始合并?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
# 开始合并
self._log("=" * 50)
self._log(f"开始合并 {len(file_paths)} 个文件 | 模式:{mode_str}")
self.btn_merge.setEnabled(False)
self.btn_cancel.setEnabled(True)
self.progress.setValue(0)
self.statusBar().showMessage("正在合并...")
self.merge_worker = MergeWorker(
file_paths=file_paths,
output_path=output_path,
reencode=self.chk_reencode.isChecked(),
output_ext=ext
)
self.merge_worker.progress_updated.connect(self.progress.setValue)
self.merge_worker.log_message.connect(self._log)
self.merge_worker.merge_finished.connect(self._on_merge_finished)
self.merge_worker.start()
def _cancel_merge(self):
if self.merge_worker and self.merge_worker.isRunning():
self.merge_worker.cancel()
self._log("用户取消合并")
self.statusBar().showMessage("正在取消...")
def _on_merge_finished(self, success, message):
self.btn_merge.setEnabled(True)
self.btn_cancel.setEnabled(False)
if success:
self.progress.setValue(100)
self.statusBar().showMessage("合并完成")
self._log("\u2705 " + message.replace("\n", " | "))
QMessageBox.information(self, "合并完成", message)
else:
self.progress.setValue(0)
self.statusBar().showMessage("合并失败")
self._log("\u274c " + message.replace("\n", " | "))
QMessageBox.critical(self, "合并失败", message)
def closeEvent(self, event):
if self.scan_worker and self.scan_worker.isRunning():
self.scan_worker.cancel()
self._scan_generation += 1
if self.merge_worker and self.merge_worker.isRunning():
reply = QMessageBox.question(
self, "确认退出",
"合并正在进行中,确定要退出吗?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.No:
event.ignore()
return
self.merge_worker.cancel()
self.merge_worker.wait(3000)
event.accept()
# ──────────────────────────────────────────────
# 程序入口
# ──────────────────────────────────────────────
def main():
sys.excepthook = exception_hook
# PySide6 (Qt6) 默认开启高 DPI 支持,无需手动设置
app = QApplication(sys.argv)
app.setApplicationName(APP_NAME)
app.setApplicationVersion(APP_VERSION)
# 应用图标
for name in ("app_icon.ico", "app_icon.png"):
p = resource_path("assets", name)
if os.path.exists(p):
app.setWindowIcon(QIcon(p))
break
# 检测 ffmpeg
if not check_ffmpeg() or not check_ffprobe():
show_ffmpeg_missing_dialog()
sys.exit(1)
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()