Files
video_concat/video_concat.py
youfu a3fe0fcf65 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>
2026-06-26 13:27:18 +00:00

257 lines
9.9 KiB
Python
Executable File

import os
import subprocess
import threading
import time
import shutil
import tkinter as tk
from tkinter import filedialog, messagebox
import customtkinter as ctk
# 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)
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)
# 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)
# 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 = ctk.CTkLabel(self, text="", text_color="blue")
self.output_dir_label.pack(pady=5)
# 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 = 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 = 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")]
)
if files:
self.selected_files = list(files)
self.update_files_listbox()
# 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 f in self.selected_files:
self.files_listbox.insert(tk.END, f)
def move_up(self):
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(i-1)
def move_down(self):
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(i+1)
def select_output_dir(self):
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):
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 self.output_dir:
messagebox.showerror("Error", "Output directory not selected.")
self.concat_btn.configure(state="normal")
return
# 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
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:
path = file.replace("\\", "/").replace("'", r"'\\''")
f.write(f"file '{path}'\n")
cmd = [
self.ffmpeg_path,
"-y",
"-f", "concat",
"-safe", "0",
"-i", "file_list.txt",
"-c:v", "copy",
"-c:a", "copy",
output_file
]
# 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:
try:
os.remove(file)
except OSError:
pass
messagebox.showinfo("Info", "Original files have been deleted.")
# cleanup & re-enable
if os.path.exists(list_path):
os.remove(list_path)
self.concat_btn.configure(state="normal")
if __name__ == "__main__":
app = VideoConcatenatorApp()
app.mainloop()