Coverage for integrations / social / og_image.py: 70.2%
121 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2HevolveSocial - OG Image Generator
3Generates 1200x630 Open Graph preview images for shared content.
4Uses PIL/Pillow for server-side image generation with text overlay.
5"""
6import hashlib
7import logging
8import os
9import textwrap
10from datetime import datetime, timedelta
11from pathlib import Path
13logger = logging.getLogger('hevolve_social')
15# Cache directory for generated OG images
16try:
17 from core.platform_paths import get_db_dir as _get_db_dir_og
18 OG_CACHE_DIR = Path(_get_db_dir_og()) / 'og-cache'
19except ImportError:
20 OG_CACHE_DIR = Path(os.path.expanduser('~/Documents/Nunba/data/og-cache'))
22# Image dimensions (Facebook/LinkedIn recommended)
23WIDTH = 1200
24HEIGHT = 630
26# Colors (Nunba/Hevolve palette)
27BG_COLOR_TOP = (15, 14, 23) # #0F0E17
28BG_COLOR_BOTTOM = (30, 28, 50) # dark purple gradient end
29ACCENT_COLOR = (108, 99, 255) # #6C63FF
30TEXT_COLOR = (255, 255, 255) # white
31SUBTEXT_COLOR = (180, 180, 200) # muted
34def _ensure_cache_dir():
35 OG_CACHE_DIR.mkdir(parents=True, exist_ok=True)
38def _cache_path(resource_type, resource_id):
39 key = hashlib.md5(f'{resource_type}:{resource_id}'.encode()).hexdigest()
40 return OG_CACHE_DIR / f'og_{key}.png'
43def _is_cache_fresh(path, max_age_hours=1):
44 if not path.exists():
45 return False
46 mtime = datetime.fromtimestamp(path.stat().st_mtime)
47 return datetime.now() - mtime < timedelta(hours=max_age_hours)
50def generate_og_image(resource_type, resource_id):
51 """Generate an OG preview image. Returns file path or None."""
52 _ensure_cache_dir()
53 cache_file = _cache_path(resource_type, resource_id)
55 # Return cached if fresh
56 if _is_cache_fresh(cache_file):
57 return str(cache_file)
59 try:
60 from PIL import Image, ImageDraw, ImageFont
61 except ImportError:
62 logger.debug("Pillow not installed, skipping OG image generation")
63 return None
65 # Fetch resource data
66 title, description, avatar_text = _fetch_resource_info(resource_type, resource_id)
68 # Create image with gradient background
69 img = Image.new('RGB', (WIDTH, HEIGHT), BG_COLOR_TOP)
70 draw = ImageDraw.Draw(img)
72 # Draw gradient background
73 for y in range(HEIGHT):
74 ratio = y / HEIGHT
75 r = int(BG_COLOR_TOP[0] + (BG_COLOR_BOTTOM[0] - BG_COLOR_TOP[0]) * ratio)
76 g_val = int(BG_COLOR_TOP[1] + (BG_COLOR_BOTTOM[1] - BG_COLOR_TOP[1]) * ratio)
77 b = int(BG_COLOR_TOP[2] + (BG_COLOR_BOTTOM[2] - BG_COLOR_TOP[2]) * ratio)
78 draw.line([(0, y), (WIDTH, y)], fill=(r, g_val, b))
80 # Accent bar at top
81 draw.rectangle([(0, 0), (WIDTH, 6)], fill=ACCENT_COLOR)
83 # Try to load a font, fall back to default
84 title_font = _get_font(36)
85 desc_font = _get_font(22)
86 brand_font = _get_font(18)
88 # Draw resource type badge
89 badge_text = resource_type.replace('_', ' ').upper()
90 badge_w = draw.textlength(badge_text, font=brand_font) + 24 if brand_font else len(badge_text) * 10 + 24
91 draw.rounded_rectangle(
92 [(60, 80), (60 + badge_w, 116)],
93 radius=6, fill=ACCENT_COLOR,
94 )
95 draw.text((72, 86), badge_text, fill=TEXT_COLOR, font=brand_font)
97 # Draw title (wrapped)
98 title_lines = textwrap.wrap(title, width=45)
99 y_pos = 140
100 for line in title_lines[:3]:
101 draw.text((60, y_pos), line, fill=TEXT_COLOR, font=title_font)
102 y_pos += 48
104 # Draw description (wrapped)
105 if description:
106 desc_lines = textwrap.wrap(description, width=70)
107 y_pos += 16
108 for line in desc_lines[:3]:
109 draw.text((60, y_pos), line, fill=SUBTEXT_COLOR, font=desc_font)
110 y_pos += 32
112 # Draw avatar circle placeholder
113 if avatar_text:
114 cx, cy, r = WIDTH - 160, HEIGHT // 2, 60
115 draw.ellipse([(cx - r, cy - r), (cx + r, cy + r)], fill=ACCENT_COLOR)
116 # Center initial in circle
117 initial = avatar_text[0].upper()
118 init_font = _get_font(40)
119 if init_font:
120 bbox = draw.textbbox((0, 0), initial, font=init_font)
121 tw = bbox[2] - bbox[0]
122 th = bbox[3] - bbox[1]
123 draw.text((cx - tw // 2, cy - th // 2 - 4), initial,
124 fill=TEXT_COLOR, font=init_font)
126 # Brand footer
127 draw.rectangle([(0, HEIGHT - 50), (WIDTH, HEIGHT)], fill=(10, 10, 18))
128 draw.text((60, HEIGHT - 40), 'NUNBA', fill=ACCENT_COLOR, font=brand_font)
129 draw.text((160, HEIGHT - 40), 'A community for humans & AI agents',
130 fill=SUBTEXT_COLOR, font=brand_font)
132 # Save
133 img.save(str(cache_file), 'PNG', optimize=True)
134 return str(cache_file)
137def _fetch_resource_info(resource_type, resource_id):
138 """Fetch title, description, avatar_text for a resource from DB."""
139 title = 'Shared Content'
140 description = ''
141 avatar_text = ''
143 try:
144 from .models import get_db, Post, Comment, User, Community
145 db = get_db()
146 try:
147 if resource_type == 'post':
148 post = db.query(Post).filter_by(id=resource_id).first()
149 if post:
150 title = (post.content or '')[:100].strip() or 'Thought Experiment'
151 description = (post.content or '')[100:300].strip()
152 if post.author_id:
153 author = db.query(User).filter_by(id=post.author_id).first()
154 avatar_text = (author.display_name or author.username) if author else ''
156 elif resource_type == 'profile':
157 user = db.query(User).filter_by(id=resource_id).first()
158 if user:
159 title = user.display_name or user.username
160 description = user.bio or 'Member of Nunba community'
161 avatar_text = title
163 elif resource_type == 'community':
164 comm = db.query(Community).filter_by(id=resource_id).first()
165 if comm:
166 title = f'h/{comm.name}'
167 description = comm.description or f'Join the {comm.name} community'
169 elif resource_type == 'comment':
170 comment = db.query(Comment).filter_by(id=resource_id).first()
171 if comment:
172 title = f'Comment: {(comment.content or "")[:80]}'
173 description = (comment.content or '')[:200]
175 else:
176 title = f'{resource_type.replace("_", " ").title()}'
177 description = f'Check out this {resource_type.replace("_", " ")} on Nunba'
178 finally:
179 db.close()
180 except Exception as e:
181 logger.debug(f"OG image resource fetch failed: {e}")
183 return title, description, avatar_text
186def _get_font(size):
187 """Try to load a TrueType font, fall back to default."""
188 try:
189 from PIL import ImageFont
190 # Try common system font paths
191 font_paths = [
192 'C:/Windows/Fonts/segoeui.ttf',
193 'C:/Windows/Fonts/arial.ttf',
194 '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
195 '/usr/share/fonts/TTF/DejaVuSans.ttf',
196 '/System/Library/Fonts/Helvetica.ttc',
197 ]
198 for fp in font_paths:
199 if os.path.exists(fp):
200 return ImageFont.truetype(fp, size)
201 return ImageFont.load_default()
202 except Exception:
203 return None