From a3fe0fcf656d43a12036a4be62d7a837e5918550 Mon Sep 17 00:00:00 2001 From: youfu Date: Fri, 26 Jun 2026 13:27:18 +0000 Subject: [PATCH] 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 --- video_concat.py | 335 ++++++++++++++++++++++++++++++------------------ 1 file changed, 209 insertions(+), 126 deletions(-) diff --git a/video_concat.py b/video_concat.py index c200090..edcb80b 100755 --- a/video_concat.py +++ b/video_concat.py @@ -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("", 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() \ No newline at end of file