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") self.geometry("800x800") # DPI scaling dpi = self.winfo_fpixels('1i') / 72 self.tk.call('tk', 'scaling', dpi) base_font = int(12 * dpi) # State self.selected_files = [] self.output_file_name = "output.mp4" self.delete_original = tk.BooleanVar(value=False) self.output_dir = "" self.ffmpeg_path = shutil.which("ffmpeg") # 1. Select and Order Videos self.label1 = ctk.CTkLabel(self, text="1. Select and Order Videos", font=(None, base_font+4)) self.label1.pack(pady=(10,5)) self.select_btn = ctk.CTkButton(self, text="Select Videos", command=self.select_videos, font=(None, base_font)) self.select_btn.pack(pady=5) # Listbox for files with drag-and-drop self.listbox_frame = ctk.CTkFrame(self) self.listbox_frame.pack(pady=5, fill="both", expand=True) self.files_listbox = tk.Listbox(self.listbox_frame, selectmode=tk.SINGLE) self.files_listbox.pack(padx=5, pady=5, fill="both", expand=True) self.files_listbox.bind('', self.on_drag_start) self.files_listbox.bind('', self.on_drag_motion) # 2. Name the Output File self.label2 = ctk.CTkLabel(self, text="2. Name the Output File", font=(None, base_font+4)) self.label2.pack(pady=(10,5)) self.output_name_entry = ctk.CTkEntry(self, width=0) self.output_name_entry.pack(pady=5, fill="x", expand=True) self.output_name_entry.insert(0, self.output_file_name) self.output_name_entry.bind("", self.check_name_conflict) self.name_warning_label = ctk.CTkLabel(self, text="", text_color="red", font=(None, base_font)) self.name_warning_label.pack(pady=(0,5)) # 3. Select Output Directory self.label3 = ctk.CTkLabel(self, text="3. Select Output Directory", font=(None, base_font+4)) self.label3.pack(pady=(10,5)) self.output_dir_btn = ctk.CTkButton(self, text="Choose Output Directory", command=self.select_output_dir, font=(None, base_font)) self.output_dir_btn.pack(pady=5) self.output_dir_label = ctk.CTkLabel(self, text="", text_color="blue", font=(None, base_font)) self.output_dir_label.pack(pady=5, fill="x", expand=True) # Delete original option self.delete_original_check = ctk.CTkCheckBox( self, text="Delete Original Files After Concatenation", variable=self.delete_original, font=(None, base_font) ) self.delete_original_check.pack(pady=5) # Concatenate Button self.concat_btn = ctk.CTkButton(self, text="Concatenate Videos", command=self.start_concatenation, state="disabled", font=(None, base_font+2)) self.concat_btn.pack(pady=10) # Time Left Label self.time_left_label = ctk.CTkLabel(self, text="", font=(None, base_font+2), text_color="green") self.time_left_label.pack(pady=5) # Disable if ffmpeg missing if not self.ffmpeg_path: messagebox.showerror( "Missing Dependency", "ffmpeg not found. Please install and add it to 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.refresh_listbox() # autofill name first = os.path.basename(self.selected_files[0]) default = os.path.splitext(first)[0] + ".mp4" self.output_name_entry.delete(0, tk.END) self.output_name_entry.insert(0, default) self.check_name_conflict() def refresh_listbox(self): self.files_listbox.delete(0, tk.END) for f in self.selected_files: self.files_listbox.insert(tk.END, f) def on_drag_start(self, event): self.drag_start = self.files_listbox.nearest(event.y) def on_drag_motion(self, event): i = self.files_listbox.nearest(event.y) if i != self.drag_start: # swap self.selected_files[self.drag_start], self.selected_files[i] = ( self.selected_files[i], self.selected_files[self.drag_start] ) self.refresh_listbox() self.files_listbox.select_set(i) self.drag_start = i def select_output_dir(self): d = filedialog.askdirectory() if d: self.output_dir = d self.output_dir_label.configure(text=d) self.check_name_conflict() def _has_name_conflict(self): name = self.output_name_entry.get().strip() base = os.path.splitext(name)[0] return any(os.path.splitext(os.path.basename(f))[0] == base for f in self.selected_files) def check_name_conflict(self, event=None): conflict = self._has_name_conflict() if conflict: self.name_warning_label.configure(text="⚠️ Name matches an original file.") self.concat_btn.configure(state="disabled") else: self.name_warning_label.configure(text="") if self.selected_files and self.output_dir 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...") threading.Thread(target=self.concatenate_videos).start() def concatenate_videos(self): # checks if not self.selected_files: messagebox.showerror("Error", "No videos selected.") return if not self.output_dir: messagebox.showerror("Error", "Output directory not selected.") return name = self.output_name_entry.get().strip() or self.output_file_name if not name.lower().endswith('.mp4'): name += '.mp4' outp = os.path.join(self.output_dir, name) 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: p = file.replace('\\', '/').replace("'", "'\\''") f.write(f"file '{p}'\n") cmd = [self.ffmpeg_path, '-y', '-f', 'concat', '-safe', '0', '-i', 'file_list.txt', '-c:v', 'copy', '-c:a', 'copy', outp] # estimated time for sec in range(len(self.selected_files)*5): time.sleep(1) self.time_left_label.configure(text=f"Est. time left: {len(self.selected_files)*5 - sec}s") self.update_idletasks() 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) else: self.time_left_label.configure(text="Done!") messagebox.showinfo("Success", f"Output saved to: {outp}") if self.delete_original.get(): for f in self.selected_files: try: os.remove(f) except: pass messagebox.showinfo("Info", "Original files deleted.") if os.path.exists(list_path): os.remove(list_path) self.concat_btn.configure(state="normal") if __name__ == '__main__': app = VideoConcatenatorApp() app.mainloop()