From c26a8fc235613a845047043387caa3570ea5e04c Mon Sep 17 00:00:00 2001 From: youfu Date: Fri, 26 Jun 2026 13:26:59 +0000 Subject: [PATCH] Initial version: basic Tkinter video concatenator - Plain tkinter GUI: file picker, listbox of selected videos - Move Up / Move Down buttons to reorder the sequence - Output name entry + output directory chooser - Optional 'delete originals after concatenation' checkbox - Concatenates with the ffmpeg concat demuxer (-c copy) on a worker thread - Crude fixed time estimate (5s-per-file countdown before running ffmpeg) Co-Authored-By: Claude Opus 4.8 --- .gitignore | 4 ++ video_concat.py | 174 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100644 .gitignore create mode 100755 video_concat.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9603f3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +video_concat_v2.1.py +video_concat_v3.py +video_concat_v4.py +video_concat_v4.1.py diff --git a/video_concat.py b/video_concat.py new file mode 100755 index 0000000..c200090 --- /dev/null +++ b/video_concat.py @@ -0,0 +1,174 @@ +import tkinter as tk +from tkinter import filedialog, messagebox, Listbox +import os +import subprocess +import threading +import time + +class VideoConcatenatorApp: + def __init__(self, root): + self.root = root + self.root.title("Video Concatenator") + + # Selected files + 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.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") + 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) + self.output_dir_btn.pack(pady=5) + + self.output_dir_label = tk.Label(root, text="", fg="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) + 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.pack(pady=10) + + # Time Left Label + self.time_left_label = tk.Label(root, text="", font=("Arial", 10), fg="green") + self.time_left_label.pack(pady=5) + + 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() + self.concat_btn.config(state=tk.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) + + 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] + self.update_files_listbox() + self.files_listbox.select_set(index - 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] + self.update_files_listbox() + self.files_listbox.select_set(index + 1) + + def select_output_dir(self): + directory = filedialog.askdirectory() + if directory: + self.output_dir = directory + self.output_dir_label.config(text=directory) + + 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 + threading.Thread(target=self.concatenate_videos).start() + + def concatenate_videos(self): + if not self.selected_files: + messagebox.showerror("Error", "No videos selected.") + return + + if not hasattr(self, "output_dir"): + messagebox.showerror("Error", "Output directory not selected.") + return + + # Get output file name + self.output_file_name = self.output_name_entry.get().strip() + if not self.output_file_name: + messagebox.showerror("Error", "Output file name cannot be empty.") + 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: + 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", + "-f", "concat", + "-safe", "0", + "-i", temp_file_list, + "-c", "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 + if self.delete_original.get(): + for file in self.selected_files: + os.remove(file) + 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) + +if __name__ == "__main__": + root = tk.Tk() + app = VideoConcatenatorApp(root) + root.mainloop()