Spaces:
Running
Running
| """ | |
| 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 "" | |