[Python] 纯文本查看 复制代码
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import queue
import shutil
import subprocess
import tempfile
import threading
import tkinter as tk
from pathlib import Path
from tkinter import ttk, filedialog, messagebox, scrolledtext
# 修改为您的实际文件路径
FFMPEG_PATH = r"C:\ffmpeg\bin\ffmpeg.exe"
class DualAudioApp:
def __init__(self, root):
self.root = root
self.root.title("MP4 双音轨工具 (人声/伴奏)")
self.root.geometry("780x480")
self.root.resizable(False, False)
self.selected_mp4 = None
self.is_processing = False
self.task_queue = queue.Queue()
self.temp_dir = tempfile.mkdtemp(prefix="dual_audio_")
self.ffmpeg_exe = FFMPEG_PATH
self.out_dir_var = tk.StringVar(value="")
self.out_dir_default = ""
self._build_ui()
self._start_ui_updater()
def _build_ui(self):
# 1. 文件选择区
file_frame = ttk.Frame(self.root, padding=(15, 15, 15, 5))
file_frame.pack(fill="x")
ttk.Label(file_frame, text="📁 目标 MP4:").pack(side="left", padx=(0, 5))
self.file_var = tk.StringVar(value="未选择文件")
ttk.Entry(file_frame, textvariable=self.file_var, state="readonly", width=45).pack(side="left", fill="x",
expand=True, padx=5)
ttk.Button(file_frame, text="选择文件", command=self._select_file).pack(side="left")
# 2. 输出目录区
out_frame = ttk.Frame(self.root, padding=(15, 5))
out_frame.pack(fill="x")
ttk.Label(out_frame, text="📂 输出目录:").pack(side="left", padx=(0, 5))
ttk.Entry(out_frame, textvariable=self.out_dir_var, width=45).pack(side="left", fill="x", expand=True, padx=5)
self.btn_select_out = ttk.Button(out_frame, text="选择目录", command=self._select_out_dir)
self.btn_select_out.pack(side="left")
ttk.Label(out_frame, text="(留空则默认保存至原文件同级目录)", foreground="gray",
font=("Microsoft YaHei", 8)).pack(side="left", padx=(8, 0))
# 3. 控制按钮区
ctrl_frame = ttk.Frame(self.root, padding=(15, 5))
ctrl_frame.pack(fill="x")
self.start_btn = ttk.Button(ctrl_frame, text="🚀 开始处理", command=self._start_processing, state="disabled")
self.start_btn.pack(side="left")
ttk.Label(ctrl_frame, text="💡 合成格式:原视频音轨 + AI分离伴奏音轨", foreground="gray",
font=("Microsoft YaHei", 9)).pack(side="left", padx=(10, 0))
# 4. 进度条 & 日志
prog_frame = ttk.Frame(self.root, padding=(15, 0))
prog_frame.pack(fill="x")
self.progress = ttk.Progressbar(prog_frame, mode="indeterminate", length=400)
self.progress.pack(fill="x")
log_frame = ttk.LabelFrame(self.root, text="运行日志", padding=(15, 5))
log_frame.pack(fill="both", expand=True, padx=15, pady=10)
self.log_text = scrolledtext.ScrolledText(log_frame, height=9, state="disabled", font=("Consolas", 9))
self.log_text.pack(fill="both", expand=True)
def _select_file(self):
file = filedialog.askopenfilename(title="选择 MP4 视频文件",
filetypes=[("MP4 视频", "*.mp4"), ("所有文件", "*.*")])
if file:
self.selected_mp4 = file
self.file_var.set(file)
# 默认输出目录设为原文件所在目录
self.out_dir_default = str(Path(file).parent)
if not self.out_dir_var.get().strip():
self.out_dir_var.set(self.out_dir_default)
self._update_start_btn_state()
self._log("📥 已选择: " + file)
def _select_out_dir(self):
path = filedialog.askdirectory(title="选择输出目录",
initialdir=self.out_dir_var.get().strip() or self.out_dir_default)
if path:
self.out_dir_var.set(path)
def _update_start_btn_state(self):
if self.selected_mp4:
self.start_btn.config(state="normal")
else:
self.start_btn.config(state="disabled")
def _log(self, msg):
self.task_queue.put(("log", msg))
def _start_ui_updater(self):
def update():
try:
while True:
msg_type, content = self.task_queue.get_nowait()
if msg_type == "log":
self.log_text.config(state="normal")
self.log_text.insert("end", content + "\n")
self.log_text.see("end")
self.log_text.config(state="disabled")
elif msg_type == "progress_start":
self.progress.config(mode="indeterminate")
self.progress.start(10)
elif msg_type == "progress_stop":
self.progress.stop()
self.progress.config(mode="determinate", value=content)
elif msg_type == "done":
self._unlock_ui()
messagebox.showinfo("成功", content)
elif msg_type == "error":
self._unlock_ui()
messagebox.showerror("错误", content)
except queue.Empty:
pass
self.root.after(100, update)
update()
def _lock_ui(self):
self.is_processing = True
self.start_btn.config(state="disabled", text="⏳ 处理中...")
self.btn_select_out.config(state="disabled")
def _unlock_ui(self):
self.is_processing = False
self.start_btn.config(state="normal", text="🚀 开始处理")
self.btn_select_out.config(state="normal")
self._update_start_btn_state()
def _start_processing(self):
if not self.selected_mp4 or self.is_processing: return
# 启动前确认路径是否有效
if not os.path.isfile(self.ffmpeg_exe):
messagebox.showerror("配置错误",
f"文件 FFmpeg 路径不存在或无效:\n{self.ffmpeg_exe}\n\n请修改代码中的 FFMPEG_PATH 变量。")
return
self._lock_ui()
self.task_queue.put(("progress_start", None))
self._log("🔄 初始化任务队列...")
threading.Thread(target=self._worker, daemon=True).start()
def _worker(self):
try:
self._log("🤖 步骤 1/2: 启动 AI 分离模型...")
out_dir = Path(self.temp_dir) / "demucs_out"
out_dir.mkdir(parents=True, exist_ok=True)
# 环境变量注入(确保 demucs 能调用到 ffmpeg)
ffmpeg_dir = os.path.dirname(os.path.abspath(self.ffmpeg_exe))
custom_env = os.environ.copy()
if ffmpeg_dir and os.path.isdir(ffmpeg_dir):
custom_env["PATH"] = ffmpeg_dir + os.pathsep + custom_env.get("PATH", "")
cmd_demucs = [
"demucs", "--two-stems=vocals", "--name", "htdemucs",
"--device", "cpu", "-o", str(out_dir), self.selected_mp4
]
proc = subprocess.run(
cmd_demucs, capture_output=True, text=True, check=True,
encoding="utf-8", errors="ignore", env=custom_env,
cwd=os.path.dirname(self.selected_mp4)
)
if proc.stdout or proc.stderr:
log_lines = (proc.stdout + proc.stderr).splitlines()
for line in log_lines:
if "%" in line or "ETA" in line: self._log(f" {line.strip()}")
self._log("✅ AI 分离完成")
self.task_queue.put(("progress_stop", 50))
audio_name = Path(self.selected_mp4).stem
model_dir = out_dir / "htdemucs" / audio_name
if not model_dir.exists(): model_dir = out_dir / "htdemucs"
# 仅提取伴奏音轨 (no_vocals 即为分离后的伴奏)
accompaniment = model_dir / "no_vocals.wav"
if not accompaniment.exists():
raise RuntimeError("未找到分离后的伴奏文件。")
self._log("🔗 步骤 2/2: 合成双音轨 MKV (原声/伴奏)...")
target_dir = self.out_dir_var.get().strip() or self.out_dir_default
os.makedirs(target_dir, exist_ok=True)
out_mkv = Path(target_dir) / f"{audio_name}.mkv"
self._log(f"📤 输出路径: {out_mkv}")
cmd_ffmpeg = [
self.ffmpeg_exe, "-y",
"-i", self.selected_mp4, # 输入0:原视频 (含视频+原音轨)
"-i", str(accompaniment), # 输入1:AI分离伴奏 WAV
# 1. 视频流(无损拷贝)
"-map", "0:v:0", "-c:v", "copy",
# 2. 音轨0:原视频混音音轨(设为默认播放)
"-map", "0:a", "-c:a:0", "aac", "-b:a:0", "192k",
"-metadata:s:a:0", "title=Original",
"-disposition:a:0", "default",
# 3. 音轨1:分离伴奏音轨
"-map", "1:a", "-c:a:1", "aac", "-b:a:1", "192k",
"-metadata:s:a:1", "title=Accompaniment",
"-disposition:a:1", "0",
str(out_mkv)
]
subprocess.run(cmd_ffmpeg, capture_output=True, text=True, check=True, encoding="utf-8", errors="ignore")
self.task_queue.put(("progress_stop", 100))
self._log("🎉 处理完成!格式已严格匹配目标 MKV 规范。")
self.task_queue.put(
("done", f"✅ 成功!\n双音轨 MKV 文件已保存至:\n{out_mkv}\n\n💡 播放器中切换「音轨」可对比原声与伴奏效果。"))
except subprocess.CalledProcessError as e:
full_err = (e.stderr or "") + (e.stdout or "")
self._log(f"❌ 失败详情: {full_err[:200]}")
self.task_queue.put(("error", f"处理失败:\n{full_err[:200]}"))
except Exception as e:
self._log(f"❌ 异常: {str(e)}")
self.task_queue.put(("error", f"发生错误:\n{str(e)}"))
finally:
if os.path.exists(self.temp_dir):
try:
shutil.rmtree(self.temp_dir)
except:
pass
self._log("🧹 临时文件已清理")
if __name__ == "__main__":
root = tk.Tk()
try:
from ctypes import windll
windll.shcore.SetProcessDpiAwareness(1)
except:
pass
app = DualAudioApp(root)
root.mainloop()