Files
toutiao-pachong/video_converter_ui.py
2026-02-07 22:23:36 +08:00

525 lines
22 KiB
Python
Raw Permalink 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.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
视频格式转换工具带GUI界面
使用ffmpeg将指定目录下的全部视频转换为mp4格式的横屏1080p视频
支持配置ffmpeg的大部分参数
"""
import os
import subprocess
import sys
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from concurrent.futures import ThreadPoolExecutor, as_completed
class VideoConverterUI:
def __init__(self, root):
self.root = root
self.root.title("视频格式转换工具")
self.root.geometry("800x800")
self.root.resizable(True, True)
# 设置主题颜色
self.bg_color = "#f0f0f0" # 主窗口背景色(浅灰)
self.accent_color = "#4CAF50" # 强调色:标题、悬停边框(绿色)
self.text_color = "#000000" # 普通文字颜色(黑色)
self.frame_color = "#ffffff" # 内部框架背景色(白色)
self.button_bg = "#4CAF50" # 按钮背景色(绿色)
self.button_fg = "#000000" # 按钮文字颜色(白色)
# 变量
self.input_dir = tk.StringVar()
self.output_dir = tk.StringVar(value="output")
self.threads = tk.StringVar(value="4")
self.ffmpeg_params = tk.StringVar()
self.is_converting = False
self.convert_thread = None
# 创建界面
self.create_widgets()
def create_widgets(self):
# 设置窗口背景
self.root.configure(bg=self.bg_color)
# 主框架
main_frame = ttk.Frame(self.root, padding="20", style="Main.TFrame")
main_frame.pack(fill=tk.BOTH, expand=True)
# 设置样式
self.setup_styles()
# 标题
title_label = ttk.Label(main_frame, text="视频格式转换工具", font=("Microsoft YaHei", 18, "bold"), style="Title.TLabel")
title_label.pack(pady=10)
# 输入目录
input_frame = ttk.LabelFrame(main_frame, text="输入设置", padding="15")
input_frame.pack(fill=tk.X, pady=10)
# 输入目录行
input_row = ttk.Frame(input_frame)
input_row.pack(fill=tk.X, pady=8)
ttk.Label(input_row, text="输入目录:", width=12, style="TLabel").pack(side=tk.LEFT, padx=5)
# 创建可拖拽的输入框
self.input_entry = ttk.Entry(input_row, textvariable=self.input_dir, width=60, style="TEntry")
self.input_entry.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
# 启用拖放功能
self.input_entry.bind("<Button-1>", self.clear_placeholder)
self.input_entry.bind("<Enter>", self.on_enter)
self.input_entry.bind("<Leave>", self.on_leave)
# 为输入框添加拖放支持
self.input_entry.bind("<Button-3>", self.on_right_click)
# 为窗口添加拖放支持使用tkinter标准事件
self.root.bind("<ButtonPress-1>", self.on_button_press)
self.root.bind("<B1-Motion>", self.on_mouse_move)
self.root.bind("<ButtonRelease-1>", self.on_button_release)
# 为输入框添加粘贴支持
self.input_entry.bind("<Button-2>", self.on_paste)
self.input_entry.bind("<Control-v>", self.on_paste)
ttk.Button(input_row, text="浏览", command=self.browse_input, style="Accent.TButton", width=8).pack(side=tk.RIGHT, padx=5)
# 输出目录行
output_row = ttk.Frame(input_frame)
output_row.pack(fill=tk.X, pady=8)
ttk.Label(output_row, text="输出目录:", width=12, style="TLabel").pack(side=tk.LEFT, padx=5)
ttk.Entry(output_row, textvariable=self.output_dir, width=60, style="TEntry").pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
ttk.Button(output_row, text="浏览", command=self.browse_output, style="Accent.TButton", width=8).pack(side=tk.RIGHT, padx=5)
# 线程数和参数行
settings_row = ttk.Frame(input_frame)
settings_row.pack(fill=tk.X, pady=8)
threads_frame = ttk.Frame(settings_row)
threads_frame.pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
ttk.Label(threads_frame, text="并发线程数:", width=12, style="TLabel").pack(side=tk.LEFT, padx=5)
ttk.Entry(threads_frame, textvariable=self.threads, width=10, style="TEntry").pack(side=tk.LEFT, padx=5)
# 分辨率设置按钮
resolution_frame = ttk.LabelFrame(main_frame, text="分辨率设置", padding="15")
resolution_frame.pack(fill=tk.X, pady=10)
buttons_frame = ttk.Frame(resolution_frame)
buttons_frame.pack(fill=tk.X, pady=5)
ttk.Button(buttons_frame, text="强制高度为1080", command=self.set_height_1080, style="Accent.TButton").pack(side=tk.LEFT, padx=10, ipady=3, ipadx=15)
ttk.Button(buttons_frame, text="强制宽度为1920", command=self.set_width_1920, style="Accent.TButton").pack(side=tk.LEFT, padx=10, ipady=3, ipadx=15)
ttk.Button(buttons_frame, text="严格1080p首选", command=self.set_strict_1080p, style="Accent.TButton").pack(side=tk.LEFT, padx=10, ipady=3, ipadx=15)
# FFmpeg参数设置
params_frame = ttk.LabelFrame(main_frame, text="FFmpeg参数设置", padding="15")
params_frame.pack(fill=tk.X, pady=10)
params_row = ttk.Frame(params_frame)
params_row.pack(fill=tk.X, pady=5)
ttk.Label(params_row, text="额外参数:", width=12, style="TLabel").pack(side=tk.LEFT, padx=5)
ttk.Entry(params_row, textvariable=self.ffmpeg_params, width=60, style="TEntry").pack(side=tk.LEFT, padx=5, fill=tk.X, expand=True)
# 控制按钮
button_frame = ttk.Frame(main_frame)
button_frame.pack(fill=tk.X, pady=15)
button_container = ttk.Frame(button_frame)
button_container.pack(side=tk.LEFT, padx=5)
self.convert_button = ttk.Button(button_container, text="开始转换", command=self.start_conversion, style="Start.TButton")
self.convert_button.pack(side=tk.LEFT, padx=10, ipady=10, ipadx=25)
self.stop_button = ttk.Button(button_container, text="停止转换", command=self.stop_conversion, state=tk.DISABLED, style="Stop.TButton")
self.stop_button.pack(side=tk.LEFT, padx=10, ipady=10, ipadx=25)
# 状态框
status_frame = ttk.LabelFrame(main_frame, text="转换状态", padding="15")
status_frame.pack(fill=tk.BOTH, expand=True, pady=10)
# 状态文本框
self.status_text = tk.Text(status_frame, wrap=tk.WORD, height=15, bg="#f8f8f8", fg=self.text_color, font=("Microsoft YaHei", 10))
self.status_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT, padx=5, pady=5)
# 滚动条
scrollbar = ttk.Scrollbar(status_frame, command=self.status_text.yview, style="TScrollbar")
scrollbar.pack(side=tk.RIGHT, fill=tk.Y, pady=5)
self.status_text.config(yscrollcommand=scrollbar.set)
# 底部状态
self.status_var = tk.StringVar(value="就绪")
status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W, style="Status.TLabel")
status_bar.pack(fill=tk.X, side=tk.BOTTOM)
def setup_styles(self):
"""设置界面样式"""
style = ttk.Style()
# 主框架样式
style.configure("Main.TFrame", background=self.bg_color)
# 标题样式
style.configure("Title.TLabel", font=("Microsoft YaHei", 18, "bold"), foreground=self.accent_color, background=self.bg_color)
# 标签样式
style.configure("TLabel", font=("Microsoft YaHei", 10, "bold"), foreground=self.text_color, background=self.frame_color)
# 输入框样式
style.configure("TEntry", font=("Microsoft YaHei", 10), padding=5, foreground=self.text_color)
# 悬停输入框样式
style.configure("Hover.TEntry", font=("Microsoft YaHei", 10), padding=5, foreground=self.accent_color, background="#f0f8f0")
# 按钮样式
style.configure("Accent.TButton", font=("Microsoft YaHei", 10), padding=5, background=self.button_bg, foreground=self.button_fg)
style.map("Accent.TButton", background=[("active", "#45a049")])
# 开始按钮样式
style.configure("Start.TButton", font=("Microsoft YaHei", 11, "bold"), padding=8, background=self.button_bg, foreground=self.button_fg)
style.map("Start.TButton", background=[("active", "#45a049")])
# 停止按钮样式
style.configure("Stop.TButton", font=("Microsoft YaHei", 11, "bold"), padding=8, background="#f44336", foreground="#ffffff")
style.map("Stop.TButton", background=[("active", "#da190b")])
# 标签框架样式
style.configure("TLabelFrame", font=("Microsoft YaHei", 10, "bold"), background=self.bg_color, foreground=self.text_color)
style.configure("TLabelFrame.Label", font=("Microsoft YaHei", 10, "bold"), foreground=self.text_color)
# 滚动条样式
style.configure("TScrollbar", background=self.bg_color, troughcolor="#e0e0e0")
# 状态标签样式
style.configure("Status.TLabel", font=("Microsoft YaHei", 9), padding=5, background="#e0e0e0", foreground=self.text_color)
def browse_input(self):
dir_path = filedialog.askdirectory(title="选择输入目录")
if dir_path:
self.input_dir.set(dir_path)
def browse_output(self):
dir_path = filedialog.askdirectory(title="选择输出目录")
if dir_path:
self.output_dir.set(dir_path)
def set_height_1080(self):
"""设置高度为1080"""
current_params = self.ffmpeg_params.get().strip()
# 移除现有的高度设置
new_params = []
for param in current_params.split():
if not (param.startswith('-h') and param != '-h'):
new_params.append(param)
# 添加新的高度设置
new_params.extend(['-vf', 'scale=-2:1080'])
self.ffmpeg_params.set(' '.join(new_params))
self.log("已设置高度为1080")
def set_width_1920(self):
"""设置宽度为1920"""
current_params = self.ffmpeg_params.get().strip()
# 移除现有的宽度设置
new_params = []
for param in current_params.split():
if not (param.startswith('-w') and param != '-w'):
new_params.append(param)
# 添加新的宽度设置
new_params.extend(['-vf', 'scale=1920:-2'])
self.ffmpeg_params.set(' '.join(new_params))
self.log("已设置宽度为1920")
def set_strict_1080p(self):
"""设置严格的1080p分辨率"""
current_params = self.ffmpeg_params.get().strip()
# 移除现有的分辨率设置
new_params = []
skip_next = False
for i, param in enumerate(current_params.split()):
if skip_next:
skip_next = False
continue
if param == '-vf' and i + 1 < len(current_params.split()):
if 'scale=' in current_params.split()[i + 1]:
skip_next = True
continue
new_params.append(param)
# 添加严格的1080p设置先缩放保持比例再用pad填充黑边至标准1080p
new_params.extend(['-vf', 'scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2'])
self.ffmpeg_params.set(' '.join(new_params))
self.log("已设置严格的1080p分辨率保持比例+黑边填充)")
def clear_placeholder(self, event):
"""清除输入框的占位符"""
pass
def on_enter(self, event):
"""鼠标进入输入框"""
self.input_entry.configure(style="Hover.TEntry")
def on_leave(self, event):
"""鼠标离开输入框"""
self.input_entry.configure(style="TEntry")
def on_button_press(self, event):
"""鼠标按下"""
pass
def on_mouse_move(self, event):
"""鼠标移动"""
pass
def on_button_release(self, event):
"""鼠标释放"""
pass
def on_right_click(self, event):
"""右键点击"""
pass
def on_paste(self, event):
"""粘贴功能"""
try:
# 获取剪贴板内容
clipboard_content = self.root.clipboard_get()
if clipboard_content:
# 检查是否是有效的文件或目录路径
if os.path.exists(clipboard_content):
# 如果是文件,使用其所在目录
if os.path.isfile(clipboard_content):
input_path = os.path.dirname(clipboard_content)
else:
input_path = clipboard_content
self.input_dir.set(input_path)
self.log(f"已添加输入目录: {input_path}")
except:
pass
def log(self, message):
"""记录日志到状态文本框"""
self.status_text.insert(tk.END, message + "\n")
self.status_text.see(tk.END)
self.root.update_idletasks()
def get_video_files(self, directory):
"""
获取目录下所有视频文件
"""
video_extensions = ['.mp4', '.avi', '.mov', '.wmv', '.flv', '.mkv', '.webm', '.m4v']
video_files = []
for root, _, files in os.walk(directory):
for file in files:
if any(file.lower().endswith(ext) for ext in video_extensions):
video_files.append(os.path.join(root, file))
return video_files
def convert_video(self, input_file, output_file, ffmpeg_args):
"""
转换单个视频文件
"""
try:
# 构建ffmpeg命令
cmd = [
'ffmpeg', '-i', input_file,
'-c:v', 'libx264', '-preset', 'medium', '-crf', '23',
'-c:a', 'aac', '-b:a', '128k',
'-y' # 覆盖输出文件
]
# 检查用户是否设置了分辨率参数
has_resolution_param = False
for i, arg in enumerate(ffmpeg_args):
if arg == '-vf' and i + 1 < len(ffmpeg_args):
if 'scale=' in ffmpeg_args[i + 1]:
has_resolution_param = True
break
# 如果用户没有设置分辨率使用默认的1080p横屏设置
if not has_resolution_param:
cmd.extend(['-vf', 'scale=1920:1080,setdar=16:9']) # 默认强制横屏1080p
# 添加用户自定义参数
cmd.extend(ffmpeg_args)
cmd.append(output_file)
# 执行命令
self.log(f"正在转换: {os.path.basename(input_file)}")
subprocess.run(cmd, check=True, capture_output=True, text=True)
self.log(f"转换完成: {os.path.basename(output_file)}")
return True
except subprocess.CalledProcessError as e:
self.log(f"转换失败: {os.path.basename(input_file)}")
self.log(f"错误信息: {e.stderr}")
return False
except Exception as e:
self.log(f"转换失败: {os.path.basename(input_file)}")
self.log(f"错误信息: {str(e)}")
return False
def start_conversion(self):
"""开始转换过程"""
# 验证输入
input_dir = self.input_dir.get().strip()
if not input_dir:
messagebox.showerror("错误", "请选择输入目录")
return
if not os.path.exists(input_dir):
messagebox.showerror("错误", f"输入目录 '{input_dir}' 不存在")
return
output_dir = self.output_dir.get().strip()
if not output_dir:
messagebox.showerror("错误", "请选择输出目录")
return
try:
threads = int(self.threads.get().strip())
if threads <= 0:
raise ValueError
except ValueError:
messagebox.showerror("错误", "并发线程数必须是正整数")
return
# 解析FFmpeg参数
ffmpeg_params = self.ffmpeg_params.get().strip()
ffmpeg_args = ffmpeg_params.split() if ffmpeg_params else []
# 更新UI状态
self.is_converting = True
self.convert_button.config(state=tk.DISABLED)
self.stop_button.config(state=tk.NORMAL)
self.status_var.set("转换中...")
self.status_text.delete(1.0, tk.END)
# 启动转换线程
def conversion_thread():
try:
# 创建输出目录
os.makedirs(output_dir, exist_ok=True)
# 获取所有视频文件
video_files = self.get_video_files(input_dir)
if not video_files:
self.log(f"错误: 输入目录 '{input_dir}' 中没有找到视频文件")
return
self.log(f"找到 {len(video_files)} 个视频文件")
# 并发转换视频
success_count = 0
failure_count = 0
with ThreadPoolExecutor(max_workers=threads) as executor:
future_to_video = {}
for video_file in video_files:
if not self.is_converting:
break
# 构建输出文件路径
relative_path = os.path.relpath(video_file, input_dir)
output_file = os.path.join(output_dir, os.path.splitext(relative_path)[0] + '.mp4')
# 创建输出文件的目录
os.makedirs(os.path.dirname(output_file), exist_ok=True)
# 提交任务
future = executor.submit(self.convert_video, video_file, output_file, ffmpeg_args)
future_to_video[future] = video_file
# 处理结果
for future in as_completed(future_to_video):
if not self.is_converting:
# 取消所有未完成的任务
for f in future_to_video:
if not f.done():
f.cancel()
break
video_file = future_to_video[future]
try:
result = future.result()
if result:
success_count += 1
else:
failure_count += 1
except Exception as e:
self.log(f"处理 {os.path.basename(video_file)} 时出错: {str(e)}")
failure_count += 1
if self.is_converting:
self.log(f"\n转换完成:")
self.log(f"成功: {success_count}")
self.log(f"失败: {failure_count}")
messagebox.showinfo("转换完成", f"转换完成:\n成功: {success_count}\n失败: {failure_count}")
else:
self.log("转换已停止")
messagebox.showinfo("转换停止", "转换已停止")
except Exception as e:
self.log(f"转换过程出错: {str(e)}")
messagebox.showerror("错误", f"转换过程出错: {str(e)}")
finally:
# 恢复UI状态
self.is_converting = False
self.convert_button.config(state=tk.NORMAL)
self.stop_button.config(state=tk.DISABLED)
self.status_var.set("就绪")
self.convert_thread = threading.Thread(target=conversion_thread)
self.convert_thread.daemon = True
self.convert_thread.start()
def stop_conversion(self):
"""停止转换过程"""
if messagebox.askyesno("确认", "确定要停止转换吗?"):
self.is_converting = False
self.status_var.set("停止中...")
# 结束所有ffmpeg进程
try:
import psutil
for proc in psutil.process_iter(['pid', 'name']):
if proc.info['name'] and 'ffmpeg' in proc.info['name'].lower():
proc.kill()
self.log(f"已结束ffmpeg进程 (PID: {proc.info['pid']})")
except ImportError:
# 如果没有psutil使用subprocess
try:
subprocess.run(['taskkill', '/F', '/IM', 'ffmpeg.exe'], capture_output=True)
self.log("已结束所有ffmpeg进程")
except:
self.log("结束ffmpeg进程失败请手动结束")
except Exception as e:
self.log(f"结束ffmpeg进程时出错: {str(e)}")
def main():
"""
主函数
"""
# 检查ffmpeg是否存在
if not os.path.exists('ffmpeg.exe'):
# 尝试在系统路径中查找
try:
subprocess.run(['ffmpeg', '-version'], capture_output=True, check=True)
except:
messagebox.showerror("错误", "未找到ffmpeg.exe请确保它与程序在同一目录下")
return
# 创建主窗口
root = tk.Tk()
app = VideoConverterUI(root)
# 运行主循环
root.mainloop()
if __name__ == '__main__':
main()