""" 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 ""