Text-Match-Cut / text_effect.py
Siam2315's picture
Upload 25 files
4a8f3c5 verified
import os
import random
import string
import sys
import time # For retry delays
import matplotlib.font_manager as fm
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from moviepy import ImageSequenceClip
# --- Mistral AI Integration ---
try:
from mistralai import UserMessage, SystemMessage, Mistral
MISTRAL_AVAILABLE = True
except ImportError:
print("Warning: Mistral AI library not found. AI text generation disabled.")
print("Install it using: pip install mistralai")
MISTRAL_AVAILABLE = False
# --- Configuration Parameters ---
# Video settings
WIDTH = 1024
HEIGHT = 1024
FPS = 10
DURATION_SECONDS = 5
OUTPUT_FILENAME = "text_match_cut_video_v2.mp4"
# Text & Highlighting settings
HIGHLIGHTED_TEXT = "Mother of Dragons"
HIGHLIGHT_COLOR = "yellow" # Pillow color name or hex code
TEXT_COLOR = "black"
BACKGROUND_COLOR = "white"
FONT_SIZE_RATIO = 0.05 # Adjusted slightly for multi-line potentially
MIN_LINES = 7 # Min number of text lines per frame
MAX_LINES = 10 # Max number of text lines per frame
VERTICAL_SPREAD_FACTOR = 1.5 # Multiplier for line height (1.0 = tight, 1.5 = looser)
# AI Text Generation Settings
AI_GENERATION_ENABLED = MISTRAL_AVAILABLE # Auto-disable if library missing
UNIQUE_TEXT_COUNT = 2 # Number of unique text snippets to generate/pre-pool
MISTRAL_MODEL = "mistral-large-latest" # Or choose another suitable model
# !! IMPORTANT: Load API Key securely !!
MISTRAL_API_KEY = os.environ.get("MISTRAL_API_KEY")
# Effect settings
BLUR_TYPE = 'radial' # Options: 'gaussian', 'radial'
BLUR_RADIUS = 4.0 # Gaussian blur radius, or the radius OUTSIDE which radial blur starts fading strongly
RADIAL_SHARPNESS_RADIUS_FACTOR = 0.3 # For 'radial': Percentage of min(W,H) to keep perfectly sharp around center
# Font settings
FONT_DIR = "fonts" # Dedicated font folder recommended
MAX_FONT_RETRIES_PER_FRAME = 5
# Generate random words only using ASCII lowercase for fallback/disabled AI
FALLBACK_CHAR_SET = string.ascii_lowercase + " "
# --- Helper Functions ---
def get_random_font(font_paths, exclude_list=None):
"""Selects a random font file path from the list, avoiding excluded ones."""
available_fonts = list(set(font_paths) - set(exclude_list or []))
if not available_fonts:
# Fallback if all fonts failed or list is empty initially
try:
prop = fm.FontProperties(family='sans-serif')
fallback = fm.findfont(prop, fallback_to_default=True)
print(f"Warning: No usable fonts found from list/system. Using fallback: {fallback}")
return fallback
except Exception:
print("ERROR: No fonts found and fallback failed. Cannot proceed.")
return None
return random.choice(available_fonts)
# Fallback random text generator
def generate_random_words(num_words):
"""Generates a string of random 'words' using only FALLBACK_CHAR_SET."""
words = []
for _ in range(num_words):
length = random.randint(3, 8)
word = ''.join(random.choice(FALLBACK_CHAR_SET.replace(" ", "")) for i in range(length))
words.append(word)
return " ".join(words)
def generate_random_text_snippet(highlighted_text, min_lines, max_lines):
"""Generates multiple lines of random text, ensuring MIN_LINES."""
# Ensure we generate at least min_lines
num_lines = random.randint(max(1, min_lines), max(min_lines, max_lines)) # Ensure at least min_lines generated
highlight_line_index = random.randint(0, num_lines - 1)
lines = []
min_words_around = 2
max_words_around = 6
for i in range(num_lines):
if i == highlight_line_index:
words_before = generate_random_words(random.randint(min_words_around, max_words_around))
words_after = generate_random_words(random.randint(min_words_around, max_words_around))
lines.append(f"{words_before} {highlighted_text} {words_after}")
else:
lines.append(generate_random_words(random.randint(max_words_around, max_words_around * 2)))
# Double-check final line count (should always pass with the adjusted randint)
if len(lines) < min_lines:
print(f"Warning: Random generator created only {len(lines)} lines (min: {min_lines}). This shouldn't happen.")
return None, -1 # Treat as failure if check fails unexpectedly
return lines, highlight_line_index
# Mistral AI Text Generation Function
def generate_ai_text_snippet(client, model, highlighted_text, min_lines, max_lines):
"""Generates a text snippet using Mistral AI containing the highlighted text."""
target_lines = random.randint(min_lines, max_lines)
prompt = (
f"Generate a text block of approximately {target_lines} distinct lines (aim for at least {min_lines}). "
f"One of the lines MUST contain the exact phrase: '{highlighted_text}'. "
f"The surrounding text should be thematically related to '{highlighted_text}' (e.g., fantasy, power, dragons, leadership). "
f"Ensure the phrase '{highlighted_text}' fits naturally within its line. "
f"Format the output ONLY as the text lines, each separated by a single newline character. Do not add any extra explanations or formatting."
# f"Example line containing the phrase: '...they bowed before the {highlighted_text}, their new queen...'"
)
try:
messages = [UserMessage(content=prompt)]
chat_response = client.chat.complete(model=model, messages=messages,
temperature=0.5, max_tokens=300)
content = chat_response.choices[0].message.content.strip()
# Basic cleanup: remove potential empty lines
lines = [line for line in content.split('\n') if line.strip()]
# --- CRITICAL CHECK: Ensure minimum lines ---
if len(lines) < min_lines:
print(
f"Warning: AI returned only {len(lines)} valid lines (minimum requested: {min_lines}). Retrying generation.")
return None, -1 # Indicate failure due to insufficient lines
# Find the highlight line
highlight_line_index = -1
for i, line in enumerate(lines):
if highlighted_text in line:
highlight_line_index = i
break
if highlight_line_index == -1:
print(f"Warning: AI response did not contain the exact phrase '{highlighted_text}'.")
# Optionally try to insert it into a random line? Or just fail.
# Let's fail for now to ensure the highlight is always from AI context
return None, -1 # Indicate failure
return lines, highlight_line_index
except Exception as e:
print(f"An unexpected error occurred during AI text generation: {e}")
return None, -1 # Indicate failure
def create_radial_blur_mask(width, height, center_x, center_y, sharp_radius, fade_radius):
"""Creates a grayscale mask for radial blur (sharp center, fades out)."""
mask = Image.new('L', (width, height), 0)
draw = ImageDraw.Draw(mask)
draw.ellipse(
(center_x - sharp_radius, center_y - sharp_radius,
center_x + sharp_radius, center_y + sharp_radius),
fill=255
)
# Gaussian blur the sharp circle mask for a smooth falloff
# Ensure fade radius is larger than sharp radius
blur_amount = max(0.1, (fade_radius - sharp_radius) / 3.5) # Adjusted divisor for smoothness
mask = mask.filter(ImageFilter.GaussianBlur(radius=blur_amount))
return mask
def create_text_image_frame(width, height, text_lines, highlight_line_index, highlighted_text,
font_path, font_size, text_color, bg_color, highlight_color,
blur_type, blur_radius, radial_sharp_radius_factor, vertical_spread_factor):
"""Creates a single frame image with centered highlight and multi-line text."""
# --- Font Loading ---
try:
font = ImageFont.truetype(font_path, font_size)
bold_font = font # Start with regular as fallback
# Simple bold variant check (can be improved)
common_bold_suffixes = ["bd.ttf", "-Bold.ttf", "b.ttf", "_Bold.ttf", " Bold.ttf"]
base_name, ext = os.path.splitext(font_path)
for suffix in common_bold_suffixes:
potential_bold_path = base_name.replace("Regular", "").replace("regular",
"") + suffix # Try removing 'Regular' too
if os.path.exists(potential_bold_path):
try:
bold_font = ImageFont.truetype(potential_bold_path, font_size)
# print(f" Using bold variant: {os.path.basename(potential_bold_path)}") # Debug
break # Use the first one found
except IOError:
continue # Try next suffix if loading fails
# Check without removing Regular if first checks failed
potential_bold_path = base_name + suffix
if os.path.exists(potential_bold_path):
try:
bold_font = ImageFont.truetype(potential_bold_path, font_size)
# print(f" Using bold variant: {os.path.basename(potential_bold_path)}") # Debug
break
except IOError:
continue
except IOError as e:
raise FontLoadError(f"Failed to load font: {font_path}") from e
except Exception as e: # Catch other potential font loading issues
raise FontLoadError(f"Unexpected error loading font {font_path}: {e}") from e
# --- Calculations ---
try:
# Line height using getmetrics()
try:
ascent, descent = font.getmetrics()
metric_height = ascent + abs(descent)
line_height = int(metric_height * vertical_spread_factor)
except AttributeError:
bbox_line_test = font.getbbox("Ay", anchor="lt")
line_height = int((bbox_line_test[3] - bbox_line_test[1]) * vertical_spread_factor)
if line_height <= font_size * 0.8:
line_height = int(font_size * 1.2 * vertical_spread_factor)
# BOLD font metrics for final highlight placement
highlight_width_bold = bold_font.getlength(highlighted_text)
highlight_bbox_h = bold_font.getbbox(highlighted_text, anchor="lt")
highlight_height_bold = highlight_bbox_h[3] - highlight_bbox_h[1]
if highlight_width_bold <= 0 or highlight_height_bold <= 0:
highlight_height_bold = int(font_size * 1.1)
if highlight_width_bold <=0: highlight_width_bold = len(highlighted_text) * font_size * 0.6
# Target position for the TOP-LEFT of the final BOLD highlight text (CENTERED)
highlight_target_x = (width - highlight_width_bold) / 2
highlight_target_y = (height - highlight_height_bold) / 2
# Block start Y calculated relative to the centered highlight's top
block_start_y = highlight_target_y - (highlight_line_index * line_height)
# Get Prefix and Suffix for background alignment
highlight_line_full_text = text_lines[highlight_line_index]
prefix_text = ""
suffix_text = "" # Also get suffix now
highlight_found_in_line = False
try:
start_index = highlight_line_full_text.index(highlighted_text)
end_index = start_index + len(highlighted_text)
prefix_text = highlight_line_full_text[:start_index]
suffix_text = highlight_line_full_text[end_index:]
highlight_found_in_line = True
except ValueError: pass # Treat line normally if not found
# Measure Prefix Width using REGULAR font (for background positioning)
prefix_width_regular = font.getlength(prefix_text)
# Calculate the required starting X for the background highlight line string
# This is the coordinate used for drawing the *full string* in the background
bg_highlight_line_start_x = highlight_target_x - prefix_width_regular
except AttributeError: raise FontDrawError(f"Font lacks methods.")
except Exception as e: raise FontDrawError(f"Measurement fail: {e}") from e
# --- Base Image Drawing (Draw FULL lines, use offset for HL line) ---
# Render onto img_base normally first
img_base = Image.new('RGB', (width, height), color=bg_color)
draw_base = ImageDraw.Draw(img_base)
try:
current_y = block_start_y
for i, line in enumerate(text_lines):
line_x = 0.0
if i == highlight_line_index and highlight_found_in_line:
line_x = bg_highlight_line_start_x
else:
line_width = font.getlength(line)
line_x = (width - line_width) / 2
draw_base.text((line_x, current_y), line, font=font, fill=text_color, anchor="lt")
current_y += line_height
except Exception as e: raise FontDrawError(f"Base draw fail: {e}") from e
# --- Apply Blur (with padding for Gaussian to avoid edge clipping) ---
img_blurred = None # Initialize
padding_for_blur = int(blur_radius * 3) # Padding based on blur radius
if blur_type == 'gaussian' and blur_radius > 0:
try:
# Create larger canvas
padded_width = width + 2 * padding_for_blur
padded_height = height + 2 * padding_for_blur
img_padded = Image.new('RGB', (padded_width, padded_height), color=bg_color)
# Paste original centered onto padded canvas
img_padded.paste(img_base, (padding_for_blur, padding_for_blur))
# Blur the padded image
img_padded_blurred = img_padded.filter(ImageFilter.GaussianBlur(radius=blur_radius))
# Crop the center back to original size
img_blurred = img_padded_blurred.crop((padding_for_blur, padding_for_blur,
padding_for_blur + width, padding_for_blur + height))
except Exception as e:
print(f"Error during padded Gaussian blur: {e}. Falling back to direct blur.")
img_blurred = img_base.filter(ImageFilter.GaussianBlur(radius=blur_radius)) # Fallback
elif blur_type == 'radial' and blur_radius > 0:
# For radial, we need img_sharp. Let's try drawing it *in parts* for reliability
# as the padded blur trick doesn't apply directly here.
img_sharp = Image.new('RGB', (width, height), color=bg_color)
draw_sharp = ImageDraw.Draw(img_sharp)
try:
current_y = block_start_y
for i, line in enumerate(text_lines):
if i == highlight_line_index and highlight_found_in_line:
# --- Draw Sharp Highlight Line in Parts ---
# Calculate positions relative to the *final* centered highlight target
prefix_x = highlight_target_x - prefix_width_regular
# Use REGULAR font for the sharp layer (it's just for the mask)
draw_sharp.text((prefix_x, current_y), prefix_text, font=font, fill=text_color, anchor="lt")
# Highlight part itself starts at highlight_target_x
highlight_width_regular = font.getlength(highlighted_text) # Width in regular font
draw_sharp.text((highlight_target_x, current_y), highlighted_text, font=font, fill=text_color, anchor="lt")
# Suffix starts after the regular highlight width
suffix_x = highlight_target_x + highlight_width_regular
draw_sharp.text((suffix_x, current_y), suffix_text, font=font, fill=text_color, anchor="lt")
else:
# Draw non-highlight lines centered normally
line_width = font.getlength(line)
line_x = (width - line_width) / 2
draw_sharp.text((line_x, current_y), line, font=font, fill=text_color, anchor="lt")
current_y += line_height
except Exception as e:
raise FontDrawError(f"Failed sharp text draw (parts): {e}") from e
# Composite blurred base and sharp center
# Base image (img_base) still uses the offset drawing method for full line
img_fully_blurred = img_base.filter(ImageFilter.GaussianBlur(radius=blur_radius * 1.5))
sharp_center_radius = min(width, height) * radial_sharp_radius_factor
fade_radius = sharp_center_radius + max(width, height) * 0.15
mask = create_radial_blur_mask(width, height, width / 2, height / 2, sharp_center_radius, fade_radius)
img_blurred = Image.composite(img_sharp, img_fully_blurred, mask)
else: # No blur
img_blurred = img_base.copy()
# --- Final Image: Draw ONLY Highlight Rectangle & Centered BOLD Text ---
final_img = img_blurred # Start with the blurred/composited image
draw_final = ImageDraw.Draw(final_img)
try:
# 1. Draw highlight rectangle (centered using bold metrics)
padding = font_size * 0.10
draw_final.rectangle(
[
(highlight_target_x - padding, highlight_target_y - padding),
(highlight_target_x + highlight_width_bold + padding, highlight_target_y + highlight_height_bold + padding)
],
fill=highlight_color
)
# 2. Draw ONLY the SHARP highlight text using BOLD font at the *perfectly centered* position
draw_final.text(
(highlight_target_x, highlight_target_y),
highlighted_text,
font=bold_font, # Use BOLD font
fill=text_color,
anchor="lt"
)
# *** No prefix/suffix drawing here ***
except Exception as e:
raise FontDrawError(f"Failed final highlight draw: {e}") from e
return final_img
# Custom Exceptions for font errors
class FontLoadError(Exception): pass
class FontDrawError(Exception): pass
def main():
print("Starting video generation...")
if AI_GENERATION_ENABLED and not MISTRAL_API_KEY:
print("ERROR: AI Generation is enabled, but MISTRAL_API_KEY environment variable is not set.")
print("Please set the environment variable or disable AI generation.")
sys.exit(1) # Exit cleanly
# --- Font Discovery ---
font_paths = []
# (Font discovery code remains the same)
if FONT_DIR and os.path.isdir(FONT_DIR):
print(f"Looking for fonts in specified directory: {FONT_DIR}")
for filename in os.listdir(FONT_DIR):
if filename.lower().endswith((".ttf", ".otf")):
font_paths.append(os.path.join(FONT_DIR, filename))
else:
print("FONT_DIR not specified or invalid, searching system fonts...")
try:
font_paths = fm.findSystemFonts(fontpaths=None, fontext='ttf')
# Optionally add otf, but ttf often more compatible with basic PIL
# font_paths.extend(fm.findSystemFonts(fontpaths=None, fontext='otf'))
except Exception as e:
print(f"Error finding system fonts: {e}")
if not font_paths:
print("ERROR: No fonts found. Please install fonts or specify a valid FONT_DIR.")
sys.exit(1)
print(f"Found {len(font_paths)} potential fonts.")
# --- Pre-generate Text Snippets ---
text_snippets_pool = []
print(f"Pre-generating {UNIQUE_TEXT_COUNT} unique text snippets...")
if AI_GENERATION_ENABLED:
client = Mistral(api_key=MISTRAL_API_KEY)
generated_count = 0
attempts = 0
max_attempts = UNIQUE_TEXT_COUNT * 3 # Allow for some failures
while generated_count < UNIQUE_TEXT_COUNT and attempts < max_attempts:
attempts += 1
print(f" Generating snippet {generated_count + 1}/{UNIQUE_TEXT_COUNT} (Attempt {attempts})...")
lines, hl_index = generate_ai_text_snippet(client, MISTRAL_MODEL, HIGHLIGHTED_TEXT, MIN_LINES, MAX_LINES)
if lines and hl_index != -1:
text_snippets_pool.append({"lines": lines, "highlight_index": hl_index})
generated_count += 1
else:
print(" Failed to generate valid snippet, trying again.")
time.sleep(1) # Wait a bit before retrying on failure
if generated_count < UNIQUE_TEXT_COUNT:
print(
f"Warning: Only generated {generated_count}/{UNIQUE_TEXT_COUNT} unique AI snippets after {max_attempts} attempts.")
if generated_count == 0:
print("ERROR: Failed to generate any AI text snippets. Cannot proceed with AI enabled.")
sys.exit(1)
else:
# Generate random snippets if AI is disabled
print("AI disabled, generating random text snippets for the pool...")
for i in range(UNIQUE_TEXT_COUNT):
lines, hl_index = generate_random_text_snippet(HIGHLIGHTED_TEXT, MIN_LINES, MAX_LINES)
text_snippets_pool.append({"lines": lines, "highlight_index": hl_index})
print(f"Generated {len(text_snippets_pool)} random snippets.")
# --- Calculate Other Parameters ---
total_frames = int(FPS * DURATION_SECONDS)
font_size = int(HEIGHT * FONT_SIZE_RATIO)
print(f"\nVideo Settings: {WIDTH}x{HEIGHT} @ {FPS}fps, {DURATION_SECONDS}s ({total_frames} frames)")
print(f"Text Settings: Highlight='{HIGHLIGHTED_TEXT}', Size={font_size}px, Spread={VERTICAL_SPREAD_FACTOR}")
print(f"Effect Settings: BlurType='{BLUR_TYPE}', BlurRadius={BLUR_RADIUS}, HighlightColor='{HIGHLIGHT_COLOR}'")
print(f"Using {'AI' if AI_GENERATION_ENABLED else 'Random'} Text Pool Size: {len(text_snippets_pool)}")
# --- Generate Frames ---
frames = []
failed_fonts = set()
print("\nGenerating frames...")
frame_num = 0
while frame_num < total_frames:
print(f" Attempting Frame {frame_num + 1}/{total_frames}")
# Select a text snippet from the pool
snippet = random.choice(text_snippets_pool)
current_lines = snippet["lines"]
highlight_idx = snippet["highlight_index"]
font_retries = 0
frame_generated = False
while font_retries < MAX_FONT_RETRIES_PER_FRAME:
current_font_path = get_random_font(font_paths, exclude_list=failed_fonts)
if current_font_path is None:
print("ERROR: Exhausted all available fonts or fallback failed. Stopping.")
sys.exit(1) # Exit if no fonts work
try:
img = create_text_image_frame(
WIDTH, HEIGHT,
current_lines, highlight_idx, HIGHLIGHTED_TEXT,
current_font_path, font_size,
TEXT_COLOR, BACKGROUND_COLOR, HIGHLIGHT_COLOR,
BLUR_TYPE, BLUR_RADIUS, RADIAL_SHARPNESS_RADIUS_FACTOR,
VERTICAL_SPREAD_FACTOR
)
frame_np = np.array(img)
frames.append(frame_np)
frame_generated = True
# print(f" Frame {frame_num + 1} generated with font: {os.path.basename(current_font_path)}") # Less verbose
break # Success, next frame
except (FontLoadError, FontDrawError) as e:
# print(f" Warning: Font '{os.path.basename(current_font_path)}' failed ({e}). Retrying frame.") # Less verbose
failed_fonts.add(current_font_path)
font_retries += 1
# time.sleep(0.05) # Optional small delay
except Exception as e:
print(
f" ERROR: Unexpected error generating frame with font {os.path.basename(current_font_path)}: {e}")
failed_fonts.add(current_font_path)
font_retries += 1
# time.sleep(0.05)
if not frame_generated:
print(
f"ERROR: Failed to generate Frame {frame_num + 1} after {MAX_FONT_RETRIES_PER_FRAME} font attempts. Stopping video generation.")
break # Stop if a frame repeatedly fails
frame_num += 1
# --- Create Video ---
if not frames:
print("ERROR: No frames were generated. Cannot create video.")
sys.exit(1)
if len(frames) < total_frames:
print(f"Warning: Only {len(frames)}/{total_frames} frames were generated due to errors. Video will be shorter.")
print("\nCompiling video...")
try:
clip = ImageSequenceClip(frames, fps=FPS)
# Explicitly use 'libx264' for broad compatibility, 'bar' for progress
clip.write_videofile(OUTPUT_FILENAME, codec='libx264', fps=FPS, logger='bar')
print(f"\nVideo saved successfully as '{OUTPUT_FILENAME}'")
except Exception as e:
print(f"\nError during video writing: {e}")
print("Check FFmpeg installation and codec support ('libx264').")
if failed_fonts:
print("\nFonts that caused errors (check character support/validity):")
for ff in sorted(list(failed_fonts)): # Sort for cleaner output
print(f" - {os.path.basename(ff)}")
print("\nScript finished.")
# --- Main Script Logic ---
if __name__ == "__main__":
if not AI_GENERATION_ENABLED:
print("NOTE: Mistral AI library not found or AI_GENERATION_ENABLED is False.")
print(" The script will use RANDOM text generation.")
elif not MISTRAL_API_KEY:
print("WARNING: MISTRAL_API_KEY environment variable not found.")
print(" AI text generation will fail if enabled.")
# The main function will catch this and exit if AI is enabled.
main()