From c9fc7c8d8038f5757a9c07d47b38aafd22142d8b Mon Sep 17 00:00:00 2001 From: klop51 Date: Tue, 12 Aug 2025 19:19:24 +0200 Subject: [PATCH] feat: Enhance ShortsEditorGUI with media bin for drag-and-drop functionality and timeline clip management --- video_editor.py | 739 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 712 insertions(+), 27 deletions(-) diff --git a/video_editor.py b/video_editor.py index d7382f4..28206fb 100644 --- a/video_editor.py +++ b/video_editor.py @@ -160,6 +160,10 @@ class ShortsEditorGUI: # Timeline interaction state for drag-and-drop editing # These variables track the current state of user interactions with timeline elements self.dragging_clip = None # Clip currently being dragged by user + self.dragging_media = None # Media file being dragged from bin to timeline + self.resizing_clip = None # Clip being resized + self.moving_clip = None # Clip being moved + self.selected_timeline_clip = None # Index of selected timeline clip self.drag_start_x = None # Mouse X position when drag started self.drag_start_time = None # Original time position of dragged clip self.drag_offset = 0 # Offset from clip start to mouse position @@ -181,6 +185,9 @@ class ShortsEditorGUI: self.clip_thumbnails = {} # Dictionary storing thumbnail images by clip ID self.audio_waveforms = {} # Dictionary storing waveform data by clip ID + # Timeline clips storage for media bin drops + self.timeline_clips = [] # List of all clips on the timeline + # Track control widget references for dynamic updates # This allows us to update track control buttons when states change self.track_widgets = {} # Dictionary storing widget references by track ID @@ -192,6 +199,7 @@ class ShortsEditorGUI: 'bg_primary': '#1a1a1a', # Main background - darkest for maximum contrast 'bg_secondary': '#2d2d2d', # Secondary panels - slightly lighter 'bg_tertiary': '#3d3d3d', # Buttons and controls - interactive elements + 'bg_hover': '#404040', # Hover state - subtle highlight for interaction 'text_primary': '#ffffff', # Main text - high contrast for readability 'text_secondary': '#b8b8b8', # Secondary text - lower contrast for hierarchy 'accent_blue': '#007acc', # Primary actions - professional blue @@ -311,14 +319,22 @@ class ShortsEditorGUI: main_frame = tk.Frame(self.editor_window, bg=self.colors['bg_primary']) main_frame.pack(fill="both", expand=True, padx=10, pady=10) main_frame.rowconfigure(0, weight=1) # Single row takes all vertical space - main_frame.columnconfigure(0, weight=2) # Left column gets 2/3 of width - main_frame.columnconfigure(1, weight=1) # Right column gets 1/3 of width + main_frame.columnconfigure(0, weight=0) # Media bin column (fixed width) + main_frame.columnconfigure(1, weight=2) # Left column gets 2/3 of remaining width + main_frame.columnconfigure(2, weight=1) # Right column gets 1/3 of width - # Left panel - Video preview and timeline workspace + # Media Bin - File library for drag and drop editing + # Container for video and audio files that can be dragged to timeline tracks + media_bin_frame = tk.Frame(main_frame, bg=self.colors['bg_tertiary'], width=200) + media_bin_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 5)) + media_bin_frame.pack_propagate(False) # Maintain fixed width + self.create_media_bin(media_bin_frame) + + # Middle panel - Video preview and timeline workspace # This is the main editing area where users see their video and timeline # Organized vertically with video preview on top and timeline below player_frame = tk.Frame(main_frame, bg=self.colors['bg_secondary']) - player_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 5)) + player_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 5)) player_frame.rowconfigure(0, weight=1) # Video preview area (expandable) player_frame.rowconfigure(1, weight=0) # Timeline area (fixed height) player_frame.columnconfigure(0, weight=1) # Full width utilization @@ -463,11 +479,18 @@ class ShortsEditorGUI: external_scrollbar_frame.pack(side="bottom", fill="x", padx=5, pady=2) external_scrollbar_frame.pack_propagate(False) + # Professional tips label + tips_label = tk.Label(external_scrollbar_frame, + text="💡 CTRL+Click: Select clips • Delete/Backspace: Delete selected • Click: Move playhead", + bg=self.colors['bg_primary'], fg=self.colors['text_secondary'], + font=('Segoe UI', 8)) + tips_label.pack(side="left", padx=5, pady=2) + # Horizontal scrollbar outside timeline but controlling it self.external_h_scrollbar = ttk.Scrollbar(external_scrollbar_frame, orient="horizontal", command=self.timeline_canvas.xview) self.timeline_canvas.configure(xscrollcommand=self.external_h_scrollbar.set) - self.external_h_scrollbar.pack(fill="x", expand=True) + self.external_h_scrollbar.pack(side="right", fill="x", expand=True) # Bind professional timeline events self.timeline_canvas.bind("", self.timeline_click) @@ -476,6 +499,11 @@ class ShortsEditorGUI: self.timeline_canvas.bind("", self.on_timeline_right_click) self.timeline_canvas.bind("", self.on_timeline_double_click) + # Keyboard shortcuts for timeline + self.timeline_canvas.bind("", self.delete_selected_clip) + self.timeline_canvas.bind("", self.delete_selected_clip) + self.timeline_canvas.focus_set() # Allow timeline to receive keyboard events + # Create track controls self.create_track_controls() @@ -487,7 +515,7 @@ class ShortsEditorGUI: # Right panel - Tools and effects tools_frame = tk.Frame(main_frame, bg=self.colors['bg_secondary']) - tools_frame.grid(row=0, column=1, sticky="nsew", padx=(5, 0)) + tools_frame.grid(row=0, column=2, sticky="nsew", padx=(5, 0)) # Tools header tools_header = tk.Label(tools_frame, text="🛠️ Editing Tools", @@ -591,6 +619,329 @@ class ShortsEditorGUI: for track_id, track_info in self.tracks.items(): self.create_track_control(track_id, track_info) + def create_media_bin(self, parent_frame): + """Create media bin for drag and drop file management""" + # Media bin header + header = tk.Frame(parent_frame, bg=self.colors['bg_secondary'], height=40) + header.pack(fill="x", padx=5, pady=5) + header.pack_propagate(False) + + title_label = tk.Label(header, text="📁 Media Bin", + fg=self.colors['text_primary'], bg=self.colors['bg_secondary'], + font=('Segoe UI', 12, 'bold')) + title_label.pack(side="left", padx=10, pady=10) + + # Add file button + add_btn = tk.Button(header, text="+ Add", command=self.add_media_file, + bg=self.colors['accent_blue'], fg='white', bd=0, + font=('Segoe UI', 10), relief='flat') + add_btn.pack(side="right", padx=10, pady=5) + + # Media files container with scrollbar + container = tk.Frame(parent_frame, bg=self.colors['bg_tertiary']) + container.pack(fill="both", expand=True, padx=5, pady=(0, 5)) + + # Create canvas for scrollable media list + self.media_canvas = tk.Canvas(container, bg=self.colors['bg_tertiary'], + highlightthickness=0) + scrollbar = ttk.Scrollbar(container, orient="vertical", command=self.media_canvas.yview) + self.scrollable_media_frame = tk.Frame(self.media_canvas, bg=self.colors['bg_tertiary']) + + self.scrollable_media_frame.bind( + "", + lambda e: self.media_canvas.configure(scrollregion=self.media_canvas.bbox("all")) + ) + + self.media_canvas.create_window((0, 0), window=self.scrollable_media_frame, anchor="nw") + self.media_canvas.configure(yscrollcommand=scrollbar.set) + + self.media_canvas.pack(side="left", fill="both", expand=True) + scrollbar.pack(side="right", fill="y") + + # Initialize media files list + self.media_files = [] + + # Create drop zone with proper drag and drop support + drop_zone = tk.Frame(self.scrollable_media_frame, bg=self.colors['bg_secondary'], + height=100, relief='solid', bd=1) + drop_zone.pack(fill="x", padx=10, pady=10) + drop_zone.pack_propagate(False) + + drop_label = tk.Label(drop_zone, text="🎬 Drop media files here\nor click 'Add' button", + fg=self.colors['text_secondary'], bg=self.colors['bg_secondary'], + font=('Segoe UI', 10), justify='center') + drop_label.pack(expand=True) + + # Enable Windows Explorer drag and drop + self.setup_external_drop(drop_zone) + self.setup_external_drop(drop_label) + + # Bind click to add files + drop_zone.bind("", lambda e: self.add_media_file()) + drop_label.bind("", lambda e: self.add_media_file()) + + def add_media_file(self): + """Add media file to the bin""" + from tkinter import filedialog + import os + + file_types = [ + ("Video files", "*.mp4 *.avi *.mov *.mkv *.wmv *.flv"), + ("Audio files", "*.mp3 *.wav *.aac *.ogg *.m4a"), + ("All files", "*.*") + ] + + # Use current directory or Desktop as default + initial_dir = os.path.join(os.path.expanduser("~"), "Desktop") + + file_path = filedialog.askopenfilename( + title="Select media file", + filetypes=file_types, + initialdir=initial_dir + ) + + if file_path: + self.add_media_to_bin(file_path) + + def add_media_to_bin(self, file_path): + """Add media file to bin with preview""" + import os + + filename = os.path.basename(file_path) + file_ext = os.path.splitext(filename)[1].lower() + + # Determine file type + video_exts = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv'] + audio_exts = ['.mp3', '.wav', '.aac', '.ogg', '.m4a'] + + if file_ext in video_exts: + file_type = "🎬" + type_name = "Video" + elif file_ext in audio_exts: + file_type = "🎵" + type_name = "Audio" + else: + file_type = "📄" + type_name = "File" + + # Create media item frame + media_item = tk.Frame(self.scrollable_media_frame, bg=self.colors['bg_secondary'], + relief='solid', bd=1) + media_item.pack(fill="x", padx=10, pady=2) + + # File info frame + info_frame = tk.Frame(media_item, bg=self.colors['bg_secondary']) + info_frame.pack(fill="x", padx=5, pady=5) + + # File type icon and name + tk.Label(info_frame, text=file_type, bg=self.colors['bg_secondary'], + font=('Segoe UI', 16)).pack(side="left", padx=(0, 5)) + + name_frame = tk.Frame(info_frame, bg=self.colors['bg_secondary']) + name_frame.pack(side="left", fill="x", expand=True) + + tk.Label(name_frame, text=filename[:25] + "..." if len(filename) > 25 else filename, + fg=self.colors['text_primary'], bg=self.colors['bg_secondary'], + font=('Segoe UI', 9, 'bold'), anchor="w").pack(anchor="w") + + tk.Label(name_frame, text=f"{type_name} • {file_ext.upper()}", + fg=self.colors['text_secondary'], bg=self.colors['bg_secondary'], + font=('Segoe UI', 8), anchor="w").pack(anchor="w") + + # Store file info + media_info = { + 'path': file_path, + 'filename': filename, + 'type': type_name.lower(), + 'widget': media_item + } + self.media_files.append(media_info) + + # Enable drag and drop from media item to timeline + self.setup_media_drag(media_item, media_info) + + print(f"📁 Added {type_name.lower()}: {filename}") + + def setup_external_drop(self, widget): + """Setup drag and drop from Windows Explorer""" + try: + # Try to enable Windows file drag and drop + import tkinter.dnd as dnd + + def drop_enter(event): + widget.configure(bg=self.colors['accent_green']) + return 'copy' + + def drop_leave(event): + widget.configure(bg=self.colors['bg_secondary']) + + def drop_action(event): + # Handle file drop from Windows Explorer + files = widget.tk.splitlist(event.data) + for file_path in files: + if file_path.startswith('{') and file_path.endswith('}'): + file_path = file_path[1:-1] # Remove braces + + # Check if it's a media file + import os + if os.path.isfile(file_path): + ext = os.path.splitext(file_path)[1].lower() + media_extensions = ['.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', + '.mp3', '.wav', '.aac', '.ogg', '.m4a'] + if ext in media_extensions: + self.add_media_to_bin(file_path) + print(f"📁 Dropped file: {os.path.basename(file_path)}") + + widget.configure(bg=self.colors['bg_secondary']) + return 'copy' + + # Register for file drops + widget.bind('', drop_action) + widget.bind('', drop_enter) + widget.bind('', drop_leave) + + # Enable as drop target + widget.drop_target_register('DND_Files') + + except Exception as e: + print(f"⚠️ External drag-drop setup failed: {e}") + # Fallback to click-to-add only + pass + + def setup_media_drag(self, widget, media_info): + """Setup drag and drop functionality for media items""" + def start_drag(event): + # Store the media info for drag operation + self.dragging_media = media_info + # Visual feedback: blue border and background when dragging starts + widget.configure(bg=self.colors['accent_blue'], relief='solid', bd=2) + print(f"🎬 Started dragging: {media_info['filename']}") + + def end_drag(event): + # Check if we're over the timeline + try: + timeline_x = self.timeline_canvas.winfo_rootx() + timeline_y = self.timeline_canvas.winfo_rooty() + timeline_width = self.timeline_canvas.winfo_width() + timeline_height = self.timeline_canvas.winfo_height() + + mouse_x = event.x_root + mouse_y = event.y_root + + print(f"🖱️ Drop coordinates: mouse({mouse_x},{mouse_y}), timeline({timeline_x},{timeline_y},{timeline_width},{timeline_height})") + + # Check if dropped on timeline + if (timeline_x <= mouse_x <= timeline_x + timeline_width and + timeline_y <= mouse_y <= timeline_y + timeline_height): + self.handle_media_drop_on_timeline(mouse_x, mouse_y) + else: + print(f"❌ Dropped outside timeline area") + + except Exception as e: + print(f"❌ Error during drag end: {e}") + + # Reset appearance to normal + widget.configure(bg=self.colors['bg_secondary'], relief='solid', bd=1) + self.dragging_media = None + + def on_drag(event): + # Visual feedback during drag - keep blue highlight + if hasattr(self, 'dragging_media') and self.dragging_media: + widget.configure(bg=self.colors['accent_blue'], relief='solid', bd=2) + + def on_hover_enter(event): + # Highlight on mouse hover (but not as strong as drag) + if not (hasattr(self, 'dragging_media') and self.dragging_media): + widget.configure(bg=self.colors['bg_hover'], relief='solid', bd=1) + + def on_hover_leave(event): + # Remove highlight when mouse leaves (unless dragging) + if not (hasattr(self, 'dragging_media') and self.dragging_media): + widget.configure(bg=self.colors['bg_secondary'], relief='solid', bd=1) + + # Bind drag events + widget.bind("", start_drag) + widget.bind("", end_drag) + widget.bind("", on_drag) + + # Bind hover events for better visual feedback + widget.bind("", on_hover_enter) + widget.bind("", on_hover_leave) + + # Make all child widgets draggable too + for child in widget.winfo_children(): + child.bind("", start_drag) + child.bind("", end_drag) + child.bind("", on_drag) + child.bind("", on_hover_enter) + child.bind("", on_hover_leave) + for grandchild in child.winfo_children(): + grandchild.bind("", start_drag) + grandchild.bind("", end_drag) + grandchild.bind("", on_drag) + grandchild.bind("", on_hover_enter) + grandchild.bind("", on_hover_leave) + + def handle_media_drop_on_timeline(self, mouse_x, mouse_y): + """Handle dropping media from bin onto timeline""" + if not self.dragging_media: + return + + try: + # Convert screen coordinates to timeline coordinates + timeline_x = self.timeline_canvas.winfo_rootx() + timeline_y = self.timeline_canvas.winfo_rooty() + + # Calculate relative position on timeline + rel_x = mouse_x - timeline_x + rel_y = mouse_y - timeline_y + + # Convert to timeline time and track + # Use a safe scale calculation to avoid division by zero + scale = max(self.timeline_scale, 1.0) # Ensure scale is at least 1.0 + time_position = rel_x / scale + + # Determine which track based on Y position + target_track = None + for track_id, track_info in self.tracks.items(): + track_top = track_info['y_offset'] + track_bottom = track_top + track_info['height'] + + if track_top <= rel_y <= track_bottom: + # Check if media type matches track type + media_type = self.dragging_media['type'] + track_type = track_info['type'] + + if (media_type == 'video' and track_type == 'video') or \ + (media_type == 'audio' and track_type == 'audio'): + target_track = track_id + break + + if target_track: + # Create a clip on the timeline with reasonable duration + clip_info = { + 'file_path': self.dragging_media['path'], + 'filename': self.dragging_media['filename'], + 'start_time': max(0, time_position), + 'duration': 3.0, # Reasonable default duration for timeline clips + 'track': target_track, + 'type': self.dragging_media['type'] + } + + # Add clip to timeline + if not hasattr(self, 'timeline_clips'): + self.timeline_clips = [] + + self.timeline_clips.append(clip_info) + self.update_timeline() # Refresh timeline display + + print(f"✅ Added {self.dragging_media['filename']} to {target_track} at {time_position:.1f}s") + else: + print(f"❌ Cannot drop {self.dragging_media['type']} file on this track location") + + except Exception as e: + print(f"❌ Error handling drop: {e}") + def draw_track_road_lines(self): """Draw road lines for track visual separation""" # Clear existing lines @@ -1142,6 +1493,10 @@ class ShortsEditorGUI: filename = os.path.basename(video_path) self.current_file_label.config(text=filename) + # Add video to media bin automatically + if hasattr(self, 'media_files'): + self.add_media_to_bin(video_path) + # Update trim controls self.trim_start_var.set(0.0) self.trim_end_var.set(self.video_duration) @@ -1277,17 +1632,15 @@ class ShortsEditorGUI: self.timeline_canvas.delete("all") - if not self.current_clip: - return - canvas_width = self.timeline_canvas.winfo_width() canvas_height = self.timeline_canvas.winfo_height() if canvas_width <= 1: return - # Calculate timeline scale - self.timeline_scale = (canvas_width - 40) / max(self.video_duration, 1) + # Calculate timeline scale based on video duration or default + video_duration = getattr(self, 'video_duration', 10.0) # Default 10 seconds if no video + self.timeline_scale = (canvas_width - 40) / max(video_duration, 1) # Draw timeline background self.timeline_canvas.create_rectangle(20, 20, canvas_width - 20, canvas_height - 20, @@ -1296,8 +1649,11 @@ class ShortsEditorGUI: # Draw track road lines self.draw_timeline_track_roads(canvas_width, canvas_height) + # Draw dropped clips from media bin + self.draw_timeline_clips(canvas_width, canvas_height) + # Draw time markers - for i in range(0, int(self.video_duration) + 1): + for i in range(0, int(video_duration) + 1): x = 20 + i * self.timeline_scale if x < canvas_width - 20: self.timeline_canvas.create_line(x, 20, x, canvas_height - 20, @@ -1309,10 +1665,11 @@ class ShortsEditorGUI: text=f"{i}s", fill=self.colors['text_secondary'], font=self.fonts['caption']) - # Draw playhead - playhead_x = 20 + self.current_time * self.timeline_scale - self.timeline_canvas.create_line(playhead_x, 20, playhead_x, canvas_height - 20, - fill=self.colors['accent_blue'], width=3) + # Draw playhead if there's a current clip + if hasattr(self, 'current_clip') and self.current_clip: + playhead_x = 20 + self.current_time * self.timeline_scale + self.timeline_canvas.create_line(playhead_x, 20, playhead_x, canvas_height - 20, + fill=self.colors['accent_blue'], width=3) # Draw playhead handle self.timeline_canvas.create_oval(playhead_x - 5, 15, playhead_x + 5, 25, @@ -1353,25 +1710,350 @@ class ShortsEditorGUI: font=('Arial', 8, 'bold'), anchor="center", tags="track_roads" ) - def timeline_click(self, event): - """Handle timeline click""" - if not self.current_clip: + def draw_timeline_clips(self, canvas_width, canvas_height): + """Draw clips dropped from media bin onto timeline""" + if not hasattr(self, 'timeline_clips'): return + for i, clip in enumerate(self.timeline_clips): + track_info = self.tracks.get(clip['track']) + if not track_info: + continue + + # Calculate clip position and size with better scaling + clip_x = 20 + clip['start_time'] * self.timeline_scale + clip_width = max(clip['duration'] * self.timeline_scale, 20) # Minimum width of 20px + + # Ensure clip fits within canvas bounds + max_x = canvas_width - 20 + if clip_x + clip_width > max_x: + clip_width = max_x - clip_x + + clip_y = track_info['y_offset'] + 2 + clip_height = track_info['height'] - 4 + + # Only draw if clip is visible + if clip_x < canvas_width - 20 and clip_x + clip_width > 20: + # Determine if this clip is selected + is_selected = (hasattr(self, 'selected_timeline_clip') and + self.selected_timeline_clip == i) + + # Clip background with selection highlighting + clip_color = '#4CAF50' if clip['type'] == 'video' else '#FF9800' + outline_color = '#FFD700' if is_selected else 'white' # Gold outline for selected + outline_width = 3 if is_selected else 2 + + clip_rect = self.timeline_canvas.create_rectangle(clip_x, clip_y, clip_x + clip_width, clip_y + clip_height, + 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' + + # 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}") + + # 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}") + + # Make clip draggable and resizable + self.setup_clip_interaction(clip_rect, left_handle, right_handle, i) + + # Clip filename (truncated) + filename = clip['filename'][:12] + "..." if len(clip['filename']) > 12 else clip['filename'] + if clip_width > 40: # Only show text if clip is wide enough + text_color = 'black' if is_selected else 'white' + self.timeline_canvas.create_text(clip_x + clip_width//2, clip_y + clip_height // 2, + text=filename, fill=text_color, + font=('Arial', 8, 'bold'), anchor='center', + tags=f"clip_text_{i}") + + def setup_clip_interaction(self, clip_rect, left_handle, right_handle, clip_index): + """Setup interaction for clip resizing and moving""" + + def start_resize_left(event): + self.resizing_clip = {'index': clip_index, 'side': 'left', 'start_x': event.x} + self.timeline_canvas.configure(cursor="sb_h_double_arrow") + + def start_resize_right(event): + self.resizing_clip = {'index': clip_index, 'side': 'right', 'start_x': event.x} + self.timeline_canvas.configure(cursor="sb_h_double_arrow") + + def start_move_clip(event): + self.moving_clip = {'index': clip_index, 'start_x': event.x} + self.timeline_canvas.configure(cursor="hand2") + + def on_resize_drag(event): + if hasattr(self, 'resizing_clip') and self.resizing_clip: + self.handle_clip_resize(event) + elif hasattr(self, 'moving_clip') and self.moving_clip: + self.handle_clip_move(event) + + def end_interaction(event): + if hasattr(self, 'resizing_clip'): + self.resizing_clip = None + if hasattr(self, 'moving_clip'): + self.moving_clip = None + self.timeline_canvas.configure(cursor="arrow") + + # Keep the clip selected after interaction + 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() + + # Bind resize handles + self.timeline_canvas.tag_bind(left_handle, "", start_resize_left) + self.timeline_canvas.tag_bind(right_handle, "", start_resize_right) + + # Bind clip body for moving + self.timeline_canvas.tag_bind(clip_rect, "", start_move_clip) + + # Bind drag motion and release + self.timeline_canvas.tag_bind(left_handle, "", on_resize_drag) + self.timeline_canvas.tag_bind(right_handle, "", on_resize_drag) + self.timeline_canvas.tag_bind(clip_rect, "", on_resize_drag) + + self.timeline_canvas.tag_bind(left_handle, "", end_interaction) + self.timeline_canvas.tag_bind(right_handle, "", end_interaction) + self.timeline_canvas.tag_bind(clip_rect, "", end_interaction) + + def handle_clip_resize(self, event): + """Handle clip resizing""" + if not self.resizing_clip or not hasattr(self, 'timeline_clips'): + return + + clip_index = self.resizing_clip['index'] + if clip_index >= len(self.timeline_clips): + return + + clip = self.timeline_clips[clip_index] + scale = max(self.timeline_scale, 1.0) + + # Calculate time delta + time_delta = (event.x - self.resizing_clip['start_x']) / scale + + if self.resizing_clip['side'] == 'left': + # Resize from left - adjust start time and duration + new_start = clip['start_time'] + time_delta + new_duration = clip['duration'] - time_delta + + if new_start >= 0 and new_duration >= 0.5: # Minimum 0.5 second duration + clip['start_time'] = new_start + clip['duration'] = new_duration + + elif self.resizing_clip['side'] == 'right': + # Resize from right - adjust duration only + new_duration = clip['duration'] + time_delta + + if new_duration >= 0.5: # Minimum 0.5 second duration + clip['duration'] = new_duration + + self.resizing_clip['start_x'] = event.x + + def handle_clip_move(self, event): + """Handle clip moving""" + if not self.moving_clip or not hasattr(self, 'timeline_clips'): + return + + clip_index = self.moving_clip['index'] + if clip_index >= len(self.timeline_clips): + return + + clip = self.timeline_clips[clip_index] + scale = max(self.timeline_scale, 1.0) + + # Calculate time delta + time_delta = (event.x - self.moving_clip['start_x']) / scale + new_start = clip['start_time'] + time_delta + + if new_start >= 0: # Don't allow negative start times + clip['start_time'] = new_start + + self.moving_clip['start_x'] = event.x + + def check_timeline_clips_at_playhead(self): + """Check if playhead is hitting any clips and trigger playback""" + if not hasattr(self, 'timeline_clips') or not self.timeline_clips: + return + + current_time = self.current_time + + for clip in self.timeline_clips: + clip_start = clip['start_time'] + clip_end = clip_start + clip['duration'] + + # Check if playhead is within this clip + if clip_start <= current_time <= clip_end: + # Calculate relative time within the clip + relative_time = current_time - clip_start + + if clip['type'] == 'video': + self.play_timeline_video_clip(clip, relative_time) + elif clip['type'] == 'audio': + self.play_timeline_audio_clip(clip, relative_time) + + def play_timeline_video_clip(self, clip, relative_time): + """Play video clip at the specified relative time""" + try: + # Load the video clip if it's different from current + if not hasattr(self, 'current_timeline_clip') or self.current_timeline_clip != clip['file_path']: + # For now, just display a frame from the clip + # This is a simplified implementation - you'd want to load the actual video + print(f"🎬 Playing video clip: {clip['filename']} at {relative_time:.1f}s") + self.current_timeline_clip = clip['file_path'] + + # Try to load and display the video clip + try: + import cv2 + cap = cv2.VideoCapture(clip['file_path']) + if cap.isOpened(): + # Seek to the relative time + fps = cap.get(cv2.CAP_PROP_FPS) or 30 + frame_number = int(relative_time * fps) + cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number) + + ret, frame = cap.read() + if ret: + # Display this frame in the video player + self.display_opencv_frame(frame) + cap.release() + except Exception as e: + print(f"⚠️ Error playing video clip: {e}") + + except Exception as e: + print(f"⚠️ Timeline video playback error: {e}") + + def play_timeline_audio_clip(self, clip, relative_time): + """Play audio clip at the specified relative time""" + try: + # Audio playback would require a library like pygame or similar + print(f"🎵 Playing audio clip: {clip['filename']} at {relative_time:.1f}s") + # This is a placeholder - actual audio playback would need implementation + except Exception as e: + print(f"⚠️ Timeline audio playback error: {e}") + + def display_opencv_frame(self, frame): + """Display an OpenCV frame in the video player""" + try: + import cv2 + from PIL import Image, ImageTk + + # Convert BGR to RGB + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + # Convert to PIL Image + image = Image.fromarray(frame_rgb) + + # Resize to fit the video player + if hasattr(self, 'video_canvas'): + canvas_width = self.video_canvas.winfo_width() or 400 + canvas_height = self.video_canvas.winfo_height() or 300 + image = image.resize((canvas_width, canvas_height), Image.Resampling.LANCZOS) + + # Convert to PhotoImage + photo = ImageTk.PhotoImage(image) + + # Display in video canvas + if hasattr(self, 'video_canvas'): + self.video_canvas.delete("all") + self.video_canvas.create_image(0, 0, anchor="nw", image=photo) + self.video_canvas.image = photo # Keep a reference + + except Exception as e: + print(f"⚠️ Frame display error: {e}") + + def timeline_click(self, event): + """Handle timeline click - CTRL+click for clip selection, regular click for playhead""" canvas_width = self.timeline_canvas.winfo_width() click_x = event.x + click_y = event.y + + # Check if CTRL key is pressed + ctrl_pressed = (event.state & 0x4) != 0 # CTRL key modifier + + if ctrl_pressed: + # CTRL+Click: Select clip without moving playhead + self.select_clip_at_position(click_x, click_y) + else: + # Regular click: Move playhead (existing behavior) + if not self.current_clip: + return + + # Convert click position to time + relative_x = click_x - 20 + if relative_x >= 0 and relative_x <= canvas_width - 40: + clicked_time = relative_x / self.timeline_scale + clicked_time = max(0, min(clicked_time, self.video_duration)) + + # Update current time and display + self.current_time = clicked_time + self.display_frame_at_time(self.current_time) + self.update_timeline() + self.update_time_display() + + def select_clip_at_position(self, click_x, click_y): + """Select clip at the clicked position without moving playhead""" + if not hasattr(self, 'timeline_clips') or not self.timeline_clips: + return # Convert click position to time relative_x = click_x - 20 - if relative_x >= 0 and relative_x <= canvas_width - 40: - clicked_time = relative_x / self.timeline_scale - clicked_time = max(0, min(clicked_time, self.video_duration)) + if relative_x < 0: + return - # Update current time and display - self.current_time = clicked_time - self.display_frame_at_time(self.current_time) - self.update_timeline() - self.update_time_display() + clicked_time = relative_x / self.timeline_scale + + # Find which clip was clicked + for i, clip in enumerate(self.timeline_clips): + clip_start = clip['start_time'] + clip_end = clip_start + clip['duration'] + + # Check if click is within this clip's time range + if clip_start <= clicked_time <= clip_end: + # Check if click is within this clip's track (Y position) + track_info = self.tracks.get(clip['track']) + if track_info: + track_top = track_info['y_offset'] + track_bottom = track_top + track_info['height'] + + if track_top <= click_y <= track_bottom: + # Select this clip + self.selected_timeline_clip = i + print(f"🎯 Selected clip: {clip['filename']} on {clip['track']}") + + # Update timeline to show selection + self.update_timeline() + return + + # No clip found at click position + self.selected_timeline_clip = None + print("🎯 Deselected all clips") + self.update_timeline() + + def delete_selected_clip(self, event): + """Delete the currently selected timeline clip""" + if (hasattr(self, 'selected_timeline_clip') and + self.selected_timeline_clip is not None and + hasattr(self, 'timeline_clips') and + self.timeline_clips): + + if 0 <= self.selected_timeline_clip < len(self.timeline_clips): + deleted_clip = self.timeline_clips[self.selected_timeline_clip] + del self.timeline_clips[self.selected_timeline_clip] + + print(f"🗑️ Deleted clip: {deleted_clip['filename']}") + + # Clear selection + self.selected_timeline_clip = None + + # Update timeline display + self.update_timeline() def timeline_drag(self, event): """Handle timeline dragging""" @@ -1524,6 +2206,9 @@ class ShortsEditorGUI: self.display_frame_at_time(self.current_time) self.update_time_display() + # Check for timeline clips at current playhead position + self.check_timeline_clips_at_playhead() + # Frame rate control (approximately 30 FPS) time.sleep(1/30)