orbis-backend / src /utils /post_renderer.py
Deusxx1234's picture
feat: viral news-style prompts and dailywireup branding on overlays
548e96b
"""
Professional Social Post Rendering Engine
Generates Instagram/LinkedIn/TikTok-optimized posts using design system principles.
Features multiple layouts, auto-scaling typography, and mobile-first design.
"""
import io
import os
import re
import math
import random
import httpx
from typing import Tuple, Optional, List
from loguru import logger
from PIL import Image, ImageDraw, ImageFont, ImageFilter
from src.utils.design_system import (
TYPOGRAPHY, SPACING, LINE_HEIGHT, PALETTES, LAYOUT_TEMPLATES,
LayoutType, AspectRatio, LayoutTemplate, ColorPalette,
detect_content_type, ensure_readable, get_contrast_ratio,
)
# ─────────────────────────────────────────────────────────────────────────────
# FONT LOADING & CACHING
# ─────────────────────────────────────────────────────────────────────────────
_FONT_CACHE: dict = {}
def _ensure_fonts():
"""Download professional fonts for social posts."""
import urllib.request
font_dir = "/tmp/orbis_fonts"
os.makedirs(font_dir, exist_ok=True)
fonts = {
"inter_bold": (
f"{font_dir}/Inter-Bold.ttf",
"https://github.com/rsms/inter/raw/master/docs/font-files/Inter-Bold.otf"
),
"inter_regular": (
f"{font_dir}/Inter-Regular.ttf",
"https://github.com/rsms/inter/raw/master/docs/font-files/Inter-Regular.otf"
),
"inter_semibold": (
f"{font_dir}/Inter-SemiBold.ttf",
"https://github.com/rsms/inter/raw/master/docs/font-files/Inter-SemiBold.otf"
),
}
for key, (path, url) in fonts.items():
if not os.path.exists(path):
try:
urllib.request.urlretrieve(url, path)
logger.info(f"[Render] Downloaded font: {path}")
except Exception as e:
logger.warning(f"[Render] Font download failed ({key}): {e}")
def _load_font(size: int, weight: str = "regular") -> ImageFont.FreeTypeFont:
"""Load font with caching. Weights: regular, bold, semibold."""
cache_key = (size, weight)
if cache_key in _FONT_CACHE:
return _FONT_CACHE[cache_key]
# Attempt downloads
_ensure_fonts()
# Try loading from cache directory
font_dir = "/tmp/orbis_fonts"
weight_map = {
"regular": "Inter-Regular.ttf",
"semibold": "Inter-SemiBold.ttf",
"bold": "Inter-Bold.ttf",
}
font_name = weight_map.get(weight, "Inter-Regular.ttf")
font_path = os.path.join(font_dir, font_name)
if os.path.exists(font_path):
try:
font = ImageFont.truetype(font_path, size)
_FONT_CACHE[cache_key] = font
return font
except Exception as e:
logger.warning(f"[Render] Could not load {font_path}: {e}")
# Fallback to system fonts
system_paths = {
"bold": [
"C:/Windows/Fonts/arialbd.ttf",
"C:/Windows/Fonts/calibrib.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
],
"regular": [
"C:/Windows/Fonts/arial.ttf",
"C:/Windows/Fonts/calibri.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
],
}
paths = system_paths.get(weight, system_paths["regular"])
for path in paths:
if os.path.exists(path):
try:
font = ImageFont.truetype(path, size)
_FONT_CACHE[cache_key] = font
return font
except Exception:
continue
# Last resort
return ImageFont.load_default()
# ─────────────────────────────────────────────────────────────────────────────
# TEXT UTILITIES
# ─────────────────────────────────────────────────────────────────────────────
def _wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int,
draw: ImageDraw.ImageDraw) -> List[str]:
"""
Wrap text intelligently for readability.
Breaks on words, respects max_width, and returns list of lines.
"""
words = text.split()
if not words:
return []
lines = []
current_line = ""
for word in words:
test_line = (current_line + " " + word).strip()
bbox = draw.textbbox((0, 0), test_line, font=font)
line_width = bbox[2] - bbox[0]
if line_width <= max_width:
current_line = test_line
else:
if current_line:
lines.append(current_line)
current_line = word
if current_line:
lines.append(current_line)
return lines
def _get_text_height(text: str, font: ImageFont.FreeTypeFont,
draw: ImageDraw.ImageDraw) -> int:
"""Get height of rendered text."""
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[3] - bbox[1]
def _parse_caption(caption: str) -> Tuple[str, List[str], str]:
"""Parse LLM caption into hook, body lines, and hashtags."""
# Remove structural markers
clean = re.sub(
r"(?im)^(HOOK|SLIDE\s*\d+[:\.\-]?[^|\n]*\|?|CAPTION|IMAGE_PROMPT)[:\s]*",
"", caption
)
clean = re.sub(r"\n{3,}", "\n\n", clean).strip()
lines = [l.strip() for l in clean.splitlines() if l.strip()]
# Find hashtags (last line usually)
tag_line = ""
if lines:
last_line = lines[-1]
if all(w.startswith("#") for w in last_line.split() if w):
tag_line = last_line
lines = lines[:-1]
# Split into hook and body
hook = lines[0] if lines else "Untitled"
body = lines[1:] if len(lines) > 1 else []
return hook, body, tag_line
# ─────────────────────────────────────────────────────────────────────────────
# GRADIENT & BACKGROUND UTILITIES
# ─────────────────────────────────────────────────────────────────────────────
def _create_gradient_background(width: int, height: int,
top_rgb: Tuple[int, int, int],
bot_rgb: Tuple[int, int, int],
angle: float = 90.0) -> Image.Image:
"""Create smooth vertical gradient background."""
canvas = Image.new("RGB", (width, height))
draw = ImageDraw.Draw(canvas)
for y in range(height):
# Linear interpolation
t = y / height
r = int(top_rgb[0] + (bot_rgb[0] - top_rgb[0]) * t)
g = int(top_rgb[1] + (bot_rgb[1] - top_rgb[1]) * t)
b = int(top_rgb[2] + (bot_rgb[2] - top_rgb[2]) * t)
draw.line([(0, y), (width, y)], fill=(r, g, b))
return canvas
def _add_scrim(image: Image.Image, top_opacity: int = 0,
bottom_opacity: int = 180, bottom_start: float = 0.4) -> Image.Image:
"""Add dark scrim overlay for text readability over images."""
width, height = image.size
if image.mode != "RGBA":
image = image.convert("RGBA")
scrim = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(scrim)
# Top scrim (subtle)
if top_opacity > 0:
top_h = int(height * 0.15)
for i in range(top_h):
alpha = int(top_opacity * (i / top_h))
draw.line([(0, i), (width, i)], fill=(0, 0, 0, alpha))
# Bottom scrim (strong)
if bottom_opacity > 0:
bot_start = int(height * bottom_start)
bot_h = height - bot_start
for i in range(bot_h):
alpha = int(bottom_opacity * (i / bot_h) ** 0.7)
draw.line([(0, bot_start + i), (width, bot_start + i)],
fill=(0, 0, 0, alpha))
return Image.alpha_composite(image, scrim)
# ─────────────────────────────────────────────────────────────────────────────
# LAYOUT IMPLEMENTATIONS
# ─────────────────────────────────────────────────────────────────────────────
class PostRenderer:
"""Main rendering engine for social posts."""
def __init__(self, template: LayoutTemplate, palette: ColorPalette):
self.template = template
self.palette = palette
self.width = template.aspect_ratio.width
self.height = template.aspect_ratio.height
self.margin = template.margin
def render_typography_bold(self, hook: str, body: List[str],
tags: str) -> Image.Image:
"""
Typography-Bold Layout:
- Large, readable headline
- Subtle body text
- High contrast gradient
"""
canvas = _create_gradient_background(
self.width, self.height,
self.palette.bg_top, self.palette.bg_bottom
)
draw = ImageDraw.Draw(canvas)
# Text color with contrast
text_color = ensure_readable(
self.palette.text_primary, self.palette.bg_top
)
# 1. Render headline with auto-scaling
headline_size = self.template.headline_size
headline_font = _load_font(headline_size, weight="bold")
# Wrap headline
wrapped_headline = _wrap_text(
hook, headline_font, self.template.inner_width, draw
)
# If too many lines, reduce size
while len(wrapped_headline) > 5 and headline_size > 48:
headline_size -= 8
headline_font = _load_font(headline_size, weight="bold")
wrapped_headline = _wrap_text(
hook, headline_font, self.template.inner_width, draw
)
# Calculate vertical centering
line_height = int(headline_size * LINE_HEIGHT["tight"])
total_headline_height = len(wrapped_headline) * line_height
body_size = int(self.template.body_size * 0.8)
body_font = _load_font(body_size, weight="regular")
# Body text wrapping
wrapped_body = []
for line in body[:2]: # Max 2 body lines
wrapped_body.extend(
_wrap_text(line, body_font, self.template.inner_width, draw)[:2]
)
body_line_height = int(body_size * LINE_HEIGHT["normal"])
total_body_height = len(wrapped_body) * body_line_height
gap = SPACING.lg
total_height = total_headline_height + (gap + total_body_height if wrapped_body else 0)
y = (self.height - total_height) // 2
# 2. Draw accent bar
bar_height = 6
bar_width = min(
self.template.inner_width,
max(SPACING.lg, int(headline_size * 0.4))
)
draw.rectangle(
[(self.margin, y - 24), (self.margin + bar_width, y - 18)],
fill=self.palette.accent_primary
)
# 3. Draw headline
for line in wrapped_headline:
draw.text(
(self.margin, y), line,
font=headline_font,
fill=text_color
)
y += line_height
# 4. Draw body
if wrapped_body:
y += gap
for line in wrapped_body:
draw.text(
(self.margin, y), line,
font=body_font,
fill=(*text_color[:3], 220)
)
y += body_line_height
# 5. Draw hashtags (bottom)
if tags:
tag_font = _load_font(TYPOGRAPHY.caption, weight="regular")
draw.text(
(self.margin, self.height - SPACING.lg - 10),
tags[:100],
font=tag_font,
fill=(*self.palette.accent_primary, 180)
)
# 6. Draw brand watermark
brand_font = _load_font(TYPOGRAPHY.tiny, weight="regular")
brand_text = "@dailywireup"
brand_bbox = draw.textbbox((0, 0), brand_text, font=brand_font)
brand_width = brand_bbox[2] - brand_bbox[0]
draw.text(
(self.width - self.margin - brand_width - 8, self.height - SPACING.lg - 10),
brand_text,
font=brand_font,
fill=(*text_color[:3], 100)
)
return canvas
def render_editorial_photo(self, hook: str, body: List[str],
tags: str, image_url: str) -> Image.Image:
"""
Editorial-Photo Layout:
- Full-bleed image with smart cropping
- Dark scrim for text readability
- Large headline positioned strategically
"""
# Download and process image
image = self._fetch_and_crop_image(image_url)
if image is None:
# Fallback to text-only
return self.render_typography_bold(hook, body, tags)
# Add dark scrim for text readability
canvas = self._add_scrim(image, top_opacity=80, bottom_opacity=220)
draw = ImageDraw.Draw(canvas)
# Text color is always white for editorials
text_color = (255, 255, 255)
# 1. Top bar with branding
brand_font = _load_font(28, weight="semibold")
draw.text(
(self.margin, SPACING.lg),
"@dailywireup",
font=brand_font,
fill=(*self.palette.accent_primary, 240)
)
draw.rectangle(
[(self.margin, SPACING.xl), (self.width - self.margin, SPACING.xl + 4)],
fill=(*self.palette.accent_primary, 180)
)
# 2. Calculate headline positioning
HEADLINE_START = int(self.height * 0.55)
PADDING_BOTTOM = SPACING.xl
headline_size = self.template.headline_size
headline_font = _load_font(headline_size, weight="bold")
wrapped_headline = _wrap_text(
hook.upper(), headline_font, self.template.inner_width, draw
)
# Auto-scale if needed
while len(wrapped_headline) > 4 and headline_size > 56:
headline_size -= 8
headline_font = _load_font(headline_size, weight="bold")
wrapped_headline = _wrap_text(
hook.upper(), headline_font, self.template.inner_width, draw
)
# 3. Draw headline with drop shadow
line_height = int(headline_size * LINE_HEIGHT["tight"])
y = HEADLINE_START
for line in wrapped_headline:
# Drop shadow
draw.text(
(self.margin + 3, y + 3), line,
font=headline_font,
fill=(0, 0, 0, 120)
)
# Main text
draw.text(
(self.margin, y), line,
font=headline_font,
fill=text_color
)
y += line_height
# 4. Draw left accent bar
bar_x = self.margin - 16
draw.rectangle(
[(bar_x, HEADLINE_START),
(bar_x + 8, HEADLINE_START + len(wrapped_headline) * line_height)],
fill=(*self.palette.accent_primary, 240)
)
# 5. Draw body text (sub-headline)
body_size = int(self.template.body_size * 0.9)
body_font = _load_font(body_size, weight="regular")
y += SPACING.md
for body_line in body[:2]:
wrapped = _wrap_text(
body_line, body_font, self.template.inner_width, draw
)[:1]
for line in wrapped:
draw.text(
(self.margin, y), line,
font=body_font,
fill=(210, 210, 210, 210)
)
y += int(body_size * LINE_HEIGHT["normal"])
# 6. Hashtags (bottom left)
if tags:
tag_font = _load_font(TYPOGRAPHY.caption, weight="regular")
draw.text(
(self.margin, self.height - SPACING.lg - 10),
tags[:80],
font=tag_font,
fill=(*self.palette.accent_primary, 200)
)
return canvas.convert("RGB")
def render_split_half(self, hook: str, body: List[str],
tags: str, image_url: str) -> Image.Image:
"""
Split-Half Layout:
- Left: image
- Right: text content
"""
# Download and process image
image = self._fetch_and_crop_image(image_url, crop_to_half=True)
if image is None:
return self.render_typography_bold(hook, body, tags)
# Resize image to fit left half
half_width = self.width // 2
image = image.resize((half_width, self.height), Image.LANCZOS)
# Create right half background
right_bg = _create_gradient_background(
half_width, self.height,
self.palette.bg_top, self.palette.bg_bottom
)
# Composite
canvas = Image.new("RGB", (self.width, self.height))
canvas.paste(image, (0, 0))
canvas.paste(right_bg, (half_width, 0))
draw = ImageDraw.Draw(canvas)
# Text on right side
text_margin = SPACING.md
text_width = half_width - (2 * text_margin)
text_color = ensure_readable(
self.palette.text_primary, self.palette.bg_top
)
# Headline
headline_font = _load_font(self.template.headline_size - 20, weight="bold")
wrapped_headline = _wrap_text(
hook, headline_font, text_width, draw
)
y = SPACING.xl
line_height = int(headline_font.size * LINE_HEIGHT["tight"])
for line in wrapped_headline:
draw.text(
(half_width + text_margin, y), line,
font=headline_font,
fill=text_color
)
y += line_height
# Body
y += SPACING.md
body_font = _load_font(self.template.body_size - 4, weight="regular")
for body_line in body[:2]:
wrapped = _wrap_text(
body_line, body_font, text_width, draw
)[:2]
for line in wrapped:
draw.text(
(half_width + text_margin, y), line,
font=body_font,
fill=text_color
)
y += int(body_font.size * LINE_HEIGHT["normal"])
y += SPACING.md
return canvas
def _fetch_and_crop_image(self, image_url: str, crop_to_half: bool = False) -> Optional[Image.Image]:
"""Download and intelligently crop image to aspect ratio."""
try:
async_client = httpx.Client()
resp = async_client.get(image_url, timeout=30, follow_redirects=True)
resp.raise_for_status()
image = Image.open(io.BytesIO(resp.content)).convert("RGB")
except Exception as e:
logger.error(f"[Render] Image fetch failed: {e}")
return None
# Determine target dimensions
target_w = self.width if not crop_to_half else self.width // 2
target_h = self.height
target_ratio = target_w / target_h
# Crop to aspect ratio
img_w, img_h = image.size
img_ratio = img_w / img_h
if img_ratio > target_ratio:
# Image too wide
new_w = int(img_h * target_ratio)
left = (img_w - new_w) // 2
image = image.crop((left, 0, left + new_w, img_h))
else:
# Image too tall
new_h = int(img_w / target_ratio)
top = (img_h - new_h) // 2
image = image.crop((0, top, img_w, top + new_h))
# Resize to target
image = image.resize((target_w, target_h), Image.LANCZOS)
return image
# ─────────────────────────────────────────────────────────────────────────────
# PUBLIC API
# ─────────────────────────────────────────────────────────────────────────────
async def create_social_post(
caption: str,
image_url: Optional[str] = None,
cloudinary_config: dict = None,
layout_type: Optional[LayoutType] = None,
palette_name: Optional[str] = None,
) -> str:
"""
Generate a professional social media post.
Args:
caption: Post text (with optional hook, body, hashtags)
image_url: Optional image URL for image-based layouts
cloudinary_config: Config for uploading to Cloudinary
layout_type: Specific layout to use (auto-selected if None)
palette_name: Specific color palette (random if None)
Returns:
URL of generated post image
"""
try:
# Parse caption
hook, body, tags = _parse_caption(caption)
# Select palette
if palette_name and palette_name in PALETTES:
palette = PALETTES[palette_name]
else:
palette = random.choice(list(PALETTES.values()))
# Select layout (smart selection)
if layout_type is None:
content_type = detect_content_type(hook, has_image=image_url is not None)
word_count = len(hook.split())
if word_count <= 10 and not image_url:
layout_type = LayoutType.TYPOGRAPHY_BOLD
elif image_url and word_count > 15:
layout_type = LayoutType.EDITORIAL_PHOTO
elif image_url and word_count <= 15:
layout_type = LayoutType.SPLIT_HALF
else:
layout_type = LayoutType.TYPOGRAPHY_BOLD
template = LAYOUT_TEMPLATES[layout_type]
renderer = PostRenderer(template, palette)
# Render based on layout
logger.info(f"[Render] {layout_type.value} layout | {palette.name} palette")
if layout_type == LayoutType.TYPOGRAPHY_BOLD:
canvas = renderer.render_typography_bold(hook, body, tags)
elif layout_type == LayoutType.EDITORIAL_PHOTO and image_url:
canvas = renderer.render_editorial_photo(hook, body, tags, image_url)
elif layout_type == LayoutType.SPLIT_HALF and image_url:
canvas = renderer.render_split_half(hook, body, tags, image_url)
else:
canvas = renderer.render_typography_bold(hook, body, tags)
# Upload to Cloudinary
if cloudinary_config:
buf = io.BytesIO()
canvas.save(buf, format="JPEG", quality=95)
buf.seek(0)
import cloudinary
import cloudinary.uploader
cloudinary.config(**cloudinary_config)
result = cloudinary.uploader.upload(
buf, folder="ai_media_os/posts", resource_type="image"
)
url = result.get("secure_url", "")
logger.info(f"[Render] Post uploaded: {url}")
return url
else:
# Save locally for testing
canvas.save("/tmp/post.jpg", quality=95)
return "/tmp/post.jpg"
except Exception as e:
logger.error(f"[Render] Post generation failed: {e}")
return image_url or ""