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 # Find working font at startup SYSTEM_FONT = find_system_font() # Global settings with defaults (no highlight_word_index here!) 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_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 def save_presets(): with open(preset_file, "w") as f: json.dump(settings, f) # Save word-specific presets with open(word_presets_file, "w") as f: json.dump(word_presets, f) print("📂 Presets and word selections saved!") def load_presets(): global settings, word_presets try: with open(preset_file, "r") as f: loaded = json.load(f) settings.update(loaded) # Load word-specific presets try: with open(word_presets_file, "r") as f: word_presets = json.load(f) except FileNotFoundError: word_presets = {} print("✅ Presets and word selections loaded!") sync_gui() except FileNotFoundError: print("⚠️ No presets found.") 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() 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 -1 # Default to last word 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]: saved_index = word_presets[srt_key][subtitle_key] print(f"🔄 Loading saved word index {saved_index} for subtitle {current_index + 1}") return saved_index else: print(f"🆕 No saved word for subtitle {current_index + 1}, using default (last word)") return -1 # Default to last word def set_current_word_index(word_index): """Set the word index for the current subtitle""" if not subtitles or not settings["srt_path"]: print("⚠️ Cannot save word index - no subtitles loaded") 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() # Get highlight word based on INDIVIDUAL subtitle word index if len(words) > 0: word_index = get_current_word_index() # Get subtitle-specific word index if word_index < 0 or word_index >= len(words): word_index = len(words) - 1 # Default to last word set_current_word_index(word_index) # Save the default highlight_word = words[word_index] print(f"🎯 Using word '{highlight_word}' (index {word_index}) for subtitle {current_index + 1}") else: highlight_word = "word" # Fallback 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 full_text = subtitle_text.upper() words = full_text.split() if highlight_word.upper() not in words: highlight_word = words[-1] # Fallback highlight_index = words.index(highlight_word.upper()) chars_before = sum(len(w) + 1 for w in words[: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 highlighted word using safe method 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"✅ Highlighted word created successfully (start: {settings['highlight_start_time']}s, duration: {settings['highlight_duration']}s)") except Exception as e: print(f"❌ Highlighted word creation failed: {e}") # Create without highlight if it fails highlighted_word = None print("⚠️ Continuing without highlight") # Compose final video with or without highlight if highlighted_word is not None: final = CompositeVideoClip([vertical_clip, base_subtitle, highlighted_word], size=(1080, 1920)) print("✅ Final video composed with highlight") else: final = CompositeVideoClip([vertical_clip, base_subtitle], size=(1080, 1920)) print("✅ Final video composed without highlight") if save_path: srt_output_path = os.path.splitext(save_path)[0] + ".srt" with open(srt_output_path, "w") as srt_file: srt_file.write(sub.__unicode__()) 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 print(f"📍 Switched to subtitle {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 print(f"📍 Switched to subtitle {current_index + 1}") update_highlight_word_display() start_preview_thread() def prev_highlight_word(): """Select the previous word to highlight""" if not subtitles: return sub = subtitles[current_index] words = sub.text.replace("\n", " ").strip().split() if len(words) <= 1: return 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() start_preview_thread() def next_highlight_word(): """Select the next word to highlight""" if not subtitles: return sub = subtitles[current_index] words = sub.text.replace("\n", " ").strip().split() if len(words) <= 1: return 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() 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)})") print(f"🔄 Display updated: subtitle {current_index + 1}, 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 load_word_presets_for_srt(path) print(f"📜 Dropped subtitles: {path}") update_highlight_word_display() # GUI Setup root = tk.Tk() root.title("Subtitle Positioning Tool - Fixed Version") root.geometry("420x800") 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('<>', 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) # Highlight word selection tk.Label(root, text="Select Highlight Word").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 Word", command=prev_highlight_word).pack(side="left", padx=2) tk.Button(word_nav_frame, text="⏭️ Next 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) nav_frame = tk.Frame(root) nav_frame.pack(pady=5) tk.Button(nav_frame, text="⏮️ Prev", command=prev_sub).pack(side="left", padx=5) tk.Button(nav_frame, text="⏭️ Next", command=next_sub).pack(side="left", padx=5) save_btn = tk.Button(root, text="📂 Save Preset", command=save_presets) save_btn.pack(pady=5) load_preset_btn = tk.Button(root, text="📂 Load Preset", command=load_presets) load_preset_btn.pack(pady=5) root.mainloop()