|
|
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') |
|
|
|
|
|
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. |
|
|
""" |
|
|
|
|
|
|
|
|
text_font_path = 'Fonts/Ubuntu-Bold.ttf' |
|
|
|
|
|
|
|
|
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) |
|
|
cta_bg_color = (255, 255, 255, 255) |
|
|
strip_bg_color = (255, 255, 255, 230) |
|
|
corner_radius = 35 |
|
|
h_padding = 50 |
|
|
v_padding = 30 |
|
|
strip_v_padding = 50 |
|
|
|
|
|
|
|
|
shadow_offset = (0, 10) |
|
|
shadow_color = (0, 0, 0, 80) |
|
|
shadow_blur_radius = 25 |
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
lines = text.split('\n') |
|
|
line_spacing = int(font_size * 0.3) |
|
|
|
|
|
|
|
|
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() |
|
|
|
|
|
|
|
|
max_line_width = 0 |
|
|
emoji_spacing = 15 |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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)}") |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
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)) |
|
|
|
|
|
|
|
|
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 = 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) |
|
|
|
|
|
|
|
|
canvas = canvas.filter(ImageFilter.GaussianBlur(shadow_blur_radius)) |
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
text_x = main_x + h_padding |
|
|
|
|
|
|
|
|
current_line_y = main_y + v_padding - text_top_offset |
|
|
|
|
|
for line in lines: |
|
|
|
|
|
parts = emoji_pattern.split(line) |
|
|
emojis = emoji_pattern.findall(line) |
|
|
|
|
|
current_x = text_x |
|
|
|
|
|
|
|
|
for i, part in enumerate(parts): |
|
|
if part: |
|
|
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]) |
|
|
|
|
|
|
|
|
if i < len(emojis): |
|
|
current_x += emoji_spacing |
|
|
|
|
|
|
|
|
if i < len(emojis): |
|
|
emoji_img = load_emoji_image(emojis[i], int(actual_text_height) + 20, emoji_folder) |
|
|
if emoji_img: |
|
|
|
|
|
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: |
|
|
|
|
|
current_x += font_size + emoji_spacing |
|
|
|
|
|
|
|
|
current_line_y += actual_text_height + line_spacing |
|
|
|
|
|
cta_element = ImageClip(np.array(canvas)) |
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
full_banner = cta_element |
|
|
|
|
|
|
|
|
try: |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
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 |
|
|
) |