- Implemented a standalone video editor for editing generated shorts. - Integrated OpenCV for basic video playback and MoviePy for advanced editing features. - Added functionalities including video trimming, speed control, volume adjustment, fade effects, and text overlays. - Created a modern GUI using Tkinter with responsive design and a professional color scheme. - Included detailed README documentation outlining features, usage, and installation requirements.
1000 lines
42 KiB
Python
1000 lines
42 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
|
|
|
|
# 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")
|
|
|
|
# Timeline workspace
|
|
timeline_workspace = tk.Frame(player_frame, bg=self.colors['bg_secondary'], height=200)
|
|
timeline_workspace.grid(row=1, column=0, sticky="ew", padx=15, pady=(0, 15))
|
|
timeline_workspace.pack_propagate(False)
|
|
|
|
# Timeline Controls
|
|
controls_frame = tk.Frame(timeline_workspace, bg=self.colors['bg_secondary'])
|
|
controls_frame.pack(fill="x", pady=(10, 0))
|
|
|
|
# Timeline control buttons
|
|
btn_frame = tk.Frame(controls_frame, bg=self.colors['bg_secondary'])
|
|
btn_frame.pack(side="left")
|
|
|
|
self.timeline_play_btn = tk.Button(btn_frame, text="▶️ Play",
|
|
command=self.timeline_play,
|
|
bg=self.colors['accent_green'], fg='white',
|
|
font=self.fonts['button'], padx=15, pady=5,
|
|
relief="flat", bd=0, cursor="hand2")
|
|
self.timeline_play_btn.pack(side="left", padx=(0, 5))
|
|
|
|
self.timeline_pause_btn = tk.Button(btn_frame, text="⏸️ Pause",
|
|
command=self.timeline_pause,
|
|
bg=self.colors['accent_orange'], fg='white',
|
|
font=self.fonts['button'], padx=15, pady=5,
|
|
relief="flat", bd=0, cursor="hand2")
|
|
self.timeline_pause_btn.pack(side="left", padx=5)
|
|
|
|
self.timeline_stop_btn = tk.Button(btn_frame, text="⏹️ Stop",
|
|
command=self.timeline_stop,
|
|
bg=self.colors['accent_red'], fg='white',
|
|
font=self.fonts['button'], padx=15, pady=5,
|
|
relief="flat", bd=0, cursor="hand2")
|
|
self.timeline_stop_btn.pack(side="left", padx=5)
|
|
|
|
# Time display
|
|
self.time_display = tk.Label(controls_frame, text="00:00 / 00:00",
|
|
font=self.fonts['body'], bg=self.colors['bg_secondary'],
|
|
fg=self.colors['text_primary'])
|
|
self.time_display.pack(side="right", padx=20)
|
|
|
|
# Timeline canvas
|
|
timeline_frame = tk.Frame(timeline_workspace, bg=self.colors['bg_tertiary'])
|
|
timeline_frame.pack(fill="both", expand=True, pady=10)
|
|
|
|
self.timeline_canvas = tk.Canvas(timeline_frame, bg=self.colors['bg_tertiary'],
|
|
height=120, highlightthickness=1,
|
|
highlightbackground=self.colors['border'])
|
|
self.timeline_canvas.pack(side="left", fill="both", expand=True)
|
|
|
|
# Bind timeline events
|
|
self.timeline_canvas.bind("<Button-1>", self.timeline_click)
|
|
self.timeline_canvas.bind("<B1-Motion>", self.timeline_drag)
|
|
|
|
# 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_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 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()
|
|
|
|
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()
|