v2.1: rewrite on CustomTkinter with safer ffmpeg handling

- Migrate the UI from tkinter to customtkinter (themed, system appearance)
- Detect ffmpeg via shutil.which; warn and disable concat if it's missing
- Auto-fill the output name from the first selected file
- Inline output-name conflict detection against the source files, with a
  live red warning and the button disabled while a conflict exists
- Escape paths in the concat list file (quotes/backslashes), write as UTF-8
- Run ffmpeg with explicit -c:v copy / -c:a copy and surface stderr on error
- Re-enable the button and clean up file_list.txt on every exit path

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 13:27:18 +00:00
parent c26a8fc235
commit a3fe0fcf65

View File

@@ -1,174 +1,257 @@
import tkinter as tk
from tkinter import filedialog, messagebox, Listbox
import os
import subprocess
import threading
import time
import shutil
import tkinter as tk
from tkinter import filedialog, messagebox
import customtkinter as ctk
class VideoConcatenatorApp:
def __init__(self, root):
self.root = root
self.root.title("Video Concatenator")
# Selected files
# Set appearance and color theme
ctk.set_appearance_mode("system") # "light" | "dark" | "system"
ctk.set_default_color_theme("blue") # "blue" | "green" | "dark-blue"
class VideoConcatenatorApp(ctk.CTk):
def __init__(self):
super().__init__()
self.title("Video Concatenator (CustomTkinter)")
self.geometry("700x700")
# State
self.selected_files = []
self.output_file_name = "output.mp4"
self.delete_original = tk.BooleanVar(value=False)
# Section 1: Select files
self.label1 = tk.Label(root, text="1. Select Video Files", font=("Arial", 12))
self.label1.pack(pady=5)
self.select_btn = tk.Button(root, text="Select Videos", command=self.select_videos)
self.output_dir = ""
# detect ffmpeg.exe
self.ffmpeg_path = shutil.which("ffmpeg")
# 1. Select Video Files
self.label1 = ctk.CTkLabel(self, text="1. Select Video Files", font=("Arial", 14))
self.label1.pack(pady=(10,5))
self.select_btn = ctk.CTkButton(self, text="Select Videos", command=self.select_videos)
self.select_btn.pack(pady=5)
self.files_listbox = Listbox(root, selectmode=tk.SINGLE, width=80, height=10)
self.files_listbox.pack(pady=5)
# Section 2: Organize sequence
self.label2 = tk.Label(root, text="2. Organize Sequence", font=("Arial", 12))
self.label2.pack(pady=5)
self.up_btn = tk.Button(root, text="Move Up", command=self.move_up)
self.up_btn.pack(pady=5)
self.down_btn = tk.Button(root, text="Move Down", command=self.move_down)
self.down_btn.pack(pady=5)
# Section 3: Name the output file
self.label3 = tk.Label(root, text="3. Name the Output File", font=("Arial", 12))
self.label3.pack(pady=5)
self.output_name_entry = tk.Entry(root, width=50)
self.output_name_entry.insert(0, "output.mp4")
# Listbox frame
self.listbox_frame = ctk.CTkFrame(self)
self.listbox_frame.pack(pady=5, fill="x", expand=False)
self.files_listbox = tk.Listbox(self.listbox_frame, selectmode=tk.SINGLE, width=80, height=8)
self.files_listbox.pack(padx=5, pady=5, fill="both", expand=True)
# 2. Organize sequence
self.label2 = ctk.CTkLabel(self, text="2. Organize Sequence", font=("Arial", 14))
self.label2.pack(pady=(10,5))
self.up_down_frame = ctk.CTkFrame(self)
self.up_down_frame.pack(pady=5)
self.up_btn = ctk.CTkButton(self.up_down_frame, text="Move Up", command=self.move_up)
self.up_btn.grid(row=0, column=0, padx=10, pady=5)
self.down_btn = ctk.CTkButton(self.up_down_frame, text="Move Down", command=self.move_down)
self.down_btn.grid(row=0, column=1, padx=10, pady=5)
# 3. Name the Output File
self.label3 = ctk.CTkLabel(self, text="3. Name the Output File", font=("Arial", 14))
self.label3.pack(pady=(10,5))
self.output_name_entry = ctk.CTkEntry(self, width=300)
self.output_name_entry.insert(0, self.output_file_name)
self.output_name_entry.pack(pady=5)
# Section 4: Choose output directory
self.label4 = tk.Label(root, text="4. Select Output Directory", font=("Arial", 12))
self.label4.pack(pady=5)
self.output_dir_btn = tk.Button(root, text="Choose Output Directory", command=self.select_output_dir)
# Inline name-conflict warning
self.name_warning_label = ctk.CTkLabel(
self,
text="",
text_color="red",
font=("Arial", 12)
)
self.name_warning_label.pack(pady=(0,5))
# Bind to update warning and button state on typing
self.output_name_entry.bind("<KeyRelease>", self.check_name_conflict)
# 4. Select Output Directory
self.label4 = ctk.CTkLabel(self, text="4. Select Output Directory", font=("Arial", 14))
self.label4.pack(pady=(10,5))
self.output_dir_btn = ctk.CTkButton(self, text="Choose Output Directory", command=self.select_output_dir)
self.output_dir_btn.pack(pady=5)
self.output_dir_label = tk.Label(root, text="", fg="blue")
self.output_dir_label = ctk.CTkLabel(self, text="", text_color="blue")
self.output_dir_label.pack(pady=5)
# Section 5: Delete original files option
self.delete_original_check = tk.Checkbutton(root, text="Delete Original Files After Concatenation", variable=self.delete_original)
# 5. Delete original files option
self.delete_original_check = ctk.CTkCheckBox(
self,
text="Delete Original Files After Concatenation",
variable=self.delete_original
)
self.delete_original_check.pack(pady=5)
# Concatenate Button
self.concat_btn = tk.Button(root, text="Concatenate Videos", command=self.start_concatenation, state=tk.DISABLED)
self.concat_btn = ctk.CTkButton(self, text="Concatenate Videos", command=self.start_concatenation, state="disabled")
self.concat_btn.pack(pady=10)
# Time Left Label
self.time_left_label = tk.Label(root, text="", font=("Arial", 10), fg="green")
self.time_left_label = ctk.CTkLabel(self, text="", font=("Arial", 12), text_color="green")
self.time_left_label.pack(pady=5)
# If ffmpeg not found, disable concatenation
if not self.ffmpeg_path:
messagebox.showerror(
"Missing Dependency",
"Could not find ffmpeg on your system.\n"
"Please install ffmpeg and make sure it's on your PATH."
)
self.concat_btn.configure(state="disabled")
def select_videos(self):
files = filedialog.askopenfilenames(filetypes=[("Video Files", "*.mp4;*.mov;*.mkv;*.avi;*.flv")])
files = filedialog.askopenfilenames(
filetypes=[("Video Files", "*.mp4;*.mov;*.mkv;*.avi;*.flv")]
)
if files:
self.selected_files = list(files)
self.update_files_listbox()
self.concat_btn.config(state=tk.NORMAL)
# Auto-fill output name from the first file
first_file = os.path.basename(self.selected_files[0])
default_name = os.path.splitext(first_file)[0] + ".mp4"
self.output_name_entry.delete(0, tk.END)
self.output_name_entry.insert(0, default_name)
# Re-check for conflicts and button state
self.check_name_conflict()
# Only enable if no conflict and ffmpeg exists
if self.ffmpeg_path and not self._has_name_conflict():
self.concat_btn.configure(state="normal")
def update_files_listbox(self):
self.files_listbox.delete(0, tk.END)
for file in self.selected_files:
self.files_listbox.insert(tk.END, file)
for f in self.selected_files:
self.files_listbox.insert(tk.END, f)
def move_up(self):
selected = self.files_listbox.curselection()
if selected and selected[0] > 0:
index = selected[0]
self.selected_files[index], self.selected_files[index - 1] = self.selected_files[index - 1], self.selected_files[index]
sel = self.files_listbox.curselection()
if sel and sel[0] > 0:
i = sel[0]
self.selected_files[i-1], self.selected_files[i] = self.selected_files[i], self.selected_files[i-1]
self.update_files_listbox()
self.files_listbox.select_set(index - 1)
self.files_listbox.select_set(i-1)
def move_down(self):
selected = self.files_listbox.curselection()
if selected and selected[0] < len(self.selected_files) - 1:
index = selected[0]
self.selected_files[index], self.selected_files[index + 1] = self.selected_files[index + 1], self.selected_files[index]
sel = self.files_listbox.curselection()
if sel and sel[0] < len(self.selected_files)-1:
i = sel[0]
self.selected_files[i+1], self.selected_files[i] = self.selected_files[i], self.selected_files[i+1]
self.update_files_listbox()
self.files_listbox.select_set(index + 1)
self.files_listbox.select_set(i+1)
def select_output_dir(self):
directory = filedialog.askdirectory()
if directory:
self.output_dir = directory
self.output_dir_label.config(text=directory)
d = filedialog.askdirectory()
if d:
self.output_dir = d
self.output_dir_label.configure(text=d)
def _has_name_conflict(self):
name = self.output_name_entry.get().strip()
base_name = os.path.splitext(name)[0]
return any(
os.path.splitext(os.path.basename(f))[0] == base_name
for f in self.selected_files
)
def check_name_conflict(self, event=None):
"""
Show warning and disable/enable the concat button based on name conflict.
"""
conflict = self._has_name_conflict()
if conflict:
self.name_warning_label.configure(
text="⚠️ This name matches one of your originals!"
)
self.concat_btn.configure(state="disabled")
else:
self.name_warning_label.configure(text="")
# Only re-enable if videos are selected and ffmpeg exists
if self.selected_files and self.ffmpeg_path:
self.concat_btn.configure(state="normal")
def start_concatenation(self):
# Disable button to prevent re-clicking
self.concat_btn.config(state=tk.DISABLED)
self.time_left_label.config(text="Starting concatenation...")
# Run the concatenation process in a separate thread
self.concat_btn.configure(state="disabled")
self.time_left_label.configure(text="Starting concatenation...")
threading.Thread(target=self.concatenate_videos).start()
def concatenate_videos(self):
# sanity checks
if not self.selected_files:
messagebox.showerror("Error", "No videos selected.")
self.concat_btn.configure(state="normal")
return
if not hasattr(self, "output_dir"):
if not self.output_dir:
messagebox.showerror("Error", "Output directory not selected.")
self.concat_btn.configure(state="normal")
return
# Get output file name
self.output_file_name = self.output_name_entry.get().strip()
if not self.output_file_name:
# get output file name
outname = self.output_name_entry.get().strip()
if not outname:
messagebox.showerror("Error", "Output file name cannot be empty.")
self.concat_btn.configure(state="normal")
return
# Ensure the output file name ends with .mp4
if not self.output_file_name.endswith(".mp4"):
self.output_file_name += ".mp4"
# Create file list for FFmpeg
temp_file_list = os.path.join(self.output_dir, "file_list.txt")
with open(temp_file_list, "w") as f:
if not outname.lower().endswith(".mp4"):
outname += ".mp4"
output_file = os.path.join(self.output_dir, outname)
# build the file list
list_path = os.path.join(self.output_dir, "file_list.txt")
with open(list_path, "w", encoding="utf-8") as f:
for file in self.selected_files:
f.write(f"file '{file}'\n")
# Output file path
output_file = os.path.join(self.output_dir, self.output_file_name)
# FFmpeg command
command = [
"ffmpeg",
path = file.replace("\\", "/").replace("'", r"'\\''")
f.write(f"file '{path}'\n")
cmd = [
self.ffmpeg_path,
"-y",
"-f", "concat",
"-safe", "0",
"-i", temp_file_list,
"-c", "copy",
"-i", "file_list.txt",
"-c:v", "copy",
"-c:a", "copy",
output_file
]
try:
total_time = len(self.selected_files) * 5 # Estimate: ~5 seconds per video
for i in range(total_time):
time.sleep(1)
self.time_left_label.config(text=f"Estimated time left: {total_time - i}s")
self.root.update_idletasks()
subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.time_left_label.config(text="Concatenation completed!")
messagebox.showinfo("Success", f"Videos concatenated successfully! Output: {output_file}")
# Optionally delete original files
# estimate time
estimate = len(self.selected_files) * 5
for i in range(estimate):
time.sleep(1)
self.time_left_label.configure(text=f"Estimated time left: {estimate - i}s")
self.update_idletasks()
# run ffmpeg
proc = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=self.output_dir
)
if proc.returncode != 0:
self.time_left_label.configure(text="Error during concatenation.")
messagebox.showerror("FFmpeg error", proc.stderr.strip())
else:
self.time_left_label.configure(text="Concatenation completed!")
messagebox.showinfo("Success", f"Output: {output_file}")
if self.delete_original.get():
for file in self.selected_files:
os.remove(file)
try:
os.remove(file)
except OSError:
pass
messagebox.showinfo("Info", "Original files have been deleted.")
except subprocess.CalledProcessError:
self.time_left_label.config(text="Error during concatenation.")
messagebox.showerror("Error", "Failed to concatenate videos.")
finally:
os.remove(temp_file_list)
self.concat_btn.config(state=tk.NORMAL)
# cleanup & re-enable
if os.path.exists(list_path):
os.remove(list_path)
self.concat_btn.configure(state="normal")
if __name__ == "__main__":
root = tk.Tk()
app = VideoConcatenatorApp(root)
root.mainloop()
app = VideoConcatenatorApp()
app.mainloop()