diff --git a/video_editor.py b/video_editor.py index 28206fb..3dfea2a 100644 --- a/video_editor.py +++ b/video_editor.py @@ -126,6 +126,27 @@ class ShortsEditorGUI: self.is_playing = False # Whether video is currently playing self.timeline_is_playing = False # Whether timeline playback is active self.play_thread = None # Background thread for video playback + self.current_frame = None # Currently displayed frame (for fullscreen sharing) + + # Real-time effect state management with time controls + # These variables control which effects are currently active and their timing + self.effects_enabled = { + 'ripple': False, # Whether ripple effect is currently active + 'fade': False, # Whether fade effect is currently active + 'text': False, # Whether text overlay is currently active + 'blur': False, # Whether blur effect is currently active + 'sepia': False, # Whether sepia effect is currently active + 'invert': False # Whether color invert effect is currently active + } + + # Time-based effect controls - when effects should be active + self.effect_times = { + 'ripple': {'start': 0.0, 'end': 0.0}, + 'fade': {'start': 0.0, 'end': 0.0}, + 'text': {'start': 0.0, 'end': 0.0} + } + + self.effect_start_time = 0.0 # When current effect animation started # Timeline display and interaction state # Controls how the timeline is rendered and how users interact with it @@ -353,6 +374,20 @@ class ShortsEditorGUI: self.video_canvas = tk.Canvas(video_container, bg='black', highlightthickness=0) self.video_canvas.grid(row=0, column=0, sticky="nsew") + # Fullscreen button overlay - positioned over video canvas + # This button allows users to view video in fullscreen mode + self.fullscreen_btn = tk.Button(video_container, text="⛶", + command=self.toggle_fullscreen, + bg=self.colors['bg_tertiary'], fg=self.colors['text_primary'], + font=('Arial', 14, 'bold'), width=2, height=1, + relief="flat", bd=1, cursor="hand2", + activebackground=self.colors['accent_blue']) + self.fullscreen_btn.place(x=10, y=10) # Position in top-left corner of video + + # Initialize fullscreen state + self.is_fullscreen = False + self.fullscreen_window = None + # Professional Timeline Workspace - Multi-track editing environment # Fixed height prevents timeline from shrinking when window is resized # This is where users perform the majority of their editing work @@ -369,22 +404,6 @@ class ShortsEditorGUI: left_controls = tk.Frame(header_frame, bg=self.colors['bg_secondary']) left_controls.pack(side="left") - # Editing mode selector - determines how mouse interactions behave - # Different modes enable different types of editing operations - tk.Label(left_controls, text="Mode:", font=self.fonts['caption'], - bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(side="left") - - # Mode selection dropdown with professional editing modes - # select: Default mode for selecting and moving clips - # cut: Razor tool for splitting clips at specific points - # trim: For adjusting clip start/end points - # ripple: Moves clips and automatically adjusts following clips - self.mode_var = tk.StringVar(value="select") - mode_combo = ttk.Combobox(left_controls, textvariable=self.mode_var, width=8, - values=["select", "cut", "trim", "ripple"], state="readonly") - mode_combo.pack(side="left", padx=(5, 10)) - mode_combo.bind('<>', self.on_mode_change) - # Professional timeline assistance features # These options help users align and position clips precisely self.snap_var = tk.BooleanVar(value=True) @@ -481,7 +500,7 @@ class ShortsEditorGUI: # Professional tips label tips_label = tk.Label(external_scrollbar_frame, - text="💡 CTRL+Click: Select clips • Delete/Backspace: Delete selected • Click: Move playhead", + text="💡 CTRL+Click: Select/Deselect clips • Delete: Remove selected • Drag orange edges: Resize • Playhead disabled when clip selected", bg=self.colors['bg_primary'], fg=self.colors['text_secondary'], font=('Segoe UI', 8)) tips_label.pack(side="left", padx=5, pady=2) @@ -598,6 +617,116 @@ class ShortsEditorGUI: export_frame = tk.Frame(self.tools_notebook, bg=self.colors['bg_secondary']) self.tools_notebook.add(export_frame, text="Export") self.create_export_tools(export_frame) + + # Setup keyboard controls for play/pause and other shortcuts + self.setup_keyboard_controls() + + def setup_keyboard_controls(self): + """Setup keyboard shortcuts for video playback and editing""" + # Make the main window focusable for keyboard events + self.editor_window.focus_set() + + # Bind keyboard shortcuts + # Spacebar: Play/Pause toggle + self.editor_window.bind('', lambda e: self.toggle_play_pause()) + self.editor_window.bind('', lambda e: self.toggle_play_pause()) + + # Left/Right arrows: Frame by frame navigation + self.editor_window.bind('', lambda e: self.seek_backward()) + self.editor_window.bind('', lambda e: self.seek_forward()) + + # Shift + Left/Right: Jump by seconds + self.editor_window.bind('', lambda e: self.seek_backward(1.0)) + self.editor_window.bind('', lambda e: self.seek_forward(1.0)) + + # Home/End: Go to beginning/end + self.editor_window.bind('', lambda e: self.seek_to_start()) + self.editor_window.bind('', lambda e: self.seek_to_end()) + + # F11: Toggle fullscreen + self.editor_window.bind('', lambda e: self.toggle_fullscreen()) + self.editor_window.bind('', lambda e: self.toggle_fullscreen()) + + # ESC: Exit fullscreen (when not in fullscreen window) + self.editor_window.bind('', lambda e: self.handle_escape()) + + print("⌨️ Keyboard controls enabled:") + print(" SPACE: Play/Pause") + print(" ←/→: Frame by frame") + print(" Shift+←/→: Skip by seconds") + print(" Home/End: Go to start/end") + print(" F11: Toggle fullscreen") + print(" ESC: Exit fullscreen") + + def toggle_play_pause(self): + """Toggle between play and pause""" + if not self.current_clip: + return + + if self.is_playing: + self.timeline_pause() + print("⏸️ Paused (SPACE)") + else: + self.timeline_play() + print("▶️ Playing (SPACE)") + + def seek_backward(self, seconds=None): + """Seek backward by frame or specified seconds""" + if not self.current_clip: + return + + if seconds is None: + # Frame by frame (1/30th of a second) + seconds = 1/30 + + new_time = max(0, self.current_time - seconds) + self.current_time = new_time + self.display_frame_at_time(new_time) + self.update_time_display() + self.update_timeline() + + def seek_forward(self, seconds=None): + """Seek forward by frame or specified seconds""" + if not self.current_clip: + return + + if seconds is None: + # Frame by frame (1/30th of a second) + seconds = 1/30 + + new_time = min(self.video_duration, self.current_time + seconds) + self.current_time = new_time + self.display_frame_at_time(new_time) + self.update_time_display() + self.update_timeline() + + def seek_to_start(self): + """Jump to the beginning of the video""" + if not self.current_clip: + return + + self.current_time = 0.0 + self.display_frame_at_time(0.0) + self.update_time_display() + self.update_timeline() + print("⏮️ Jumped to start (HOME)") + + def seek_to_end(self): + """Jump to the end of the video""" + if not self.current_clip: + return + + self.current_time = self.video_duration + self.display_frame_at_time(self.video_duration) + self.update_time_display() + self.update_timeline() + print("⏭️ Jumped to end (END)") + + def handle_escape(self): + """Handle ESC key press""" + if self.is_fullscreen: + self.exit_fullscreen() + # Could add other ESC behaviors here (like deselecting clips) def create_track_controls(self): """Create professional track control panel with road lines""" @@ -1261,6 +1390,68 @@ class ShortsEditorGUI: bg=self.colors['bg_tertiary'], fg=self.colors['text_primary'], font=self.fonts['caption'], relief="flat", bd=0, cursor="hand2") btn.pack(fill="x", pady=2) + + # Ripple Effect controls + ripple_frame = tk.LabelFrame(scrollable_frame, text="🌊 Ripple Effect", font=self.fonts['body'], + bg=self.colors['bg_secondary'], fg=self.colors['text_primary'], + relief="flat", bd=1) + ripple_frame.pack(fill="x", padx=10, pady=5) + + # Time controls for ripple effect + time_frame = tk.Frame(ripple_frame, bg=self.colors['bg_secondary']) + time_frame.pack(fill="x", pady=5) + + # Start time + start_frame = tk.Frame(time_frame, bg=self.colors['bg_secondary']) + start_frame.pack(side="left", fill="x", expand=True, padx=5) + tk.Label(start_frame, text="Start Time (s):", font=self.fonts['caption'], + bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(anchor="w") + self.ripple_start_var = tk.DoubleVar(value=0.0) + start_entry = tk.Entry(start_frame, textvariable=self.ripple_start_var, width=8, + bg=self.colors['bg_tertiary'], fg=self.colors['text_primary'], + font=self.fonts['body'], relief="flat", bd=1) + start_entry.pack(anchor="w") + + # End time + end_frame = tk.Frame(time_frame, bg=self.colors['bg_secondary']) + end_frame.pack(side="left", fill="x", expand=True, padx=5) + tk.Label(end_frame, text="End Time (s):", font=self.fonts['caption'], + bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(anchor="w") + self.ripple_end_var = tk.DoubleVar(value=5.0) + end_entry = tk.Entry(end_frame, textvariable=self.ripple_end_var, width=8, + bg=self.colors['bg_tertiary'], fg=self.colors['text_primary'], + font=self.fonts['body'], relief="flat", bd=1) + end_entry.pack(anchor="w") + + # Ripple intensity control + ripple_controls = tk.Frame(ripple_frame, bg=self.colors['bg_secondary']) + ripple_controls.pack(fill="x", pady=5) + + tk.Label(ripple_controls, text="Intensity:", font=self.fonts['caption'], + bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(anchor="w", padx=10) + + self.ripple_intensity_var = tk.DoubleVar(value=0.5) + ripple_scale = tk.Scale(ripple_frame, from_=0.1, to=2.0, resolution=0.1, + orient="horizontal", variable=self.ripple_intensity_var, + bg=self.colors['bg_secondary'], fg=self.colors['text_primary'], + highlightthickness=0, troughcolor=self.colors['bg_tertiary']) + ripple_scale.pack(fill="x", pady=5, padx=10) + + # Ripple frequency control + tk.Label(ripple_frame, text="Frequency:", font=self.fonts['caption'], + bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(anchor="w", padx=10) + + self.ripple_frequency_var = tk.DoubleVar(value=10.0) + frequency_scale = tk.Scale(ripple_frame, from_=1.0, to=50.0, resolution=1.0, + orient="horizontal", variable=self.ripple_frequency_var, + bg=self.colors['bg_secondary'], fg=self.colors['text_primary'], + highlightthickness=0, troughcolor=self.colors['bg_tertiary']) + frequency_scale.pack(fill="x", pady=5, padx=10) + + ripple_btn = tk.Button(ripple_frame, text="🌊 Apply Ripple Effect", command=self.apply_ripple_effect, + bg=self.colors['accent_blue'], fg='white', font=self.fonts['button'], + relief="flat", bd=0, cursor="hand2") + ripple_btn.pack(fill="x", padx=10, pady=5) def create_video_effects_tools(self, parent): """Create video effects tools""" @@ -1286,7 +1477,33 @@ class ShortsEditorGUI: relief="flat", bd=1) fade_frame.pack(fill="x", padx=10, pady=5) - fade_btn = tk.Button(fade_frame, text="🌅 Add Fade In/Out", command=self.apply_fade, + # Time controls for fade effect + fade_time_frame = tk.Frame(fade_frame, bg=self.colors['bg_secondary']) + fade_time_frame.pack(fill="x", pady=5) + + # Start time + fade_start_frame = tk.Frame(fade_time_frame, bg=self.colors['bg_secondary']) + fade_start_frame.pack(side="left", fill="x", expand=True, padx=5) + tk.Label(fade_start_frame, text="Start Time (s):", font=self.fonts['caption'], + bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(anchor="w") + self.fade_start_var = tk.DoubleVar(value=0.0) + fade_start_entry = tk.Entry(fade_start_frame, textvariable=self.fade_start_var, width=8, + bg=self.colors['bg_tertiary'], fg=self.colors['text_primary'], + font=self.fonts['body'], relief="flat", bd=1) + fade_start_entry.pack(anchor="w") + + # End time + fade_end_frame = tk.Frame(fade_time_frame, bg=self.colors['bg_secondary']) + fade_end_frame.pack(side="left", fill="x", expand=True, padx=5) + tk.Label(fade_end_frame, text="End Time (s):", font=self.fonts['caption'], + bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(anchor="w") + self.fade_end_var = tk.DoubleVar(value=2.0) + fade_end_entry = tk.Entry(fade_end_frame, textvariable=self.fade_end_var, width=8, + bg=self.colors['bg_tertiary'], fg=self.colors['text_primary'], + font=self.fonts['body'], relief="flat", bd=1) + fade_end_entry.pack(anchor="w") + + fade_btn = tk.Button(fade_frame, text="🌅 Apply Fade Effect", command=self.apply_fade, bg=self.colors['accent_blue'], fg='white', font=self.fonts['button'], relief="flat", bd=0, cursor="hand2") fade_btn.pack(fill="x", padx=10, pady=5) @@ -1308,10 +1525,45 @@ class ShortsEditorGUI: width=25) text_entry.pack(fill="x", pady=5) - text_btn = tk.Button(text_frame, text="📝 Add Text", command=self.apply_text, + # Time controls for text effect + text_time_frame = tk.Frame(text_frame, bg=self.colors['bg_secondary']) + text_time_frame.pack(fill="x", pady=5) + + # Start time + text_start_frame = tk.Frame(text_time_frame, bg=self.colors['bg_secondary']) + text_start_frame.pack(side="left", fill="x", expand=True, padx=5) + tk.Label(text_start_frame, text="Start Time (s):", font=self.fonts['caption'], + bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(anchor="w") + self.text_start_var = tk.DoubleVar(value=1.0) + text_start_entry = tk.Entry(text_start_frame, textvariable=self.text_start_var, width=8, + bg=self.colors['bg_tertiary'], fg=self.colors['text_primary'], + font=self.fonts['body'], relief="flat", bd=1) + text_start_entry.pack(anchor="w") + + # End time + text_end_frame = tk.Frame(text_time_frame, bg=self.colors['bg_secondary']) + text_end_frame.pack(side="left", fill="x", expand=True, padx=5) + tk.Label(text_end_frame, text="End Time (s):", font=self.fonts['caption'], + bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(anchor="w") + self.text_end_var = tk.DoubleVar(value=4.0) + text_end_entry = tk.Entry(text_end_frame, textvariable=self.text_end_var, width=8, + bg=self.colors['bg_tertiary'], fg=self.colors['text_primary'], + font=self.fonts['body'], relief="flat", bd=1) + text_end_entry.pack(anchor="w") + + text_btn = tk.Button(text_frame, text="📝 Apply Text", command=self.apply_text, bg=self.colors['accent_green'], fg='white', font=self.fonts['button'], relief="flat", bd=0, cursor="hand2") text_btn.pack(fill="x", padx=10, pady=5) + + # Clear all effects button + clear_frame = tk.Frame(scrollable_effects_frame, bg=self.colors['bg_secondary']) + clear_frame.pack(fill="x", padx=10, pady=10) + + clear_btn = tk.Button(clear_frame, text="🗑️ Clear All Effects", command=self.clear_all_effects, + bg=self.colors['accent_red'], fg='white', font=self.fonts['button'], + relief="flat", bd=0, cursor="hand2") + clear_btn.pack(fill="x") def create_audio_effects_tools(self, parent): """Create audio effects tools""" @@ -1536,6 +1788,12 @@ class ShortsEditorGUI: if not ret: return + # Apply real-time effects to the frame + frame = self.apply_effects_to_frame(frame) + + # Store current frame for fullscreen use (prevents video file conflicts) + self.current_frame = frame.copy() + # Convert BGR to RGB frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) @@ -1581,6 +1839,10 @@ class ShortsEditorGUI: self.display_frame_at_time_moviepy(time_sec) else: self.display_frame_at_time_opencv(time_sec) + + # Update fullscreen display if active + if self.is_fullscreen and self.fullscreen_canvas: + self.display_frame_fullscreen() def display_frame_at_time_moviepy(self, time_sec): """Display a specific frame using MoviePy""" @@ -1747,19 +2009,20 @@ class ShortsEditorGUI: fill=clip_color, outline=outline_color, width=outline_width, tags=f"clip_{i}") - # Add resize handles (small rectangles on the edges) - handle_size = 8 - handle_color = '#FFD700' if is_selected else 'white' + # Add resize handles (small rectangles on the edges) - smaller for precision + handle_size = 8 if is_selected else 6 # Bigger when selected for easier clicking + handle_color = '#FFD700' if is_selected else '#999999' # Gold when selected, gray when not + handle_outline = '#FF6B35' if is_selected else 'gray' # Orange outline when selected # Left resize handle left_handle = self.timeline_canvas.create_rectangle( clip_x, clip_y, clip_x + handle_size, clip_y + clip_height, - fill=handle_color, outline='gray', width=1, tags=f"resize_left_{i}") + fill=handle_color, outline=handle_outline, width=2 if is_selected else 1, tags=f"resize_left_{i}") # Right resize handle right_handle = self.timeline_canvas.create_rectangle( clip_x + clip_width - handle_size, clip_y, clip_x + clip_width, clip_y + clip_height, - fill=handle_color, outline='gray', width=1, tags=f"resize_right_{i}") + fill=handle_color, outline=handle_outline, width=2 if is_selected else 1, tags=f"resize_right_{i}") # Make clip draggable and resizable self.setup_clip_interaction(clip_rect, left_handle, right_handle, i) @@ -1777,24 +2040,44 @@ class ShortsEditorGUI: """Setup interaction for clip resizing and moving""" def start_resize_left(event): + # Resize handles should always work, regardless of CTRL state self.resizing_clip = {'index': clip_index, 'side': 'left', 'start_x': event.x} self.timeline_canvas.configure(cursor="sb_h_double_arrow") + print(f"🔧 Started resizing left edge of clip {clip_index}") + # Stop event propagation to prevent selection + return "break" def start_resize_right(event): + # Resize handles should always work, regardless of CTRL state self.resizing_clip = {'index': clip_index, 'side': 'right', 'start_x': event.x} self.timeline_canvas.configure(cursor="sb_h_double_arrow") + print(f"🔧 Started resizing right edge of clip {clip_index}") + # Stop event propagation to prevent selection + return "break" def start_move_clip(event): - self.moving_clip = {'index': clip_index, 'start_x': event.x} - self.timeline_canvas.configure(cursor="hand2") + # Only start moving if not a CTRL+click (which is for selection) + if not (event.state & 0x4): # Not CTRL key + self.moving_clip = {'index': clip_index, 'start_x': event.x} + self.timeline_canvas.configure(cursor="hand2") + print(f"🔄 Started moving clip {clip_index}") + else: + print(f"🎯 CTRL+click detected - selection handled by timeline_click") def on_resize_drag(event): if hasattr(self, 'resizing_clip') and self.resizing_clip: + # Always handle resizing immediately - no threshold needed for resize handles self.handle_clip_resize(event) elif hasattr(self, 'moving_clip') and self.moving_clip: - self.handle_clip_move(event) + # Only move if we've moved at least 5 pixels to avoid accidental moves + dx = abs(event.x - self.moving_clip['start_x']) + if dx > 5: + self.handle_clip_move(event) def end_interaction(event): + was_resizing = hasattr(self, 'resizing_clip') and self.resizing_clip + was_moving = hasattr(self, 'moving_clip') and self.moving_clip + if hasattr(self, 'resizing_clip'): self.resizing_clip = None if hasattr(self, 'moving_clip'): @@ -1805,7 +2088,9 @@ class ShortsEditorGUI: if hasattr(self, 'selected_timeline_clip') and self.selected_timeline_clip == clip_index: print(f"🎯 Clip {clip_index} modified and still selected") - self.update_timeline() + # Only update timeline if we weren't resizing (resize already updates continuously) + if not was_resizing: + self.update_timeline() # Bind resize handles self.timeline_canvas.tag_bind(left_handle, "", start_resize_left) @@ -1854,7 +2139,12 @@ class ShortsEditorGUI: if new_duration >= 0.5: # Minimum 0.5 second duration clip['duration'] = new_duration + # Update the start_x for continuous resizing self.resizing_clip['start_x'] = event.x + + # Update the timeline to show the changes + self.update_timeline() + print(f"🔧 Resized clip {clip_index}: start={clip['start_time']:.2f}s, duration={clip['duration']:.2f}s") def handle_clip_move(self, event): """Handle clip moving""" @@ -1981,7 +2271,12 @@ class ShortsEditorGUI: # CTRL+Click: Select clip without moving playhead self.select_clip_at_position(click_x, click_y) else: - # Regular click: Move playhead (existing behavior) + # Check if a clip is currently selected + if hasattr(self, 'selected_timeline_clip') and self.selected_timeline_clip is not None: + print("🚫 Playhead movement disabled - clip is selected. Use CTRL+Click empty area to deselect.") + return + + # Regular click: Move playhead (only if no clip is selected) if not self.current_clip: return @@ -1996,6 +2291,7 @@ class ShortsEditorGUI: self.display_frame_at_time(self.current_time) self.update_timeline() self.update_time_display() + print(f"⏭️ Playhead moved to {self.current_time:.2f}s") def select_clip_at_position(self, click_x, click_y): """Select clip at the clicked position without moving playhead""" @@ -2160,6 +2456,142 @@ class ShortsEditorGUI: self.update_timeline() self.update_time_display() + def toggle_fullscreen(self): + """Toggle fullscreen mode for video player""" + if not self.current_clip: + messagebox.showwarning("No Video", "Please load a video first.") + return + + if not self.is_fullscreen: + # Enter fullscreen mode + self.enter_fullscreen() + else: + # Exit fullscreen mode + self.exit_fullscreen() + + def enter_fullscreen(self): + """Enter fullscreen video mode""" + self.is_fullscreen = True + + # Create fullscreen window + self.fullscreen_window = tk.Toplevel(self.editor_window) + self.fullscreen_window.title("Video Fullscreen") + self.fullscreen_window.configure(bg='black') + + # Make window fullscreen + self.fullscreen_window.attributes('-fullscreen', True) + self.fullscreen_window.attributes('-topmost', True) + + # Create fullscreen video canvas + self.fullscreen_canvas = tk.Canvas(self.fullscreen_window, bg='black', highlightthickness=0) + self.fullscreen_canvas.pack(fill="both", expand=True) + + # Add exit button (ESC key or click) + exit_btn = tk.Button(self.fullscreen_window, text="✕ Exit (ESC/F11)", + command=self.exit_fullscreen, + bg='#333333', fg='white', + font=('Arial', 12, 'bold'), relief="flat", bd=0, cursor="hand2") + exit_btn.place(x=20, y=20) + + # Bind ESC key to exit fullscreen + self.fullscreen_window.bind('', lambda e: self.exit_fullscreen()) + self.fullscreen_window.bind('', lambda e: self.exit_fullscreen()) + + # Bind keyboard controls for fullscreen mode + self.fullscreen_window.bind('', lambda e: self.toggle_play_pause()) + self.fullscreen_window.bind('', lambda e: self.toggle_play_pause()) + self.fullscreen_window.bind('', lambda e: self.seek_backward()) + self.fullscreen_window.bind('', lambda e: self.seek_forward()) + self.fullscreen_window.bind('', lambda e: self.seek_backward(1.0)) + self.fullscreen_window.bind('', lambda e: self.seek_forward(1.0)) + self.fullscreen_window.bind('', lambda e: self.seek_to_start()) + self.fullscreen_window.bind('', lambda e: self.seek_to_end()) + self.fullscreen_window.bind('', lambda e: self.exit_fullscreen()) + + self.fullscreen_window.focus_set() + + # Display current frame in fullscreen + self.display_frame_fullscreen() + + # Update fullscreen button text + self.fullscreen_btn.config(text="🗗") + + def exit_fullscreen(self): + """Exit fullscreen video mode""" + if self.fullscreen_window: + self.fullscreen_window.destroy() + self.fullscreen_window = None + self.fullscreen_canvas = None + + self.is_fullscreen = False + # Restore fullscreen button text + self.fullscreen_btn.config(text="⛶") + + def display_frame_fullscreen(self): + """Display current video frame in fullscreen canvas""" + if not self.fullscreen_canvas or not self.current_clip: + return + + try: + # Instead of reading from video file again (which conflicts with playback), + # get the frame that's currently displayed in the main canvas + if hasattr(self, 'current_frame') and self.current_frame is not None: + frame = self.current_frame.copy() + else: + # Fallback: read frame only if not playing + if self.is_playing: + return # Don't interfere with playback + + # Calculate frame number for OpenCV + fps = self.current_clip.get(cv2.CAP_PROP_FPS) + frame_number = int(self.current_time * fps) + + # Set video position + self.current_clip.set(cv2.CAP_PROP_POS_FRAMES, frame_number) + + # Read frame + ret, frame = self.current_clip.read() + if not ret: + return + + # Apply effects to frame if any are active + frame = self.apply_effects_to_frame(frame) + + # Get fullscreen canvas dimensions + self.fullscreen_window.update_idletasks() + canvas_width = self.fullscreen_canvas.winfo_width() + canvas_height = self.fullscreen_canvas.winfo_height() + + if canvas_width > 1 and canvas_height > 1: + # Calculate scaling to fit screen while maintaining aspect ratio + frame_height, frame_width = frame.shape[:2] + scale_w = canvas_width / frame_width + scale_h = canvas_height / frame_height + scale = min(scale_w, scale_h) + + new_width = int(frame_width * scale) + new_height = int(frame_height * scale) + + # Resize frame + resized_frame = cv2.resize(frame, (new_width, new_height)) + + # Convert to RGB and create PhotoImage + frame_rgb = cv2.cvtColor(resized_frame, cv2.COLOR_BGR2RGB) + pil_image = Image.fromarray(frame_rgb) + photo = ImageTk.PhotoImage(pil_image) + + # Center the image + x = canvas_width // 2 + y = canvas_height // 2 + + # Display image + self.fullscreen_canvas.delete("all") + self.fullscreen_canvas.create_image(x, y, image=photo) + self.fullscreen_canvas.image = photo # Keep reference + + except Exception as e: + print(f"Error displaying fullscreen frame: {e}") + def _start_timeline_playback(self): """Start the timeline playback loop""" def playback_loop(): @@ -2417,78 +2849,193 @@ class ShortsEditorGUI: messagebox.showerror("Volume Error", f"Could not adjust volume: {e}") def apply_fade(self): - """Apply fade in/out effects""" - if not self.current_clip: + """Apply fade effect with time-based controls""" + if not self.current_video: messagebox.showwarning("No Video", "Please load a video first.") return - try: - if MOVIEPY_AVAILABLE and hasattr(self.current_clip, 'fadein'): - # Use MoviePy for advanced fade effects - fade_duration = min(1.0, self.video_duration / 4) - - if hasattr(self.current_clip, 'fadein') and hasattr(self.current_clip, 'fadeout'): - self.current_clip = self.current_clip.fadein(fade_duration).fadeout(fade_duration) - messagebox.showinfo("Success", f"Fade effects applied ({fade_duration:.1f}s)") - else: - messagebox.showinfo("Not Available", "Fade effects not available in this MoviePy version") - else: - # OpenCV mode - export video with fade effect - if not self.current_video: - messagebox.showwarning("No Video", "No video file loaded.") - return - - timestamp = datetime.now().strftime("%H%M%S") - base_name = os.path.splitext(os.path.basename(self.current_video))[0] - output_path = os.path.join(os.path.dirname(self.current_video), - f"{base_name}_faded_{timestamp}.mp4") - - self.apply_fade_opencv(self.current_video, output_path) - self.load_video(output_path) - messagebox.showinfo("Success", f"Fade effects applied and saved as:\n{os.path.basename(output_path)}") - - except Exception as e: - messagebox.showerror("Fade Error", f"Could not apply fade effects: {e}") + # Get time range from UI + start_time = self.fade_start_var.get() + end_time = self.fade_end_var.get() + + # Validate time range + if start_time < 0 or end_time <= start_time or end_time > self.video_duration: + messagebox.showwarning("Invalid Time Range", + f"Please enter valid times between 0 and {self.video_duration:.1f} seconds.\nStart time must be less than end time.") + return + + # Set effect time range + self.effect_times['fade']['start'] = start_time + self.effect_times['fade']['end'] = end_time + self.effects_enabled['fade'] = True + + messagebox.showinfo("Fade Effect", + f"Fade effect applied from {start_time:.1f}s to {end_time:.1f}s\nYou can see it in the video preview during this time range.") + + # Refresh current frame to show effect + self.display_frame_at_time(self.current_time) + + def apply_ripple_effect(self): + """Apply ripple effect with time-based controls""" + if not self.current_video: + messagebox.showwarning("No Video", "Please load a video first.") + return + + # Get time range from UI + start_time = self.ripple_start_var.get() + end_time = self.ripple_end_var.get() + + # Validate time range + if start_time < 0 or end_time <= start_time or end_time > self.video_duration: + messagebox.showwarning("Invalid Time Range", + f"Please enter valid times between 0 and {self.video_duration:.1f} seconds.\nStart time must be less than end time.") + return + + # Set effect time range + self.effect_times['ripple']['start'] = start_time + self.effect_times['ripple']['end'] = end_time + self.effects_enabled['ripple'] = True + + # Start effect animation timer + self.effect_start_time = time.time() + + messagebox.showinfo("Ripple Effect", + f"Ripple effect applied from {start_time:.1f}s to {end_time:.1f}s\nYou can see it in the video preview during this time range.\n\nDEBUG: Effect enabled = {self.effects_enabled['ripple']}\nCurrent time = {self.current_time:.2f}s") + + # Refresh current frame to show effect + self.display_frame_at_time(self.current_time) - def apply_fade_opencv(self, input_path, output_path): - """Apply fade effects using OpenCV""" - cap = cv2.VideoCapture(input_path) - fps = cap.get(cv2.CAP_PROP_FPS) - width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) - height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + def apply_ripple_to_frame(self, frame): + """Apply ripple effect to a single frame in real-time""" + intensity = self.ripple_intensity_var.get() + frequency = self.ripple_frequency_var.get() - fourcc = cv2.VideoWriter_fourcc(*'mp4v') - out = cv2.VideoWriter(output_path, fourcc, fps, (width, height)) + # Calculate animation time for moving ripples + current_time = time.time() + animation_time = current_time - self.effect_start_time - # Calculate fade frames (1 second fade in/out) - fade_frames = min(int(fps), total_frames // 4) + height, width = frame.shape[:2] + center_x, center_y = width // 2, height // 2 - frame_count = 0 - while True: - ret, frame = cap.read() - if not ret: - break - - # Apply fade in - if frame_count < fade_frames: - alpha = frame_count / fade_frames - frame = cv2.convertScaleAbs(frame, alpha=alpha, beta=0) - - # Apply fade out - elif frame_count >= total_frames - fade_frames: - alpha = (total_frames - frame_count) / fade_frames - frame = cv2.convertScaleAbs(frame, alpha=alpha, beta=0) - - out.write(frame) - frame_count += 1 + # Use numpy for vectorized operations (fast real-time processing) + y, x = np.ogrid[:height, :width] - cap.release() - out.release() + # Calculate distance from center using numpy arrays + distance = np.sqrt((x - center_x)**2 + (y - center_y)**2) + + # Calculate ripple displacement with animation - make it more visible + ripple = np.sin(distance / frequency - animation_time * 5) * intensity * 3 # Multiply by 3 for more intensity + + # Create displacement maps + map_x = (x + ripple).astype(np.float32) + map_y = (y + ripple * 0.5).astype(np.float32) + + # Apply the ripple effect + rippled_frame = cv2.remap(frame, map_x, map_y, cv2.INTER_LINEAR) + + # DEBUG: Add visible red border to show effect is active + cv2.rectangle(rippled_frame, (5, 5), (width-5, height-5), (0, 0, 255), 8) + cv2.putText(rippled_frame, "RIPPLE ACTIVE", (10, 40), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 3) + + return rippled_frame + + def apply_fade_to_frame(self, frame): + """Apply fade in/out effect to a single frame in real-time""" + # Get effect time range + start_time = self.effect_times['fade']['start'] + end_time = self.effect_times['fade']['end'] + effect_duration = end_time - start_time + + # Calculate fade amount based on current position within effect range + relative_time = self.current_time - start_time + fade_duration = min(0.5, effect_duration / 4) # Fade duration is 25% of effect duration or 0.5s, whichever is smaller + + alpha = 1.0 # Default to full opacity + + # Fade in at the beginning of effect + if relative_time < fade_duration: + alpha = relative_time / fade_duration + # Fade out at the end of effect + elif relative_time > (effect_duration - fade_duration): + alpha = (effect_duration - relative_time) / fade_duration + + # Apply fade by adjusting frame brightness + alpha = max(0.0, min(1.0, alpha)) # Clamp between 0 and 1 + faded_frame = cv2.multiply(frame, alpha) + + # DEBUG: Add blue border to show fade is active + height, width = frame.shape[:2] + cv2.rectangle(faded_frame, (5, 5), (width-5, height-5), (255, 0, 0), 5) + cv2.putText(faded_frame, f"FADE ALPHA:{alpha:.2f}", (10, height-20), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (255, 0, 0), 2) + + return faded_frame.astype(np.uint8) + + def apply_text_to_frame(self, frame): + """Apply text overlay to a single frame in real-time""" + # Get text content + text_content = self.text_var.get().strip() if hasattr(self, 'text_var') and self.text_var.get().strip() else "Sample Text" + + # Create a copy to avoid modifying original + text_frame = frame.copy() + + # Text settings + font = cv2.FONT_HERSHEY_SIMPLEX + font_scale = 2 + font_color = (255, 255, 255) # White text + thickness = 3 + + # Get text size to center it + text_size = cv2.getTextSize(text_content, font, font_scale, thickness)[0] + height, width = frame.shape[:2] + + # Position text at bottom center + text_x = (width - text_size[0]) // 2 + text_y = height - 50 # 50 pixels from bottom + + # Add black outline for better readability + cv2.putText(text_frame, text_content, (text_x, text_y), font, font_scale, (0, 0, 0), thickness + 2) + # Add white text on top + cv2.putText(text_frame, text_content, (text_x, text_y), font, font_scale, font_color, thickness) + + return text_frame + + def apply_effects_to_frame(self, frame): + """Apply all enabled real-time effects to a frame based on current time""" + + # DEBUG: Draw a red rectangle when any effect should be active + debug_active = False + + # Check if ripple effect should be active at current time + if (self.effects_enabled['ripple'] and + self.effect_times['ripple']['start'] <= self.current_time <= self.effect_times['ripple']['end']): + print(f"🌊 Applying ripple at time {self.current_time:.2f}s") + debug_active = True + frame = self.apply_ripple_to_frame(frame) + + # Check if fade effect should be active at current time + if (self.effects_enabled['fade'] and + self.effect_times['fade']['start'] <= self.current_time <= self.effect_times['fade']['end']): + print(f"🌅 Applying fade at time {self.current_time:.2f}s") + debug_active = True + frame = self.apply_fade_to_frame(frame) + + # Check if text overlay should be active at current time + if (self.effects_enabled['text'] and + self.effect_times['text']['start'] <= self.current_time <= self.effect_times['text']['end']): + print(f"📝 Applying text at time {self.current_time:.2f}s") + debug_active = True + frame = self.apply_text_to_frame(frame) + + # DEBUG: Draw a green rectangle in top-left corner when effects are active + if debug_active: + cv2.rectangle(frame, (10, 10), (100, 50), (0, 255, 0), 3) + cv2.putText(frame, "EFFECT", (15, 35), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) + + return frame def apply_text(self): - """Apply text overlay""" - if not self.current_clip: + """Apply text overlay with time-based controls""" + if not self.current_video: messagebox.showwarning("No Video", "Please load a video first.") return @@ -2499,71 +3046,42 @@ class ShortsEditorGUI: messagebox.showwarning("No Text", "Please enter text to overlay.") return - try: - if MOVIEPY_AVAILABLE and hasattr(self.current_clip, 'duration'): - # Use MoviePy for text overlay - text_clip = TextClip(text_content, fontsize=50, color='white', font='Arial-Bold') - text_clip = text_clip.with_duration(self.current_clip.duration) - text_clip = text_clip.with_position(('center', 'bottom')) - - # Composite with video - self.current_clip = CompositeVideoClip([self.current_clip, text_clip]) - messagebox.showinfo("Success", f"Text overlay added: '{text_content}'") - else: - # OpenCV mode - export video with text overlay - if not self.current_video: - messagebox.showwarning("No Video", "No video file loaded.") - return - - timestamp = datetime.now().strftime("%H%M%S") - base_name = os.path.splitext(os.path.basename(self.current_video))[0] - output_path = os.path.join(os.path.dirname(self.current_video), - f"{base_name}_with_text_{timestamp}.mp4") - - self.apply_text_opencv(self.current_video, output_path, text_content) - self.load_video(output_path) - messagebox.showinfo("Success", f"Text '{text_content}' added and saved as:\n{os.path.basename(output_path)}") - - except Exception as e: - messagebox.showerror("Text Error", f"Could not add text overlay: {e}") + # Get time range from UI + start_time = self.text_start_var.get() + end_time = self.text_end_var.get() + + # Validate time range + if start_time < 0 or end_time <= start_time or end_time > self.video_duration: + messagebox.showwarning("Invalid Time Range", + f"Please enter valid times between 0 and {self.video_duration:.1f} seconds.\nStart time must be less than end time.") + return + + # Set effect time range + self.effect_times['text']['start'] = start_time + self.effect_times['text']['end'] = end_time + self.effects_enabled['text'] = True + + messagebox.showinfo("Text Overlay", + f"Text '{text_content}' applied from {start_time:.1f}s to {end_time:.1f}s\nYou can see it in the video preview during this time range.") + + # Refresh current frame to show effect + self.display_frame_at_time(self.current_time) - def apply_text_opencv(self, input_path, output_path, text): - """Apply text overlay using OpenCV""" - cap = cv2.VideoCapture(input_path) - fps = cap.get(cv2.CAP_PROP_FPS) - width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) - height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + def clear_all_effects(self): + """Clear all active effects""" + # Disable all effects + for effect in self.effects_enabled: + self.effects_enabled[effect] = False - fourcc = cv2.VideoWriter_fourcc(*'mp4v') - out = cv2.VideoWriter(output_path, fourcc, fps, (width, height)) + # Reset all effect times + for effect in self.effect_times: + self.effect_times[effect]['start'] = 0.0 + self.effect_times[effect]['end'] = 0.0 - # Text settings - font = cv2.FONT_HERSHEY_SIMPLEX - font_scale = min(width, height) / 800 # Scale font based on video size - color = (255, 255, 255) # White color - thickness = max(1, int(font_scale * 2)) + messagebox.showinfo("Effects Cleared", "All effects have been cleared from the video.") - # Get text size for positioning - (text_width, text_height), baseline = cv2.getTextSize(text, font, font_scale, thickness) - x = (width - text_width) // 2 - y = height - 50 # Bottom position - - while True: - ret, frame = cap.read() - if not ret: - break - - # Add black outline for better visibility - cv2.putText(frame, text, (x-2, y-2), font, font_scale, (0, 0, 0), thickness+2, cv2.LINE_AA) - cv2.putText(frame, text, (x+2, y+2), font, font_scale, (0, 0, 0), thickness+2, cv2.LINE_AA) - - # Add white text - cv2.putText(frame, text, (x, y), font, font_scale, color, thickness, cv2.LINE_AA) - - out.write(frame) - - cap.release() - out.release() + # Refresh current frame to remove all effects + self.display_frame_at_time(self.current_time) def apply_resize(self, target_width, target_height): """Apply resize to video"""