ShortGenerator/subtitle_generator2.py
klop51 5ce79f084d Refactor subtitle rendering and highlight functionality
- Improved the rendering of subtitles in preview mode to highlight the currently selected word.
- Enhanced export functionality to create highlights for all words in sequence using preset settings.
- Updated the GUI to reflect changes in highlight word selection and preset slot navigation.
- Added error handling for subtitle creation and improved logging for better debugging.
- Introduced a new script for extracting subtitles and generating short clips based on loud moments in the audio.
- Updated JSON preset files to remove obsolete fields and ensure compatibility with the new features.
2025-08-07 22:25:56 +02:00

770 lines
29 KiB
Python
Raw Blame History

import tkinter as tk
from tkinter import filedialog
from moviepy import VideoFileClip, ImageClip, CompositeVideoClip
import threading
import json
import pysrt
import os
from PIL import Image, ImageDraw, ImageFont
import numpy as np
def find_system_font():
"""Find a working system font for PIL"""
print("Finding system fonts for PIL...")
fonts_to_try = [
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/calibri.ttf",
"C:/Windows/Fonts/times.ttf",
"C:/Windows/Fonts/verdana.ttf",
"C:/Windows/Fonts/tahoma.ttf",
"C:/Windows/Fonts/segoeui.ttf",
]
for font_path in fonts_to_try:
try:
if os.path.exists(font_path):
# Test the font by creating a small text image
font = ImageFont.truetype(font_path, 20)
img = Image.new('RGB', (100, 50), color='white')
draw = ImageDraw.Draw(img)
draw.text((10, 10), "Test", font=font, fill='black')
print(f"Using font: {font_path}")
return font_path
except Exception as e:
print(f"Font test failed for {font_path}: {e}")
continue
print("Using default font")
return None
def create_text_image(text, font_size=50, color='white', stroke_color='black', stroke_width=3, font_path=None):
"""Create a text image using PIL"""
try:
# Load font
if font_path and os.path.exists(font_path):
font = ImageFont.truetype(font_path, font_size)
else:
# Try to use default font
try:
font = ImageFont.load_default()
except:
# Last resort - create a basic font
font = ImageFont.load_default()
# Get text dimensions
temp_img = Image.new('RGB', (1, 1))
temp_draw = ImageDraw.Draw(temp_img)
try:
bbox = temp_draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
except:
# Fallback method for older PIL versions
text_width, text_height = temp_draw.textsize(text, font=font)
# Add padding
padding = max(stroke_width * 2, 10)
img_width = text_width + padding * 2
img_height = text_height + padding * 2
# Create transparent image
img = Image.new('RGBA', (img_width, img_height), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# Calculate text position (centered)
x = (img_width - text_width) // 2
y = (img_height - text_height) // 2
# Convert color names to RGB
if isinstance(color, str):
if color == 'white':
color = (255, 255, 255, 255)
elif color.startswith('#'):
# Convert hex to RGB
hex_color = color.lstrip('#')
color = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + (255,)
if isinstance(stroke_color, str):
if stroke_color == 'black':
stroke_color = (0, 0, 0, 255)
elif stroke_color.startswith('#'):
hex_color = stroke_color.lstrip('#')
stroke_color = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + (255,)
# Draw text with stroke
if stroke_width > 0 and stroke_color:
# Draw stroke by drawing text in multiple positions
for dx in range(-stroke_width, stroke_width + 1):
for dy in range(-stroke_width, stroke_width + 1):
if dx*dx + dy*dy <= stroke_width*stroke_width:
draw.text((x + dx, y + dy), text, font=font, fill=stroke_color)
# Draw main text
draw.text((x, y), text, font=font, fill=color)
# Convert to numpy array for MoviePy
img_array = np.array(img)
return img_array, img_width, img_height
except Exception as e:
print(f"Text image creation failed: {e}")
# Create a simple colored rectangle as fallback
img = Image.new('RGBA', (200, 50), (255, 255, 255, 255))
return np.array(img), 200, 50
def debug_moviepy_text():
"""Debug MoviePy TextClip functionality"""
print("🔍 Debugging MoviePy TextClip...")
# Set the correct ImageMagick path
imagemagick_path = r"C:\Program Files\ImageMagick-7.1.2-Q16-HDRI\magick.exe"
os.environ["IMAGEMAGICK_BINARY"] = imagemagick_path
print(f"<EFBFBD> Setting ImageMagick path: {imagemagick_path}")
# Test different approaches to fix the font issue
methods_to_try = [
{"name": "Basic TextClip", "params": {"font_size": 20}},
{"name": "TextClip with method='caption'", "params": {"font_size": 20, "method": "caption"}},
{"name": "TextClip with method='label'", "params": {"font_size": 20, "method": "label"}},
{"name": "TextClip color only", "params": {"color": "white"}},
{"name": "TextClip minimal", "params": {}},
]
for i, method in enumerate(methods_to_try, 1):
try:
print(f"Test {i}: {method['name']}...")
clip = TextClip("test", **method["params"])
clip.close()
print(f"{method['name']} works")
return method["params"]
except Exception as e:
print(f"{method['name']} failed: {e}")
# Try additional environment variables
print("🔧 Attempting additional font fixes...")
try:
os.environ["FONTCONFIG_PATH"] = "C:/Windows/Fonts"
os.environ["MAGICK_FONT_PATH"] = "C:/Windows/Fonts"
clip = TextClip("test", font_size=20)
clip.close()
print("✅ Font fix successful")
return {"font_size": 20}
except Exception as e:
print(f"❌ Font fix failed: {e}")
print("❌ All TextClip methods failed")
print("🆘 For now, the app will work without text overlays")
return None
# Find working font at startup
SYSTEM_FONT = find_system_font()
# Global settings with defaults
settings = {
"subtitle_y_px": 1550,
"highlight_offset": -8,
"font_size_subtitle": 65,
"font_size_highlight": 68,
"highlight_x_offset": 0,
"video_path": None,
"font": "Arial",
"srt_path": None,
"highlight_word_index": -1, # Index of word to highlight (-1 = last word)
"highlight_start_time": 0.1, # When highlight appears (seconds)
"highlight_duration": 1.5 # How long highlight lasts (seconds)
}
preset_file = "subtitle_gui_presets.json"
word_presets_file = "word_presets.json" # Store word selections per subtitle
subtitles = []
current_index = 0
word_presets = {} # Dictionary to store word index for each subtitle
current_preset_slot = 1 # Currently selected preset slot
def save_presets():
global current_preset_slot
preset_file_numbered = f"subtitle_gui_presets_slot_{current_preset_slot}.json"
word_presets_file_numbered = f"word_presets_slot_{current_preset_slot}.json"
with open(preset_file_numbered, "w") as f:
json.dump(settings, f)
# Save word-specific presets
with open(word_presets_file_numbered, "w") as f:
json.dump(word_presets, f)
print(f"📂 Preset slot {current_preset_slot} saved!")
update_preset_display()
def load_presets():
global settings, word_presets, current_preset_slot
preset_file_numbered = f"subtitle_gui_presets_slot_{current_preset_slot}.json"
word_presets_file_numbered = f"word_presets_slot_{current_preset_slot}.json"
try:
with open(preset_file_numbered, "r") as f:
loaded = json.load(f)
settings.update(loaded)
# Load word-specific presets
try:
with open(word_presets_file_numbered, "r") as f:
word_presets = json.load(f)
except FileNotFoundError:
word_presets = {}
print(f"✅ Preset slot {current_preset_slot} loaded!")
sync_gui()
update_preset_display()
except FileNotFoundError:
print(f"⚠️ No preset found in slot {current_preset_slot}.")
def change_preset_slot(slot_number):
global current_preset_slot
current_preset_slot = slot_number
print(f"🔄 Switched to preset slot {current_preset_slot}")
update_preset_display()
def update_preset_display():
preset_label.config(text=f"Current Preset Slot: {current_preset_slot}")
# Check if preset file exists and update button colors
preset_file_numbered = f"subtitle_gui_presets_slot_{current_preset_slot}.json"
if os.path.exists(preset_file_numbered):
preset_label.config(bg="lightgreen")
load_preset_btn.config(text=f"📂 Load Preset {current_preset_slot}", state="normal")
else:
preset_label.config(bg="lightcoral")
load_preset_btn.config(text=f"📂 Load Preset {current_preset_slot} (Empty)", state="disabled")
save_btn.config(text=f"📂 Save Preset {current_preset_slot}")
def sync_gui():
sub_y_slider.set(settings["subtitle_y_px"])
highlight_slider.set(settings["highlight_offset"])
highlight_x_slider.set(settings["highlight_x_offset"])
sub_font_slider.set(settings["font_size_subtitle"])
highlight_font_slider.set(settings["font_size_highlight"])
font_dropdown_var.set(settings["font"])
highlight_start_slider.set(settings["highlight_start_time"])
highlight_duration_slider.set(settings["highlight_duration"])
update_highlight_word_display()
update_preset_display()
def load_srt():
global subtitles, current_index, word_presets
path = filedialog.askopenfilename(filetypes=[("SRT files", "*.srt")])
if path:
settings["srt_path"] = path
subtitles = pysrt.open(path)
current_index = 0
# Load word presets for this SRT file
load_word_presets_for_srt(path)
print(f"📜 Loaded subtitles: {path}")
update_highlight_word_display()
def load_word_presets_for_srt(srt_path):
"""Load word presets specific to this SRT file"""
global word_presets
try:
srt_key = os.path.basename(srt_path)
if srt_key in word_presets:
print(f"✅ Loaded word selections for {srt_key}")
else:
word_presets[srt_key] = {}
print(f"📝 Created new word selection storage for {srt_key}")
except Exception as e:
print(f"⚠️ Error loading word presets: {e}")
word_presets = {}
def get_current_word_index():
"""Get the word index for the current subtitle"""
if not subtitles or not settings["srt_path"]:
return settings["highlight_word_index"]
srt_key = os.path.basename(settings["srt_path"])
subtitle_key = str(current_index)
if srt_key in word_presets and subtitle_key in word_presets[srt_key]:
return word_presets[srt_key][subtitle_key]
else:
return settings["highlight_word_index"] # Default
def set_current_word_index(word_index):
"""Set the word index for the current subtitle"""
if not subtitles or not settings["srt_path"]:
settings["highlight_word_index"] = word_index
return
srt_key = os.path.basename(settings["srt_path"])
subtitle_key = str(current_index)
if srt_key not in word_presets:
word_presets[srt_key] = {}
word_presets[srt_key][subtitle_key] = word_index
print(f"💾 Saved word selection for subtitle {current_index + 1}: word {word_index + 1}")
def create_safe_textclip(text, font_size=50, color='white', stroke_color=None, stroke_width=0):
"""Create a text clip using PIL instead of MoviePy TextClip"""
try:
# Create text image using PIL
if stroke_color is None:
stroke_color = 'black'
if stroke_width == 0:
stroke_width = 3
img_array, img_width, img_height = create_text_image(
text=text,
font_size=font_size,
color=color,
stroke_color=stroke_color,
stroke_width=stroke_width,
font_path=SYSTEM_FONT
)
# Convert PIL image to MoviePy ImageClip
from moviepy import ImageClip
clip = ImageClip(img_array, duration=1)
print(f"Created text clip: '{text}' ({img_width}x{img_height})")
return clip
except Exception as e:
print(f"Text clip creation failed: {e}")
# Return a simple colored clip as placeholder
from moviepy import ColorClip
placeholder = ColorClip(size=(800, 100), color=(255, 255, 255)).with_duration(1)
return placeholder
def render_preview(save_path=None):
if not settings["video_path"]:
print("⚠️ No video selected.")
return
if not subtitles:
print("⚠️ No subtitles loaded.")
return
sub = subtitles[current_index]
subtitle_text = sub.text.replace("\n", " ").strip()
words = subtitle_text.split()
start_time = sub.start.ordinal / 1000.0
end_time = sub.end.ordinal / 1000.0
clip = VideoFileClip(settings["video_path"]).subclipped(start_time, min(end_time, start_time + 3))
vertical_clip = clip.resized(height=1920).cropped(width=1080, x_center=clip.w / 2)
# Create base subtitle using safe method
try:
base_subtitle = create_safe_textclip(
subtitle_text,
font_size=settings["font_size_subtitle"],
color='white',
stroke_color='black',
stroke_width=5
).with_duration(clip.duration).with_position(('center', settings["subtitle_y_px"]))
print("✅ Base subtitle created successfully")
except Exception as e:
print(f"❌ Base subtitle creation failed: {e}")
return
# For preview mode (not export), show only current selected word
if save_path is None:
# Get highlight word based on index for preview
if len(words) > 0:
word_index = get_current_word_index() # Use subtitle-specific word index
if word_index < 0 or word_index >= len(words):
word_index = len(words) - 1 # Default to last word
highlight_word = words[word_index]
else:
highlight_word = "word" # Fallback
full_text = subtitle_text.upper()
words_upper = full_text.split()
if highlight_word.upper() not in words_upper:
highlight_word = words_upper[-1] # Fallback
highlight_index = words_upper.index(highlight_word.upper())
chars_before = sum(len(w) + 1 for w in words_upper[:highlight_index])
char_width = 35
total_width = len(full_text) * char_width
x_offset = (chars_before * char_width) - (total_width // 2) + settings["highlight_x_offset"]
# Create single highlighted word for preview
try:
highlighted_word = create_safe_textclip(
highlight_word,
font_size=settings["font_size_highlight"],
color='#FFD700',
stroke_color='#FF6B35',
stroke_width=5
).with_duration(min(settings["highlight_duration"], clip.duration)).with_start(settings["highlight_start_time"]).with_position((540 + x_offset, settings["subtitle_y_px"] + settings["highlight_offset"]))
print(f"✅ Preview highlighted word created: '{highlight_word}'")
except Exception as e:
print(f"❌ Highlighted word creation failed: {e}")
highlighted_word = None
# Compose preview video
if highlighted_word is not None:
final = CompositeVideoClip([vertical_clip, base_subtitle, highlighted_word], size=(1080, 1920))
print("✅ Preview video composed with highlight")
else:
final = CompositeVideoClip([vertical_clip, base_subtitle], size=(1080, 1920))
print("✅ Preview video composed without highlight")
else:
# For export mode, create highlights for ALL words in sequence
print(f"🎬 Creating export with ALL {len(words)} words highlighted in sequence...")
highlighted_clips = []
full_text = subtitle_text.upper()
words_upper = full_text.split()
# Store current settings to restore later
original_slot = current_preset_slot
original_settings = settings.copy()
# Calculate timing for each word highlight
if len(words) > 0:
time_per_word = clip.duration / len(words)
for i, word in enumerate(words):
try:
# Calculate preset slot for this word (cycle through slots 1-5)
word_slot = (i % 5) + 1
# Load preset for this word's slot (without changing global current_preset_slot)
preset_file_numbered = f"subtitle_gui_presets_slot_{word_slot}.json"
if os.path.exists(preset_file_numbered):
with open(preset_file_numbered, "r") as f:
slot_settings = json.load(f)
# Apply slot settings for this word
word_font_size = slot_settings.get("font_size_highlight", original_settings["font_size_highlight"])
word_y_offset = slot_settings.get("highlight_offset", original_settings["highlight_offset"])
word_x_offset = slot_settings.get("highlight_x_offset", original_settings["highlight_x_offset"])
print(f"📂 Using preset slot {word_slot} for word '{word}' (font: {word_font_size}, y: {word_y_offset}, x: {word_x_offset})")
else:
# Use original settings if preset doesn't exist
word_font_size = original_settings["font_size_highlight"]
word_y_offset = original_settings["highlight_offset"]
word_x_offset = original_settings["highlight_x_offset"]
print(f"⚠️ No preset in slot {word_slot}, using defaults for word '{word}'")
# Calculate position for this word
chars_before = sum(len(w) + 1 for w in words_upper[:i])
char_width = 35
total_width = len(full_text) * char_width
x_offset = (chars_before * char_width) - (total_width // 2) + word_x_offset
# Calculate timing for this word
word_start_time = i * time_per_word
word_duration = min(time_per_word * 1.5, original_settings["highlight_duration"]) # Slight overlap
# Create highlighted word clip with slot-specific settings
highlighted_word = create_safe_textclip(
word,
font_size=word_font_size,
color='#FFD700',
stroke_color='#FF6B35',
stroke_width=5
).with_duration(word_duration).with_start(word_start_time).with_position((540 + x_offset, original_settings["subtitle_y_px"] + word_y_offset))
highlighted_clips.append(highlighted_word)
print(f"✅ Created highlight {i+1}/{len(words)}: '{word}' at {word_start_time:.1f}s with slot {word_slot} settings")
except Exception as e:
print(f"❌ Failed to create highlight for word '{word}': {e}")
# No need to restore settings since we didn't modify the global variables
# Compose final video with all highlights
all_clips = [vertical_clip, base_subtitle] + highlighted_clips
final = CompositeVideoClip(all_clips, size=(1080, 1920))
print(f"✅ Export video composed with {len(highlighted_clips)} word highlights using different presets")
if save_path:
srt_output_path = os.path.splitext(save_path)[0] + ".srt"
with open(srt_output_path, "w", encoding='utf-8') as srt_file:
srt_file.write(str(sub))
final.write_videofile(save_path, fps=24)
print(f"✅ Exported SRT: {srt_output_path}")
else:
# Create a smaller preview version to fit 1080p screen better
# Scale down to 50% size: 540x960 instead of 1080x1920
preview_final = final.resized(0.5)
print("🎬 Opening preview window (540x960 - scaled to fit 1080p screen)")
preview_final.preview(fps=24)
preview_final.close()
clip.close()
final.close()
def update_setting(var_name, value):
if var_name in ["highlight_start_time", "highlight_duration"]:
settings[var_name] = float(value)
else:
settings[var_name] = int(value) if var_name.startswith("font_size") or "offset" in var_name or "y_px" in var_name else value
def update_font(value):
settings["font"] = value
def open_video():
file_path = filedialog.askopenfilename(filetypes=[("MP4 files", "*.mp4")])
if file_path:
settings["video_path"] = file_path
print(f"📂 Loaded video: {file_path}")
def start_preview_thread():
threading.Thread(target=render_preview).start()
def export_clip():
if settings["video_path"]:
base_name = os.path.splitext(os.path.basename(settings["video_path"]))[0]
out_path = os.path.join(os.path.dirname(settings["video_path"]), f"{base_name}_clip_exported.mp4")
threading.Thread(target=render_preview, args=(out_path,)).start()
print(f"💾 Exporting to: {out_path}")
def prev_sub():
global current_index
if current_index > 0:
current_index -= 1
update_highlight_word_display()
start_preview_thread()
def next_sub():
global current_index
if current_index < len(subtitles) - 1:
current_index += 1
update_highlight_word_display()
start_preview_thread()
def prev_highlight_word():
"""Switch to the previous preset slot, select previous word, AND load the preset"""
global current_preset_slot
# First, change the highlighted word
if subtitles:
sub = subtitles[current_index]
words = sub.text.replace("\n", " ").strip().split()
if len(words) > 1:
current_word_index = get_current_word_index()
if current_word_index < 0:
current_word_index = len(words) - 1
new_index = current_word_index - 1
if new_index < 0:
new_index = len(words) - 1 # Wrap to last word
set_current_word_index(new_index)
update_highlight_word_display()
# Then, switch to previous preset slot
new_slot = current_preset_slot - 1
if new_slot < 1:
new_slot = 5 # Wrap to last slot
change_preset_slot(new_slot)
# Automatically load the preset for this slot
load_presets()
# Start preview to show changes
start_preview_thread()
def next_highlight_word():
"""Switch to the next preset slot, select next word, AND load the preset"""
global current_preset_slot
# First, change the highlighted word
if subtitles:
sub = subtitles[current_index]
words = sub.text.replace("\n", " ").strip().split()
if len(words) > 1:
current_word_index = get_current_word_index()
if current_word_index < 0:
current_word_index = len(words) - 1
new_index = (current_word_index + 1) % len(words)
set_current_word_index(new_index)
update_highlight_word_display()
# Then, switch to next preset slot
new_slot = current_preset_slot + 1
if new_slot > 5:
new_slot = 1 # Wrap to first slot
change_preset_slot(new_slot)
# Automatically load the preset for this slot
load_presets()
# Start preview to show changes
start_preview_thread()
def update_highlight_word_display():
"""Update the display showing which word is selected for highlight"""
if not subtitles:
highlight_word_label.config(text="Highlighted Word: None")
return
sub = subtitles[current_index]
words = sub.text.replace("\n", " ").strip().split()
if len(words) > 0:
word_index = get_current_word_index()
if word_index < 0 or word_index >= len(words):
word_index = len(words) - 1
set_current_word_index(word_index) # Save the default
highlight_word = words[word_index]
highlight_word_label.config(text=f"Highlighted Word: '{highlight_word}' ({word_index + 1}/{len(words)})")
else:
highlight_word_label.config(text="Highlighted Word: None")
def handle_drop(event):
path = event.data
if path.endswith(".mp4"):
settings["video_path"] = path
print(f"🎥 Dropped video: {path}")
elif path.endswith(".srt"):
settings["srt_path"] = path
global subtitles, current_index
subtitles = pysrt.open(path)
current_index = 0
print(f"📜 Dropped subtitles: {path}")
# GUI Setup
root = tk.Tk()
root.title("Subtitle Positioning Tool")
root.geometry("420x850")
root.drop_target_register = getattr(root, 'drop_target_register', lambda *args: None)
root.dnd_bind = getattr(root, 'dnd_bind', lambda *args, **kwargs: None)
try:
import tkinterdnd2 as tkdnd
root.drop_target_register(tkdnd.DND_FILES)
root.dnd_bind('<<Drop>>', handle_drop)
except:
pass
load_btn = tk.Button(root, text="🎥 Load Video", command=open_video)
load_btn.pack(pady=5)
load_srt_btn = tk.Button(root, text="📜 Load SRT", command=load_srt)
load_srt_btn.pack(pady=5)
tk.Label(root, text="Subtitle Y Position").pack()
sub_y_slider = tk.Scale(root, from_=1000, to=1800, orient="horizontal", command=lambda v: update_setting("subtitle_y_px", v))
sub_y_slider.set(settings["subtitle_y_px"])
sub_y_slider.pack()
tk.Label(root, text="Highlight Y Offset").pack()
highlight_slider = tk.Scale(root, from_=-100, to=100, orient="horizontal", command=lambda v: update_setting("highlight_offset", v))
highlight_slider.set(settings["highlight_offset"])
highlight_slider.pack()
tk.Label(root, text="Highlight X Offset").pack()
highlight_x_slider = tk.Scale(root, from_=-300, to=300, orient="horizontal", command=lambda v: update_setting("highlight_x_offset", v))
highlight_x_slider.set(settings["highlight_x_offset"])
highlight_x_slider.pack()
tk.Label(root, text="Subtitle Font Size").pack()
sub_font_slider = tk.Scale(root, from_=30, to=100, orient="horizontal", command=lambda v: update_setting("font_size_subtitle", v))
sub_font_slider.set(settings["font_size_subtitle"])
sub_font_slider.pack()
tk.Label(root, text="Highlight Font Size").pack()
highlight_font_slider = tk.Scale(root, from_=30, to=100, orient="horizontal", command=lambda v: update_setting("font_size_highlight", v))
highlight_font_slider.set(settings["font_size_highlight"])
highlight_font_slider.pack()
tk.Label(root, text="Highlight Start Time (seconds)").pack()
highlight_start_slider = tk.Scale(root, from_=0.0, to=3.0, resolution=0.1, orient="horizontal", command=lambda v: update_setting("highlight_start_time", v))
highlight_start_slider.set(settings["highlight_start_time"])
highlight_start_slider.pack()
tk.Label(root, text="Highlight Duration (seconds)").pack()
highlight_duration_slider = tk.Scale(root, from_=0.1, to=5.0, resolution=0.1, orient="horizontal", command=lambda v: update_setting("highlight_duration", v))
highlight_duration_slider.set(settings["highlight_duration"])
highlight_duration_slider.pack()
font_dropdown_var = tk.StringVar(value=settings["font"])
tk.Label(root, text="Font").pack()
font_dropdown = tk.OptionMenu(
root,
font_dropdown_var,
"Arial",
"Courier",
"Times-Roman",
"Helvetica-Bold",
"Verdana",
"Georgia",
"Impact",
command=update_font
)
font_dropdown.pack(pady=5)
# Preset Slot Navigation + Word Selection
tk.Label(root, text="Preset Slot Navigation + Word Selection").pack()
highlight_word_label = tk.Label(root, text="Highlighted Word: None", bg="lightgray", relief="sunken")
highlight_word_label.pack(pady=5)
word_nav_frame = tk.Frame(root)
word_nav_frame.pack(pady=5)
tk.Button(word_nav_frame, text="⏮️ Prev Slot+Word", command=prev_highlight_word).pack(side="left", padx=2)
tk.Button(word_nav_frame, text="⏭️ Next Slot+Word", command=next_highlight_word).pack(side="left", padx=2)
preview_btn = tk.Button(root, text="▶️ Preview Clip", command=start_preview_thread)
preview_btn.pack(pady=10)
export_btn = tk.Button(root, text="💾 Export Clip", command=export_clip)
export_btn.pack(pady=5)
# Preset slot selection
tk.Label(root, text="Preset Slots").pack()
preset_label = tk.Label(root, text="Current Preset Slot: 1", bg="lightcoral", relief="sunken")
preset_label.pack(pady=2)
preset_slot_frame = tk.Frame(root)
preset_slot_frame.pack(pady=2)
tk.Button(preset_slot_frame, text="Slot 1", command=lambda: change_preset_slot(1)).pack(side="left", padx=1)
tk.Button(preset_slot_frame, text="Slot 2", command=lambda: change_preset_slot(2)).pack(side="left", padx=1)
tk.Button(preset_slot_frame, text="Slot 3", command=lambda: change_preset_slot(3)).pack(side="left", padx=1)
tk.Button(preset_slot_frame, text="Slot 4", command=lambda: change_preset_slot(4)).pack(side="left", padx=1)
tk.Button(preset_slot_frame, text="Slot 5", command=lambda: change_preset_slot(5)).pack(side="left", padx=1)
save_btn = tk.Button(root, text="📂 Save Preset 1", command=save_presets)
save_btn.pack(pady=5)
load_preset_btn = tk.Button(root, text="📂 Load Preset 1", command=load_presets)
load_preset_btn.pack(pady=5)
root.mainloop()