ShortGenerator/subtitle_generator2_fixed.py
klop51 336ef32a49 Add subtitle generator with GUI and preset management
- Implemented a new subtitle generator tool using Tkinter for GUI.
- Added functionality to load video and SRT files, and display subtitles.
- Created text rendering using PIL for better font handling.
- Introduced preset saving and loading for subtitle settings and word selections.
- Added support for highlighting specific words in subtitles.
- Included multiple preset slots for different configurations.
- Created JSON files for storing presets and word selections.
2025-08-07 00:34:27 +02:00

601 lines
21 KiB
Python

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('<<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)
# 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()