242 lines
8.3 KiB
Python
242 lines
8.3 KiB
Python
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)
|