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 os
import subprocess import subprocess
import threading import threading
import time import time
import shutil
import tkinter as tk
from tkinter import filedialog, messagebox
import customtkinter as ctk
class VideoConcatenatorApp: # Set appearance and color theme
def __init__(self, root): ctk.set_appearance_mode("system") # "light" | "dark" | "system"
self.root = root ctk.set_default_color_theme("blue") # "blue" | "green" | "dark-blue"
self.root.title("Video Concatenator")
# Selected files class VideoConcatenatorApp(ctk.CTk):
def __init__(self):
super().__init__()
self.title("Video Concatenator (CustomTkinter)")
self.geometry("700x700")
# State
self.selected_files = [] self.selected_files = []
self.output_file_name = "output.mp4" self.output_file_name = "output.mp4"
self.delete_original = tk.BooleanVar(value=False) self.delete_original = tk.BooleanVar(value=False)
self.output_dir = ""
# detect ffmpeg.exe
self.ffmpeg_path = shutil.which("ffmpeg")
# Section 1: Select files # 1. Select Video Files
self.label1 = tk.Label(root, text="1. Select Video Files", font=("Arial", 12)) self.label1 = ctk.CTkLabel(self, text="1. Select Video Files", font=("Arial", 14))
self.label1.pack(pady=5) self.label1.pack(pady=(10,5))
self.select_btn = ctk.CTkButton(self, text="Select Videos", command=self.select_videos)
self.select_btn = tk.Button(root, text="Select Videos", command=self.select_videos)
self.select_btn.pack(pady=5) self.select_btn.pack(pady=5)
self.files_listbox = Listbox(root, selectmode=tk.SINGLE, width=80, height=10) # Listbox frame
self.files_listbox.pack(pady=5) 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)
# Section 2: Organize sequence # 2. Organize sequence
self.label2 = tk.Label(root, text="2. Organize Sequence", font=("Arial", 12)) self.label2 = ctk.CTkLabel(self, text="2. Organize Sequence", font=("Arial", 14))
self.label2.pack(pady=5) 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)
self.up_btn = tk.Button(root, text="Move Up", command=self.move_up) # 3. Name the Output File
self.up_btn.pack(pady=5) self.label3 = ctk.CTkLabel(self, text="3. Name the Output File", font=("Arial", 14))
self.label3.pack(pady=(10,5))
self.down_btn = tk.Button(root, text="Move Down", command=self.move_down) self.output_name_entry = ctk.CTkEntry(self, width=300)
self.down_btn.pack(pady=5) self.output_name_entry.insert(0, self.output_file_name)
# 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")
self.output_name_entry.pack(pady=5) self.output_name_entry.pack(pady=5)
# Section 4: Choose output directory # Inline name-conflict warning
self.label4 = tk.Label(root, text="4. Select Output Directory", font=("Arial", 12)) self.name_warning_label = ctk.CTkLabel(
self.label4.pack(pady=5) self,
text="",
text_color="red",
font=("Arial", 12)
)
self.name_warning_label.pack(pady=(0,5))
self.output_dir_btn = tk.Button(root, text="Choose Output Directory", command=self.select_output_dir) # 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_btn.pack(pady=5)
self.output_dir_label = ctk.CTkLabel(self, text="", text_color="blue")
self.output_dir_label = tk.Label(root, text="", fg="blue")
self.output_dir_label.pack(pady=5) self.output_dir_label.pack(pady=5)
# Section 5: Delete original files option # 5. Delete original files option
self.delete_original_check = tk.Checkbutton(root, text="Delete Original Files After Concatenation", variable=self.delete_original) self.delete_original_check = ctk.CTkCheckBox(
self,
text="Delete Original Files After Concatenation",
variable=self.delete_original
)
self.delete_original_check.pack(pady=5) self.delete_original_check.pack(pady=5)
# Concatenate Button # 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) self.concat_btn.pack(pady=10)
# Time Left Label # 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) 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): 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: if files:
self.selected_files = list(files) self.selected_files = list(files)
self.update_files_listbox() 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): def update_files_listbox(self):
self.files_listbox.delete(0, tk.END) self.files_listbox.delete(0, tk.END)
for file in self.selected_files: for f in self.selected_files:
self.files_listbox.insert(tk.END, file) self.files_listbox.insert(tk.END, f)
def move_up(self): def move_up(self):
selected = self.files_listbox.curselection() sel = self.files_listbox.curselection()
if selected and selected[0] > 0: if sel and sel[0] > 0:
index = selected[0] i = sel[0]
self.selected_files[index], self.selected_files[index - 1] = self.selected_files[index - 1], self.selected_files[index] self.selected_files[i-1], self.selected_files[i] = self.selected_files[i], self.selected_files[i-1]
self.update_files_listbox() self.update_files_listbox()
self.files_listbox.select_set(index - 1) self.files_listbox.select_set(i-1)
def move_down(self): def move_down(self):
selected = self.files_listbox.curselection() sel = self.files_listbox.curselection()
if selected and selected[0] < len(self.selected_files) - 1: if sel and sel[0] < len(self.selected_files)-1:
index = selected[0] i = sel[0]
self.selected_files[index], self.selected_files[index + 1] = self.selected_files[index + 1], self.selected_files[index] self.selected_files[i+1], self.selected_files[i] = self.selected_files[i], self.selected_files[i+1]
self.update_files_listbox() self.update_files_listbox()
self.files_listbox.select_set(index + 1) self.files_listbox.select_set(i+1)
def select_output_dir(self): def select_output_dir(self):
directory = filedialog.askdirectory() d = filedialog.askdirectory()
if directory: if d:
self.output_dir = directory self.output_dir = d
self.output_dir_label.config(text=directory) 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): def start_concatenation(self):
# Disable button to prevent re-clicking self.concat_btn.configure(state="disabled")
self.concat_btn.config(state=tk.DISABLED) self.time_left_label.configure(text="Starting concatenation...")
self.time_left_label.config(text="Starting concatenation...")
# Run the concatenation process in a separate thread
threading.Thread(target=self.concatenate_videos).start() threading.Thread(target=self.concatenate_videos).start()
def concatenate_videos(self): def concatenate_videos(self):
# sanity checks
if not self.selected_files: if not self.selected_files:
messagebox.showerror("Error", "No videos selected.") messagebox.showerror("Error", "No videos selected.")
self.concat_btn.configure(state="normal")
return return
if not self.output_dir:
if not hasattr(self, "output_dir"):
messagebox.showerror("Error", "Output directory not selected.") messagebox.showerror("Error", "Output directory not selected.")
self.concat_btn.configure(state="normal")
return return
# Get output file name # get output file name
self.output_file_name = self.output_name_entry.get().strip() outname = self.output_name_entry.get().strip()
if not self.output_file_name: if not outname:
messagebox.showerror("Error", "Output file name cannot be empty.") messagebox.showerror("Error", "Output file name cannot be empty.")
self.concat_btn.configure(state="normal")
return return
if not outname.lower().endswith(".mp4"):
outname += ".mp4"
# Ensure the output file name ends with .mp4 output_file = os.path.join(self.output_dir, outname)
if not self.output_file_name.endswith(".mp4"):
self.output_file_name += ".mp4"
# Create file list for FFmpeg # build the file list
temp_file_list = os.path.join(self.output_dir, "file_list.txt") list_path = os.path.join(self.output_dir, "file_list.txt")
with open(temp_file_list, "w") as f: with open(list_path, "w", encoding="utf-8") as f:
for file in self.selected_files: for file in self.selected_files:
f.write(f"file '{file}'\n") path = file.replace("\\", "/").replace("'", r"'\\''")
f.write(f"file '{path}'\n")
# Output file path cmd = [
output_file = os.path.join(self.output_dir, self.output_file_name) self.ffmpeg_path,
"-y",
# FFmpeg command
command = [
"ffmpeg",
"-f", "concat", "-f", "concat",
"-safe", "0", "-safe", "0",
"-i", temp_file_list, "-i", "file_list.txt",
"-c", "copy", "-c:v", "copy",
"-c:a", "copy",
output_file output_file
] ]
try: # estimate time
total_time = len(self.selected_files) * 5 # Estimate: ~5 seconds per video estimate = len(self.selected_files) * 5
for i in range(total_time): for i in range(estimate):
time.sleep(1) time.sleep(1)
self.time_left_label.config(text=f"Estimated time left: {total_time - i}s") self.time_left_label.configure(text=f"Estimated time left: {estimate - i}s")
self.root.update_idletasks() self.update_idletasks()
subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # run ffmpeg
self.time_left_label.config(text="Concatenation completed!") proc = subprocess.run(
messagebox.showinfo("Success", f"Videos concatenated successfully! Output: {output_file}") 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}")
# Optionally delete original files
if self.delete_original.get(): if self.delete_original.get():
for file in self.selected_files: for file in self.selected_files:
try:
os.remove(file) os.remove(file)
except OSError:
pass
messagebox.showinfo("Info", "Original files have been deleted.") messagebox.showinfo("Info", "Original files have been deleted.")
except subprocess.CalledProcessError:
self.time_left_label.config(text="Error during concatenation.") # cleanup & re-enable
messagebox.showerror("Error", "Failed to concatenate videos.") if os.path.exists(list_path):
finally: os.remove(list_path)
os.remove(temp_file_list) self.concat_btn.configure(state="normal")
self.concat_btn.config(state=tk.NORMAL)
if __name__ == "__main__": if __name__ == "__main__":
root = tk.Tk() app = VideoConcatenatorApp()
app = VideoConcatenatorApp(root) app.mainloop()
root.mainloop()