Tools / src /video_editor /onscreen_cta.py
jebin2's picture
refactor: Centralize logger import to src.logger_config across various modules.
f20025d
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
)