diff --git a/video_concat.py b/video_concat.py index edcb80b..59ca8f5 100755 --- a/video_concat.py +++ b/video_concat.py @@ -14,88 +14,76 @@ 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") + 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 = "" - # 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)) + # 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) + self.select_btn = ctk.CTkButton(self, text="Select Videos", command=self.select_videos, font=(None, base_font)) self.select_btn.pack(pady=5) - # Listbox frame + # Listbox for files with drag-and-drop 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.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. Organize sequence - self.label2 = ctk.CTkLabel(self, text="2. Organize Sequence", font=("Arial", 14)) + # 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.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 = 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.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) + self.name_warning_label = ctk.CTkLabel(self, text="", text_color="red", font=(None, base_font)) + self.name_warning_label.pack(pady=(0,5)) - # 5. Delete original files option + # 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 + 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") + 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=("Arial", 12), text_color="green") + self.time_left_label = ctk.CTkLabel(self, text="", font=(None, base_font+2), text_color="green") self.time_left_label.pack(pady=5) - # If ffmpeg not found, disable concatenation + # Disable if ffmpeg missing 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." + "ffmpeg not found. Please install and add it to PATH." ) self.concat_btn.configure(state="disabled") @@ -105,153 +93,102 @@ class VideoConcatenatorApp(ctk.CTk): ) 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.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_name) - - # Re-check for conflicts and button state + self.output_name_entry.insert(0, default) 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): + 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 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 on_drag_start(self, event): + self.drag_start = self.files_listbox.nearest(event.y) - 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 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_name = os.path.splitext(name)[0] - return any( - os.path.splitext(os.path.basename(f))[0] == base_name - for f in self.selected_files - ) + 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): - """ - 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.name_warning_label.configure(text="⚠️ Name matches an original file.") 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: + 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 concatenation...") + self.time_left_label.configure(text="Starting...") threading.Thread(target=self.concatenate_videos).start() def concatenate_videos(self): - # sanity checks + # 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 + 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) - # 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: + 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") + 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", - output_file - ] + cmd = [self.ffmpeg_path, '-y', '-f', 'concat', '-safe', '0', '-i', 'file_list.txt', '-c:v', 'copy', '-c:a', 'copy', outp] - # estimate time - estimate = len(self.selected_files) * 5 - for i in range(estimate): + # estimated time + for sec in range(len(self.selected_files)*5): time.sleep(1) - self.time_left_label.configure(text=f"Estimated time left: {estimate - i}s") + self.time_left_label.configure(text=f"Est. time left: {len(self.selected_files)*5 - sec}s") self.update_idletasks() - # run ffmpeg - proc = subprocess.run( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - cwd=self.output_dir - ) - + 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()) + messagebox.showerror("FFmpeg error", proc.stderr) else: - self.time_left_label.configure(text="Concatenation completed!") - messagebox.showinfo("Success", f"Output: {output_file}") - + self.time_left_label.configure(text="Done!") + messagebox.showinfo("Success", f"Output saved to: {outp}") 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) + 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__": +if __name__ == '__main__': app = VideoConcatenatorApp() - app.mainloop() \ No newline at end of file + app.mainloop()