1486 lines
64 KiB
Python
1486 lines
64 KiB
Python
"""
|
|
Professional Video Editor for Generated Shorts
|
|
Standalone application for editing video clips with timeline controls and video synchronization
|
|
"""
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, filedialog, messagebox
|
|
import cv2
|
|
import numpy as np
|
|
import os
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from PIL import Image, ImageTk
|
|
|
|
# Try to import MoviePy, handle if not available
|
|
try:
|
|
from moviepy.editor import VideoFileClip, TextClip, CompositeVideoClip
|
|
from moviepy.video.fx import FadeIn, FadeOut, Resize
|
|
from moviepy.audio.fx import MultiplyVolume
|
|
MOVIEPY_AVAILABLE = True
|
|
except ImportError:
|
|
print("⚠️ MoviePy not available - using OpenCV backend for video processing")
|
|
MOVIEPY_AVAILABLE = False
|
|
|
|
# Create dummy classes for compatibility
|
|
class VideoFileClip:
|
|
def __init__(self, *args, **kwargs):
|
|
raise ImportError("MoviePy not available")
|
|
class TextClip:
|
|
def __init__(self, *args, **kwargs):
|
|
raise ImportError("MoviePy not available")
|
|
class CompositeVideoClip:
|
|
def __init__(self, *args, **kwargs):
|
|
raise ImportError("MoviePy not available")
|
|
|
|
class ShortsEditorGUI:
|
|
"""Professional video editing interface with timeline controls and real-time preview"""
|
|
|
|
def __init__(self, parent=None, shorts_folder="shorts"):
|
|
self.parent = parent
|
|
self.shorts_folder = shorts_folder
|
|
|
|
# Video state
|
|
self.current_video = None
|
|
self.current_clip = None
|
|
self.current_time = 0.0
|
|
self.video_duration = 0.0
|
|
self.is_playing = False
|
|
self.timeline_is_playing = False
|
|
self.play_thread = None
|
|
|
|
# Timeline state
|
|
self.timeline_position = 0.0
|
|
self.timeline_scale = 1.0 # Pixels per second
|
|
self.timeline_width = 800
|
|
|
|
# Professional timeline features
|
|
self.timeline_clips = []
|
|
self.selected_clip = None
|
|
self.markers = []
|
|
|
|
# Multi-track system
|
|
self.tracks = {
|
|
'video_1': {'y_offset': 40, 'height': 60, 'color': '#3498db', 'name': 'Video 1', 'muted': False, 'locked': False, 'solo': False, 'visible': True},
|
|
'video_2': {'y_offset': 105, 'height': 60, 'color': '#2ecc71', 'name': 'Video 2', 'muted': False, 'locked': False, 'solo': False, 'visible': True},
|
|
'audio_1': {'y_offset': 170, 'height': 40, 'color': '#e74c3c', 'name': 'Audio 1', 'muted': False, 'locked': False, 'solo': False, 'visible': True},
|
|
'audio_2': {'y_offset': 215, 'height': 40, 'color': '#f39c12', 'name': 'Audio 2', 'muted': False, 'locked': False, 'solo': False, 'visible': True},
|
|
'text_1': {'y_offset': 260, 'height': 35, 'color': '#9b59b6', 'name': 'Text/Graphics', 'muted': False, 'locked': False, 'solo': False, 'visible': True}
|
|
}
|
|
|
|
# Timeline interaction state
|
|
self.dragging_clip = None
|
|
self.drag_start_x = None
|
|
self.drag_start_time = None
|
|
self.drag_offset = 0
|
|
self.snap_enabled = True
|
|
self.magnetic_timeline = True
|
|
self.grid_size = 1.0 # Snap grid in seconds
|
|
|
|
# Timeline editing modes
|
|
self.edit_mode = 'select' # 'select', 'cut', 'trim', 'ripple'
|
|
|
|
# Visual enhancements
|
|
self.show_thumbnails = True
|
|
self.show_waveforms = True
|
|
self.clip_thumbnails = {}
|
|
self.audio_waveforms = {}
|
|
|
|
# Track widgets for UI
|
|
self.track_widgets = {}
|
|
|
|
# Modern color scheme
|
|
self.colors = {
|
|
'bg_primary': '#1a1a1a',
|
|
'bg_secondary': '#2d2d2d',
|
|
'bg_tertiary': '#3d3d3d',
|
|
'text_primary': '#ffffff',
|
|
'text_secondary': '#b8b8b8',
|
|
'accent_blue': '#007acc',
|
|
'accent_green': '#28a745',
|
|
'accent_orange': '#fd7e14',
|
|
'accent_red': '#dc3545',
|
|
'border': '#404040'
|
|
}
|
|
|
|
# Modern fonts
|
|
self.fonts = {
|
|
'title': ('Segoe UI', 16, 'bold'),
|
|
'heading': ('Segoe UI', 11, 'bold'),
|
|
'body': ('Segoe UI', 10),
|
|
'caption': ('Segoe UI', 9),
|
|
'button': ('Segoe UI', 10, 'bold')
|
|
}
|
|
|
|
def open_editor(self):
|
|
"""Open the video editor window"""
|
|
# Create editor window
|
|
self.editor_window = tk.Toplevel(self.parent) if self.parent else tk.Tk()
|
|
self.editor_window.title("Professional Shorts Editor")
|
|
self.editor_window.geometry("1200x800")
|
|
self.editor_window.minsize(900, 600)
|
|
self.editor_window.configure(bg=self.colors['bg_primary'])
|
|
|
|
# Make window responsive
|
|
self.editor_window.rowconfigure(1, weight=1)
|
|
self.editor_window.columnconfigure(0, weight=1)
|
|
|
|
# Create interface
|
|
self.create_editor_interface()
|
|
|
|
# Start the editor
|
|
if not self.parent:
|
|
self.editor_window.mainloop()
|
|
|
|
def create_editor_interface(self):
|
|
"""Create the main editor interface"""
|
|
# Header with file selection
|
|
header_frame = tk.Frame(self.editor_window, bg=self.colors['bg_secondary'], height=60)
|
|
header_frame.pack(fill="x", padx=10, pady=(10, 0))
|
|
header_frame.pack_propagate(False)
|
|
|
|
# Title
|
|
title_label = tk.Label(header_frame, text="✏️ Professional Shorts Editor",
|
|
font=self.fonts['title'], bg=self.colors['bg_secondary'],
|
|
fg=self.colors['text_primary'])
|
|
title_label.pack(side="left", padx=20, pady=15)
|
|
|
|
# File selection
|
|
file_frame = tk.Frame(header_frame, bg=self.colors['bg_secondary'])
|
|
file_frame.pack(side="right", padx=20, pady=15)
|
|
|
|
self.current_file_label = tk.Label(file_frame, text="No video selected",
|
|
font=self.fonts['body'], bg=self.colors['bg_tertiary'],
|
|
fg=self.colors['text_secondary'], padx=15, pady=8)
|
|
self.current_file_label.pack(side="left", padx=(0, 10))
|
|
|
|
select_btn = tk.Button(file_frame, text="📁 Select Video",
|
|
command=self.select_video_file, font=self.fonts['button'],
|
|
bg=self.colors['accent_blue'], fg='white', padx=20, pady=8,
|
|
relief="flat", bd=0, cursor="hand2")
|
|
select_btn.pack(side="left")
|
|
|
|
# Main content area
|
|
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)
|
|
main_frame.columnconfigure(0, weight=2)
|
|
main_frame.columnconfigure(1, weight=1)
|
|
|
|
# Left panel - Video player and timeline
|
|
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.rowconfigure(0, weight=1)
|
|
player_frame.rowconfigure(1, weight=0)
|
|
player_frame.columnconfigure(0, weight=1)
|
|
|
|
# Video display area
|
|
video_container = tk.Frame(player_frame, bg=self.colors['bg_tertiary'])
|
|
video_container.grid(row=0, column=0, sticky="nsew", padx=15, pady=15)
|
|
video_container.rowconfigure(0, weight=1)
|
|
video_container.columnconfigure(0, weight=1)
|
|
|
|
# Video canvas
|
|
self.video_canvas = tk.Canvas(video_container, bg='black', highlightthickness=0)
|
|
self.video_canvas.grid(row=0, column=0, sticky="nsew")
|
|
|
|
# Professional Timeline Workspace
|
|
timeline_workspace = tk.Frame(player_frame, bg=self.colors['bg_secondary'], height=350)
|
|
timeline_workspace.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 15))
|
|
timeline_workspace.pack_propagate(False)
|
|
|
|
# Timeline header with editing tools
|
|
header_frame = tk.Frame(timeline_workspace, bg=self.colors['bg_secondary'])
|
|
header_frame.pack(fill="x", pady=(10, 5))
|
|
|
|
# Left side - Timeline controls
|
|
left_controls = tk.Frame(header_frame, bg=self.colors['bg_secondary'])
|
|
left_controls.pack(side="left")
|
|
|
|
# Editing mode selector
|
|
tk.Label(left_controls, text="Mode:", font=self.fonts['caption'],
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(side="left")
|
|
|
|
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('<<ComboboxSelected>>', self.on_mode_change)
|
|
|
|
# Snap and magnetic timeline toggles
|
|
self.snap_var = tk.BooleanVar(value=True)
|
|
snap_check = tk.Checkbutton(left_controls, text="Snap", variable=self.snap_var,
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_primary'],
|
|
selectcolor=self.colors['accent_blue'], command=self.toggle_snap)
|
|
snap_check.pack(side="left", padx=5)
|
|
|
|
self.magnetic_var = tk.BooleanVar(value=True)
|
|
magnetic_check = tk.Checkbutton(left_controls, text="Magnetic", variable=self.magnetic_var,
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_primary'],
|
|
selectcolor=self.colors['accent_blue'], command=self.toggle_magnetic)
|
|
magnetic_check.pack(side="left", padx=5)
|
|
|
|
# Center - Playback controls
|
|
center_controls = tk.Frame(header_frame, bg=self.colors['bg_secondary'])
|
|
center_controls.pack(side="left", padx=20)
|
|
|
|
self.timeline_play_btn = tk.Button(center_controls, text="▶️",
|
|
command=self.timeline_play,
|
|
bg=self.colors['accent_green'], fg='white',
|
|
font=('Arial', 12, 'bold'), width=3, height=1,
|
|
relief="flat", bd=0, cursor="hand2")
|
|
self.timeline_play_btn.pack(side="left", padx=2)
|
|
|
|
self.timeline_pause_btn = tk.Button(center_controls, text="⏸️",
|
|
command=self.timeline_pause,
|
|
bg=self.colors['accent_orange'], fg='white',
|
|
font=('Arial', 12, 'bold'), width=3, height=1,
|
|
relief="flat", bd=0, cursor="hand2")
|
|
self.timeline_pause_btn.pack(side="left", padx=2)
|
|
|
|
self.timeline_stop_btn = tk.Button(center_controls, text="⏹️",
|
|
command=self.timeline_stop,
|
|
bg=self.colors['accent_red'], fg='white',
|
|
font=('Arial', 12, 'bold'), width=3, height=1,
|
|
relief="flat", bd=0, cursor="hand2")
|
|
self.timeline_stop_btn.pack(side="left", padx=2)
|
|
|
|
# Right side - Zoom and time display
|
|
right_controls = tk.Frame(header_frame, bg=self.colors['bg_secondary'])
|
|
right_controls.pack(side="right")
|
|
|
|
# Zoom control
|
|
tk.Label(right_controls, text="Zoom:", font=self.fonts['caption'],
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(side="left")
|
|
|
|
self.zoom_var = tk.DoubleVar(value=1.0)
|
|
zoom_scale = tk.Scale(right_controls, from_=0.1, to=5.0, resolution=0.1,
|
|
orient="horizontal", variable=self.zoom_var,
|
|
command=self.on_zoom_change, length=150,
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_primary'],
|
|
highlightthickness=0, troughcolor=self.colors['bg_tertiary'])
|
|
zoom_scale.pack(side="left", padx=10)
|
|
|
|
# Time display
|
|
self.time_display = tk.Label(right_controls, text="00:00 / 00:00",
|
|
font=self.fonts['body'], bg=self.colors['bg_secondary'],
|
|
fg=self.colors['text_primary'])
|
|
self.time_display.pack(side="left", padx=20)
|
|
|
|
# Main timeline container
|
|
timeline_container = tk.Frame(timeline_workspace, bg=self.colors['bg_tertiary'])
|
|
timeline_container.pack(fill="both", expand=True, pady=5)
|
|
|
|
# Track labels panel (left side)
|
|
self.track_panel = tk.Frame(timeline_container, bg=self.colors['bg_secondary'], width=120)
|
|
self.track_panel.pack(side="left", fill="y")
|
|
self.track_panel.pack_propagate(False)
|
|
|
|
# Timeline canvas with scrollbars
|
|
canvas_frame = tk.Frame(timeline_container, bg=self.colors['bg_tertiary'])
|
|
canvas_frame.pack(side="right", fill="both", expand=True)
|
|
|
|
# Create canvas with scrollbars
|
|
self.timeline_canvas = tk.Canvas(canvas_frame, bg='#1a1a1a',
|
|
highlightthickness=0, scrollregion=(0, 0, 2000, 300))
|
|
|
|
# Scrollbars
|
|
h_scrollbar = ttk.Scrollbar(canvas_frame, orient="horizontal", command=self.timeline_canvas.xview)
|
|
v_scrollbar = ttk.Scrollbar(canvas_frame, orient="vertical", command=self.timeline_canvas.yview)
|
|
self.timeline_canvas.configure(xscrollcommand=h_scrollbar.set, yscrollcommand=v_scrollbar.set)
|
|
|
|
# Pack scrollbars and canvas
|
|
h_scrollbar.pack(side="bottom", fill="x")
|
|
v_scrollbar.pack(side="right", fill="y")
|
|
self.timeline_canvas.pack(side="left", fill="both", expand=True)
|
|
|
|
# Bind professional timeline events
|
|
self.timeline_canvas.bind("<Button-1>", self.timeline_click)
|
|
self.timeline_canvas.bind("<B1-Motion>", self.timeline_drag)
|
|
self.timeline_canvas.bind("<ButtonRelease-1>", self.on_timeline_drag_end)
|
|
self.timeline_canvas.bind("<Button-3>", self.on_timeline_right_click)
|
|
self.timeline_canvas.bind("<Double-Button-1>", self.on_timeline_double_click)
|
|
|
|
# Create track controls
|
|
self.create_track_controls()
|
|
|
|
# Initialize sample clips for demonstration
|
|
self.create_sample_timeline_content()
|
|
|
|
# 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 header
|
|
tools_header = tk.Label(tools_frame, text="🛠️ Editing Tools",
|
|
font=self.fonts['heading'], bg=self.colors['bg_secondary'],
|
|
fg=self.colors['text_primary'])
|
|
tools_header.pack(pady=(15, 10))
|
|
|
|
# Create tool sections
|
|
self.create_basic_tools(tools_frame)
|
|
self.create_effects_tools(tools_frame)
|
|
self.create_export_tools(tools_frame)
|
|
|
|
# Initialize timeline
|
|
self.update_timeline()
|
|
|
|
def create_track_controls(self):
|
|
"""Create professional track control panel"""
|
|
# Clear existing track controls
|
|
for widget in self.track_panel.winfo_children():
|
|
widget.destroy()
|
|
|
|
# Header
|
|
header = tk.Label(self.track_panel, text="TRACKS", font=('Arial', 9, 'bold'),
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_secondary'])
|
|
header.pack(fill="x", pady=(5, 10))
|
|
|
|
# Create controls for each track
|
|
for track_id, track_info in self.tracks.items():
|
|
self.create_track_control(track_id, track_info)
|
|
|
|
def create_track_control(self, track_id, track_info):
|
|
"""Create control panel for a single track"""
|
|
# Track frame
|
|
track_frame = tk.Frame(self.track_panel, bg=self.colors['bg_secondary'],
|
|
height=track_info['height'], relief="raised", bd=1)
|
|
track_frame.pack(fill="x", pady=1)
|
|
track_frame.pack_propagate(False)
|
|
|
|
# Track name
|
|
name_label = tk.Label(track_frame, text=track_info['name'],
|
|
font=('Arial', 8, 'bold'), bg=self.colors['bg_secondary'],
|
|
fg=track_info['color'])
|
|
name_label.pack(anchor="w", padx=5, pady=2)
|
|
|
|
# Control buttons frame
|
|
controls = tk.Frame(track_frame, bg=self.colors['bg_secondary'])
|
|
controls.pack(fill="x", padx=5)
|
|
|
|
# Mute button
|
|
mute_text = "🔇" if track_info['muted'] else "🔊"
|
|
mute_btn = tk.Button(controls, text=mute_text, width=2, height=1,
|
|
bg=self.colors['accent_red'] if track_info['muted'] else self.colors['bg_tertiary'],
|
|
fg='white', font=('Arial', 8), relief="flat", bd=0,
|
|
command=lambda: self.toggle_track_mute(track_id))
|
|
mute_btn.pack(side="left", padx=1)
|
|
|
|
# Solo button
|
|
solo_text = "S"
|
|
solo_btn = tk.Button(controls, text=solo_text, width=2, height=1,
|
|
bg=self.colors['accent_orange'] if track_info['solo'] else self.colors['bg_tertiary'],
|
|
fg='white', font=('Arial', 8, 'bold'), relief="flat", bd=0,
|
|
command=lambda: self.toggle_track_solo(track_id))
|
|
solo_btn.pack(side="left", padx=1)
|
|
|
|
# Lock button
|
|
lock_text = "🔒" if track_info['locked'] else "🔓"
|
|
lock_btn = tk.Button(controls, text=lock_text, width=2, height=1,
|
|
bg=self.colors['accent_blue'] if track_info['locked'] else self.colors['bg_tertiary'],
|
|
fg='white', font=('Arial', 8), relief="flat", bd=0,
|
|
command=lambda: self.toggle_track_lock(track_id))
|
|
lock_btn.pack(side="left", padx=1)
|
|
|
|
# Store track widgets for updates
|
|
self.track_widgets[track_id] = {
|
|
'frame': track_frame,
|
|
'mute_btn': mute_btn,
|
|
'solo_btn': solo_btn,
|
|
'lock_btn': lock_btn
|
|
}
|
|
|
|
def create_sample_timeline_content(self):
|
|
"""Create sample timeline content for demonstration"""
|
|
if self.current_video and self.video_duration > 0:
|
|
# Create a sample clip representing the loaded video
|
|
sample_clip = {
|
|
'id': 1,
|
|
'name': os.path.basename(self.current_video) if self.current_video else 'Sample Video',
|
|
'start_time': 0,
|
|
'end_time': min(self.video_duration, 10), # Cap at 10 seconds for demo
|
|
'track': 'video_1',
|
|
'color': self.tracks['video_1']['color'],
|
|
'file_path': self.current_video,
|
|
'type': 'video'
|
|
}
|
|
self.timeline_clips = [sample_clip]
|
|
|
|
# Add sample markers
|
|
self.markers = [
|
|
{'time': 2.0, 'name': 'Intro End', 'color': '#ffeb3b'},
|
|
{'time': 5.0, 'name': 'Mid Point', 'color': '#4caf50'},
|
|
{'time': 8.0, 'name': 'Outro Start', 'color': '#f44336'}
|
|
]
|
|
|
|
self.update_timeline()
|
|
|
|
# Professional timeline interaction methods
|
|
def on_mode_change(self, event=None):
|
|
"""Handle editing mode change"""
|
|
self.edit_mode = self.mode_var.get()
|
|
print(f"🎬 Editing mode changed to: {self.edit_mode}")
|
|
|
|
# Update cursor based on mode
|
|
cursor_map = {
|
|
'select': 'hand2',
|
|
'cut': 'crosshair',
|
|
'trim': 'sb_h_double_arrow',
|
|
'ripple': 'fleur'
|
|
}
|
|
self.timeline_canvas.configure(cursor=cursor_map.get(self.edit_mode, 'hand2'))
|
|
|
|
def toggle_snap(self):
|
|
"""Toggle snap to grid"""
|
|
self.snap_enabled = self.snap_var.get()
|
|
print(f"🧲 Snap enabled: {self.snap_enabled}")
|
|
|
|
def toggle_magnetic(self):
|
|
"""Toggle magnetic timeline"""
|
|
self.magnetic_timeline = self.magnetic_var.get()
|
|
print(f"🧲 Magnetic timeline: {self.magnetic_timeline}")
|
|
|
|
def toggle_track_mute(self, track_id):
|
|
"""Toggle track mute"""
|
|
self.tracks[track_id]['muted'] = not self.tracks[track_id]['muted']
|
|
self.update_track_controls()
|
|
print(f"🔇 Track {track_id} muted: {self.tracks[track_id]['muted']}")
|
|
|
|
def toggle_track_solo(self, track_id):
|
|
"""Toggle track solo"""
|
|
self.tracks[track_id]['solo'] = not self.tracks[track_id]['solo']
|
|
self.update_track_controls()
|
|
print(f"🎵 Track {track_id} solo: {self.tracks[track_id]['solo']}")
|
|
|
|
def toggle_track_lock(self, track_id):
|
|
"""Toggle track lock"""
|
|
self.tracks[track_id]['locked'] = not self.tracks[track_id]['locked']
|
|
self.update_track_controls()
|
|
print(f"🔒 Track {track_id} locked: {self.tracks[track_id]['locked']}")
|
|
|
|
def update_track_controls(self):
|
|
"""Update track control button states"""
|
|
for track_id, widgets in self.track_widgets.items():
|
|
track_info = self.tracks[track_id]
|
|
|
|
# Update mute button
|
|
mute_text = "🔇" if track_info['muted'] else "🔊"
|
|
mute_color = self.colors['accent_red'] if track_info['muted'] else self.colors['bg_tertiary']
|
|
widgets['mute_btn'].configure(text=mute_text, bg=mute_color)
|
|
|
|
# Update solo button
|
|
solo_color = self.colors['accent_orange'] if track_info['solo'] else self.colors['bg_tertiary']
|
|
widgets['solo_btn'].configure(bg=solo_color)
|
|
|
|
# Update lock button
|
|
lock_text = "🔒" if track_info['locked'] else "🔓"
|
|
lock_color = self.colors['accent_blue'] if track_info['locked'] else self.colors['bg_tertiary']
|
|
widgets['lock_btn'].configure(text=lock_text, bg=lock_color)
|
|
|
|
def on_zoom_change(self, value):
|
|
"""Handle timeline zoom change"""
|
|
zoom_level = float(value)
|
|
self.timeline_scale = 50 * zoom_level # Base scale of 50 pixels per second
|
|
self.update_timeline()
|
|
print(f"🔍 Timeline zoom: {zoom_level:.1f}x")
|
|
|
|
# Initialize timeline
|
|
self.update_timeline()
|
|
|
|
def create_basic_tools(self, parent):
|
|
"""Create basic editing tools"""
|
|
basic_frame = tk.LabelFrame(parent, text="Basic Editing", font=self.fonts['heading'],
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_primary'],
|
|
relief="flat", bd=1)
|
|
basic_frame.pack(fill="x", padx=15, pady=5)
|
|
|
|
# Trim controls
|
|
trim_frame = tk.Frame(basic_frame, bg=self.colors['bg_secondary'])
|
|
trim_frame.pack(fill="x", padx=10, pady=5)
|
|
|
|
tk.Label(trim_frame, text="Trim Video:", font=self.fonts['body'],
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_primary']).pack(anchor="w")
|
|
|
|
trim_controls = tk.Frame(trim_frame, bg=self.colors['bg_secondary'])
|
|
trim_controls.pack(fill="x", pady=5)
|
|
|
|
tk.Label(trim_controls, text="Start:", font=self.fonts['caption'],
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(side="left")
|
|
|
|
self.trim_start_var = tk.DoubleVar(value=0.0)
|
|
trim_start_spin = tk.Spinbox(trim_controls, from_=0, to=999, increment=0.1,
|
|
textvariable=self.trim_start_var, width=8,
|
|
font=self.fonts['caption'])
|
|
trim_start_spin.pack(side="left", padx=5)
|
|
|
|
tk.Label(trim_controls, text="End:", font=self.fonts['caption'],
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(side="left", padx=(10, 0))
|
|
|
|
self.trim_end_var = tk.DoubleVar(value=10.0)
|
|
trim_end_spin = tk.Spinbox(trim_controls, from_=0, to=999, increment=0.1,
|
|
textvariable=self.trim_end_var, width=8,
|
|
font=self.fonts['caption'])
|
|
trim_end_spin.pack(side="left", padx=5)
|
|
|
|
trim_btn = tk.Button(basic_frame, text="✂️ Apply Trim", command=self.apply_trim,
|
|
bg=self.colors['accent_blue'], fg='white', font=self.fonts['button'],
|
|
relief="flat", bd=0, cursor="hand2")
|
|
trim_btn.pack(fill="x", padx=10, pady=5)
|
|
|
|
# Speed controls
|
|
speed_frame = tk.Frame(basic_frame, bg=self.colors['bg_secondary'])
|
|
speed_frame.pack(fill="x", padx=10, pady=5)
|
|
|
|
tk.Label(speed_frame, text="Speed:", font=self.fonts['body'],
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_primary']).pack(anchor="w")
|
|
|
|
self.speed_var = tk.DoubleVar(value=1.0)
|
|
speed_scale = tk.Scale(speed_frame, from_=0.25, to=3.0, resolution=0.25,
|
|
orient="horizontal", variable=self.speed_var,
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_primary'],
|
|
highlightthickness=0, troughcolor=self.colors['bg_tertiary'])
|
|
speed_scale.pack(fill="x", pady=5)
|
|
|
|
speed_btn = tk.Button(basic_frame, text="⚡ Apply Speed", command=self.apply_speed,
|
|
bg=self.colors['accent_green'], fg='white', font=self.fonts['button'],
|
|
relief="flat", bd=0, cursor="hand2")
|
|
speed_btn.pack(fill="x", padx=10, pady=5)
|
|
|
|
# Volume controls
|
|
volume_frame = tk.Frame(basic_frame, bg=self.colors['bg_secondary'])
|
|
volume_frame.pack(fill="x", padx=10, pady=5)
|
|
|
|
tk.Label(volume_frame, text="Volume:", font=self.fonts['body'],
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_primary']).pack(anchor="w")
|
|
|
|
self.volume_var = tk.DoubleVar(value=1.0)
|
|
volume_scale = tk.Scale(volume_frame, from_=0.0, to=2.0, resolution=0.1,
|
|
orient="horizontal", variable=self.volume_var,
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_primary'],
|
|
highlightthickness=0, troughcolor=self.colors['bg_tertiary'])
|
|
volume_scale.pack(fill="x", pady=5)
|
|
|
|
volume_btn = tk.Button(basic_frame, text="🔊 Apply Volume", command=self.apply_volume,
|
|
bg=self.colors['accent_orange'], fg='white', font=self.fonts['button'],
|
|
relief="flat", bd=0, cursor="hand2")
|
|
volume_btn.pack(fill="x", padx=10, pady=5)
|
|
|
|
def create_effects_tools(self, parent):
|
|
"""Create effects tools"""
|
|
effects_frame = tk.LabelFrame(parent, text="Effects", font=self.fonts['heading'],
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_primary'],
|
|
relief="flat", bd=1)
|
|
effects_frame.pack(fill="x", padx=15, pady=5)
|
|
|
|
# Fade effects
|
|
fade_btn = tk.Button(effects_frame, text="🌅 Add Fade In/Out", 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)
|
|
|
|
# Text overlay
|
|
text_frame = tk.Frame(effects_frame, bg=self.colors['bg_secondary'])
|
|
text_frame.pack(fill="x", padx=10, pady=5)
|
|
|
|
tk.Label(text_frame, text="Text Overlay:", font=self.fonts['body'],
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_primary']).pack(anchor="w")
|
|
|
|
self.text_var = tk.StringVar(value="Sample Text")
|
|
text_entry = tk.Entry(text_frame, textvariable=self.text_var, font=self.fonts['body'])
|
|
text_entry.pack(fill="x", pady=5)
|
|
|
|
text_btn = tk.Button(effects_frame, text="📝 Add 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)
|
|
|
|
def create_export_tools(self, parent):
|
|
"""Create export tools"""
|
|
export_frame = tk.LabelFrame(parent, text="Export", font=self.fonts['heading'],
|
|
bg=self.colors['bg_secondary'], fg=self.colors['text_primary'],
|
|
relief="flat", bd=1)
|
|
export_frame.pack(fill="x", padx=15, pady=5)
|
|
|
|
# Reset button
|
|
reset_btn = tk.Button(export_frame, text="🔄 Reset", command=self.reset_video,
|
|
bg=self.colors['accent_red'], fg='white', font=self.fonts['button'],
|
|
relief="flat", bd=0, cursor="hand2")
|
|
reset_btn.pack(fill="x", padx=10, pady=5)
|
|
|
|
# Export button
|
|
export_btn = tk.Button(export_frame, text="💾 Export Video", command=self.export_video,
|
|
bg=self.colors['accent_green'], fg='white', font=self.fonts['button'],
|
|
relief="flat", bd=0, cursor="hand2")
|
|
export_btn.pack(fill="x", padx=10, pady=5)
|
|
|
|
def select_video_file(self):
|
|
"""Select a video file to edit"""
|
|
# Check for videos in shorts folder first
|
|
if os.path.exists(self.shorts_folder):
|
|
video_files = [f for f in os.listdir(self.shorts_folder)
|
|
if f.lower().endswith(('.mp4', '.avi', '.mov', '.mkv'))]
|
|
|
|
if video_files:
|
|
# Show selection dialog for shorts
|
|
choice_window = tk.Toplevel(self.editor_window)
|
|
choice_window.title("Select Video to Edit")
|
|
choice_window.geometry("400x300")
|
|
choice_window.configure(bg=self.colors['bg_primary'])
|
|
choice_window.transient(self.editor_window)
|
|
choice_window.grab_set()
|
|
|
|
tk.Label(choice_window, text="Select a video to edit:",
|
|
font=self.fonts['heading'], bg=self.colors['bg_primary'],
|
|
fg=self.colors['text_primary']).pack(pady=10)
|
|
|
|
selected_file = None
|
|
|
|
def select_file(filename):
|
|
nonlocal selected_file
|
|
selected_file = os.path.join(self.shorts_folder, filename)
|
|
choice_window.destroy()
|
|
|
|
# List videos
|
|
for video_file in video_files:
|
|
btn = tk.Button(choice_window, text=f"📹 {video_file}",
|
|
command=lambda f=video_file: select_file(f),
|
|
bg=self.colors['accent_blue'], fg='white',
|
|
font=self.fonts['button'], relief="flat", bd=0,
|
|
cursor="hand2")
|
|
btn.pack(fill="x", padx=20, pady=2)
|
|
|
|
# Browse button
|
|
browse_btn = tk.Button(choice_window, text="📁 Browse Other Files",
|
|
command=lambda: self.browse_video_file(choice_window),
|
|
bg=self.colors['accent_orange'], fg='white',
|
|
font=self.fonts['button'], relief="flat", bd=0,
|
|
cursor="hand2")
|
|
browse_btn.pack(fill="x", padx=20, pady=10)
|
|
|
|
choice_window.wait_window()
|
|
|
|
if selected_file:
|
|
self.load_video(selected_file)
|
|
else:
|
|
self.browse_video_file()
|
|
else:
|
|
self.browse_video_file()
|
|
|
|
def browse_video_file(self, parent_window=None):
|
|
"""Browse for video file"""
|
|
filetypes = [
|
|
("Video files", "*.mp4 *.avi *.mov *.mkv *.wmv *.flv *.webm"),
|
|
("All files", "*.*")
|
|
]
|
|
|
|
file_path = filedialog.askopenfilename(
|
|
title="Select Video File",
|
|
filetypes=filetypes,
|
|
parent=parent_window or self.editor_window
|
|
)
|
|
|
|
if file_path:
|
|
if parent_window:
|
|
parent_window.destroy()
|
|
self.load_video(file_path)
|
|
|
|
def load_video(self, video_path):
|
|
"""Load a video for editing"""
|
|
try:
|
|
# Clean up previous video
|
|
if hasattr(self, 'current_clip') and self.current_clip:
|
|
if MOVIEPY_AVAILABLE:
|
|
self.current_clip.close()
|
|
else:
|
|
if hasattr(self.current_clip, 'release'):
|
|
self.current_clip.release()
|
|
|
|
# Load new video
|
|
self.current_video = video_path
|
|
|
|
if MOVIEPY_AVAILABLE:
|
|
# Use MoviePy for full functionality
|
|
self.current_clip = VideoFileClip(video_path)
|
|
self.video_duration = self.current_clip.duration
|
|
self.current_time = 0.0
|
|
|
|
# Display first frame
|
|
self.display_frame_at_time(0.0)
|
|
else:
|
|
# Use OpenCV for basic functionality
|
|
self.current_clip = cv2.VideoCapture(video_path)
|
|
if not self.current_clip.isOpened():
|
|
raise Exception("Could not open video file")
|
|
|
|
# Get video properties
|
|
fps = self.current_clip.get(cv2.CAP_PROP_FPS)
|
|
frame_count = self.current_clip.get(cv2.CAP_PROP_FRAME_COUNT)
|
|
self.video_duration = frame_count / fps if fps > 0 else 0
|
|
self.current_time = 0.0
|
|
|
|
# Display first frame
|
|
self.display_frame_at_time_opencv(0.0)
|
|
|
|
# Update UI
|
|
filename = os.path.basename(video_path)
|
|
self.current_file_label.config(text=filename)
|
|
|
|
# Update trim controls
|
|
self.trim_start_var.set(0.0)
|
|
self.trim_end_var.set(self.video_duration)
|
|
|
|
# Update timeline
|
|
self.update_timeline()
|
|
self.update_time_display()
|
|
|
|
backend = "MoviePy" if MOVIEPY_AVAILABLE else "OpenCV"
|
|
print(f"✅ Loaded video: {filename} ({self.video_duration:.1f}s) using {backend}")
|
|
|
|
if not MOVIEPY_AVAILABLE:
|
|
messagebox.showinfo("Limited Functionality",
|
|
"Video editor is running with limited functionality.\n" +
|
|
"Only basic playback and timeline controls are available.\n" +
|
|
"For full editing features, install MoviePy:\n" +
|
|
"pip install moviepy")
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Load Error", f"Could not load video: {e}")
|
|
|
|
def display_frame_at_time_opencv(self, time_sec):
|
|
"""Display a specific frame using OpenCV"""
|
|
if not self.current_clip or not hasattr(self.current_clip, 'get'):
|
|
return
|
|
|
|
try:
|
|
# Calculate frame number
|
|
fps = self.current_clip.get(cv2.CAP_PROP_FPS)
|
|
frame_number = int(time_sec * 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
|
|
|
|
# Convert BGR to RGB
|
|
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
|
|
# Convert to PIL Image
|
|
pil_image = Image.fromarray(frame_rgb)
|
|
|
|
# Resize to fit canvas while maintaining aspect ratio
|
|
canvas_width = self.video_canvas.winfo_width()
|
|
canvas_height = self.video_canvas.winfo_height()
|
|
|
|
if canvas_width > 1 and canvas_height > 1:
|
|
# Calculate scaling to fit canvas
|
|
scale_w = canvas_width / pil_image.width
|
|
scale_h = canvas_height / pil_image.height
|
|
scale = min(scale_w, scale_h)
|
|
|
|
new_width = int(pil_image.width * scale)
|
|
new_height = int(pil_image.height * scale)
|
|
|
|
pil_image = pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
|
|
# Convert to PhotoImage
|
|
photo = ImageTk.PhotoImage(pil_image)
|
|
|
|
# Clear canvas and display image
|
|
self.video_canvas.delete("all")
|
|
x = canvas_width // 2
|
|
y = canvas_height // 2
|
|
self.video_canvas.create_image(x, y, image=photo)
|
|
|
|
# Keep reference to prevent garbage collection
|
|
self.video_canvas.image = photo
|
|
|
|
except Exception as e:
|
|
print(f"Frame display error: {e}")
|
|
|
|
def display_frame_at_time(self, time_sec):
|
|
"""Display a specific frame from the video"""
|
|
if not self.current_clip:
|
|
return
|
|
|
|
if MOVIEPY_AVAILABLE:
|
|
self.display_frame_at_time_moviepy(time_sec)
|
|
else:
|
|
self.display_frame_at_time_opencv(time_sec)
|
|
|
|
def display_frame_at_time_moviepy(self, time_sec):
|
|
"""Display a specific frame using MoviePy"""
|
|
try:
|
|
# Get frame at specified time
|
|
time_sec = max(0, min(time_sec, self.video_duration))
|
|
frame = self.current_clip.get_frame(time_sec)
|
|
|
|
# Convert to PIL Image
|
|
if frame.dtype != np.uint8:
|
|
frame = (frame * 255).astype(np.uint8)
|
|
|
|
pil_image = Image.fromarray(frame)
|
|
|
|
# Resize to fit canvas while maintaining aspect ratio
|
|
canvas_width = self.video_canvas.winfo_width()
|
|
canvas_height = self.video_canvas.winfo_height()
|
|
|
|
if canvas_width > 1 and canvas_height > 1:
|
|
# Calculate scaling to fit canvas
|
|
scale_w = canvas_width / pil_image.width
|
|
scale_h = canvas_height / pil_image.height
|
|
scale = min(scale_w, scale_h)
|
|
|
|
new_width = int(pil_image.width * scale)
|
|
new_height = int(pil_image.height * scale)
|
|
|
|
pil_image = pil_image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
|
|
# Convert to PhotoImage
|
|
photo = ImageTk.PhotoImage(pil_image)
|
|
|
|
# Clear canvas and display image
|
|
self.video_canvas.delete("all")
|
|
x = canvas_width // 2
|
|
y = canvas_height // 2
|
|
self.video_canvas.create_image(x, y, image=photo)
|
|
|
|
# Keep reference to prevent garbage collection
|
|
self.video_canvas.image = photo
|
|
|
|
except Exception as e:
|
|
print(f"Frame display error: {e}")
|
|
|
|
def update_timeline(self):
|
|
"""Update the timeline display"""
|
|
if not self.timeline_canvas.winfo_exists():
|
|
return
|
|
|
|
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)
|
|
|
|
# Draw timeline background
|
|
self.timeline_canvas.create_rectangle(20, 20, canvas_width - 20, canvas_height - 20,
|
|
fill=self.colors['bg_primary'], outline=self.colors['border'])
|
|
|
|
# Draw time markers
|
|
for i in range(0, int(self.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,
|
|
fill=self.colors['border'], width=1)
|
|
|
|
# Time labels
|
|
if i % 2 == 0: # Every 2 seconds
|
|
self.timeline_canvas.create_text(x, canvas_height - 35,
|
|
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 handle
|
|
self.timeline_canvas.create_oval(playhead_x - 5, 15, playhead_x + 5, 25,
|
|
fill=self.colors['accent_blue'], outline='white')
|
|
|
|
def timeline_click(self, event):
|
|
"""Handle timeline click"""
|
|
if not self.current_clip:
|
|
return
|
|
|
|
canvas_width = self.timeline_canvas.winfo_width()
|
|
click_x = event.x
|
|
|
|
# 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 timeline_drag(self, event):
|
|
"""Handle timeline dragging"""
|
|
self.timeline_click(event) # Same behavior as click for now
|
|
|
|
def on_timeline_drag_end(self, event):
|
|
"""End timeline drag operation"""
|
|
if hasattr(self, 'dragging_clip') and self.dragging_clip:
|
|
print(f"🎬 Moved clip '{self.dragging_clip['name']}' to {self.dragging_clip['start_time']:.2f}s")
|
|
|
|
# Clear drag state
|
|
if hasattr(self, 'dragging_clip'):
|
|
self.dragging_clip = None
|
|
if hasattr(self, 'drag_start_x'):
|
|
self.drag_start_x = None
|
|
if hasattr(self, 'drag_start_time'):
|
|
self.drag_start_time = None
|
|
if hasattr(self, 'drag_offset'):
|
|
self.drag_offset = 0
|
|
|
|
def on_timeline_right_click(self, event):
|
|
"""Handle right-click context menu"""
|
|
try:
|
|
canvas_x = self.timeline_canvas.canvasx(event.x)
|
|
canvas_y = self.timeline_canvas.canvasy(event.y)
|
|
clicked_clip = self.get_clip_at_position(canvas_x, canvas_y) if hasattr(self, 'get_clip_at_position') else None
|
|
|
|
# Create context menu
|
|
context_menu = tk.Menu(self.root, tearoff=0, bg=self.colors['bg_secondary'],
|
|
fg=self.colors['text_primary'])
|
|
|
|
if clicked_clip:
|
|
# Clip context menu
|
|
self.selected_clip = clicked_clip
|
|
context_menu.add_command(label=f"Cut '{clicked_clip['name']}'",
|
|
command=lambda: self.cut_clip_at_playhead())
|
|
context_menu.add_command(label=f"Delete '{clicked_clip['name']}'",
|
|
command=lambda: self.delete_clip(clicked_clip))
|
|
context_menu.add_separator()
|
|
context_menu.add_command(label="Duplicate Clip",
|
|
command=lambda: self.duplicate_clip(clicked_clip))
|
|
context_menu.add_command(label="Properties",
|
|
command=lambda: self.show_clip_properties(clicked_clip))
|
|
else:
|
|
# Timeline context menu
|
|
click_time = canvas_x / self.timeline_scale if hasattr(self, 'timeline_scale') else 0
|
|
context_menu.add_command(label="Add Marker",
|
|
command=lambda: self.add_marker_at_time(click_time))
|
|
context_menu.add_command(label="Zoom to Fit", command=self.zoom_to_fit)
|
|
|
|
try:
|
|
context_menu.tk_popup(event.x_root, event.y_root)
|
|
finally:
|
|
context_menu.grab_release()
|
|
except Exception as e:
|
|
print(f"Context menu error: {e}")
|
|
|
|
def on_timeline_double_click(self, event):
|
|
"""Handle timeline double-click"""
|
|
try:
|
|
canvas_x = self.timeline_canvas.canvasx(event.x)
|
|
canvas_y = self.timeline_canvas.canvasy(event.y)
|
|
clicked_clip = self.get_clip_at_position(canvas_x, canvas_y) if hasattr(self, 'get_clip_at_position') else None
|
|
|
|
if clicked_clip:
|
|
self.show_clip_properties(clicked_clip)
|
|
else:
|
|
# Add marker on double-click
|
|
click_time = canvas_x / self.timeline_scale if hasattr(self, 'timeline_scale') else 0
|
|
self.add_marker_at_time(click_time)
|
|
except Exception as e:
|
|
print(f"Double-click error: {e}")
|
|
|
|
def update_time_display(self):
|
|
"""Update the time display"""
|
|
current_min = int(self.current_time // 60)
|
|
current_sec = int(self.current_time % 60)
|
|
total_min = int(self.video_duration // 60)
|
|
total_sec = int(self.video_duration % 60)
|
|
|
|
time_text = f"{current_min:02d}:{current_sec:02d} / {total_min:02d}:{total_sec:02d}"
|
|
self.time_display.config(text=time_text)
|
|
|
|
def timeline_play(self):
|
|
"""Start timeline playback"""
|
|
if not self.current_clip:
|
|
return
|
|
|
|
self.timeline_is_playing = True
|
|
self.play_video() # Start actual video playback
|
|
self._start_timeline_playback()
|
|
|
|
def timeline_pause(self):
|
|
"""Pause timeline playback"""
|
|
self.timeline_is_playing = False
|
|
self.pause_video() # Pause actual video
|
|
|
|
def timeline_stop(self):
|
|
"""Stop timeline playback"""
|
|
self.timeline_is_playing = False
|
|
self.stop_video() # Stop actual video
|
|
self.current_time = 0.0
|
|
self.display_frame_at_time(0.0)
|
|
self.update_timeline()
|
|
self.update_time_display()
|
|
|
|
def _start_timeline_playback(self):
|
|
"""Start the timeline playback loop"""
|
|
def playback_loop():
|
|
while self.timeline_is_playing and self.current_time < self.video_duration:
|
|
if not self.is_playing: # Sync with video player state
|
|
break
|
|
|
|
# Update timeline display
|
|
self.editor_window.after(0, self.update_timeline)
|
|
self.editor_window.after(0, self.update_time_display)
|
|
|
|
time.sleep(1/30) # 30 FPS update rate
|
|
|
|
# Playback finished
|
|
self.timeline_is_playing = False
|
|
|
|
if not hasattr(self, 'timeline_thread') or not self.timeline_thread.is_alive():
|
|
self.timeline_thread = threading.Thread(target=playback_loop, daemon=True)
|
|
self.timeline_thread.start()
|
|
|
|
def play_video(self):
|
|
"""Start video playback"""
|
|
if not self.current_clip or self.is_playing:
|
|
return
|
|
|
|
self.is_playing = True
|
|
|
|
def play_thread():
|
|
start_time = time.time()
|
|
start_video_time = self.current_time
|
|
|
|
while self.is_playing and self.current_time < self.video_duration:
|
|
try:
|
|
# Calculate current video time
|
|
elapsed = time.time() - start_time
|
|
self.current_time = start_video_time + elapsed
|
|
|
|
if self.current_time >= self.video_duration:
|
|
self.current_time = self.video_duration
|
|
self.is_playing = False
|
|
break
|
|
|
|
# Update display
|
|
self.display_frame_at_time(self.current_time)
|
|
self.update_time_display()
|
|
|
|
# Frame rate control (approximately 30 FPS)
|
|
time.sleep(1/30)
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ Playback error: {e}")
|
|
break
|
|
|
|
# Playback finished
|
|
self.is_playing = False
|
|
|
|
self.play_thread = threading.Thread(target=play_thread, daemon=True)
|
|
self.play_thread.start()
|
|
|
|
def pause_video(self):
|
|
"""Pause video playback"""
|
|
self.is_playing = False
|
|
|
|
def stop_video(self):
|
|
"""Stop video and return to beginning"""
|
|
self.is_playing = False
|
|
self.current_time = 0.0
|
|
self.display_frame_at_time(0.0)
|
|
|
|
def apply_trim(self):
|
|
"""Apply trim to the video"""
|
|
if not MOVIEPY_AVAILABLE:
|
|
messagebox.showwarning("Feature Unavailable",
|
|
"Trim feature requires MoviePy.\nInstall with: pip install moviepy")
|
|
return
|
|
|
|
if not self.current_clip:
|
|
messagebox.showwarning("No Video", "Please load a video first.")
|
|
return
|
|
|
|
start_time = self.trim_start_var.get()
|
|
end_time = self.trim_end_var.get()
|
|
|
|
if start_time >= end_time:
|
|
messagebox.showerror("Invalid Range", "Start time must be less than end time.")
|
|
return
|
|
|
|
if end_time > self.video_duration:
|
|
messagebox.showerror("Invalid Range", f"End time cannot exceed video duration ({self.video_duration:.1f}s).")
|
|
return
|
|
|
|
try:
|
|
# Apply trim
|
|
self.current_clip = self.current_clip.subclipped(start_time, end_time)
|
|
self.video_duration = self.current_clip.duration
|
|
self.current_time = 0.0
|
|
|
|
# Update UI
|
|
self.trim_end_var.set(self.video_duration)
|
|
self.display_frame_at_time(0.0)
|
|
self.update_timeline()
|
|
self.update_time_display()
|
|
|
|
messagebox.showinfo("Success", f"Video trimmed to {start_time:.1f}s - {end_time:.1f}s")
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Trim Error", f"Could not trim video: {e}")
|
|
|
|
def apply_speed(self):
|
|
"""Apply speed change to the video"""
|
|
if not MOVIEPY_AVAILABLE:
|
|
messagebox.showwarning("Feature Unavailable",
|
|
"Speed change requires MoviePy.\nInstall with: pip install moviepy")
|
|
return
|
|
|
|
if not self.current_clip:
|
|
messagebox.showwarning("No Video", "Please load a video first.")
|
|
return
|
|
|
|
speed_factor = self.speed_var.get()
|
|
|
|
try:
|
|
if speed_factor > 1:
|
|
# Speed up
|
|
self.current_clip = self.current_clip.with_fps(self.current_clip.fps * speed_factor)
|
|
self.current_clip = self.current_clip.subclipped(0, self.current_clip.duration / speed_factor)
|
|
else:
|
|
# Slow down
|
|
self.current_clip = self.current_clip.with_fps(self.current_clip.fps * speed_factor)
|
|
|
|
self.video_duration = self.current_clip.duration
|
|
self.current_time = 0.0
|
|
|
|
# Update UI
|
|
self.trim_end_var.set(self.video_duration)
|
|
self.display_frame_at_time(0.0)
|
|
self.update_timeline()
|
|
self.update_time_display()
|
|
|
|
messagebox.showinfo("Success", f"Speed changed to {speed_factor:.1f}x")
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Speed Error", f"Could not change speed: {e}")
|
|
|
|
def apply_volume(self):
|
|
"""Apply volume adjustment"""
|
|
if not MOVIEPY_AVAILABLE:
|
|
messagebox.showwarning("Feature Unavailable",
|
|
"Volume adjustment requires MoviePy.\nInstall with: pip install moviepy")
|
|
return
|
|
|
|
if not self.current_clip:
|
|
messagebox.showwarning("No Video", "Please load a video first.")
|
|
return
|
|
|
|
if not self.current_clip.audio:
|
|
messagebox.showwarning("No Audio", "This video has no audio track.")
|
|
return
|
|
|
|
volume_factor = self.volume_var.get()
|
|
|
|
try:
|
|
self.current_clip = self.current_clip.with_effects([MultiplyVolume(volume_factor)])
|
|
messagebox.showinfo("Success", f"Volume adjusted to {volume_factor:.1f}x")
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Volume Error", f"Could not adjust volume: {e}")
|
|
|
|
def apply_fade(self):
|
|
"""Apply fade in/out effects"""
|
|
if not MOVIEPY_AVAILABLE:
|
|
messagebox.showwarning("Feature Unavailable",
|
|
"Fade effects require MoviePy.\nInstall with: pip install moviepy")
|
|
return
|
|
|
|
if not self.current_clip:
|
|
messagebox.showwarning("No Video", "Please load a video first.")
|
|
return
|
|
|
|
try:
|
|
fade_duration = min(1.0, self.video_duration / 4) # Max 1 second or 1/4 of video
|
|
|
|
self.current_clip = self.current_clip.with_effects([
|
|
FadeIn(fade_duration),
|
|
FadeOut(fade_duration)
|
|
])
|
|
|
|
messagebox.showinfo("Success", f"Fade effects applied ({fade_duration:.1f}s)")
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Fade Error", f"Could not apply fade effects: {e}")
|
|
|
|
def apply_text(self):
|
|
"""Apply text overlay"""
|
|
if not MOVIEPY_AVAILABLE:
|
|
messagebox.showwarning("Feature Unavailable",
|
|
"Text overlay requires MoviePy.\nInstall with: pip install moviepy")
|
|
return
|
|
|
|
if not self.current_clip:
|
|
messagebox.showwarning("No Video", "Please load a video first.")
|
|
return
|
|
|
|
text = self.text_var.get().strip()
|
|
if not text:
|
|
messagebox.showwarning("No Text", "Please enter text to overlay.")
|
|
return
|
|
|
|
try:
|
|
# Create text clip
|
|
text_clip = TextClip(text, 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}'")
|
|
|
|
except Exception as e:
|
|
messagebox.showerror("Text Error", f"Could not add text overlay: {e}")
|
|
|
|
def reset_video(self):
|
|
"""Reset video to original state"""
|
|
if not self.current_video:
|
|
messagebox.showwarning("No Video", "No video loaded.")
|
|
return
|
|
|
|
if messagebox.askyesno("Reset Video", "Reset all changes and reload original video?"):
|
|
self.load_video(self.current_video)
|
|
|
|
def export_video(self):
|
|
"""Export the edited video"""
|
|
if not MOVIEPY_AVAILABLE:
|
|
messagebox.showwarning("Feature Unavailable",
|
|
"Video export requires MoviePy.\nInstall with: pip install moviepy")
|
|
return
|
|
|
|
if not self.current_clip:
|
|
messagebox.showwarning("No Video", "Please load a video first.")
|
|
return
|
|
|
|
# Get output filename
|
|
timestamp = datetime.now().strftime("%H%M%S")
|
|
default_name = f"edited_video_{timestamp}.mp4"
|
|
|
|
output_path = filedialog.asksaveasfilename(
|
|
title="Save Edited Video",
|
|
defaultextension=".mp4",
|
|
filetypes=[("MP4 files", "*.mp4"), ("All files", "*.*")],
|
|
initialname=default_name
|
|
)
|
|
|
|
if not output_path:
|
|
return
|
|
|
|
# Create progress window
|
|
progress_window = tk.Toplevel(self.editor_window)
|
|
progress_window.title("Exporting Video")
|
|
progress_window.geometry("400x150")
|
|
progress_window.configure(bg=self.colors['bg_primary'])
|
|
progress_window.transient(self.editor_window)
|
|
progress_window.grab_set()
|
|
|
|
progress_label = tk.Label(progress_window, text="Exporting video...",
|
|
font=self.fonts['body'], bg=self.colors['bg_primary'],
|
|
fg=self.colors['text_primary'])
|
|
progress_label.pack(pady=20)
|
|
|
|
progress_bar = ttk.Progressbar(progress_window, mode='indeterminate')
|
|
progress_bar.pack(pady=10, padx=20, fill="x")
|
|
progress_bar.start()
|
|
|
|
def export_thread():
|
|
try:
|
|
# Export video
|
|
self.current_clip.write_videofile(
|
|
output_path,
|
|
codec="libx264",
|
|
audio_codec="aac",
|
|
verbose=False,
|
|
logger=None
|
|
)
|
|
|
|
def show_success():
|
|
progress_window.destroy()
|
|
messagebox.showinfo("Export Complete", f"Video exported successfully!\n\nSaved to: {output_path}")
|
|
|
|
self.editor_window.after(0, show_success)
|
|
|
|
except Exception as e:
|
|
def show_error():
|
|
progress_window.destroy()
|
|
messagebox.showerror("Export Error", f"Could not export video: {e}")
|
|
|
|
self.editor_window.after(0, show_error)
|
|
|
|
# Start export in background thread
|
|
threading.Thread(target=export_thread, daemon=True).start()
|
|
|
|
# Professional timeline helper methods
|
|
def get_clip_at_position(self, x, y):
|
|
"""Get the clip at the given canvas position"""
|
|
time_pos = x / self.timeline_scale
|
|
|
|
for clip in self.timeline_clips:
|
|
if clip['start_time'] <= time_pos <= clip['end_time']:
|
|
# Check if Y position is within the clip's track
|
|
track_info = self.tracks[clip['track']]
|
|
if track_info['y_offset'] <= y <= track_info['y_offset'] + track_info['height']:
|
|
return clip
|
|
return None
|
|
|
|
def snap_to_grid(self, time_value):
|
|
"""Snap time value to grid"""
|
|
if self.snap_enabled and self.grid_size > 0:
|
|
return round(time_value / self.grid_size) * self.grid_size
|
|
return time_value
|
|
|
|
def magnetic_snap(self, new_time, dragging_clip):
|
|
"""Apply magnetic timeline snapping to other clips"""
|
|
if not self.magnetic_timeline:
|
|
return new_time
|
|
|
|
snap_distance = 0.2 # 200ms snap distance
|
|
clip_duration = dragging_clip['end_time'] - dragging_clip['start_time']
|
|
|
|
for clip in self.timeline_clips:
|
|
if clip == dragging_clip or clip['track'] != dragging_clip['track']:
|
|
continue
|
|
|
|
# Snap to start of other clips
|
|
if abs(new_time - clip['start_time']) < snap_distance:
|
|
return clip['start_time']
|
|
|
|
# Snap to end of other clips
|
|
if abs(new_time - clip['end_time']) < snap_distance:
|
|
return clip['end_time']
|
|
|
|
# Snap end of dragging clip to start of other clips
|
|
if abs((new_time + clip_duration) - clip['start_time']) < snap_distance:
|
|
return clip['start_time'] - clip_duration
|
|
|
|
return new_time
|
|
|
|
def cut_clip_at_position(self, clip, cut_time):
|
|
"""Cut a clip at the specified time"""
|
|
if cut_time <= clip['start_time'] or cut_time >= clip['end_time']:
|
|
return
|
|
|
|
# Create two new clips
|
|
first_clip = clip.copy()
|
|
first_clip['id'] = len(self.timeline_clips) + 1
|
|
first_clip['end_time'] = cut_time
|
|
first_clip['name'] = f"{clip['name']} (1)"
|
|
|
|
second_clip = clip.copy()
|
|
second_clip['id'] = len(self.timeline_clips) + 2
|
|
second_clip['start_time'] = cut_time
|
|
second_clip['name'] = f"{clip['name']} (2)"
|
|
|
|
# Remove original clip and add new ones
|
|
self.timeline_clips.remove(clip)
|
|
self.timeline_clips.extend([first_clip, second_clip])
|
|
|
|
self.selected_clip = first_clip
|
|
self.update_timeline()
|
|
print(f"✂️ Cut clip at {cut_time:.2f}s")
|
|
|
|
def cut_clip_at_playhead(self):
|
|
"""Cut selected clip at current playhead position"""
|
|
if self.selected_clip:
|
|
self.cut_clip_at_position(self.selected_clip, self.current_time)
|
|
|
|
def delete_clip(self, clip):
|
|
"""Delete a clip from timeline"""
|
|
if clip in self.timeline_clips:
|
|
self.timeline_clips.remove(clip)
|
|
if self.selected_clip == clip:
|
|
self.selected_clip = None
|
|
self.update_timeline()
|
|
print(f"🗑️ Deleted clip: {clip['name']}")
|
|
|
|
def duplicate_clip(self, clip):
|
|
"""Duplicate a clip"""
|
|
new_clip = clip.copy()
|
|
new_clip['id'] = len(self.timeline_clips) + 1
|
|
new_clip['name'] = f"{clip['name']} (Copy)"
|
|
|
|
# Place after original clip
|
|
duration = clip['end_time'] - clip['start_time']
|
|
new_clip['start_time'] = clip['end_time']
|
|
new_clip['end_time'] = clip['end_time'] + duration
|
|
|
|
self.timeline_clips.append(new_clip)
|
|
self.selected_clip = new_clip
|
|
self.update_timeline()
|
|
print(f"📄 Duplicated clip: {new_clip['name']}")
|
|
|
|
def add_marker_at_time(self, time):
|
|
"""Add a marker at specified time"""
|
|
marker = {
|
|
'time': time,
|
|
'name': f"Marker {len(self.markers) + 1}",
|
|
'color': '#ffeb3b'
|
|
}
|
|
self.markers.append(marker)
|
|
self.update_timeline()
|
|
print(f"📍 Added marker at {time:.2f}s")
|
|
|
|
def show_clip_properties(self, clip):
|
|
"""Show clip properties dialog"""
|
|
props_window = tk.Toplevel(self.root)
|
|
props_window.title(f"Clip Properties - {clip['name']}")
|
|
props_window.configure(bg=self.colors['bg_primary'])
|
|
props_window.geometry("400x300")
|
|
|
|
# Clip info
|
|
tk.Label(props_window, text=f"Clip: {clip['name']}",
|
|
font=self.fonts['heading'], bg=self.colors['bg_primary'],
|
|
fg=self.colors['text_primary']).pack(pady=10)
|
|
|
|
info_frame = tk.Frame(props_window, bg=self.colors['bg_primary'])
|
|
info_frame.pack(fill='x', padx=20)
|
|
|
|
# Duration, start time, etc.
|
|
duration = clip['end_time'] - clip['start_time']
|
|
tk.Label(info_frame, text=f"Duration: {duration:.2f}s",
|
|
bg=self.colors['bg_primary'], fg=self.colors['text_secondary']).pack(anchor='w')
|
|
tk.Label(info_frame, text=f"Start: {clip['start_time']:.2f}s",
|
|
bg=self.colors['bg_primary'], fg=self.colors['text_secondary']).pack(anchor='w')
|
|
tk.Label(info_frame, text=f"End: {clip['end_time']:.2f}s",
|
|
bg=self.colors['bg_primary'], fg=self.colors['text_secondary']).pack(anchor='w')
|
|
tk.Label(info_frame, text=f"Track: {clip['track']}",
|
|
bg=self.colors['bg_primary'], fg=self.colors['text_secondary']).pack(anchor='w')
|
|
|
|
def zoom_to_fit(self):
|
|
"""Zoom timeline to fit all content"""
|
|
if not self.timeline_clips:
|
|
return
|
|
|
|
# Find the last clip end time
|
|
max_time = max(clip['end_time'] for clip in self.timeline_clips)
|
|
canvas_width = self.timeline_canvas.winfo_width()
|
|
|
|
if max_time > 0 and canvas_width > 100:
|
|
zoom_level = (canvas_width - 100) / (max_time * 50) # 50 is base scale
|
|
self.zoom_var.set(max(0.1, min(5.0, zoom_level)))
|
|
self.on_zoom_change(zoom_level)
|
|
print(f"🔍 Zoomed to fit content ({zoom_level:.1f}x)")
|
|
|
|
def open_shorts_editor(shorts_folder="shorts"):
|
|
"""Open the shorts editor as a standalone application"""
|
|
editor = ShortsEditorGUI(shorts_folder=shorts_folder)
|
|
editor.open_editor()
|
|
|
|
if __name__ == "__main__":
|
|
# Run as standalone application
|
|
open_shorts_editor()
|