import os import textwrap import subprocess import urllib.request from PIL import Image, ImageDraw, ImageFont, ImageFilter FONT_URL = "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Bold.ttf" FONT_DIR = "fonts" FONT_PATH = os.path.join(FONT_DIR, "NotoSerif-Bold.ttf") def download_font_if_needed(): """Downloads a serif font for the hook text if not present.""" if not os.path.exists(FONT_DIR): os.makedirs(FONT_DIR) if not os.path.exists(FONT_PATH): print(f"⬇️ Downloading font from {FONT_URL}...") try: # Add user agent to avoid 403s slightly req = urllib.request.Request( FONT_URL, headers={'User-Agent': 'Mozilla/5.0'} ) with urllib.request.urlopen(req) as response, open(FONT_PATH, 'wb') as out_file: out_file.write(response.read()) print("✅ Font downloaded.") except Exception as e: print(f"❌ Failed to download font: {e}") def create_hook_image(text, target_width, output_image_path="hook_overlay.png", font_scale=1.0): """ Generates a white box with black serif text using pixel-based wrapping. target_width: The max width the box should occupy (e.g. 85% of video) """ download_font_if_needed() # Configuration padding_x = 30 # Balanced padding padding_y = 25 line_spacing = 20 # Increased spacing cornerradius = 20 shadow_offset = (5, 5) shadow_blur = 10 # Font Size Calculation (approx 5% of width - tuned to match Noto Serif Bold metrics in browser) base_font_size = int(target_width * 0.05) font_size = int(base_font_size * font_scale) try: font = ImageFont.truetype(FONT_PATH, font_size) except Exception as e: print(f"⚠️ Warning: Could not load font {FONT_PATH}, using default. Error: {e}") font = ImageFont.load_default() # Wrap text logic (Pixel-based) dummy_img = Image.new('RGBA', (1, 1)) draw = ImageDraw.Draw(dummy_img) max_text_width = target_width - (2 * padding_x) # Handle manual newlines first paragraphs = text.split('\n') lines = [] for p in paragraphs: if not p.strip(): lines.append("") continue words = p.split() current_line = [] for word in words: # Test if adding word fits test_line = ' '.join(current_line + [word]) bbox = draw.textbbox((0, 0), test_line, font=font) w = bbox[2] - bbox[0] if w <= max_text_width: current_line.append(word) else: # Line full, push current_line and start new if current_line: lines.append(' '.join(current_line)) current_line = [word] else: # Single word too long? Force it. lines.append(word) current_line = [] if current_line: lines.append(' '.join(current_line)) # Recalculate true width/height max_line_width = 0 text_heights = [] for line in lines: if not line: text_heights.append(font_size) # Use font size for empty line height continue bbox = draw.textbbox((0, 0), line, font=font) w = bbox[2] - bbox[0] h = bbox[3] - bbox[1] max_line_width = max(max_line_width, w) text_heights.append(h) # Box dimensions # We want the box to fit the text exactly + padding # Ensure min width for aesthetic reasons if text is short (at least 30% of target) box_width = max(max_line_width + (2 * padding_x), int(target_width * 0.3)) # Total Text Height: sum(heights) + spacing * (n-1) if not text_heights: total_text_height = font_size else: total_text_height = sum(text_heights) + (len(text_heights) - 1) * line_spacing box_height = total_text_height + (2 * padding_y) # Create Final Image with Rounded Corners and Shadow # 1. Canvas for Shadow (larger than box) canvas_w = box_width + 40 canvas_h = box_height + 40 img = Image.new('RGBA', (canvas_w, canvas_h), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) # 2. Draw Shadow shadow_box = [ (20 + shadow_offset[0], 20 + shadow_offset[1]), (20 + box_width + shadow_offset[0], 20 + box_height + shadow_offset[1]) ] draw.rounded_rectangle(shadow_box, radius=cornerradius, fill=(0, 0, 0, 100)) # 3. Blur Shadow img = img.filter(ImageFilter.GaussianBlur(5)) # 4. Draw White Box (sharper, on top of blurred shadow) draw_final = ImageDraw.Draw(img) main_box = [ (20, 20), (20 + box_width, 20 + box_height) ] # Semi-transparent white (240/255 alpha ~ 94% opacity) draw_final.rounded_rectangle(main_box, radius=cornerradius, fill=(255, 255, 255, 240)) # 5. Draw Text current_y = 20 + padding_y - 2 # Minor visual adjustment for i, line in enumerate(lines): if not line: current_y += font_size + line_spacing continue bbox = draw_final.textbbox((0, 0), line, font=font) line_w = bbox[2] - bbox[0] line_h = text_heights[i] if i < len(text_heights) else bbox[3] - bbox[1] # Center X x = 20 + (box_width - line_w) // 2 # Draw Black Text draw_final.text((x, current_y), line, font=font, fill="black") current_y += line_h + line_spacing img.save(output_image_path) return output_image_path, canvas_w, canvas_h def add_hook_to_video(video_path, text, output_path, position="top", font_scale=1.0): """ Overlays text hook onto video. position: 'top', 'center', 'bottom' font_scale: float multiplier (1.0 = default) """ if not os.path.exists(video_path): raise FileNotFoundError(f"Video {video_path} not found") # 1. Probe video width to scale text properly try: cmd = ['ffprobe', '-v', 'error', '-show_entries', 'stream=width,height', '-of', 'csv=s=x:p=0', video_path] res = subprocess.check_output(cmd).decode().strip() # Takes first stream if multiple dims = res.split('\n')[0].split('x') video_width = int(dims[0]) video_height = int(dims[1]) except Exception as e: print(f"⚠️ FFprobe failed: {e}. Assuming 1080x1920") video_width = 1080 video_height = 1920 # 2. Generate Image # Box check: Don't let it be wider than 90% of screen target_box_width = int(video_width * 0.9) hook_filename = f"temp_hook_{os.path.basename(video_path)}.png" # Ensure unique or temp location if needed, but relative is fine for this app structure try: img_path, box_w, box_h = create_hook_image(text, target_box_width, hook_filename, font_scale=font_scale) # 3. Calculate Overlay Position overlay_x = (video_width - box_w) // 2 if position == "center": overlay_y = (video_height - box_h) // 2 elif position == "bottom": # Bottom 20% mark (approx) overlay_y = int(video_height * 0.70) else: # Top 20% mark overlay_y = int(video_height * 0.20) # 4. FFmpeg Command print(f"🎬 Overlaying hook: '{text}' at {overlay_x},{overlay_y}") ffmpeg_cmd = [ 'ffmpeg', '-y', '-i', video_path, '-i', img_path, '-filter_complex', f"[0:v][1:v]overlay={overlay_x}:{overlay_y}", '-c:a', 'copy', '-c:v', 'libx264', '-preset', 'fast', '-crf', '22', output_path ] subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) print(f"✅ Hook added to {output_path}") return True except subprocess.CalledProcessError as e: print(f"❌ FFmpeg Error: {e.stderr.decode() if e.stderr else 'Unknown'}") raise e except Exception as e: print(f"❌ Hook Gen Error: {e}") raise e finally: # Cleanup temp image if os.path.exists(hook_filename): os.remove(hook_filename)