Created
October 18, 2025 04:55
-
-
Save lemonhall/8db4510ab210a23e011c9533be071eb3 to your computer and use it in GitHub Desktop.
MP4 Video Merger GUI - 智能视频合并工具
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# -*- coding: utf-8 -*- | |
""" | |
MP4视频合并工具 - GUI 版本 | |
=============================== | |
功能说明: | |
这个工具可以批量扫描文件夹中的MP4文件,自动处理分辨率和帧率不一致的问题, | |
然后将所有视频合并成一个大文件,并支持合并后再重复N遍。 | |
核心设计理由: | |
1. 分辨率处理方式(使用 Letterboxing 黑边缩放): | |
├─ 问题:不同视频有不同分辨率(如 225x391、720x1072、480x720 等) | |
├─ 方案选择对比: | |
│ ├─ 方案A: 拉伸视频 → ✗ 会扭曲画面,看起来很丑 | |
│ ├─ 方案B: 裁剪视频 → ✗ 会损失画面内容,不可接受 | |
│ └─ 方案C: Letterboxing(添加黑边) → ✓ 保留完整画面,不拉伸不裁剪 | |
└─ 实现:使用 ffmpeg 的 scale + pad 滤镜组合 | |
• scale={宽}:{高}:force_original_aspect_ratio=decrease | |
(按原始宽高比缩放,但不超过目标分辨率) | |
• pad={宽}:{高}:(ow-iw)/2:(oh-ih)/2:black | |
(添加黑色边框使其填满目标分辨率) | |
2. 帧率统一处理: | |
├─ 问题:视频帧率不一致会导致合并时播放卡顿或不连贯 | |
│ (因为ffmpeg在拼接时需要所有视频有相同的帧率) | |
├─ 为什么统一用30fps? | |
│ ├─ 30fps 是国际标准(NTSC制式) | |
│ ├─ 大多数手机录制视频都是 30fps 或 60fps | |
│ ├─ 30fps 是短视频平台(抖音、快手等)的标准 | |
│ └─ CPU 负担适中,比 60fps 省资源 | |
└─ 实现:使用 ffmpeg 的 fps 滤镜 | |
• fps=30 会自动插帧或丢帧来调整帧率 | |
3. 音频处理: | |
├─ 统一采样率为 48000Hz(专业标准) | |
├─ 统一使用 AAC 编码(兼容性最好) | |
└─ 原因:避免播放器因音频参数不一致而出现问题 | |
4. 编码质量选项: | |
├─ lossless(默认): CRF 18 + preset slow | |
│ └─ 用于你的短视频场景,不在乎文件大小,追求最高画质 | |
├─ medium: preset medium | |
│ └─ 平衡质量和速度 | |
└─ 其他 preset (fast/faster/veryfast 等) | |
└─ 用于追求速度的场景 | |
5. 为什么要重新编码所有视频? | |
├─ 问题:直接用 -c copy 简单复制虽然快,但是: | |
│ ├─ 不能统一帧率和分辨率 | |
│ ├─ 不同来源的视频编码参数可能冲突 | |
│ └─ 合并时容易出现播放不连贯的问题 | |
├─ 解决:重新编码能保证: | |
│ ├─ 所有视频使用完全相同的参数 | |
│ ├─ 合并时毫无缝隙 | |
│ └─ 播放时完全顺畅(不卡顿) | |
└─ 权衡:虽然比 -c copy 慢,但质量和稳定性值得 | |
6. 临时文件管理: | |
├─ 创建 temp_converted 文件夹存储转换后的视频 | |
├─ 创建 concat_list.txt 来指导 ffmpeg 合并顺序 | |
└─ 完成后自动清理这些临时文件 | |
使用流程: | |
1. 在含有MP4文件的目录运行此脚本 | |
2. 点击"扫描并分析"查看分辨率统计 | |
3. 调整输出参数(文件名、重复次数、编码质量) | |
4. 点击"开始合并",程序会自动: | |
a. 统一所有视频为最常见的分辨率(用letterboxing保持宽高比) | |
b. 统一所有视频为30fps帧率 | |
c. 统一音频为48kHz AAC编码 | |
d. 合并所有视频成一个文件 | |
e. 如需要则重复N遍 | |
f. 自动清理临时文件 | |
依赖项: | |
- Python 3.6+ | |
- tkinter (通常自带) | |
- ffmpeg (需要单独安装并加入 PATH) | |
- ffprobe (ffmpeg 自带) | |
作者笔记: | |
这个脚本针对处理来自不同来源、参数混乱的短视频进行了优化。 | |
相比简单的合并,它虽然处理时间更长,但能保证最终视频的质量和稳定性。 | |
短视频合并本来就需要高质量,所以值得多花点时间! | |
""" | |
import tkinter as tk | |
from tkinter import ttk, filedialog, messagebox, scrolledtext | |
import threading | |
import subprocess | |
import os | |
import json | |
from pathlib import Path | |
from collections import Counter | |
import re | |
from datetime import datetime | |
class VideoMergerApp: | |
def __init__(self, root): | |
self.root = root | |
self.root.title("MP4 视频合并工具") | |
self.root.geometry("900x700") | |
self.root.resizable(True, True) | |
# 样式配置 | |
style = ttk.Style() | |
style.theme_use('clam') | |
# 默认使用当前目录 | |
self.video_folder = tk.StringVar(value=os.getcwd()) | |
self.output_name = tk.StringVar(value="merged_output.mp4") | |
self.repeat_times = tk.IntVar(value=1) | |
self.encoding_quality = tk.StringVar(value="lossless") | |
self.is_processing = False | |
self.process_thread = None | |
self.setup_ui() | |
def setup_ui(self): | |
""" | |
设置用户界面 | |
布局由四个主要部分组成: | |
1. 文件夹选择 - 让用户选择包含MP4的目录 | |
2. 视频信息分析 - 显示扫描结果和分辨率统计 | |
3. 输出设置 - 配置输出参数(文件名、重复次数、编码质量) | |
4. 处理进度 - 显示处理进度和日志输出 | |
""" | |
# 主容器 | |
main_frame = ttk.Frame(self.root, padding="10") | |
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S)) | |
# 配置权重以支持响应式布局 | |
self.root.columnconfigure(0, weight=1) | |
self.root.rowconfigure(0, weight=1) | |
main_frame.columnconfigure(1, weight=1) | |
# ========== 第一部分:文件夹选择 ========== | |
folder_frame = ttk.LabelFrame(main_frame, text="1. 选择视频文件夹", padding="10") | |
folder_frame.grid(row=0, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=10) | |
folder_frame.columnconfigure(1, weight=1) | |
ttk.Label(folder_frame, text="文件夹路径:").grid(row=0, column=0, sticky=tk.W) | |
ttk.Entry(folder_frame, textvariable=self.video_folder, state='readonly').grid( | |
row=0, column=1, sticky=(tk.W, tk.E), padx=5) | |
ttk.Button(folder_frame, text="浏览...", command=self.select_folder).grid( | |
row=0, column=2, padx=5) | |
# 在启动时自动扫描当前目录 | |
self.root.after(500, self.scan_videos) | |
# ========== 第二部分:视频信息 ========== | |
info_frame = ttk.LabelFrame(main_frame, text="2. 视频信息分析", padding="10") | |
info_frame.grid(row=1, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=10) | |
info_frame.columnconfigure(1, weight=1) | |
self.info_text = scrolledtext.ScrolledText( | |
info_frame, height=8, width=80, state='disabled', font=('Courier', 9)) | |
self.info_text.grid(row=0, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) | |
ttk.Button(info_frame, text="扫描并分析", command=self.scan_videos).grid( | |
row=1, column=0, pady=10) | |
# ========== 第三部分:输出设置 ========== | |
output_frame = ttk.LabelFrame(main_frame, text="3. 输出设置", padding="10") | |
output_frame.grid(row=2, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=10) | |
output_frame.columnconfigure(1, weight=1) | |
# 输出文件名 | |
ttk.Label(output_frame, text="输出文件名:").grid(row=0, column=0, sticky=tk.W) | |
ttk.Entry(output_frame, textvariable=self.output_name).grid( | |
row=0, column=1, sticky=(tk.W, tk.E), padx=5) | |
# 重复次数 | |
ttk.Label(output_frame, text="重复次数:").grid(row=1, column=0, sticky=tk.W, pady=5) | |
repeat_frame = ttk.Frame(output_frame) | |
repeat_frame.grid(row=1, column=1, sticky=(tk.W, tk.E), padx=5) | |
ttk.Spinbox(repeat_frame, from_=1, to=100, textvariable=self.repeat_times, | |
width=10).pack(side=tk.LEFT) | |
ttk.Label(repeat_frame, text="(合并后再重复N遍)").pack(side=tk.LEFT, padx=5) | |
# 编码质量 | |
ttk.Label(output_frame, text="编码质量:").grid(row=2, column=0, sticky=tk.W, pady=5) | |
quality_frame = ttk.Frame(output_frame) | |
quality_frame.grid(row=2, column=1, sticky=(tk.W, tk.E), padx=5) | |
for quality in ['lossless', 'ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium']: | |
ttk.Radiobutton(quality_frame, text=quality, variable=self.encoding_quality, | |
value=quality).pack(side=tk.LEFT, padx=5) | |
# ========== 第四部分:处理按钮 ========== | |
button_frame = ttk.Frame(main_frame) | |
button_frame.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=15) | |
button_frame.columnconfigure(1, weight=1) | |
self.start_button = ttk.Button(button_frame, text="开始合并", command=self.start_merge) | |
self.start_button.pack(side=tk.LEFT, padx=5) | |
self.cancel_button = ttk.Button(button_frame, text="取消", command=self.cancel_process, | |
state='disabled') | |
self.cancel_button.pack(side=tk.LEFT, padx=5) | |
ttk.Button(button_frame, text="打开输出文件夹", command=self.open_output_folder).pack( | |
side=tk.RIGHT, padx=5) | |
# ========== 第五部分:进度显示 ========== | |
progress_frame = ttk.LabelFrame(main_frame, text="4. 处理进度", padding="10") | |
progress_frame.grid(row=4, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), | |
pady=10) | |
progress_frame.columnconfigure(0, weight=1) | |
progress_frame.rowconfigure(1, weight=1) | |
# 进度条 | |
self.progress_var = tk.DoubleVar() | |
self.progress_bar = ttk.Progressbar(progress_frame, variable=self.progress_var, | |
maximum=100, length=400) | |
self.progress_bar.grid(row=0, column=0, sticky=(tk.W, tk.E), pady=5) | |
self.progress_label = ttk.Label(progress_frame, text="就绪") | |
self.progress_label.grid(row=0, column=1, padx=10) | |
# 日志显示 | |
self.log_text = scrolledtext.ScrolledText(progress_frame, height=10, width=80, | |
state='disabled', font=('Courier', 9)) | |
self.log_text.grid(row=1, column=0, columnspan=2, sticky=(tk.W, tk.E, tk.N, tk.S), | |
pady=5) | |
def log_message(self, message): | |
""" | |
向日志添加消息 | |
作用: | |
• 在UI中实时显示处理过程 | |
• 每条日志都带有时间戳 | |
• 自动滚动到最新消息 | |
• 用户可以看到详细的处理进程 | |
""" | |
self.log_text.config(state='normal') | |
timestamp = datetime.now().strftime("%H:%M:%S") | |
self.log_text.insert(tk.END, f"[{timestamp}] {message}\n") | |
self.log_text.see(tk.END) | |
self.log_text.config(state='disabled') | |
self.root.update() | |
def get_ffmpeg_video_codec_args(self, quality): | |
""" | |
根据质量等级返回视频编码参数 | |
参数选择的设计理由: | |
1. lossless(最高画质,默认) | |
├─ CRF 18: 质量参数 (0-51, 越低越高质) | |
│ ├─ CRF 0: 无损,质量最高但文件巨大 | |
│ ├─ CRF 18: 可见质量损失极少,日常够用(推荐) | |
│ ├─ CRF 23: 默认,肉眼难以察觉质量差异 | |
│ └─ CRF 51: 最低质量 | |
├─ preset slow: 最慢的编码速度 | |
│ └─ 原因:慢速编码能更好地优化视频,文件更小但质量更高 | |
└─ 适用场景:你的短视频场景,不在乎文件大小,追求最高画质 | |
2. 其他 preset(fast/faster/medium 等) | |
├─ 这些直接使用 libx264 的 preset 参数 | |
└─ 原因:快速编码,适合不需要最高质量的场景 | |
为什么要分离这个函数? | |
└─ 因为转换、合并等多个地方都需要用到编码参数 | |
这样改一个地方,所有地方都生效 | |
""" | |
if quality == 'lossless': | |
# 最高画质(接近无损) | |
return ['-c:v', 'libx264', '-crf', '18', '-preset', 'slow'] | |
else: | |
# 其他质量等级使用 preset | |
return ['-c:v', 'libx264', '-preset', quality] | |
def select_folder(self): | |
"""选择文件夹""" | |
folder = filedialog.askdirectory(title="选择包含MP4文件的文件夹") | |
if folder: | |
self.video_folder.set(folder) | |
self.log_message(f"已选择文件夹: {folder}") | |
def scan_videos(self): | |
"""扫描并分析视频""" | |
folder = self.video_folder.get() | |
if not folder or not os.path.exists(folder): | |
messagebox.showerror("错误", "请先选择有效的文件夹") | |
return | |
self.info_text.config(state='normal') | |
self.info_text.delete(1.0, tk.END) | |
self.info_text.insert(tk.END, "正在扫描视频...\n") | |
self.info_text.config(state='disabled') | |
self.root.update() | |
# 在后台线程执行 | |
thread = threading.Thread(target=self._scan_videos_thread, args=(folder,)) | |
thread.daemon = True | |
thread.start() | |
def _scan_videos_thread(self, folder): | |
""" | |
扫描视频的后台线程 | |
这个方法会: | |
1. 列出所有MP4文件 | |
2. 使用 ffprobe 逐个检测分辨率 | |
3. 统计不同分辨率的视频数量 | |
4. 在UI中显示统计结果 | |
关键设计: | |
• 使用后台线程避免UI冻结 | |
• ffprobe 的 timeout=10 防止某些损坏视频导致卡死 | |
• 对异常处理友好,单个文件失败不影响整体流程 | |
""" | |
try: | |
mp4_files = sorted(Path(folder).glob("*.mp4")) | |
if not mp4_files: | |
self.info_text.config(state='normal') | |
self.info_text.delete(1.0, tk.END) | |
self.info_text.insert(tk.END, "未找到MP4文件!") | |
self.info_text.config(state='disabled') | |
return | |
info_text = f"找到 {len(mp4_files)} 个视频文件\n" | |
info_text += "=" * 60 + "\n" | |
resolutions = {} | |
for i, video_file in enumerate(mp4_files, 1): | |
try: | |
# 获取视频分辨率 | |
cmd = [ | |
'ffprobe', '-v', 'error', | |
'-select_streams', 'v:0', | |
'-show_entries', 'stream=width,height', | |
'-of', 'default=noprint_wrappers=1:nokey=1', | |
str(video_file) | |
] | |
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) | |
lines = result.stdout.strip().split('\n') | |
if len(lines) >= 2: | |
width = lines[0].strip() | |
height = lines[1].strip() | |
resolution = f"{width}x{height}" | |
if resolution not in resolutions: | |
resolutions[resolution] = [] | |
resolutions[resolution].append(video_file.name) | |
info_text += f"{i:2d}. {video_file.name:40s} {resolution:15s}\n" | |
else: | |
info_text += f"{i:2d}. {video_file.name:40s} 无法识别\n" | |
except subprocess.TimeoutExpired: | |
info_text += f"{i:2d}. {video_file.name:40s} 超时\n" | |
except Exception as e: | |
info_text += f"{i:2d}. {video_file.name:40s} 错误: {str(e)}\n" | |
info_text += "=" * 60 + "\n" | |
info_text += "分辨率统计:\n" | |
if resolutions: | |
# 找到最常见的分辨率 | |
resolution_counts = {res: len(files) for res, files in resolutions.items()} | |
sorted_resolutions = sorted(resolution_counts.items(), key=lambda x: x[1], | |
reverse=True) | |
for res, count in sorted_resolutions: | |
info_text += f" {res:15s}: {count:3d} 个视频\n" | |
target_res = sorted_resolutions[0][0] | |
info_text += f"\n目标分辨率: {target_res}\n" | |
if sorted_resolutions[0][1] < len(mp4_files): | |
info_text += f"需要转换: {len(mp4_files) - sorted_resolutions[0][1]} 个视频\n" | |
else: | |
info_text += "无需转换: 所有视频分辨率已统一\n" | |
self.info_text.config(state='normal') | |
self.info_text.delete(1.0, tk.END) | |
self.info_text.insert(tk.END, info_text) | |
self.info_text.config(state='disabled') | |
except Exception as e: | |
self.log_message(f"扫描失败: {str(e)}") | |
messagebox.showerror("错误", f"扫描失败: {str(e)}") | |
def start_merge(self): | |
""" | |
开始合并(在主线程中调用) | |
这个方法的职责: | |
1. 验证输入参数有效性 | |
2. 禁用相关按钮防止重复点击 | |
3. 清空旧的日志 | |
4. 在后台线程中启动真正的合并工作 | |
为什么要用后台线程? | |
└─ 避免长时间的视频处理导致UI冻结无响应 | |
用户可以看到实时进度而不是卡住 | |
""" | |
folder = self.video_folder.get() | |
if not folder or not os.path.exists(folder): | |
messagebox.showerror("错误", "请先选择有效的文件夹") | |
return | |
if not any(Path(folder).glob("*.mp4")): | |
messagebox.showerror("错误", "文件夹中没有MP4文件") | |
return | |
# 禁用按钮 | |
self.start_button.config(state='disabled') | |
self.cancel_button.config(state='normal') | |
self.is_processing = True | |
# 清空日志 | |
self.log_text.config(state='normal') | |
self.log_text.delete(1.0, tk.END) | |
self.log_text.config(state='disabled') | |
# 在后台线程执行 | |
self.process_thread = threading.Thread( | |
target=self._merge_videos_thread, | |
args=(folder, self.output_name.get(), self.repeat_times.get(), | |
self.encoding_quality.get()) | |
) | |
self.process_thread.daemon = True | |
self.process_thread.start() | |
def _merge_videos_thread(self, folder, output_name, repeat_times, quality): | |
""" | |
合并视频的后台线程 - 核心处理逻辑 | |
处理流程分为6个步骤: | |
步骤1: 扫描视频 - 找到所有MP4文件 | |
步骤2: 分析分辨率 - 检测每个视频的分辨率,选择最常见的作为目标 | |
为什么选择最常见的? | |
- 最少转换工作量 | |
- 最多视频无需转换(更快) | |
步骤3: 转换视频 - 这是关键步骤 | |
对于每个视频: | |
├─ 如果分辨率已匹配且帧率已匹配 | |
│ └─ 直接复制(最快) | |
├─ 如果分辨率匹配但帧率不匹配 | |
│ └─ 使用 fps 滤镜调整帧率 | |
└─ 如果分辨率不匹配 | |
└─ 同时应用 scale(缩放) + pad(添黑边) 滤镜,再调整帧率 | |
关键参数解释: | |
• fps=30: 统一到30帧/秒 | |
• scale=W:H:force_original_aspect_ratio=decrease | |
└─ 按原宽高比缩放,但不超过目标分辨率 | |
• pad=W:H:(ow-iw)/2:(oh-ih)/2:black | |
└─ 添加黑色边框使最终分辨率恰好是目标分辨率 | |
└─ (ow-iw)/2 和 (oh-ih)/2 是自动居中计算 | |
步骤4: 创建合并列表 - concat_list.txt 用来告诉ffmpeg按顺序合并哪些文件 | |
步骤5: 合并视频 - 使用 ffmpeg 的 concat 复用器 | |
为什么要重新编码而不用 -c copy? | |
├─ 直接复制虽然快,但不能保证兼容性 | |
├─ 不同来源的视频可能有冲突的编码参数 | |
└─ 重新编码能确保最终视频完全一致且无缝播放 | |
步骤6: 重复(可选)- 如果 repeat_times > 1 | |
再次合并,这次是同一个视频重复N遍 | |
""" | |
try: | |
self.log_message("=" * 60) | |
self.log_message("开始处理视频...") | |
self.log_message("=" * 60) | |
# 第一步:扫描视频 | |
self.progress_label.config(text="第1步: 扫描视频") | |
mp4_files = sorted(Path(folder).glob("*.mp4")) | |
self.log_message(f"找到 {len(mp4_files)} 个视频文件") | |
# 第二步:获取分辨率信息 | |
self.progress_label.config(text="第2步: 分析分辨率") | |
resolutions = {} | |
for video_file in mp4_files: | |
try: | |
cmd = [ | |
'ffprobe', '-v', 'error', | |
'-select_streams', 'v:0', | |
'-show_entries', 'stream=width,height', | |
'-of', 'default=noprint_wrappers=1:nokey=1', | |
str(video_file) | |
] | |
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) | |
lines = result.stdout.strip().split('\n') | |
if len(lines) >= 2: | |
width = lines[0].strip() | |
height = lines[1].strip() | |
resolution = f"{width}x{height}" | |
if resolution not in resolutions: | |
resolutions[resolution] = [] | |
resolutions[resolution].append(video_file) | |
except Exception as e: | |
self.log_message(f"警告: 无法分析 {video_file.name}: {str(e)}") | |
if not resolutions: | |
self.log_message("错误: 无法识别任何视频") | |
return | |
# 确定目标分辨率 | |
target_res = max(resolutions.items(), key=lambda x: len(x[1]))[0] | |
target_width, target_height = map(int, target_res.split('x')) | |
self.log_message(f"目标分辨率: {target_res}") | |
# 获取目标帧率(使用最多见的帧率或30fps) | |
fps_list = [] | |
for video_file in mp4_files: | |
try: | |
cmd_fps = [ | |
'ffprobe', '-v', 'error', | |
'-select_streams', 'v:0', | |
'-show_entries', 'stream=r_frame_rate', | |
'-of', 'default=noprint_wrappers=1:nokey=1', | |
str(video_file) | |
] | |
result_fps = subprocess.run(cmd_fps, capture_output=True, text=True, timeout=10) | |
fps_str = result_fps.stdout.strip() | |
if '/' in fps_str: | |
num, den = fps_str.split('/') | |
fps = float(num) / float(den) | |
else: | |
fps = float(fps_str) if fps_str else 30 | |
fps_list.append(fps) | |
except: | |
fps_list.append(30) | |
# 使用中位数或30fps作为目标帧率 | |
target_fps = 30 # 统一用30fps以保证兼容性 | |
self.log_message(f"目标帧率: {target_fps} fps") | |
# 第三步:转换视频 | |
self.progress_label.config(text="第3步: 转换视频分辨率") | |
temp_folder = Path(folder) / "temp_converted" | |
temp_folder.mkdir(exist_ok=True) | |
converted_videos = [] | |
total_videos = len(mp4_files) | |
for idx, video_file in enumerate(mp4_files, 1): | |
if not self.is_processing: | |
self.log_message("已取消处理") | |
return | |
# 获取当前视频分辨率 | |
try: | |
cmd = [ | |
'ffprobe', '-v', 'error', | |
'-select_streams', 'v:0', | |
'-show_entries', 'stream=width,height', | |
'-of', 'default=noprint_wrappers=1:nokey=1', | |
str(video_file) | |
] | |
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10) | |
lines = result.stdout.strip().split('\n') | |
current_width, current_height = int(lines[0]), int(lines[1]) | |
except: | |
current_width = current_height = None | |
progress = (idx - 1) / total_videos * 100 | |
self.progress_var.set(progress) | |
output_path = temp_folder / f"converted_{idx:03d}.mp4" | |
# 获取视频帧率信息 | |
try: | |
cmd_fps = [ | |
'ffprobe', '-v', 'error', | |
'-select_streams', 'v:0', | |
'-show_entries', 'stream=r_frame_rate', | |
'-of', 'default=noprint_wrappers=1:nokey=1', | |
str(video_file) | |
] | |
result_fps = subprocess.run(cmd_fps, capture_output=True, text=True, timeout=10) | |
fps_str = result_fps.stdout.strip() | |
# 转换帧率格式 (例如 "30000/1001" 或 "25/1") | |
if '/' in fps_str: | |
num, den = fps_str.split('/') | |
fps = float(num) / float(den) | |
else: | |
fps = float(fps_str) if fps_str else 30 | |
except: | |
fps = 30 # 默认30fps | |
# 统一帧率为30fps以保证兼容性 | |
target_fps = 30 | |
if current_width == target_width and current_height == target_height: | |
# 即使分辨率匹配,也要检查帧率和编码参数是否需要统一 | |
self.log_message(f"[{idx}/{total_videos}] {video_file.name} - 检查帧率...") | |
if abs(fps - target_fps) > 0.1: # 帧率差异大于0.1 | |
self.log_message(f" 帧率不匹配 ({fps:.2f} -> {target_fps}), 转换中...") | |
filter_complex = f"fps={target_fps}" | |
codec_args = self.get_ffmpeg_video_codec_args(quality) | |
cmd = [ | |
'ffmpeg', '-i', str(video_file), | |
'-vf', filter_complex, | |
] + codec_args + [ | |
'-c:a', 'aac', '-y', | |
'-loglevel', 'error', | |
str(output_path) | |
] | |
result = subprocess.run(cmd, capture_output=True, text=True) | |
if result.returncode == 0: | |
self.log_message(f" ✓ 帧率调整完成") | |
else: | |
self.log_message(f" ✗ 转换失败: {result.stderr}") | |
return | |
else: | |
self.log_message(f" 帧率已匹配 ({fps:.2f}fps), 复制中...") | |
import shutil | |
shutil.copy2(video_file, output_path) | |
self.log_message(f" ✓ 完成") | |
else: | |
self.log_message(f"[{idx}/{total_videos}] {video_file.name} - 转换中 (分辨率 + 帧率统一)...") | |
# 使用letterboxing转换,同时统一帧率 | |
filter_complex = ( | |
f"fps={target_fps}," | |
f"scale={target_width}:{target_height}:force_original_aspect_ratio=decrease," | |
f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2:black" | |
) | |
codec_args = self.get_ffmpeg_video_codec_args(quality) | |
cmd = [ | |
'ffmpeg', '-i', str(video_file), | |
'-vf', filter_complex, | |
] + codec_args + [ | |
'-c:a', 'aac', '-ar', '48000', '-y', | |
'-loglevel', 'error', | |
str(output_path) | |
] | |
result = subprocess.run(cmd, capture_output=True, text=True) | |
if result.returncode == 0: | |
self.log_message(f" ✓ 转换完成") | |
else: | |
self.log_message(f" ✗ 转换失败: {result.stderr}") | |
return | |
converted_videos.append(output_path) | |
self.progress_var.set(100) | |
# 第四步:创建concat文件 | |
self.progress_label.config(text="第4步: 准备合并列表") | |
self.log_message("创建合并列表...") | |
concat_file = Path(folder) / "concat_list_temp.txt" | |
with open(concat_file, 'w', encoding='ascii') as f: | |
for video_path in converted_videos: | |
f.write(f"file '{video_path}'\n") | |
# 第五步:合并视频 | |
self.progress_label.config(text="第5步: 合并视频") | |
self.log_message("正在合并视频... (这可能需要几分钟)") | |
first_output = Path(folder) / output_name | |
codec_args = self.get_ffmpeg_video_codec_args(quality) | |
cmd = [ | |
'ffmpeg', '-f', 'concat', '-safe', '0', | |
'-protocol_whitelist', 'file,pipe', | |
'-i', str(concat_file), | |
] + codec_args + [ | |
'-c:a', 'aac', '-y', | |
'-loglevel', 'error', | |
str(first_output) | |
] | |
result = subprocess.run(cmd, capture_output=True, text=True) | |
if result.returncode != 0: | |
self.log_message(f"合并失败: {result.stderr}") | |
return | |
self.log_message(f"✓ 第一步合并完成: {output_name}") | |
# 第六步:重复(如果需要) | |
if repeat_times > 1: | |
self.progress_label.config(text=f"第6步: 重复{repeat_times}遍") | |
self.log_message(f"正在重复视频{repeat_times}遍...") | |
repeat_file = Path(folder) / "concat_repeat_temp.txt" | |
with open(repeat_file, 'w', encoding='ascii') as f: | |
for _ in range(repeat_times): | |
f.write(f"file '{first_output}'\n") | |
final_output = Path(folder) / output_name.replace('.mp4', f'_x{repeat_times}.mp4') | |
cmd = [ | |
'ffmpeg', '-f', 'concat', '-safe', '0', | |
'-protocol_whitelist', 'file,pipe', | |
'-i', str(repeat_file), | |
'-c', 'copy', '-y', | |
'-loglevel', 'error', | |
str(final_output) | |
] | |
result = subprocess.run(cmd, capture_output=True, text=True) | |
if result.returncode != 0: | |
self.log_message(f"重复失败: {result.stderr}") | |
return | |
self.log_message(f"✓ 重复完成: {final_output.name}") | |
# 清理临时文件 | |
repeat_file.unlink(missing_ok=True) | |
else: | |
final_output = first_output | |
# 清理临时文件 | |
self.log_message("清理临时文件...") | |
import shutil | |
shutil.rmtree(temp_folder, ignore_errors=True) | |
concat_file.unlink(missing_ok=True) | |
# 获取输出文件大小 | |
file_size = final_output.stat().st_size / (1024 ** 3) | |
self.log_message("=" * 60) | |
self.log_message(f"✓ 处理完成!") | |
self.log_message(f"输出文件: {final_output.name}") | |
self.log_message(f"文件大小: {file_size:.2f} GB") | |
self.log_message("=" * 60) | |
self.progress_label.config(text="完成!") | |
messagebox.showinfo("成功", f"视频合并成功!\n\n输出文件: {final_output.name}\n大小: {file_size:.2f} GB") | |
except Exception as e: | |
self.log_message(f"错误: {str(e)}") | |
messagebox.showerror("错误", f"处理失败: {str(e)}") | |
finally: | |
self.start_button.config(state='normal') | |
self.cancel_button.config(state='disabled') | |
self.is_processing = False | |
def cancel_process(self): | |
"""取消处理""" | |
self.is_processing = False | |
self.log_message("正在取消处理...") | |
def open_output_folder(self): | |
"""打开输出文件夹""" | |
folder = self.video_folder.get() | |
if folder and os.path.exists(folder): | |
os.startfile(folder) | |
else: | |
messagebox.showerror("错误", "请先选择文件夹") | |
def main(): | |
""" | |
程序入口点 | |
使用方法: | |
1. 确保当前目录有MP4文件,或通过浏览按钮选择其他目录 | |
2. 程序自动扫描并显示分辨率信息 | |
3. 根据需要调整输出参数 | |
4. 点击"开始合并",程序会自动处理 | |
5. 完成后会提示并显示输出文件信息 | |
系统要求: | |
• Python 3.6+ | |
• tkinter (通常自带) | |
• ffmpeg 和 ffprobe 已安装在系统 PATH 中 | |
典型场景: | |
• 合并多个短视频素材 | |
• 处理来自不同设备/应用录制的视频 | |
• 需要高质量的无缝视频拼接 | |
• 想要自动处理分辨率不一致的问题 | |
""" | |
root = tk.Tk() | |
app = VideoMergerApp(root) | |
root.mainloop() | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment