feat: Implement professional thumbnail editor with video frame capture, emoji stickers, and text editing features
BIN
stickers/confused.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
stickers/emoji (1).png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
stickers/emoji.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
stickers/happy-face.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
stickers/laugh.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
stickers/party.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
stickers/sad-face.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
stickers/smile (1).png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
stickers/smile.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
394
thumbnail_editor.py
Normal file
@ -0,0 +1,394 @@
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, simpledialog, colorchooser, messagebox
|
||||
from moviepy import VideoFileClip
|
||||
from PIL import Image, ImageTk, ImageDraw, ImageFont
|
||||
|
||||
# Enhanced Thumbnail Editor with Frame Slider + Default Emoji Pack + Text Adding
|
||||
|
||||
def open_thumbnail_editor(video_path):
|
||||
try:
|
||||
editor = tk.Toplevel()
|
||||
editor.title("📸 Professional Thumbnail Editor")
|
||||
editor.geometry("1200x800")
|
||||
|
||||
# Load video
|
||||
print(f"📹 Loading video: {os.path.basename(video_path)}")
|
||||
clip = VideoFileClip(video_path)
|
||||
duration = int(clip.duration)
|
||||
|
||||
# Default emoji pack folder
|
||||
stickers_folder = os.path.join(os.path.dirname(__file__), "stickers")
|
||||
os.makedirs(stickers_folder, exist_ok=True)
|
||||
|
||||
# Create default stickers if folder is empty
|
||||
create_default_stickers(stickers_folder)
|
||||
|
||||
# Main layout
|
||||
main_frame = tk.Frame(editor)
|
||||
main_frame.pack(fill="both", expand=True, padx=10, pady=10)
|
||||
|
||||
# Canvas setup (left side)
|
||||
canvas_frame = tk.Frame(main_frame)
|
||||
canvas_frame.pack(side="left", fill="both", expand=True)
|
||||
|
||||
tk.Label(canvas_frame, text="🎬 Thumbnail Preview", font=("Arial", 12, "bold")).pack()
|
||||
|
||||
canvas = tk.Canvas(canvas_frame, width=720, height=405, bg="black", relief="sunken", bd=2)
|
||||
canvas.pack(pady=10)
|
||||
|
||||
# Track items for dragging
|
||||
drag_data = {"item": None, "x": 0, "y": 0}
|
||||
|
||||
def capture_frame_at(time_sec):
|
||||
try:
|
||||
frame = clip.get_frame(max(0, min(time_sec, clip.duration - 0.1)))
|
||||
img = Image.fromarray(frame)
|
||||
# Maintain aspect ratio while fitting in canvas
|
||||
img.thumbnail((720, 405), Image.Resampling.LANCZOS)
|
||||
return img
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error capturing frame: {e}")
|
||||
# Create a placeholder image
|
||||
img = Image.new('RGB', (720, 405), color='black')
|
||||
return img
|
||||
|
||||
# Displayed image
|
||||
current_frame = capture_frame_at(duration // 2)
|
||||
tk_frame_img = ImageTk.PhotoImage(current_frame)
|
||||
image_item = canvas.create_image(360, 202, image=tk_frame_img)
|
||||
canvas.image = tk_frame_img
|
||||
|
||||
# Items data
|
||||
sticker_items = []
|
||||
text_items = []
|
||||
|
||||
def update_canvas_frame(val):
|
||||
nonlocal current_frame, tk_frame_img
|
||||
try:
|
||||
sec = float(val)
|
||||
current_frame = capture_frame_at(sec)
|
||||
tk_frame_img = ImageTk.PhotoImage(current_frame)
|
||||
canvas.itemconfig(image_item, image=tk_frame_img)
|
||||
canvas.image = tk_frame_img
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error updating frame: {e}")
|
||||
|
||||
# Frame controls
|
||||
controls_frame = tk.Frame(canvas_frame)
|
||||
controls_frame.pack(fill="x", pady=5)
|
||||
|
||||
tk.Label(controls_frame, text="⏱️ Frame Time (seconds):").pack()
|
||||
frame_slider = tk.Scale(controls_frame, from_=0, to=duration, orient="horizontal",
|
||||
command=update_canvas_frame, length=600, resolution=0.1)
|
||||
frame_slider.set(duration // 2)
|
||||
frame_slider.pack(fill="x", pady=5)
|
||||
|
||||
# Tools panel (right side)
|
||||
tools_frame = tk.Frame(main_frame, width=300, relief="groove", bd=2)
|
||||
tools_frame.pack(side="right", fill="y", padx=(10, 0))
|
||||
tools_frame.pack_propagate(False)
|
||||
|
||||
tk.Label(tools_frame, text="🛠️ Editing Tools", font=("Arial", 14, "bold")).pack(pady=10)
|
||||
|
||||
# Stickers section
|
||||
stickers_label_frame = tk.LabelFrame(tools_frame, text="🎭 Stickers & Emojis", padx=10, pady=5)
|
||||
stickers_label_frame.pack(fill="x", padx=10, pady=5)
|
||||
|
||||
# Create scrollable frame for stickers
|
||||
stickers_canvas = tk.Canvas(stickers_label_frame, height=200)
|
||||
stickers_scrollbar = tk.Scrollbar(stickers_label_frame, orient="vertical", command=stickers_canvas.yview)
|
||||
stickers_scrollable_frame = tk.Frame(stickers_canvas)
|
||||
|
||||
stickers_scrollable_frame.bind(
|
||||
"<Configure>",
|
||||
lambda e: stickers_canvas.configure(scrollregion=stickers_canvas.bbox("all"))
|
||||
)
|
||||
|
||||
stickers_canvas.create_window((0, 0), window=stickers_scrollable_frame, anchor="nw")
|
||||
stickers_canvas.configure(yscrollcommand=stickers_scrollbar.set)
|
||||
|
||||
stickers_canvas.pack(side="left", fill="both", expand=True)
|
||||
stickers_scrollbar.pack(side="right", fill="y")
|
||||
|
||||
def add_sticker(path):
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
img.thumbnail((60, 60), Image.Resampling.LANCZOS)
|
||||
tk_img = ImageTk.PhotoImage(img)
|
||||
item = canvas.create_image(360, 200, image=tk_img)
|
||||
|
||||
# Keep reference to prevent garbage collection
|
||||
if not hasattr(canvas, 'images'):
|
||||
canvas.images = []
|
||||
canvas.images.append(tk_img)
|
||||
sticker_items.append((item, img))
|
||||
print(f"✅ Added sticker: {os.path.basename(path)}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to load sticker {path}: {e}")
|
||||
|
||||
# Load default stickers
|
||||
sticker_count = 0
|
||||
stickers_row_frame = None
|
||||
|
||||
for file in os.listdir(stickers_folder):
|
||||
if file.lower().endswith(('.png', '.jpg', '.jpeg', '.gif')):
|
||||
try:
|
||||
if sticker_count % 4 == 0: # 4 stickers per row
|
||||
stickers_row_frame = tk.Frame(stickers_scrollable_frame)
|
||||
stickers_row_frame.pack(fill="x", pady=2)
|
||||
|
||||
btn_img = Image.open(os.path.join(stickers_folder, file)).convert("RGBA")
|
||||
btn_img.thumbnail((40, 40), Image.Resampling.LANCZOS)
|
||||
tk_btn_img = ImageTk.PhotoImage(btn_img)
|
||||
|
||||
b = tk.Button(stickers_row_frame, image=tk_btn_img,
|
||||
command=lambda f=file: add_sticker(os.path.join(stickers_folder, f)))
|
||||
b.image = tk_btn_img
|
||||
b.pack(side="left", padx=2)
|
||||
sticker_count += 1
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to load sticker {file}: {e}")
|
||||
|
||||
# Add custom sticker button
|
||||
tk.Button(stickers_label_frame, text="📁 Add Custom Sticker",
|
||||
command=lambda: add_custom_sticker()).pack(pady=5)
|
||||
|
||||
def add_custom_sticker():
|
||||
file_path = filedialog.askopenfilename(
|
||||
title="Select Sticker Image",
|
||||
filetypes=[("Image files", "*.png *.jpg *.jpeg *.gif"), ("All files", "*.*")]
|
||||
)
|
||||
if file_path:
|
||||
add_sticker(file_path)
|
||||
|
||||
# Text section
|
||||
text_label_frame = tk.LabelFrame(tools_frame, text="📝 Text Tools", padx=10, pady=5)
|
||||
text_label_frame.pack(fill="x", padx=10, pady=5)
|
||||
|
||||
def add_text():
|
||||
text_value = simpledialog.askstring("Add Text", "Enter text:")
|
||||
if not text_value:
|
||||
return
|
||||
|
||||
color_result = colorchooser.askcolor(title="Choose text color")
|
||||
color = color_result[1] if color_result[1] else "white"
|
||||
|
||||
size = simpledialog.askinteger("Font size", "Enter font size:",
|
||||
initialvalue=48, minvalue=8, maxvalue=200)
|
||||
if not size:
|
||||
size = 48
|
||||
|
||||
try:
|
||||
item = canvas.create_text(360, 200, text=text_value, fill=color,
|
||||
font=("Arial", size, "bold"), anchor="center")
|
||||
text_items.append((item, text_value, color, size))
|
||||
print(f"✅ Added text: '{text_value}'")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error adding text: {e}")
|
||||
|
||||
tk.Button(text_label_frame, text="➕ Add Text", command=add_text,
|
||||
bg="#4CAF50", fg="white", font=("Arial", 10, "bold")).pack(pady=5, fill="x")
|
||||
|
||||
# Clear all button
|
||||
def clear_all():
|
||||
if messagebox.askyesno("Clear All", "Remove all stickers and text?"):
|
||||
for item_id, _ in sticker_items + text_items:
|
||||
canvas.delete(item_id)
|
||||
sticker_items.clear()
|
||||
text_items.clear()
|
||||
print("🗑️ Cleared all items")
|
||||
|
||||
tk.Button(text_label_frame, text="🗑️ Clear All", command=clear_all,
|
||||
bg="#F44336", fg="white").pack(pady=5, fill="x")
|
||||
|
||||
# Drag handling
|
||||
def on_drag_start(event):
|
||||
items = canvas.find_overlapping(event.x, event.y, event.x, event.y)
|
||||
items = [i for i in items if i != image_item]
|
||||
if not items:
|
||||
return
|
||||
item = items[-1] # topmost
|
||||
drag_data["item"] = item
|
||||
drag_data["x"] = event.x
|
||||
drag_data["y"] = event.y
|
||||
|
||||
def on_drag_motion(event):
|
||||
if drag_data["item"] is None:
|
||||
return
|
||||
dx = event.x - drag_data["x"]
|
||||
dy = event.y - drag_data["y"]
|
||||
canvas.move(drag_data["item"], dx, dy)
|
||||
drag_data["x"] = event.x
|
||||
drag_data["y"] = event.y
|
||||
|
||||
def on_drag_release(event):
|
||||
drag_data["item"] = None
|
||||
|
||||
canvas.bind("<Button-1>", on_drag_start)
|
||||
canvas.bind("<B1-Motion>", on_drag_motion)
|
||||
canvas.bind("<ButtonRelease-1>", on_drag_release)
|
||||
|
||||
# Save section
|
||||
save_frame = tk.LabelFrame(tools_frame, text="💾 Export Options", padx=10, pady=5)
|
||||
save_frame.pack(fill="x", padx=10, pady=5)
|
||||
|
||||
def save_thumbnail():
|
||||
try:
|
||||
save_path = filedialog.asksaveasfilename(
|
||||
defaultextension=".jpg",
|
||||
filetypes=[("JPEG", "*.jpg"), ("PNG", "*.png"), ("All files", "*.*")],
|
||||
title="Save Thumbnail As"
|
||||
)
|
||||
if not save_path:
|
||||
return
|
||||
|
||||
print("💾 Generating high-quality thumbnail...")
|
||||
|
||||
# Get the current frame at full resolution
|
||||
sec = float(frame_slider.get())
|
||||
frame = Image.fromarray(clip.get_frame(sec)).convert("RGBA")
|
||||
|
||||
# Calculate scaling factors
|
||||
canvas_w, canvas_h = 720, 405
|
||||
scale_x = frame.width / canvas_w
|
||||
scale_y = frame.height / canvas_h
|
||||
|
||||
# Add stickers
|
||||
for item_id, sticker_img in sticker_items:
|
||||
coords = canvas.coords(item_id)
|
||||
if not coords:
|
||||
continue
|
||||
|
||||
x, y = coords[0], coords[1]
|
||||
# Convert canvas coordinates to frame coordinates
|
||||
px = int(x * scale_x)
|
||||
py = int(y * scale_y)
|
||||
|
||||
# Scale sticker size
|
||||
target_w = int(sticker_img.width * scale_x)
|
||||
target_h = int(sticker_img.height * scale_y)
|
||||
|
||||
if target_w > 0 and target_h > 0:
|
||||
sticker_resized = sticker_img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
# Paste with alpha blending
|
||||
frame.paste(sticker_resized, (px - target_w//2, py - target_h//2), sticker_resized)
|
||||
|
||||
# Add text
|
||||
draw = ImageDraw.Draw(frame)
|
||||
for item_id, text_value, color, font_size in text_items:
|
||||
coords = canvas.coords(item_id)
|
||||
if not coords:
|
||||
continue
|
||||
|
||||
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)}")
|
||||
|
||||
tk.Button(save_frame, text="💾 Save Thumbnail", command=save_thumbnail,
|
||||
bg="#2196F3", fg="white", font=("Arial", 12, "bold")).pack(pady=5, fill="x")
|
||||
|
||||
# Info label
|
||||
info_text = f"📹 Video: {os.path.basename(video_path)}\n⏱️ Duration: {duration}s\n📐 Size: {clip.size[0]}x{clip.size[1]}"
|
||||
tk.Label(save_frame, text=info_text, font=("Arial", 8), justify="left").pack(pady=5)
|
||||
|
||||
print(f"✅ Thumbnail editor loaded successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error opening thumbnail editor: {e}")
|
||||
messagebox.showerror("Error", f"Failed to open thumbnail editor:\n{str(e)}")
|
||||
|
||||
def create_default_stickers(stickers_folder):
|
||||
"""Create some default emoji stickers if folder is empty"""
|
||||
if os.listdir(stickers_folder):
|
||||
return # Already has stickers
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# Create simple emoji stickers
|
||||
emojis = [
|
||||
("😀", (255, 255, 0)), # Happy face
|
||||
("❤️", (255, 0, 0)), # Heart
|
||||
("👍", (255, 220, 177)), # Thumbs up
|
||||
("🔥", (255, 100, 0)), # Fire
|
||||
("⭐", (255, 215, 0)), # Star
|
||||
("💯", (0, 255, 0)), # 100
|
||||
]
|
||||
|
||||
for i, (emoji, color) in enumerate(emojis):
|
||||
# Create a simple colored circle as placeholder
|
||||
img = Image.new('RGBA', (80, 80), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.ellipse([10, 10, 70, 70], fill=color)
|
||||
|
||||
# Save as PNG
|
||||
img.save(os.path.join(stickers_folder, f"emoji_{i+1}.png"))
|
||||
|
||||
print("✅ Created default sticker pack")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not create default stickers: {e}")
|
||||
|
||||
# Main execution
|
||||
if __name__ == '__main__':
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
|
||||
video_path = filedialog.askopenfilename(
|
||||
title='Select a video file',
|
||||
filetypes=[('Video files', '*.mp4 *.mov *.avi *.mkv'), ('All files', '*.*')]
|
||||
)
|
||||
|
||||
if video_path:
|
||||
try:
|
||||
root.deiconify() # Show root window
|
||||
root.title("Thumbnail Editor")
|
||||
open_thumbnail_editor(video_path)
|
||||
root.mainloop()
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
messagebox.showerror("Error", f"Failed to start thumbnail editor:\n{str(e)}")
|
||||
else:
|
||||
print('No video selected.')
|
||||
root.destroy()
|
||||