import os from moviepy.editor import * from PIL import Image, ImageDraw, ImageFilter, ImageFont import numpy as np import re from video_editor.safe_zone import SafeZone import uuid from src.logger_config import logger def get_emoji_unicode(emoji_char): """Convert emoji character to unicode hex string (e.g., '1f6cd')""" try: cps = [f"{ord(ch):x}" for ch in emoji_char][0] return cps except: return None def load_emoji_image(emoji_char, target_height, emoji_folder="emojis"): """Load emoji PNG and resize it to match text height""" unicode_hex = get_emoji_unicode(emoji_char) if not unicode_hex: return None emoji_filename = f"emoji_u{unicode_hex}.png" emoji_path = os.path.join(emoji_folder, emoji_filename) if not os.path.exists(emoji_path): logger.warning(f"Warning: Emoji PNG not found: {emoji_path}") return None try: emoji_img = Image.open(emoji_path).convert('RGBA') # Resize to match text height while maintaining aspect ratio aspect_ratio = emoji_img.width / emoji_img.height new_height = target_height new_width = int(new_height * aspect_ratio) emoji_img = emoji_img.resize((new_width, new_height), Image.Resampling.LANCZOS) return emoji_img except Exception as e: logger.error(f"Error loading emoji {emoji_path}: {e}") return None def create_cta_on_strip( text: str, video_clip: VideoClip, appear_at_percent: float = 0.65, emoji_folder: str = "emoji", above_caption: bool = True, padding: int = 20, show_strip: bool = False, bottom_safe_y: int = None ) -> CompositeVideoClip: """ Overlays a full-width white strip and a dynamically sized, elevated CTA label with color emojis. The CTA appears at a specific percentage through the video. """ # --- 1. Customize Appearance --- # Use text font only (no emoji font needed) text_font_path = 'Fonts/Ubuntu-Bold.ttf' # Or your preferred font # Fallback to system fonts if custom fonts not found if not os.path.exists(text_font_path): logger.warning(f"Warning: '{text_font_path}' not found. Using Arial Bold.") text_font_path = 'arial.ttf' logger.debug(f"Using text font: {text_font_path}") text_color = (0, 0, 0) # Black cta_bg_color = (255, 255, 255, 255) # White strip_bg_color = (255, 255, 255, 230) # Slightly transparent white corner_radius = 35 h_padding = 50 v_padding = 30 # Increased for more vertical space strip_v_padding = 50 # Enhanced shadow for elevated look shadow_offset = (0, 10) shadow_color = (0, 0, 0, 80) shadow_blur_radius = 25 # --- 2. Dynamic Font Sizing --- # Separate text from emojis for measurement emoji_pattern = re.compile(r'[\U0001F300-\U0001F9FF]', re.UNICODE) max_text_width = video_clip.w * 0.85 font_size = int(video_clip.w * 0.12) font_size = min(font_size, 50) # Cap at 200px # Split by newlines for multi-line support lines = text.split('\n') line_spacing = int(font_size * 0.3) # 30% of font size for line spacing # Create font try: text_font = ImageFont.truetype(text_font_path, font_size) except Exception as e: logger.error(f"Text font loading error: {e}. Using default font.") text_font = ImageFont.load_default() # Measure all lines and find the widest max_line_width = 0 emoji_spacing = 15 # Space between emoji and text for line in lines: text_only = emoji_pattern.sub('', line).strip() emojis_in_line = emoji_pattern.findall(line) text_upper = text_only.upper() try: text_only_length = text_font.getlength(text_upper) if text_upper else 0 except: text_bbox = text_font.getbbox(text_upper) if text_upper else (0, 0, 0, 0) text_only_length = text_bbox[2] - text_bbox[0] # Add space for emojis (add spacing before first emoji and between elements) if emojis_in_line: total_emoji_width = len(emojis_in_line) * (font_size * 1.2 + emoji_spacing) else: total_emoji_width = 0 line_width = text_only_length + total_emoji_width max_line_width = max(max_line_width, line_width) # Adjust font size if needed while max_line_width > max_text_width and font_size > 40: font_size -= 2 line_spacing = int(font_size * 0.3) try: text_font = ImageFont.truetype(text_font_path, font_size) except: pass max_line_width = 0 for line in lines: text_only = emoji_pattern.sub('', line).strip() emojis_in_line = emoji_pattern.findall(line) text_upper = text_only.upper() try: text_only_length = text_font.getlength(text_upper) if text_upper else 0 except: text_bbox = text_font.getbbox(text_upper) if text_upper else (0, 0, 0, 0) text_only_length = text_bbox[2] - text_bbox[0] if emojis_in_line: total_emoji_width = len(emojis_in_line) * (font_size * 1.2 + emoji_spacing) else: total_emoji_width = 0 line_width = text_only_length + total_emoji_width max_line_width = max(max_line_width, line_width) logger.debug(f"Final font size: {font_size}px") logger.debug(f"Number of lines: {len(lines)}") # --- 3. Create the Elevated CTA Label --- # Get accurate text metrics (for text only, without emojis) # Use the first line or a sample to get height sample_line = lines[0] if lines else "A" sample_text = emoji_pattern.sub('', sample_line).upper() or "A" text_bbox = text_font.getbbox(sample_text) actual_text_height = text_bbox[3] - text_bbox[1] text_top_offset = text_bbox[1] # Calculate total height for all lines total_text_height = (actual_text_height * len(lines)) + (line_spacing * (len(lines) - 1)) base_width = int(max_line_width + (h_padding * 2)) base_height = int(total_text_height + (v_padding * 2)) # Create canvas with extra space for shadow canvas_size = ( base_width + shadow_blur_radius * 3, base_height + shadow_blur_radius * 3 ) canvas = Image.new('RGBA', canvas_size, (0, 0, 0, 0)) # Draw shadow draw = ImageDraw.Draw(canvas) shadow_x = shadow_blur_radius + shadow_offset[0] shadow_y = shadow_blur_radius + shadow_offset[1] shadow_box = ( shadow_x, shadow_y, shadow_x + base_width, shadow_y + base_height ) draw.rounded_rectangle(shadow_box, radius=corner_radius, fill=shadow_color) # Apply blur to shadow canvas = canvas.filter(ImageFilter.GaussianBlur(shadow_blur_radius)) # Draw main white rounded rectangle draw = ImageDraw.Draw(canvas) main_x = shadow_blur_radius main_y = shadow_blur_radius main_box = ( main_x, main_y, main_x + base_width, main_y + base_height ) draw.rounded_rectangle(main_box, radius=corner_radius, fill=cta_bg_color) # Calculate text starting position text_x = main_x + h_padding # Draw text and emojis - now supporting multiple lines current_line_y = main_y + v_padding - text_top_offset for line in lines: # Split the line to preserve emoji positions parts = emoji_pattern.split(line) emojis = emoji_pattern.findall(line) current_x = text_x # Interleave text parts and emojis for this line for i, part in enumerate(parts): if part: # Draw text part (don't strip to preserve spaces) part_upper = part.upper() draw.text( (current_x, current_line_y), part_upper, font=text_font, fill=text_color ) try: current_x += text_font.getlength(part_upper) except: part_bbox = text_font.getbbox(part_upper) current_x += (part_bbox[2] - part_bbox[0]) # Add spacing only if there's an emoji coming next if i < len(emojis): current_x += emoji_spacing # Draw emoji if available if i < len(emojis): emoji_img = load_emoji_image(emojis[i], int(actual_text_height) + 20, emoji_folder) if emoji_img: # Center emoji vertically with text emoji_y = current_line_y + text_top_offset + (actual_text_height - emoji_img.height) // 2 canvas.paste(emoji_img, (int(current_x), int(emoji_y)), emoji_img) current_x += emoji_img.width + emoji_spacing else: # If emoji image not found, skip with spacing current_x += font_size + emoji_spacing # Move to next line current_line_y += actual_text_height + line_spacing cta_element = ImageClip(np.array(canvas)) # --- 4. Optionally Create the Full-Width Semi-Transparent Strip --- if show_strip: strip_height = cta_element.h + (strip_v_padding * 2) white_strip_array = np.full( (strip_height, video_clip.w, 4), [255, 255, 255, 230], dtype=np.uint8 ) white_strip = ImageClip(white_strip_array) full_banner = CompositeVideoClip([ white_strip, cta_element.set_position(('center', 'center')) ]) else: # No strip → use only the CTA card full_banner = cta_element # --- 5. Calculate Final Position using SafeZone --- try: # _, bottom_safe_y = SafeZone.get_caption_position( # video_width=video_clip.w, # video_height=video_clip.h, # caption_height=full_banner.h, # position="bottom", # padding=padding # ) # Position above the safe zone bottom if not bottom_safe_y: bottom_safe_y = video_clip.h/2 if above_caption: final_y_pos = bottom_safe_y else: final_y_pos = bottom_safe_y + full_banner.h except: # Fallback: position at 75% of video height final_y_pos = int(video_clip.h * 0.75) logger.debug(f"{video_clip.h}") logger.debug(f"{bottom_safe_y}") logger.debug(f"Final CTA Y position: {final_y_pos}px") final_position = ('center', final_y_pos) # --- 6. Set timing: appear at specified percentage --- start_time = video_clip.duration * appear_at_percent duration = video_clip.duration - start_time final_banner = (full_banner .set_duration(duration) .set_start(start_time) .set_position(final_position)) return CompositeVideoClip([video_clip, final_banner]) def add_cta(input_video_path: str, cta_text: str, above_caption: bool = True, padding: int = 20, show_strip: bool = False, bottom_safe_y: int = None) -> str: if above_caption: output_video_path = f"/tmp/{uuid.uuid4().hex[:8]}_above_caption.mp4" else: output_video_path = f"/tmp/{uuid.uuid4().hex[:8]}_below_caption.mp4" logger.debug(f"Loading video: '{input_video_path}'...") base_video = VideoFileClip(input_video_path) logger.debug(f"Generating CTA overlay with text: '{cta_text}'") logger.debug(f"CTA will appear at 75% mark ({base_video.duration * 0.75:.2f}s)") if not bottom_safe_y: if padding == 20: bottom_safe_y = 860 else: bottom_safe_y = 920 final_video = create_cta_on_strip(cta_text, base_video, above_caption = above_caption, padding=padding, show_strip=show_strip, bottom_safe_y=bottom_safe_y) logger.debug(f"Writing final video to '{output_video_path}'...") final_video.write_videofile( output_video_path, codec="libx264", audio_codec="aac", fps=25, ) base_video.close() final_video.close() logger.debug(f"✅ Successfully created video: {output_video_path}") return output_video_path # --- MAIN EXECUTION BLOCK --- if __name__ == '__main__': input_video_path = "testData/output/video_no_audio_9beb42ca.mp4" import utils input_video_path = utils.ratio_1x1_to9x16(input_video_path) cta_text = "LINK IN BIO 🛍️" bottom_safe_y = 200 add_cta( input_video_path, cta_text, bottom_safe_y=bottom_safe_y )