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