- Reduced top padding in canvas header for a more compact look. - Adjusted canvas container to minimize empty space by changing expand behavior. - Centered the canvas within its container for better positioning. - Decreased bottom padding in timeline frame and label for a cleaner interface.
809 lines
36 KiB
Python
809 lines
36 KiB
Python
import os
|
||
import tkinter as tk
|
||
from tkinter import filedialog, simpledialog, colorchooser, messagebox, ttk
|
||
from moviepy import VideoFileClip
|
||
from PIL import Image, ImageTk, ImageDraw, ImageFont
|
||
|
||
# Modern Thumbnail Editor with Professional UI Design
|
||
|
||
class ModernThumbnailEditor:
|
||
def __init__(self, video_path):
|
||
self.video_path = video_path
|
||
self.clip = None
|
||
self.current_frame_img = None
|
||
self.canvas_items = []
|
||
self.drag_data = {"item": None, "x": 0, "y": 0}
|
||
|
||
# Modern color scheme
|
||
self.colors = {
|
||
'bg_primary': '#1a1a1a', # Dark background
|
||
'bg_secondary': '#2d2d2d', # Card backgrounds
|
||
'bg_tertiary': '#3d3d3d', # Elevated elements
|
||
'accent_blue': '#007acc', # Primary blue
|
||
'accent_green': '#28a745', # Success green
|
||
'accent_orange': '#fd7e14', # Warning orange
|
||
'accent_purple': '#6f42c1', # Secondary purple
|
||
'accent_red': '#dc3545', # Error red
|
||
'text_primary': '#ffffff', # Primary text
|
||
'text_secondary': '#b8b8b8', # Secondary text
|
||
'text_muted': '#6c757d', # Muted text
|
||
'border': '#404040', # Border color
|
||
'hover': '#4a4a4a' # Hover state
|
||
}
|
||
|
||
# Modern fonts
|
||
self.fonts = {
|
||
'title': ('Segoe UI', 18, 'bold'),
|
||
'heading': ('Segoe UI', 14, 'bold'),
|
||
'subheading': ('Segoe UI', 12, 'bold'),
|
||
'body': ('Segoe UI', 10),
|
||
'caption': ('Segoe UI', 9),
|
||
'button': ('Segoe UI', 10, 'bold')
|
||
}
|
||
|
||
self.setup_ui()
|
||
|
||
def setup_ui(self):
|
||
self.editor = tk.Toplevel()
|
||
self.editor.title("📸 Professional Thumbnail Editor")
|
||
self.editor.geometry("1200x800") # Reduced window size to match smaller canvas
|
||
self.editor.minsize(1000, 700) # Reduced minimum size
|
||
self.editor.configure(bg=self.colors['bg_primary'])
|
||
|
||
# Load video
|
||
try:
|
||
print(f"📹 Loading video: {os.path.basename(self.video_path)}")
|
||
self.clip = VideoFileClip(self.video_path)
|
||
self.duration = int(self.clip.duration)
|
||
except Exception as e:
|
||
messagebox.showerror("Video Error", f"Failed to load video: {e}")
|
||
self.editor.destroy()
|
||
return
|
||
|
||
# Setup stickers folder
|
||
self.stickers_folder = os.path.join(os.path.dirname(__file__), "stickers")
|
||
os.makedirs(self.stickers_folder, exist_ok=True)
|
||
self.create_default_stickers()
|
||
|
||
self.create_modern_interface()
|
||
|
||
def create_modern_interface(self):
|
||
"""Create the modern thumbnail editor interface"""
|
||
# Header
|
||
header_frame = tk.Frame(self.editor, bg=self.colors['bg_secondary'], height=70)
|
||
header_frame.pack(fill="x", padx=0, pady=0)
|
||
header_frame.pack_propagate(False)
|
||
|
||
title_frame = tk.Frame(header_frame, bg=self.colors['bg_secondary'])
|
||
title_frame.pack(expand=True, fill="both", padx=30, pady=15)
|
||
|
||
title_label = tk.Label(title_frame, text="📸 Professional Thumbnail Editor",
|
||
font=self.fonts['title'], bg=self.colors['bg_secondary'],
|
||
fg=self.colors['text_primary'])
|
||
title_label.pack(side="left")
|
||
|
||
# Video info
|
||
video_name = os.path.basename(self.video_path)
|
||
info_label = tk.Label(title_frame, text=f"Editing: {video_name}",
|
||
font=self.fonts['caption'], bg=self.colors['bg_secondary'],
|
||
fg=self.colors['text_secondary'])
|
||
info_label.pack(side="right")
|
||
|
||
# Main content area
|
||
main_container = tk.Frame(self.editor, bg=self.colors['bg_primary'])
|
||
main_container.pack(fill="both", expand=True, padx=20, pady=20)
|
||
|
||
# Left panel - Canvas area
|
||
left_panel = tk.Frame(main_container, bg=self.colors['bg_secondary'])
|
||
left_panel.pack(side="left", fill="both", expand=True, padx=(0, 10))
|
||
|
||
self.setup_canvas_area(left_panel)
|
||
|
||
# Right panel - Controls
|
||
right_panel = tk.Frame(main_container, bg=self.colors['bg_secondary'], width=350)
|
||
right_panel.pack(side="right", fill="y")
|
||
right_panel.pack_propagate(False)
|
||
|
||
self.setup_controls_panel(right_panel)
|
||
|
||
def setup_canvas_area(self, parent):
|
||
"""Setup the main canvas area with modern styling"""
|
||
# Canvas header (reduced top padding)
|
||
canvas_header = tk.Frame(parent, bg=self.colors['bg_secondary'])
|
||
canvas_header.pack(fill="x", padx=20, pady=(10, 5)) # Reduced from (20, 10) to (10, 5)
|
||
|
||
canvas_title = tk.Label(canvas_header, text="🎬 Thumbnail Preview",
|
||
font=self.fonts['heading'], bg=self.colors['bg_secondary'],
|
||
fg=self.colors['text_primary'])
|
||
canvas_title.pack(side="left")
|
||
|
||
# Canvas container (reduced expand behavior to minimize empty space)
|
||
canvas_container = tk.Frame(parent, bg=self.colors['bg_tertiary'], relief="flat", bd=2)
|
||
canvas_container.pack(fill="x", padx=20, pady=(0, 5)) # Further reduced bottom padding from 10 to 5
|
||
|
||
# Calculate proper canvas size based on video dimensions
|
||
video_width, video_height = self.clip.size
|
||
aspect_ratio = video_width / video_height
|
||
|
||
# Set canvas size to maintain video aspect ratio (reduced to match video player)
|
||
max_width, max_height = 480, 360 # Smaller size for better interface fit
|
||
if aspect_ratio > max_width / max_height:
|
||
# Video is wider
|
||
canvas_width = max_width
|
||
canvas_height = int(max_width / aspect_ratio)
|
||
else:
|
||
# Video is taller
|
||
canvas_height = max_height
|
||
canvas_width = int(max_height * aspect_ratio)
|
||
|
||
# Modern canvas with proper video proportions (centered)
|
||
self.canvas = tk.Canvas(canvas_container, bg='#000000', highlightthickness=0,
|
||
relief="flat", bd=0, width=canvas_width, height=canvas_height)
|
||
self.canvas.pack(anchor="center", padx=5, pady=5) # Added anchor="center" for better positioning
|
||
|
||
# Store canvas dimensions for boundary checking
|
||
self.canvas_width = canvas_width
|
||
self.canvas_height = canvas_height
|
||
|
||
# Store center coordinates for consistent frame positioning
|
||
self.canvas_center_x = canvas_width // 2
|
||
self.canvas_center_y = canvas_height // 2
|
||
|
||
# Add visual border to show canvas boundaries
|
||
self.canvas.create_rectangle(2, 2, canvas_width-2, canvas_height-2,
|
||
outline='#333333', width=1, tags="border")
|
||
|
||
# Bind canvas events for dragging
|
||
self.canvas.bind("<Button-1>", self.on_canvas_click)
|
||
self.canvas.bind("<B1-Motion>", self.on_canvas_drag)
|
||
self.canvas.bind("<ButtonRelease-1>", self.on_canvas_release)
|
||
|
||
# Frame timeline slider (reduced spacing)
|
||
timeline_frame = tk.Frame(parent, bg=self.colors['bg_secondary'])
|
||
timeline_frame.pack(fill="x", padx=20, pady=(0, 5)) # Further reduced bottom padding from 10 to 5
|
||
|
||
tk.Label(timeline_frame, text="⏱️ Timeline", font=self.fonts['subheading'],
|
||
bg=self.colors['bg_secondary'], fg=self.colors['text_primary']).pack(anchor="w", pady=(0, 5)) # Reduced from 10 to 5
|
||
|
||
# Modern slider styling
|
||
style = ttk.Style()
|
||
style.configure("Modern.Horizontal.TScale",
|
||
background=self.colors['bg_secondary'],
|
||
troughcolor=self.colors['bg_tertiary'],
|
||
sliderlength=20,
|
||
sliderrelief="flat")
|
||
|
||
self.time_var = tk.DoubleVar(value=0)
|
||
self.time_slider = ttk.Scale(timeline_frame, from_=0, to=self.duration,
|
||
orient="horizontal", variable=self.time_var,
|
||
command=self.on_time_change, style="Modern.Horizontal.TScale")
|
||
self.time_slider.pack(fill="x", pady=(0, 5))
|
||
|
||
# Time display
|
||
time_display_frame = tk.Frame(timeline_frame, bg=self.colors['bg_secondary'])
|
||
time_display_frame.pack(fill="x")
|
||
|
||
self.time_label = tk.Label(time_display_frame, text="00:00",
|
||
font=self.fonts['body'], bg=self.colors['bg_secondary'],
|
||
fg=self.colors['text_secondary'])
|
||
self.time_label.pack(side="left")
|
||
|
||
duration_label = tk.Label(time_display_frame, text=f"/ {self.duration//60:02d}:{self.duration%60:02d}",
|
||
font=self.fonts['body'], bg=self.colors['bg_secondary'],
|
||
fg=self.colors['text_muted'])
|
||
duration_label.pack(side="right")
|
||
|
||
# Load initial frame
|
||
self.update_canvas_frame(0)
|
||
|
||
def setup_controls_panel(self, parent):
|
||
"""Setup the right panel controls with modern design and scrollbar"""
|
||
# Create main container
|
||
main_container = tk.Frame(parent, bg=self.colors['bg_secondary'])
|
||
main_container.pack(fill="both", expand=True, padx=10, pady=10)
|
||
|
||
# Create canvas for scrolling
|
||
self.controls_canvas = tk.Canvas(main_container, bg=self.colors['bg_secondary'],
|
||
highlightthickness=0, bd=0)
|
||
|
||
# Create scrollbar with modern styling
|
||
scrollbar = tk.Scrollbar(main_container, orient="vertical",
|
||
command=self.controls_canvas.yview,
|
||
bg=self.colors['bg_tertiary'],
|
||
activebackground=self.colors['accent_blue'],
|
||
troughcolor=self.colors['bg_primary'],
|
||
width=12, relief="flat", bd=0)
|
||
|
||
# Create scrollable frame
|
||
self.scrollable_frame = tk.Frame(self.controls_canvas, bg=self.colors['bg_secondary'])
|
||
|
||
# Create window in canvas first
|
||
canvas_window = self.controls_canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
|
||
|
||
# Configure scrolling with better update handling
|
||
def configure_scroll_region(event):
|
||
self.controls_canvas.configure(scrollregion=self.controls_canvas.bbox("all"))
|
||
|
||
self.scrollable_frame.bind("<Configure>", configure_scroll_region)
|
||
|
||
# Update canvas width when it's resized
|
||
def on_canvas_configure(event):
|
||
self.controls_canvas.itemconfig(canvas_window, width=event.width)
|
||
|
||
self.controls_canvas.bind("<Configure>", on_canvas_configure)
|
||
self.controls_canvas.configure(yscrollcommand=scrollbar.set)
|
||
|
||
# Pack scrollbar and canvas
|
||
scrollbar.pack(side="right", fill="y")
|
||
self.controls_canvas.pack(side="left", fill="both", expand=True)
|
||
|
||
# Bind mouse wheel to canvas
|
||
self.controls_canvas.bind("<MouseWheel>", self._on_mousewheel)
|
||
self.scrollable_frame.bind("<MouseWheel>", self._on_mousewheel)
|
||
|
||
# Also bind to main container for better coverage
|
||
main_container.bind("<MouseWheel>", self._on_mousewheel)
|
||
|
||
# Add padding container inside scrollable frame
|
||
scroll_frame = tk.Frame(self.scrollable_frame, bg=self.colors['bg_secondary'])
|
||
scroll_frame.pack(fill="both", expand=True, padx=10, pady=10)
|
||
|
||
# Title
|
||
controls_title = tk.Label(scroll_frame, text="🎨 Editing Tools",
|
||
font=self.fonts['heading'], bg=self.colors['bg_secondary'],
|
||
fg=self.colors['text_primary'])
|
||
controls_title.pack(anchor="w", pady=(0, 20))
|
||
|
||
# Text Tools Card
|
||
self.create_text_tools_card(scroll_frame)
|
||
|
||
# Stickers Card
|
||
self.create_stickers_card(scroll_frame)
|
||
|
||
# Export Card
|
||
self.create_export_card(scroll_frame)
|
||
|
||
# Bind mousewheel to all widgets in the scroll frame
|
||
self.bind_mousewheel_to_widget(scroll_frame)
|
||
|
||
# Ensure canvas starts at top
|
||
self.controls_canvas.yview_moveto(0)
|
||
|
||
def create_text_tools_card(self, parent):
|
||
"""Create modern text tools card"""
|
||
text_card = self.create_modern_card(parent, "✍️ Text Tools")
|
||
|
||
# Add text button
|
||
add_text_btn = self.create_modern_button(text_card, "➕ Add Text",
|
||
self.colors['accent_blue'], self.add_text)
|
||
add_text_btn.pack(fill="x", pady=(0, 10))
|
||
|
||
# Text style options
|
||
style_frame = tk.Frame(text_card, bg=self.colors['bg_secondary'])
|
||
style_frame.pack(fill="x", pady=(0, 10))
|
||
|
||
tk.Label(style_frame, text="Text Size:", font=self.fonts['body'],
|
||
bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(anchor="w")
|
||
|
||
# Replace limited buttons with spinbox for any size
|
||
size_control_frame = tk.Frame(style_frame, bg=self.colors['bg_secondary'])
|
||
size_control_frame.pack(fill="x", pady=(5, 0))
|
||
|
||
self.text_size_var = tk.IntVar(value=36)
|
||
size_spinbox = tk.Spinbox(size_control_frame, from_=8, to=200, increment=1,
|
||
textvariable=self.text_size_var, width=10,
|
||
font=self.fonts['body'], bg=self.colors['bg_tertiary'],
|
||
fg=self.colors['text_primary'], insertbackground=self.colors['text_primary'],
|
||
buttonbackground=self.colors['accent_blue'],
|
||
relief="flat", bd=1, highlightthickness=1,
|
||
highlightcolor=self.colors['accent_blue'],
|
||
command=self.update_selected_text_size)
|
||
size_spinbox.pack(side="left", padx=(0, 10))
|
||
|
||
# Bind spinbox value changes
|
||
self.text_size_var.trace('w', lambda *args: self.update_selected_text_size())
|
||
|
||
# Quick size buttons for common sizes
|
||
quick_sizes_frame = tk.Frame(size_control_frame, bg=self.colors['bg_secondary'])
|
||
quick_sizes_frame.pack(side="left")
|
||
|
||
tk.Label(quick_sizes_frame, text="Quick:", font=self.fonts['caption'],
|
||
bg=self.colors['bg_secondary'], fg=self.colors['text_muted']).pack(side="left", padx=(0, 5))
|
||
|
||
for size in [24, 36, 48, 64, 80]:
|
||
btn = tk.Button(quick_sizes_frame, text=str(size), font=self.fonts['caption'],
|
||
bg=self.colors['bg_tertiary'], fg=self.colors['text_primary'],
|
||
relief="flat", bd=0, padx=8, pady=3,
|
||
activebackground=self.colors['hover'],
|
||
command=lambda s=size: self.text_size_var.set(s))
|
||
btn.pack(side="left", padx=(0, 3))
|
||
self.add_hover_effect(btn)
|
||
|
||
# Text color
|
||
color_frame = tk.Frame(text_card, bg=self.colors['bg_secondary'])
|
||
color_frame.pack(fill="x", pady=(10, 0))
|
||
|
||
tk.Label(color_frame, text="Text Color:", font=self.fonts['body'],
|
||
bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(anchor="w")
|
||
|
||
self.text_color_btn = self.create_modern_button(color_frame, "🎨 Choose Color",
|
||
self.colors['accent_purple'], self.choose_text_color)
|
||
self.text_color_btn.pack(fill="x", pady=(5, 0))
|
||
|
||
self.current_text_color = "#FFFFFF"
|
||
|
||
def create_stickers_card(self, parent):
|
||
"""Create modern stickers card"""
|
||
stickers_card = self.create_modern_card(parent, "😊 Stickers & Emojis")
|
||
|
||
# Load stickers button
|
||
load_btn = self.create_modern_button(stickers_card, "📁 Load Custom Sticker",
|
||
self.colors['accent_green'], self.load_custom_sticker)
|
||
load_btn.pack(fill="x", pady=(0, 15))
|
||
|
||
# Default stickers grid
|
||
stickers_frame = tk.Frame(stickers_card, bg=self.colors['bg_secondary'])
|
||
stickers_frame.pack(fill="x")
|
||
|
||
tk.Label(stickers_frame, text="Default Stickers:", font=self.fonts['body'],
|
||
bg=self.colors['bg_secondary'], fg=self.colors['text_secondary']).pack(anchor="w", pady=(0, 10))
|
||
|
||
# Create grid for stickers
|
||
self.create_stickers_grid(stickers_frame)
|
||
|
||
def create_stickers_grid(self, parent):
|
||
"""Create a grid of default stickers"""
|
||
sticker_files = [f for f in os.listdir(self.stickers_folder) if f.endswith(('.png', '.jpg', '.jpeg'))]
|
||
|
||
grid_frame = tk.Frame(parent, bg=self.colors['bg_secondary'])
|
||
grid_frame.pack(fill="x")
|
||
|
||
cols = 3
|
||
for i, sticker_file in enumerate(sticker_files[:12]): # Limit to 12 stickers
|
||
row = i // cols
|
||
col = i % cols
|
||
|
||
try:
|
||
sticker_path = os.path.join(self.stickers_folder, sticker_file)
|
||
img = Image.open(sticker_path)
|
||
img.thumbnail((40, 40), Image.Resampling.LANCZOS)
|
||
photo = ImageTk.PhotoImage(img)
|
||
|
||
btn = tk.Button(grid_frame, image=photo,
|
||
bg=self.colors['bg_tertiary'], relief="flat", bd=0,
|
||
activebackground=self.colors['hover'],
|
||
command=lambda path=sticker_path: self.add_sticker(path))
|
||
btn.image = photo # Keep reference
|
||
btn.grid(row=row, column=col, padx=5, pady=5, sticky="nsew")
|
||
self.add_hover_effect(btn)
|
||
|
||
# Configure grid weights
|
||
grid_frame.grid_columnconfigure(col, weight=1)
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error loading sticker {sticker_file}: {e}")
|
||
|
||
def create_export_card(self, parent):
|
||
"""Create modern export options card"""
|
||
export_card = self.create_modern_card(parent, "💾 Export Options")
|
||
|
||
# Clear all button
|
||
clear_btn = self.create_modern_button(export_card, "🗑️ Clear All Elements",
|
||
self.colors['accent_orange'], self.clear_all_elements)
|
||
clear_btn.pack(fill="x", pady=(0, 10))
|
||
|
||
# Save thumbnail button
|
||
save_btn = self.create_modern_button(export_card, "💾 Save Thumbnail",
|
||
self.colors['accent_green'], self.save_thumbnail)
|
||
save_btn.pack(fill="x", pady=(0, 10))
|
||
|
||
# Close editor button
|
||
close_btn = self.create_modern_button(export_card, "❌ Close Editor",
|
||
self.colors['accent_red'], self.close_editor)
|
||
close_btn.pack(fill="x")
|
||
|
||
def _on_mousewheel(self, event):
|
||
"""Handle mouse wheel scrolling with cross-platform support"""
|
||
# Check if there's content to scroll
|
||
if self.controls_canvas.winfo_exists():
|
||
# Windows
|
||
if event.delta:
|
||
self.controls_canvas.yview_scroll(int(-1*(event.delta/120)), "units")
|
||
# Linux
|
||
elif event.num == 4:
|
||
self.controls_canvas.yview_scroll(-1, "units")
|
||
elif event.num == 5:
|
||
self.controls_canvas.yview_scroll(1, "units")
|
||
|
||
def bind_mousewheel_to_widget(self, widget):
|
||
"""Recursively bind mousewheel to widget and all its children"""
|
||
widget.bind("<MouseWheel>", self._on_mousewheel)
|
||
for child in widget.winfo_children():
|
||
self.bind_mousewheel_to_widget(child)
|
||
|
||
def create_modern_card(self, parent, title):
|
||
"""Create a modern card container"""
|
||
card_frame = tk.Frame(parent, bg=self.colors['bg_tertiary'], relief="flat", bd=0)
|
||
card_frame.pack(fill="x", pady=(0, 20))
|
||
|
||
# Card header
|
||
header_frame = tk.Frame(card_frame, bg=self.colors['bg_tertiary'])
|
||
header_frame.pack(fill="x", padx=15, pady=(15, 10))
|
||
|
||
title_label = tk.Label(header_frame, text=title, font=self.fonts['subheading'],
|
||
bg=self.colors['bg_tertiary'], fg=self.colors['text_primary'])
|
||
title_label.pack(anchor="w")
|
||
|
||
# Card content
|
||
content_frame = tk.Frame(card_frame, bg=self.colors['bg_secondary'])
|
||
content_frame.pack(fill="both", expand=True, padx=15, pady=(0, 15))
|
||
|
||
return content_frame
|
||
|
||
def create_modern_button(self, parent, text, color, command):
|
||
"""Create a modern styled button"""
|
||
btn = tk.Button(parent, text=text, font=self.fonts['button'],
|
||
bg=color, fg=self.colors['text_primary'],
|
||
relief="flat", bd=0, padx=20, pady=12,
|
||
activebackground=self.colors['hover'],
|
||
command=command, cursor="hand2")
|
||
self.add_hover_effect(btn, color)
|
||
return btn
|
||
|
||
def add_hover_effect(self, widget, base_color=None):
|
||
"""Add hover effect to widget"""
|
||
if base_color is None:
|
||
base_color = self.colors['bg_tertiary']
|
||
|
||
def on_enter(e):
|
||
widget.configure(bg=self.colors['hover'])
|
||
|
||
def on_leave(e):
|
||
widget.configure(bg=base_color)
|
||
|
||
widget.bind("<Enter>", on_enter)
|
||
widget.bind("<Leave>", on_leave)
|
||
|
||
def capture_frame_at(self, time_sec):
|
||
"""Capture frame from video at specific time, sized for canvas"""
|
||
try:
|
||
frame = self.clip.get_frame(max(0, min(time_sec, self.clip.duration - 0.1)))
|
||
img = Image.fromarray(frame)
|
||
# Maintain aspect ratio while fitting in the smaller canvas
|
||
img.thumbnail((self.canvas_width, self.canvas_height), Image.Resampling.LANCZOS)
|
||
return img
|
||
except Exception as e:
|
||
print(f"⚠️ Error capturing frame: {e}")
|
||
# Create a placeholder image sized for canvas
|
||
img = Image.new('RGB', (self.canvas_width, self.canvas_height), color='black')
|
||
return img
|
||
|
||
def update_canvas_frame(self, time_sec):
|
||
"""Update canvas with frame at specific time, centered within bounds"""
|
||
try:
|
||
self.current_frame_img = self.capture_frame_at(time_sec)
|
||
self.tk_frame_img = ImageTk.PhotoImage(self.current_frame_img)
|
||
|
||
# Clear canvas and add new frame, centered within canvas bounds
|
||
self.canvas.delete("frame")
|
||
|
||
# Use stored center coordinates for consistent positioning
|
||
self.canvas.create_image(self.canvas_center_x, self.canvas_center_y,
|
||
image=self.tk_frame_img, tags="frame")
|
||
self.canvas.image = self.tk_frame_img
|
||
|
||
# Update time display
|
||
minutes = int(time_sec) // 60
|
||
seconds = int(time_sec) % 60
|
||
self.time_label.config(text=f"{minutes:02d}:{seconds:02d}")
|
||
|
||
except Exception as e:
|
||
print(f"⚠️ Error updating frame: {e}")
|
||
|
||
def on_time_change(self, val):
|
||
"""Handle timeline slider change"""
|
||
self.update_canvas_frame(float(val))
|
||
|
||
# Canvas interaction methods
|
||
def on_canvas_click(self, event):
|
||
"""Handle canvas click for dragging (excluding video frame)"""
|
||
item = self.canvas.find_closest(event.x, event.y)[0]
|
||
# Prevent dragging the video frame or border
|
||
if item and item not in self.canvas.find_withtag("frame") and item not in self.canvas.find_withtag("border"):
|
||
self.drag_data["item"] = item
|
||
self.drag_data["x"] = event.x
|
||
self.drag_data["y"] = event.y
|
||
|
||
def on_canvas_drag(self, event):
|
||
"""Handle canvas dragging with boundary constraints"""
|
||
if self.drag_data["item"]:
|
||
dx = event.x - self.drag_data["x"]
|
||
dy = event.y - self.drag_data["y"]
|
||
|
||
# Get current item position and bounds
|
||
item = self.drag_data["item"]
|
||
bbox = self.canvas.bbox(item)
|
||
|
||
if bbox:
|
||
x1, y1, x2, y2 = bbox
|
||
|
||
# Calculate new position
|
||
new_x1 = x1 + dx
|
||
new_y1 = y1 + dy
|
||
new_x2 = x2 + dx
|
||
new_y2 = y2 + dy
|
||
|
||
# Check boundaries and constrain movement
|
||
if new_x1 < 0:
|
||
dx = -x1
|
||
elif new_x2 > self.canvas_width:
|
||
dx = self.canvas_width - x2
|
||
|
||
if new_y1 < 0:
|
||
dy = -y1
|
||
elif new_y2 > self.canvas_height:
|
||
dy = self.canvas_height - y2
|
||
|
||
# Move item with constraints
|
||
self.canvas.move(item, dx, dy)
|
||
self.drag_data["x"] = event.x
|
||
self.drag_data["y"] = event.y
|
||
|
||
def on_canvas_release(self, event):
|
||
"""Handle canvas release"""
|
||
self.drag_data["item"] = None
|
||
|
||
# Editing functionality
|
||
def add_text(self):
|
||
"""Add text to the canvas within boundaries"""
|
||
text = simpledialog.askstring("Add Text", "Enter text:")
|
||
if text:
|
||
try:
|
||
size = self.text_size_var.get()
|
||
# Center text but ensure it's within canvas bounds
|
||
x = min(self.canvas_width // 2, self.canvas_width - 50)
|
||
y = min(self.canvas_height // 2, self.canvas_height - 30)
|
||
item = self.canvas.create_text(x, y, text=text, fill=self.current_text_color,
|
||
font=("Arial", size, "bold"), tags="draggable")
|
||
self.canvas_items.append(("text", item, text, self.current_text_color, size))
|
||
print(f"✅ Added text: {text}")
|
||
except Exception as e:
|
||
print(f"⚠️ Error adding text: {e}")
|
||
|
||
def update_selected_text_size(self):
|
||
"""Update the size of selected text items"""
|
||
try:
|
||
new_size = self.text_size_var.get()
|
||
# Update all selected text items or the last added text
|
||
for i, (item_type, item_id, text, color, size) in enumerate(self.canvas_items):
|
||
if item_type == "text":
|
||
# Update the font size
|
||
current_font = self.canvas.itemcget(item_id, "font")
|
||
if isinstance(current_font, str):
|
||
font_parts = current_font.split()
|
||
if len(font_parts) >= 2:
|
||
font_family = font_parts[0]
|
||
font_style = font_parts[2] if len(font_parts) > 2 else "bold"
|
||
new_font = (font_family, new_size, font_style)
|
||
else:
|
||
new_font = ("Arial", new_size, "bold")
|
||
else:
|
||
new_font = ("Arial", new_size, "bold")
|
||
|
||
self.canvas.itemconfig(item_id, font=new_font)
|
||
# Update the stored size in canvas_items
|
||
self.canvas_items[i] = (item_type, item_id, text, color, new_size)
|
||
except Exception as e:
|
||
print(f"⚠️ Error updating text size: {e}")
|
||
|
||
def choose_text_color(self):
|
||
"""Choose text color"""
|
||
color = colorchooser.askcolor(title="Choose text color")
|
||
if color[1]:
|
||
self.current_text_color = color[1]
|
||
# Update button color to show current selection
|
||
self.text_color_btn.config(bg=self.current_text_color)
|
||
|
||
def add_sticker(self, path):
|
||
"""Add sticker to canvas within boundaries"""
|
||
try:
|
||
img = Image.open(path).convert("RGBA")
|
||
img.thumbnail((60, 60), Image.Resampling.LANCZOS)
|
||
tk_img = ImageTk.PhotoImage(img)
|
||
|
||
# Place sticker within canvas bounds
|
||
x = min(self.canvas_width // 2, self.canvas_width - 30)
|
||
y = min(self.canvas_height // 2, self.canvas_height - 30)
|
||
item = self.canvas.create_image(x, y, image=tk_img, tags="draggable")
|
||
|
||
# Keep reference to prevent garbage collection
|
||
if not hasattr(self.canvas, 'images'):
|
||
self.canvas.images = []
|
||
self.canvas.images.append(tk_img)
|
||
self.canvas_items.append(("sticker", item, img, path))
|
||
print(f"✅ Added sticker: {os.path.basename(path)}")
|
||
except Exception as e:
|
||
print(f"⚠️ Failed to load sticker {path}: {e}")
|
||
|
||
def load_custom_sticker(self):
|
||
"""Load custom sticker file"""
|
||
file_path = filedialog.askopenfilename(
|
||
title="Select Sticker",
|
||
filetypes=[("Image files", "*.png *.jpg *.jpeg *.gif *.bmp")]
|
||
)
|
||
if file_path:
|
||
self.add_sticker(file_path)
|
||
|
||
def clear_all_elements(self):
|
||
"""Clear all added elements"""
|
||
# Clear all draggable items
|
||
self.canvas.delete("draggable")
|
||
self.canvas_items.clear()
|
||
if hasattr(self.canvas, 'images'):
|
||
self.canvas.images.clear()
|
||
print("🗑️ Cleared all elements")
|
||
|
||
def save_thumbnail(self):
|
||
"""Save the current thumbnail"""
|
||
if not self.current_frame_img:
|
||
messagebox.showerror("Error", "No frame loaded")
|
||
return
|
||
|
||
save_path = filedialog.asksaveasfilename(
|
||
title="Save Thumbnail",
|
||
defaultextension=".jpg",
|
||
filetypes=[("JPEG files", "*.jpg"), ("PNG files", "*.png")]
|
||
)
|
||
|
||
if not save_path:
|
||
return
|
||
|
||
try:
|
||
# Create a copy of the current frame
|
||
frame = self.current_frame_img.copy().convert("RGBA")
|
||
canvas_width = self.canvas.winfo_width()
|
||
canvas_height = self.canvas.winfo_height()
|
||
|
||
# Calculate scaling factors
|
||
scale_x = frame.width / canvas_width
|
||
scale_y = frame.height / canvas_height
|
||
|
||
draw = ImageDraw.Draw(frame)
|
||
|
||
# Process all canvas items
|
||
for item_type, item_id, *data in self.canvas_items:
|
||
coords = self.canvas.coords(item_id)
|
||
if not coords:
|
||
continue
|
||
|
||
if item_type == "sticker":
|
||
# Handle sticker overlay
|
||
img_data, path = data
|
||
x, y = coords[0], coords[1]
|
||
px = int(x * scale_x)
|
||
py = int(y * scale_y)
|
||
|
||
# Scale sticker size
|
||
sticker_img = img_data.copy()
|
||
new_size = (int(60 * scale_x), int(60 * scale_y))
|
||
sticker_img = sticker_img.resize(new_size, Image.Resampling.LANCZOS)
|
||
|
||
# Calculate position to center the sticker
|
||
paste_x = px - sticker_img.width // 2
|
||
paste_y = py - sticker_img.height // 2
|
||
|
||
frame.paste(sticker_img, (paste_x, paste_y), sticker_img)
|
||
|
||
elif item_type == "text":
|
||
# Handle text overlay
|
||
text_value, color, font_size = data
|
||
x, y = coords[0], coords[1]
|
||
px = int(x * scale_x)
|
||
py = int(y * scale_y)
|
||
|
||
# Scale font size
|
||
scaled_font_size = int(font_size * scale_x)
|
||
|
||
try:
|
||
font = ImageFont.truetype("arial.ttf", scaled_font_size)
|
||
except:
|
||
try:
|
||
font = ImageFont.truetype("calibri.ttf", scaled_font_size)
|
||
except:
|
||
font = ImageFont.load_default()
|
||
|
||
# Get text bounding box for centering
|
||
bbox = draw.textbbox((0, 0), text_value, font=font)
|
||
text_w = bbox[2] - bbox[0]
|
||
text_h = bbox[3] - bbox[1]
|
||
|
||
# Draw text with outline
|
||
outline_w = max(2, scaled_font_size // 15)
|
||
for dx in range(-outline_w, outline_w + 1):
|
||
for dy in range(-outline_w, outline_w + 1):
|
||
draw.text((px - text_w//2 + dx, py - text_h//2 + dy),
|
||
text_value, font=font, fill="black")
|
||
|
||
draw.text((px - text_w//2, py - text_h//2), text_value, font=font, fill=color)
|
||
|
||
# Convert to RGB and save
|
||
if save_path.lower().endswith('.png'):
|
||
frame.save(save_path, "PNG", quality=95)
|
||
else:
|
||
background = Image.new("RGB", frame.size, (255, 255, 255))
|
||
background.paste(frame, mask=frame.split()[3] if frame.mode == 'RGBA' else None)
|
||
background.save(save_path, "JPEG", quality=95)
|
||
|
||
print(f"✅ Thumbnail saved: {save_path}")
|
||
messagebox.showinfo("Success", f"Thumbnail saved successfully!\n{save_path}")
|
||
|
||
except Exception as e:
|
||
print(f"❌ Error saving thumbnail: {e}")
|
||
messagebox.showerror("Error", f"Failed to save thumbnail:\n{str(e)}")
|
||
|
||
def close_editor(self):
|
||
"""Close the editor"""
|
||
try:
|
||
if self.clip:
|
||
self.clip.close()
|
||
except:
|
||
pass
|
||
self.editor.destroy()
|
||
|
||
def create_default_stickers(self):
|
||
"""Create default emoji stickers"""
|
||
stickers_data = {
|
||
"smile.png": "😊",
|
||
"laugh.png": "😂",
|
||
"happy-face.png": "😀",
|
||
"sad-face.png": "😢",
|
||
"confused.png": "😕",
|
||
"party.png": "🎉",
|
||
"emoji.png": "👍",
|
||
"emoji (1).png": "❤️",
|
||
"smile (1).png": "😄"
|
||
}
|
||
|
||
for filename, emoji in stickers_data.items():
|
||
filepath = os.path.join(self.stickers_folder, filename)
|
||
if not os.path.exists(filepath):
|
||
try:
|
||
# Create simple emoji images
|
||
img = Image.new('RGBA', (64, 64), (255, 255, 255, 0))
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
# Try to use a font for emoji, fallback to colored rectangles
|
||
try:
|
||
font = ImageFont.truetype("seguiemj.ttf", 48)
|
||
draw.text((8, 8), emoji, font=font, fill="black")
|
||
except:
|
||
# Fallback: create colored circles/shapes
|
||
colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE']
|
||
color = colors[hash(filename) % len(colors)]
|
||
draw.ellipse([8, 8, 56, 56], fill=color)
|
||
draw.text((20, 20), emoji[:2], fill="white", font=ImageFont.load_default())
|
||
|
||
img.save(filepath, 'PNG')
|
||
print(f"✅ Created default sticker: {filename}")
|
||
except Exception as e:
|
||
print(f"⚠️ Error creating sticker {filename}: {e}")
|
||
|
||
|
||
# Legacy function to maintain compatibility
|
||
def open_thumbnail_editor(video_path):
|
||
"""Legacy function for backward compatibility"""
|
||
editor = ModernThumbnailEditor(video_path)
|
||
return editor
|
||
|
||
|
||
if __name__ == "__main__":
|
||
# Test the editor
|
||
test_video = "myvideo.mp4" # Replace with actual video path
|
||
if os.path.exists(test_video):
|
||
root = tk.Tk()
|
||
root.withdraw() # Hide main window
|
||
editor = ModernThumbnailEditor(test_video)
|
||
root.mainloop()
|
||
else:
|
||
print("Please provide a valid video file path")
|