feat: Enhance ShortsEditorGUI with media bin for drag-and-drop functionality and timeline clip management
This commit is contained in:
parent
d589700d7a
commit
c9fc7c8d80
717
video_editor.py
717
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("<Button-1>", self.timeline_click)
|
||||
@ -476,6 +499,11 @@ class ShortsEditorGUI:
|
||||
self.timeline_canvas.bind("<Button-3>", self.on_timeline_right_click)
|
||||
self.timeline_canvas.bind("<Double-Button-1>", self.on_timeline_double_click)
|
||||
|
||||
# Keyboard shortcuts for timeline
|
||||
self.timeline_canvas.bind("<KeyPress-Delete>", self.delete_selected_clip)
|
||||
self.timeline_canvas.bind("<KeyPress-BackSpace>", 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(
|
||||
"<Configure>",
|
||||
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("<Button-1>", lambda e: self.add_media_file())
|
||||
drop_label.bind("<Button-1>", 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>', drop_action)
|
||||
widget.bind('<DragEnter>', drop_enter)
|
||||
widget.bind('<DragLeave>', 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("<Button-1>", start_drag)
|
||||
widget.bind("<ButtonRelease-1>", end_drag)
|
||||
widget.bind("<B1-Motion>", on_drag)
|
||||
|
||||
# Bind hover events for better visual feedback
|
||||
widget.bind("<Enter>", on_hover_enter)
|
||||
widget.bind("<Leave>", on_hover_leave)
|
||||
|
||||
# Make all child widgets draggable too
|
||||
for child in widget.winfo_children():
|
||||
child.bind("<Button-1>", start_drag)
|
||||
child.bind("<ButtonRelease-1>", end_drag)
|
||||
child.bind("<B1-Motion>", on_drag)
|
||||
child.bind("<Enter>", on_hover_enter)
|
||||
child.bind("<Leave>", on_hover_leave)
|
||||
for grandchild in child.winfo_children():
|
||||
grandchild.bind("<Button-1>", start_drag)
|
||||
grandchild.bind("<ButtonRelease-1>", end_drag)
|
||||
grandchild.bind("<B1-Motion>", on_drag)
|
||||
grandchild.bind("<Enter>", on_hover_enter)
|
||||
grandchild.bind("<Leave>", 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,7 +1665,8 @@ class ShortsEditorGUI:
|
||||
text=f"{i}s", fill=self.colors['text_secondary'],
|
||||
font=self.fonts['caption'])
|
||||
|
||||
# Draw playhead
|
||||
# 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)
|
||||
@ -1353,13 +1710,280 @@ 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, "<Button-1>", start_resize_left)
|
||||
self.timeline_canvas.tag_bind(right_handle, "<Button-1>", start_resize_right)
|
||||
|
||||
# Bind clip body for moving
|
||||
self.timeline_canvas.tag_bind(clip_rect, "<Button-1>", start_move_clip)
|
||||
|
||||
# Bind drag motion and release
|
||||
self.timeline_canvas.tag_bind(left_handle, "<B1-Motion>", on_resize_drag)
|
||||
self.timeline_canvas.tag_bind(right_handle, "<B1-Motion>", on_resize_drag)
|
||||
self.timeline_canvas.tag_bind(clip_rect, "<B1-Motion>", on_resize_drag)
|
||||
|
||||
self.timeline_canvas.tag_bind(left_handle, "<ButtonRelease-1>", end_interaction)
|
||||
self.timeline_canvas.tag_bind(right_handle, "<ButtonRelease-1>", end_interaction)
|
||||
self.timeline_canvas.tag_bind(clip_rect, "<ButtonRelease-1>", 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
|
||||
@ -1373,6 +1997,64 @@ class ShortsEditorGUI:
|
||||
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:
|
||||
return
|
||||
|
||||
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"""
|
||||
self.timeline_click(event) # Same behavior as click for now
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user