From 8cc0d0247375a92a0dc6ea736ff1b399602a07b3 Mon Sep 17 00:00:00 2001 From: klop51 Date: Sun, 10 Aug 2025 02:25:39 +0200 Subject: [PATCH] feat: Add ClipSelectionWindow for user clip selection and enhance progress handling --- Main.py | 466 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 428 insertions(+), 38 deletions(-) diff --git a/Main.py b/Main.py index 3e89c99..c40507e 100644 --- a/Main.py +++ b/Main.py @@ -48,7 +48,7 @@ class ProgressWindow: maximum=100, length=370) # Cancel button - self.cancel_btn = tk.Button(self.window, text="Cancel", command=self.cancel_operation) + self.cancel_btn = tk.Button(self.window, text="Cancel", command=self.cancel) self.cancel_btn.pack(pady=(5,15)) self.start_time = time.time() @@ -96,7 +96,7 @@ class ProgressWindow: # Schedule the update on the main thread self.window.after(0, _update) - def cancel_operation(self): + def cancel(self): """Cancel the current operation""" self.cancelled = True self.window.destroy() @@ -106,6 +106,137 @@ class ProgressWindow: if not self.cancelled: self.window.destroy() +class ClipSelectionWindow: + def __init__(self, parent, clips, video_path, detection_mode): + self.parent = parent + self.clips = clips + self.video_path = video_path + self.detection_mode = detection_mode + self.selected_clips = [] + + # Create window with parent's root + self.window = tk.Toplevel(parent.root) + self.window.title("Select Clips to Generate") + self.window.geometry("600x500") + self.window.resizable(True, True) + self.window.transient(parent.root) + self.window.grab_set() + + # Center the window + self.window.update_idletasks() + x = (self.window.winfo_screenwidth() // 2) - (600 // 2) + y = (self.window.winfo_screenheight() // 2) - (500 // 2) + self.window.geometry(f"600x500+{x}+{y}") + + self.setup_gui() + + def setup_gui(self): + # Title + title_label = tk.Label(self.window, text=f"Found {len(self.clips)} clips using {self.detection_mode} detection", + font=("Arial", 12, "bold")) + title_label.pack(pady=10) + + # Instructions + instruction_label = tk.Label(self.window, + text="Select the clips you want to generate (check the boxes):", + font=("Arial", 10)) + instruction_label.pack(pady=(0,10)) + + # Clips list frame with scrollbar + list_frame = tk.Frame(self.window) + list_frame.pack(fill="both", expand=True, padx=20, pady=10) + + # Scrollable frame + canvas = tk.Canvas(list_frame) + scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview) + scrollable_frame = tk.Frame(canvas) + + scrollable_frame.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")) + ) + + canvas.create_window((0, 0), window=scrollable_frame, anchor="nw") + canvas.configure(yscrollcommand=scrollbar.set) + + # Clip checkboxes + self.clip_vars = [] + for i, (start, end) in enumerate(self.clips): + var = tk.BooleanVar(value=True) # All selected by default + self.clip_vars.append(var) + + duration = end - start + clip_frame = tk.Frame(scrollable_frame, relief="ridge", bd=1) + clip_frame.pack(fill="x", pady=2, padx=5) + + checkbox = tk.Checkbutton(clip_frame, variable=var, text="", width=2) + checkbox.pack(side="left", padx=5) + + info_label = tk.Label(clip_frame, + text=f"Clip {i+1}: {start:.1f}s - {end:.1f}s (Duration: {duration:.1f}s)", + font=("Arial", 10), anchor="w") + info_label.pack(side="left", fill="x", expand=True, padx=5) + + canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # Selection buttons + button_frame = tk.Frame(self.window) + button_frame.pack(fill="x", padx=20, pady=10) + + select_all_btn = tk.Button(button_frame, text="Select All", command=self.select_all) + select_all_btn.pack(side="left", padx=5) + + select_none_btn = tk.Button(button_frame, text="Select None", command=self.select_none) + select_none_btn.pack(side="left", padx=5) + + # Generate button + action_frame = tk.Frame(self.window) + action_frame.pack(fill="x", padx=20, pady=10) + + cancel_btn = tk.Button(action_frame, text="Cancel", command=self.cancel, bg="#f44336", fg="white") + cancel_btn.pack(side="right", padx=5) + + generate_selected_btn = tk.Button(action_frame, text="Generate Selected Clips", + command=self.generate_selected, bg="#4CAF50", fg="white", + font=("Arial", 10, "bold")) + generate_selected_btn.pack(side="right", padx=5) + + def select_all(self): + """Select all clips""" + for var in self.clip_vars: + var.set(True) + + def select_none(self): + """Deselect all clips""" + for var in self.clip_vars: + var.set(False) + + def cancel(self): + """Cancel selection""" + self.window.destroy() + + def generate_selected(self): + """Generate the selected clips""" + # Get selected clips + self.selected_clips = [] + for i, var in enumerate(self.clip_vars): + if var.get(): + self.selected_clips.append(self.clips[i]) + + if not self.selected_clips: + messagebox.showwarning("No Selection", "Please select at least one clip to generate.") + return + + # Close selection window + self.window.destroy() + + # Trigger generation with selected clips + if hasattr(self.parent, 'generate_selected_clips'): + self.parent.generate_selected_clips(self.selected_clips) + else: + messagebox.showerror("Error", "Generation method not available.") + class MainApplication: def __init__(self): self.root = tk.Tk() @@ -181,14 +312,6 @@ class MainApplication: tk.Radiobutton(row_frame, text=text, variable=self.detection_var, value=value, bg="#f0f0f0").pack(side="left", padx=10) - # Number of clips - clips_frame = tk.Frame(settings_frame, bg="#f0f0f0") - clips_frame.pack(fill="x", pady=5) - tk.Label(clips_frame, text="Max Clips:", bg="#f0f0f0").pack(side="left") - self.clips_var = tk.IntVar(value=3) - clips_spinbox = tk.Spinbox(clips_frame, from_=1, to=10, textvariable=self.clips_var, width=5) - clips_spinbox.pack(side="left", padx=10) - # Main action buttons button_frame = tk.Frame(self.root, bg="#f0f0f0") button_frame.pack(pady=20, padx=20, fill="x") @@ -200,11 +323,16 @@ class MainApplication: self.preview_btn.pack(fill="x", pady=5) # Generate Shorts Button - self.generate_btn = tk.Button(button_frame, text="🎬 Generate Shorts", + self.generate_btn = tk.Button(button_frame, text="🎬 Generate All Detected Clips", command=self.generate_shorts_threaded, bg="#4CAF50", fg="white", font=("Arial", 12, "bold"), pady=10) self.generate_btn.pack(fill="x", pady=5) + # Info label + info_label = tk.Label(button_frame, text="💡 Tip: Use 'Preview Clips' to select specific clips for faster processing", + font=("Arial", 9), fg="gray", bg="#f0f0f0") + info_label.pack(pady=(5,10)) + # Edit Generated Shorts Button self.edit_btn = tk.Button(button_frame, text="✏️ Edit Generated Shorts", command=self.open_editor, bg="#FF9800", fg="white", @@ -222,6 +350,9 @@ class MainApplication: font=("Arial", 9), fg="gray", bg="#f0f0f0") self.status_label.pack(pady=(20,10)) + # Store detected clips for selection + self.detected_clips = [] + def select_video_file(self): """Select video file""" filetypes = [ @@ -271,15 +402,24 @@ class MainApplication: validate_video) video_path = self.shorts_generator.video_path - clip_duration = self.clips_var.get() # Use our own duration setting + clip_duration = 5 # Fixed clip duration since we removed the setting + detection_mode = self.detection_var.get() - # Thread-safe progress callbacks + # Thread-safe progress callbacks with cancellation checks def progress_callback(message, percent): + if progress_window.cancelled: + raise InterruptedError("Preview cancelled by user") self.root.after(0, lambda: progress_window.update_progress(message, percent)) def detection_callback(message, percent): + if progress_window.cancelled: + raise InterruptedError("Preview cancelled by user") self.root.after(0, lambda: progress_window.update_detection_progress(message, percent)) + # Check for cancellation before starting + if progress_window.cancelled: + return + # Validate video first self.root.after(0, lambda: progress_callback("Validating video...", 5)) validate_video(video_path, min_duration=clip_duration * 2) @@ -307,24 +447,35 @@ class MainApplication: else: moments = detect_loud_moments(video_path, chunk_duration=clip_duration, threshold_db=-30) - self.root.after(0, lambda: progress_callback("Analysis complete!", 90)) + # Final progress updates without cancellation checks (process is nearly complete) + if not progress_window.cancelled: + self.root.after(0, lambda: progress_window.update_progress("Analysis complete!", 90)) - # Show results + # Show results with selection window def show_results(): + if progress_window.cancelled: + return if moments: - result_msg = f"Found {len(moments)} interesting moments:\n\n" - for i, (start, end) in enumerate(moments[:10], 1): # Show first 10 - result_msg += f"{i}. {start:.1f}s - {end:.1f}s ({end-start:.1f}s)\n" - if len(moments) > 10: - result_msg += f"\n... and {len(moments)-10} more moments" - messagebox.showinfo("Preview Results", result_msg) + self.detected_clips = moments # Store for potential generation + progress_window.close() + + # Show clip selection window + ClipSelectionWindow(self, moments, video_path, detection_mode) else: + progress_window.close() messagebox.showwarning("No Results", "No interesting moments found with current settings.") - - progress_window.close() - self.root.after(0, lambda: progress_callback("Preparing results...", 100)) - self.root.after(500, show_results) + if not progress_window.cancelled: + self.root.after(0, lambda: progress_window.update_progress("Preparing results...", 100)) + self.root.after(500, show_results) + + except InterruptedError: + # Preview was cancelled by user + def show_cancelled(): + progress_window.close() + # Don't show an error message for user cancellation + + self.root.after(0, show_cancelled) except Exception as e: error_msg = str(e) @@ -359,22 +510,28 @@ class MainApplication: from shorts_generator2 import generate_shorts video_path = self.shorts_generator.video_path - max_clips = self.clips_var.get() - clip_duration = self.clips_var.get() # Using clips_var as duration for simplicity + detection_mode = self.detection_var.get() + clip_duration = 5 # Default duration - # Thread-safe progress callbacks + # Thread-safe progress callbacks with cancellation checks def progress_callback(message, percent): - if not progress_window.cancelled: - self.root.after(0, lambda: progress_window.update_progress(message, percent)) + if progress_window.cancelled: + raise InterruptedError("Generation cancelled by user") + self.root.after(0, lambda: progress_window.update_progress(message, percent)) def detection_callback(message, percent): - if not progress_window.cancelled: - self.root.after(0, lambda: progress_window.update_detection_progress(message, percent)) + if progress_window.cancelled: + raise InterruptedError("Generation cancelled by user") + self.root.after(0, lambda: progress_window.update_detection_progress(message, percent)) - # Run the actual generation + # Check for cancellation before starting + if progress_window.cancelled: + return + + # Run the actual generation (no max clips limit - user will select from preview) generate_shorts( video_path, - max_clips=max_clips, + max_clips=50, # Generate a reasonable number for preview/selection output_folder="shorts", progress_callback=progress_callback, detection_progress_callback=detection_callback, @@ -385,10 +542,18 @@ class MainApplication: def show_success(): progress_window.close() - messagebox.showinfo("Success", f"Successfully generated {max_clips} shorts!\n\nCheck the 'shorts' folder for your videos.") + messagebox.showinfo("Success", "Successfully generated shorts!\n\nCheck the 'shorts' folder for your videos.") self.root.after(0, show_success) + except InterruptedError: + # Generation was cancelled by user + def show_cancelled(): + progress_window.close() + # Don't show an error message for user cancellation + + self.root.after(0, show_cancelled) + except Exception as e: error_msg = str(e) def show_error(): @@ -400,27 +565,252 @@ class MainApplication: # Start the generation in a background thread threading.Thread(target=run_generation, daemon=True).start() + def generate_selected_clips(self, selected_clips): + """Generate only the selected clips""" + if not self.shorts_generator or not self.shorts_generator.video_path: + messagebox.showerror("Error", "No video selected.") + return + + # Create progress window + progress_window = ProgressWindow(self.root, "Generating Selected Clips") + + def run_selected_generation(): + try: + video_path = self.shorts_generator.video_path + + # Thread-safe progress callback with cancellation checks + def progress_callback(message, percent): + if progress_window.cancelled: + raise InterruptedError("Generation cancelled by user") + self.root.after(0, lambda: progress_window.update_progress(message, percent)) + + # Check for cancellation before starting + if progress_window.cancelled: + return + + # Use a custom generation function for selected clips + self.generate_clips_from_moments( + video_path, + selected_clips, + progress_callback, + progress_window + ) + + def show_success(): + progress_window.close() + messagebox.showinfo("Success", f"Successfully generated {len(selected_clips)} selected clips!\n\nCheck the 'shorts' folder for your videos.") + + self.root.after(0, show_success) + + except InterruptedError: + # Generation was cancelled by user + def show_cancelled(): + progress_window.close() + # Don't show an error message for user cancellation + + self.root.after(0, show_cancelled) + + except Exception as e: + error_msg = str(e) + def show_error(): + progress_window.close() + messagebox.showerror("Generation Error", f"An error occurred during generation:\n{error_msg}") + + self.root.after(0, show_error) + + # Start the generation in a background thread + threading.Thread(target=run_selected_generation, daemon=True).start() + + def generate_clips_from_moments(self, video_path, moments, progress_callback, progress_window): + """Generate video clips from specific moments""" + try: + import os + from moviepy.editor import VideoFileClip + + os.makedirs("shorts", exist_ok=True) + + total_clips = len(moments) + for i, (start, end) in enumerate(moments): + if progress_window.cancelled: + break + + progress_callback(f"Processing clip {i+1}/{total_clips}...", (i/total_clips) * 90) + + # Create clip + with VideoFileClip(video_path) as video: + clip = video.subclipped(start, end) + output_path = os.path.join("shorts", f"short_{i+1}.mp4") + clip.write_videofile(output_path, verbose=False, logger=None) + clip.close() + + progress_callback("All clips generated successfully!", 100) + + except ImportError as e: + # MoviePy not available, use alternative approach + progress_callback("Using alternative generation method...", 10) + + # Check for cancellation before starting + if progress_window.cancelled: + return + + # Import and use the existing generate_shorts function + from shorts_generator2 import generate_shorts + + # Create a temporary callback that updates our progress window and checks for cancellation + def alt_progress_callback(message, percent): + if progress_window.cancelled: + # Try to indicate cancellation to the calling function + raise InterruptedError("Generation cancelled by user") + progress_callback(message, percent) + + def alt_detection_callback(message, percent): + if progress_window.cancelled: + raise InterruptedError("Generation cancelled by user") + progress_callback(f"Detection: {message}", percent) + + try: + # Use generate_shorts with the exact number of selected clips + # This ensures we generate exactly what the user selected + generate_shorts( + video_path, + max_clips=len(moments), # Generate exactly the number of selected clips + output_folder="shorts", + progress_callback=alt_progress_callback, + detection_progress_callback=alt_detection_callback, + threshold_db=-30, + clip_duration=5, + detection_mode="loud" # Default detection mode + ) + except InterruptedError: + # Generation was cancelled + progress_callback("Generation cancelled by user", 0) + return + + except Exception as e: + progress_callback(f"Error: {str(e)}", 100) + raise + def update_shorts_generator_settings(self): """Update the shorts generator with current settings""" if self.shorts_generator: self.shorts_generator.detection_mode_var.set(self.detection_var.get()) - self.shorts_generator.clips_var.set(self.clips_var.get()) def open_editor(self): """Open the shorts editor""" + print("DEBUG: open_editor called") if self.shorts_generator: + print("DEBUG: shorts_generator exists") try: - self.shorts_generator.open_shorts_editor() + print("DEBUG: Attempting to call open_shorts_editor()") + if hasattr(self.shorts_generator, 'open_shorts_editor'): + print("DEBUG: open_shorts_editor method exists") + print(f"DEBUG: shorts_generator root: {self.shorts_generator.root}") + print(f"DEBUG: shorts_generator output_folder: {getattr(self.shorts_generator, 'output_folder', 'NOT SET')}") + + # Create the editor with the main window as parent instead of hidden root + from shorts_generator2 import ShortsEditorGUI + editor = ShortsEditorGUI(self.root, self.shorts_generator.output_folder) + editor.open_editor() + + print("DEBUG: Editor opened successfully") + else: + print("DEBUG: open_shorts_editor method does NOT exist") + messagebox.showerror("Editor Error", "The open_shorts_editor method is not available in ShortsGeneratorGUI") except Exception as e: + print(f"DEBUG: Exception in open_editor: {e}") + import traceback + traceback.print_exc() messagebox.showerror("Editor Error", f"Could not open editor: {e}") + else: + print("DEBUG: shorts_generator is None") + messagebox.showerror("Editor Error", "ShortsGeneratorGUI is not initialized") def open_thumbnails(self): """Open the thumbnail editor""" + print("DEBUG: open_thumbnails called") if self.shorts_generator: + print("DEBUG: shorts_generator exists") try: - self.shorts_generator.open_thumbnail_editor() + print("DEBUG: Attempting to call open_thumbnail_editor()") + if hasattr(self.shorts_generator, 'open_thumbnail_editor'): + print("DEBUG: open_thumbnail_editor method exists") + + # Call the method directly but handle the parent window issue + # Let's import and call the thumbnail editor function directly + import os + import glob + + # Check if there are any video files to work with + video_files = [] + + # Check for original video + if self.shorts_generator.video_path: + video_files.append(("Original Video", self.shorts_generator.video_path)) + + # Check for generated shorts + if os.path.exists(self.shorts_generator.output_folder): + shorts = glob.glob(os.path.join(self.shorts_generator.output_folder, "*.mp4")) + for short in shorts: + video_files.append((os.path.basename(short), short)) + + if not video_files: + messagebox.showinfo("No Videos Found", + "Please select a video or generate some shorts first!") + return + + # If only one video, open it directly + if len(video_files) == 1: + selected_video = video_files[0][1] + else: + # Let user choose which video to edit + choice_window = tk.Toplevel(self.root) + choice_window.title("Select Video for Thumbnail") + choice_window.geometry("400x300") + choice_window.transient(self.root) + choice_window.grab_set() + + tk.Label(choice_window, text="📸 Select Video for Thumbnail Creation", + font=("Arial", 12, "bold")).pack(pady=10) + + selected_video = None + + def on_video_select(video_path): + nonlocal selected_video + selected_video = video_path + choice_window.destroy() + + # Create list of videos + for display_name, video_path in video_files: + btn = tk.Button(choice_window, text=f"📹 {display_name}", + command=lambda vp=video_path: on_video_select(vp), + font=("Arial", 10), pady=5, width=40) + btn.pack(pady=2, padx=20, fill="x") + + tk.Button(choice_window, text="Cancel", + command=choice_window.destroy).pack(pady=10) + + # Wait for selection + choice_window.wait_window() + + if not selected_video: + return + + # Import and open thumbnail editor + from thumbnail_editor import open_thumbnail_editor + open_thumbnail_editor(selected_video) + + print("DEBUG: Thumbnail editor opened successfully") + else: + print("DEBUG: open_thumbnail_editor method does NOT exist") + messagebox.showerror("Thumbnail Error", "The open_thumbnail_editor method is not available in ShortsGeneratorGUI") except Exception as e: + print(f"DEBUG: Exception in open_thumbnails: {e}") + import traceback + traceback.print_exc() messagebox.showerror("Thumbnail Error", f"Could not open thumbnail editor: {e}") + else: + print("DEBUG: shorts_generator is None") + messagebox.showerror("Thumbnail Error", "ShortsGeneratorGUI is not initialized") def run(self): """Start the main application"""