Spaces:
Runtime error
Runtime error
| """ | |
| OG Image Generation Service | |
| ============================ | |
| Generates custom Open Graph preview images for forms using their actual styling. | |
| Images are saved to S3 for fast CDN delivery. | |
| """ | |
| import io | |
| import os | |
| import logging | |
| import hashlib | |
| from typing import Optional, Tuple | |
| from PIL import Image, ImageDraw, ImageFont | |
| logger = logging.getLogger(__name__) | |
| # Standard OG image dimensions (LinkedIn/Facebook recommended) | |
| OG_WIDTH = 1200 | |
| OG_HEIGHT = 630 | |
| class OGImageService: | |
| """Service to generate and cache Open Graph images for forms.""" | |
| def __init__(self): | |
| self.s3_service = None | |
| self._font_cache = {} | |
| def _get_s3_service(self): | |
| """Lazy load S3 service.""" | |
| if self.s3_service is None: | |
| from .s3_service import s3_service | |
| self.s3_service = s3_service | |
| return self.s3_service | |
| def _get_font(self, size: int, bold: bool = False) -> ImageFont.FreeTypeFont: | |
| """Get a font, with caching. Falls back to default if custom fonts unavailable.""" | |
| cache_key = f"{size}_{bold}" | |
| if cache_key in self._font_cache: | |
| return self._font_cache[cache_key] | |
| # Try common system fonts | |
| font_candidates = [ | |
| # Windows | |
| "C:/Windows/Fonts/segoeui.ttf", | |
| "C:/Windows/Fonts/segoeuib.ttf" if bold else "C:/Windows/Fonts/segoeui.ttf", | |
| "C:/Windows/Fonts/arial.ttf", | |
| "C:/Windows/Fonts/arialbd.ttf" if bold else "C:/Windows/Fonts/arial.ttf", | |
| # Linux | |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", | |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", | |
| "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", | |
| "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf" if bold else "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", | |
| # macOS | |
| "/System/Library/Fonts/Helvetica.ttc", | |
| "/Library/Fonts/Arial.ttf", | |
| ] | |
| font = None | |
| for font_path in font_candidates: | |
| try: | |
| if os.path.exists(font_path): | |
| font = ImageFont.truetype(font_path, size) | |
| break | |
| except Exception: | |
| continue | |
| if font is None: | |
| # Fallback to default | |
| try: | |
| font = ImageFont.truetype("arial.ttf", size) | |
| except: | |
| font = ImageFont.load_default() | |
| self._font_cache[cache_key] = font | |
| return font | |
| def _hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]: | |
| """Convert hex color to RGB tuple.""" | |
| hex_color = hex_color.lstrip('#') | |
| if len(hex_color) == 3: | |
| hex_color = ''.join([c*2 for c in hex_color]) | |
| try: | |
| return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) | |
| except: | |
| return (255, 255, 255) # Default white | |
| def _get_contrast_color(self, bg_color: Tuple[int, int, int]) -> Tuple[int, int, int]: | |
| """Get a contrasting text color (black or white) based on background luminance.""" | |
| # Calculate relative luminance | |
| r, g, b = bg_color | |
| luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 | |
| return (0, 0, 0) if luminance > 0.5 else (255, 255, 255) | |
| def _wrap_text(self, text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.Draw) -> list: | |
| """Wrap text to fit within max_width.""" | |
| words = text.split() | |
| lines = [] | |
| current_line = [] | |
| for word in words: | |
| test_line = ' '.join(current_line + [word]) | |
| bbox = draw.textbbox((0, 0), test_line, font=font) | |
| width = bbox[2] - bbox[0] | |
| if width <= max_width: | |
| current_line.append(word) | |
| else: | |
| if current_line: | |
| lines.append(' '.join(current_line)) | |
| current_line = [word] | |
| if current_line: | |
| lines.append(' '.join(current_line)) | |
| return lines | |
| def generate_og_image( | |
| self, | |
| title: str, | |
| description: Optional[str] = None, | |
| background_color: str = "#ffffff", | |
| accent_color: str = "#9333ea", | |
| text_color: Optional[str] = None, | |
| question_count: int = 0 | |
| ) -> bytes: | |
| """ | |
| Generate an OG image with the form's styling. | |
| Returns PNG image as bytes. | |
| """ | |
| # Parse colors | |
| bg_rgb = self._hex_to_rgb(background_color) | |
| accent_rgb = self._hex_to_rgb(accent_color) | |
| # Auto-detect text color if not provided | |
| if text_color: | |
| text_rgb = self._hex_to_rgb(text_color) | |
| else: | |
| text_rgb = self._get_contrast_color(bg_rgb) | |
| # Create image | |
| img = Image.new('RGB', (OG_WIDTH, OG_HEIGHT), bg_rgb) | |
| draw = ImageDraw.Draw(img) | |
| # Draw accent bar at top | |
| draw.rectangle([(0, 0), (OG_WIDTH, 8)], fill=accent_rgb) | |
| # Draw accent circle/logo area | |
| circle_x, circle_y = 100, OG_HEIGHT // 2 | |
| circle_radius = 40 | |
| draw.ellipse( | |
| [(circle_x - circle_radius, circle_y - circle_radius), | |
| (circle_x + circle_radius, circle_y + circle_radius)], | |
| fill=accent_rgb | |
| ) | |
| # Draw "AF" text in circle | |
| try: | |
| logo_font = self._get_font(28, bold=True) | |
| logo_color = self._get_contrast_color(accent_rgb) | |
| bbox = draw.textbbox((0, 0), "AF", font=logo_font) | |
| logo_w = bbox[2] - bbox[0] | |
| logo_h = bbox[3] - bbox[1] | |
| draw.text( | |
| (circle_x - logo_w // 2, circle_y - logo_h // 2 - 5), | |
| "AF", | |
| font=logo_font, | |
| fill=logo_color | |
| ) | |
| except: | |
| pass | |
| # Content area starts after logo | |
| content_x = 180 | |
| content_width = OG_WIDTH - content_x - 80 | |
| # Draw title | |
| title_font = self._get_font(56, bold=True) | |
| title = title or "Untitled Form" | |
| title_lines = self._wrap_text(title, title_font, content_width, draw)[:2] # Max 2 lines | |
| title_y = 180 | |
| for line in title_lines: | |
| draw.text((content_x, title_y), line, font=title_font, fill=text_rgb) | |
| bbox = draw.textbbox((0, 0), line, font=title_font) | |
| title_y += bbox[3] - bbox[1] + 10 | |
| # Draw description | |
| if description: | |
| desc_font = self._get_font(28) | |
| # Slightly muted description color | |
| desc_rgb = tuple(int(c * 0.7 + bg_rgb[i] * 0.3) for i, c in enumerate(text_rgb)) | |
| desc_lines = self._wrap_text(description, desc_font, content_width, draw)[:3] # Max 3 lines | |
| desc_y = title_y + 30 | |
| for line in desc_lines: | |
| draw.text((content_x, desc_y), line, font=desc_font, fill=desc_rgb) | |
| bbox = draw.textbbox((0, 0), line, font=desc_font) | |
| desc_y += bbox[3] - bbox[1] + 8 | |
| # Draw question count badge | |
| if question_count > 0: | |
| badge_text = f"{question_count} question{'s' if question_count != 1 else ''}" | |
| badge_font = self._get_font(20) | |
| bbox = draw.textbbox((0, 0), badge_text, font=badge_font) | |
| badge_w = bbox[2] - bbox[0] + 24 | |
| badge_h = bbox[3] - bbox[1] + 12 | |
| badge_x = content_x | |
| badge_y = OG_HEIGHT - 100 | |
| # Draw badge background | |
| draw.rounded_rectangle( | |
| [(badge_x, badge_y), (badge_x + badge_w, badge_y + badge_h)], | |
| radius=6, | |
| fill=accent_rgb | |
| ) | |
| badge_text_color = self._get_contrast_color(accent_rgb) | |
| draw.text( | |
| (badge_x + 12, badge_y + 4), | |
| badge_text, | |
| font=badge_font, | |
| fill=badge_text_color | |
| ) | |
| # Draw "AutoForm" branding at bottom right | |
| brand_font = self._get_font(24) | |
| brand_text = "AutoForm" | |
| bbox = draw.textbbox((0, 0), brand_text, font=brand_font) | |
| brand_w = bbox[2] - bbox[0] | |
| brand_rgb = tuple(int(c * 0.5 + bg_rgb[i] * 0.5) for i, c in enumerate(text_rgb)) | |
| draw.text( | |
| (OG_WIDTH - brand_w - 60, OG_HEIGHT - 60), | |
| brand_text, | |
| font=brand_font, | |
| fill=brand_rgb | |
| ) | |
| # Convert to bytes | |
| buffer = io.BytesIO() | |
| img.save(buffer, format='PNG', optimize=True) | |
| buffer.seek(0) | |
| return buffer.getvalue() | |
| def generate_and_upload( | |
| self, | |
| form_id: int, | |
| token: str, | |
| title: str, | |
| description: Optional[str] = None, | |
| background_color: str = "#ffffff", | |
| accent_color: str = "#9333ea", | |
| text_color: Optional[str] = None, | |
| question_count: int = 0 | |
| ) -> Optional[str]: | |
| """ | |
| Generate OG image and upload to S3. | |
| Returns the public URL of the uploaded image, or None if upload fails. | |
| """ | |
| try: | |
| logger.warning(f"[OG] Starting generation for form {form_id}, title: {title}") | |
| # Generate image | |
| image_bytes = self.generate_og_image( | |
| title=title, | |
| description=description, | |
| background_color=background_color, | |
| accent_color=accent_color, | |
| text_color=text_color, | |
| question_count=question_count | |
| ) | |
| logger.warning(f"[OG] Image generated, size: {len(image_bytes)} bytes") | |
| # Generate a deterministic filename based on form styling | |
| style_hash = hashlib.md5( | |
| f"{title}{description}{background_color}{accent_color}{text_color}{question_count}".encode() | |
| ).hexdigest()[:12] | |
| s3_key = f"og-images/form-{form_id}-{style_hash}.png" | |
| # Upload to S3 | |
| s3 = self._get_s3_service() | |
| if not s3.bucket_name: | |
| logger.warning("[OG] S3 not configured (no bucket name)") | |
| return None | |
| logger.warning(f"[OG] Uploading to bucket: {s3.bucket_name}, key: {s3_key}") | |
| # Try upload - first with ACL, then without if that fails | |
| try: | |
| url = s3.put_object( | |
| key=s3_key, | |
| body=image_bytes, | |
| content_type="image/png", | |
| public=True | |
| ) | |
| except Exception as acl_error: | |
| logger.warning(f"[OG] ACL upload failed ({acl_error}), trying without ACL") | |
| url = s3.put_object( | |
| key=s3_key, | |
| body=image_bytes, | |
| content_type="image/png", | |
| public=False | |
| ) | |
| logger.warning(f"[OG] SUCCESS! Uploaded to: {url}") | |
| return url | |
| except Exception as e: | |
| import traceback | |
| logger.error(f"[OG] FAILED for form {form_id}: {e}") | |
| logger.error(f"[OG] Traceback: {traceback.format_exc()}") | |
| return None | |
| def get_or_generate_url( | |
| self, | |
| form_id: int, | |
| token: str, | |
| title: str, | |
| description: Optional[str] = None, | |
| settings: Optional[dict] = None, | |
| question_count: int = 0, | |
| force_regenerate: bool = False | |
| ) -> str: | |
| """ | |
| Get the OG image URL for a form, generating if needed. | |
| Returns S3 URL if available, otherwise returns a fallback URL. | |
| """ | |
| settings = settings or {} | |
| background_color = settings.get('background_color', '#ffffff') | |
| accent_color = settings.get('accent_color', '#9333ea') | |
| text_color = settings.get('text_color') | |
| # Try to upload to S3 | |
| url = self.generate_and_upload( | |
| form_id=form_id, | |
| token=token, | |
| title=title, | |
| description=description, | |
| background_color=background_color, | |
| accent_color=accent_color, | |
| text_color=text_color, | |
| question_count=question_count | |
| ) | |
| if url: | |
| return url | |
| # Fallback to default banner | |
| frontend_url = os.getenv('FRONTEND_URL', 'http://localhost:5173').split(',')[0].strip() | |
| return f"{frontend_url}/banner.png" | |
| # Singleton instance | |
| og_image_service = OGImageService() | |