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

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 

12 

13logger = logging.getLogger('hevolve_social') 

14 

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')) 

21 

22# Image dimensions (Facebook/LinkedIn recommended) 

23WIDTH = 1200 

24HEIGHT = 630 

25 

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 

32 

33 

34def _ensure_cache_dir(): 

35 OG_CACHE_DIR.mkdir(parents=True, exist_ok=True) 

36 

37 

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' 

41 

42 

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) 

48 

49 

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) 

54 

55 # Return cached if fresh 

56 if _is_cache_fresh(cache_file): 

57 return str(cache_file) 

58 

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 

64 

65 # Fetch resource data 

66 title, description, avatar_text = _fetch_resource_info(resource_type, resource_id) 

67 

68 # Create image with gradient background 

69 img = Image.new('RGB', (WIDTH, HEIGHT), BG_COLOR_TOP) 

70 draw = ImageDraw.Draw(img) 

71 

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

79 

80 # Accent bar at top 

81 draw.rectangle([(0, 0), (WIDTH, 6)], fill=ACCENT_COLOR) 

82 

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) 

87 

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) 

96 

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 

103 

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 

111 

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) 

125 

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) 

131 

132 # Save 

133 img.save(str(cache_file), 'PNG', optimize=True) 

134 return str(cache_file) 

135 

136 

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 = '' 

142 

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

155 

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 

162 

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' 

168 

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] 

174 

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

182 

183 return title, description, avatar_text 

184 

185 

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