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("", 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()