import base64 import asyncio from io import BytesIO from pathlib import Path from PIL import Image, ImageDraw, ImageFont, ImageOps from openai import AsyncOpenAI from app.core.config import settings from app.models.palm_report import PalmReport from app.models.uploaded_image import UploadedImage from app.services.image_service import ImageService POSTER_W = 1080 POSTER_H = 2400 INK = "#080d10" PANEL = "#10191c" PANEL_2 = "#162326" TEXT = "#f2e9d8" MUTED = "#9db0b4" DIM = "#728389" CYAN = "#00e0b8" GOLD = "#d8a84e" WARN = "#ff6b4a" class SharePosterService: def __init__(self) -> None: self.image_service = ImageService() self.client = ( AsyncOpenAI(api_key=settings.openai_api_key, base_url=settings.openai_base_url) if settings.openai_api_key else None ) self.font_regular = _font(34) self.font_small = _font(26) self.font_tiny = _font(22) self.font_medium = _font(40) self.font_title = _font(70) self.font_score = _font(98) async def render_ai_or_fallback(self, report: PalmReport, image: UploadedImage | None) -> bytes: if settings.share_image_mode == "ai" and self.client: try: background = await asyncio.wait_for(self.render_ai_background(), timeout=120) return self.render(report, image, background=background) except Exception: return self.render(report, image) return self.render(report, image) async def render_ai_background(self) -> Image.Image: response = await self.client.images.generate( model=settings.openai_image_model, prompt=( "生成一张竖版高端移动端分享海报背景,不要任何文字、不要数字、不要 logo。" "主题:赛博先生 AI 命理实验室,手相报告。" "必须包含精美的发光手掌、掌纹扫描线、AI 电路线、东方玄学圆形符号。" "设计风格:深墨黑背景 #080d10,青绿色 AI 发光线条 #00e0b8,少量金色点缀 #d8a84e;" "现代、东方玄学、AI 扫描终端感;不要卡通,不要恐怖,不要传统庙宇风,不要紫色渐变。" "画面中预留干净的暗色区域,方便后续叠加中文报告文字。" ), size="1024x1536", quality="high", output_format="png", response_format="b64_json", ) b64 = response.data[0].b64_json if not b64: raise RuntimeError("Image model returned no background data") return Image.open(BytesIO(base64.b64decode(b64))).convert("RGB") async def render_ai(self, report: PalmReport) -> bytes: data = report.report_data or {} response = await self.client.images.generate( model=settings.openai_image_model, prompt=self._build_ai_prompt(report, data), size="1024x1536", quality="high", output_format="png", response_format="b64_json", ) b64 = response.data[0].b64_json if not b64: raise RuntimeError("Image model returned no image data") return base64.b64decode(b64) def render(self, report: PalmReport, image: UploadedImage | None, background: Image.Image | None = None) -> bytes: data = report.report_data or {} poster_h = self._estimate_height(data) poster = self._base_canvas(poster_h, background) draw = ImageDraw.Draw(poster) self._draw_background(draw, poster_h) y = 70 draw.text((64, y), "CYBER FORTUNE REPORT", font=self.font_tiny, fill=GOLD) y += 42 draw.text((64, y), "赛博先生手相报告", font=self.font_title, fill=TEXT) y += 96 score = self._score(data) y = self._draw_score_card(poster, draw, y, score, data) y = self._draw_palm_card(poster, draw, y, image) y = self._draw_dimensions(draw, y, data) y = self._draw_suggestions(draw, y, data) self._draw_footer(draw, poster_h) out = BytesIO() poster.save(out, format="PNG", optimize=True) return out.getvalue() def _base_canvas(self, poster_h: int, background: Image.Image | None) -> Image.Image: if not background: return Image.new("RGB", (POSTER_W, poster_h), INK) base = ImageOps.fit(background, (POSTER_W, poster_h), method=Image.Resampling.LANCZOS) overlay = Image.new("RGBA", (POSTER_W, poster_h), (8, 13, 16, 188)) base = base.convert("RGBA") base.alpha_composite(overlay) return base.convert("RGB") def _build_ai_prompt(self, report: PalmReport, data: dict) -> str: score = self._score(data) dimensions = data.get("dimensions") or [] dimension_lines = [] for item in dimensions[:6]: name = item.get("name", "维度") confidence = int((item.get("confidence") or 0) * 100) interpretation = _trim(item.get("interpretation") or "", 48) advice = _trim(item.get("advice") or "", 34) dimension_lines.append(f"- {name}|{confidence}分|{interpretation}|建议:{advice}") suggestions = data.get("suggestions") or [] keywords = data.get("lucky_keywords") or [] summary = _trim(data.get("overall_summary") or "", 90) hand_side = {"left": "左手", "right": "右手", "unknown": "未知手"}.get(report.hand_side, "未知手") return ( "生成一张竖版中文移动端分享海报,比例 2:3,精美、高级、可读性很高。" "品牌是“赛博先生”,定位是 AI 命理实验室,功能是手相报告。" "设计风格:深墨黑背景 #080d10,青绿色 AI 发光线条 #00e0b8,少量金色点缀 #d8a84e;" "现代、东方玄学、AI 扫描终端感;不要卡通,不要恐怖,不要传统庙宇风,不要紫色渐变。" "必须有一个清晰的手掌视觉元素,可以是发光掌纹图、掌心扫描线或手掌轮廓。" "文字必须清楚、中文可读、排版整齐,像高端 App 分享长图。" "禁止使用省略号,禁止把任何维度分析或建议截断;如果内容多,就把版面做成长图。" "请在画面中包含以下内容:" f"标题:赛博先生手相报告;副标题:{hand_side} · AI 掌纹解读;" f"综合能量分:{score}/100;" f"先生结论:{summary};" f"幸运关键词:{'、'.join(keywords[:5])};" "核心维度分析:" + ";".join(dimension_lines) + ";近期建议:" + ";".join(_trim(item, 42) for item in suggestions[:4]) + ";底部小字:仅供娱乐与自我反思,不构成现实决策建议。" "版式要求:顶部品牌标题,中部综合分和手掌图,下面是 6 个维度卡片,底部是建议和免责声明。" "不要遗漏分数、手掌图、维度分析和建议,不要出现“...”或“…”省略。" ) def _estimate_height(self, data: dict) -> int: dimensions = data.get("dimensions") or [] suggestions = data.get("suggestions") or [] dim_h = 0 scratch = Image.new("RGB", (POSTER_W, 200), INK) draw = ImageDraw.Draw(scratch) for item in dimensions: interpretation_lines = self._wrap_text(draw, item.get("interpretation") or "", 850, self.font_tiny) advice_lines = self._wrap_text(draw, f"建议:{item.get('advice') or ''}", 850, self.font_tiny) dim_h += 102 + len(interpretation_lines) * 33 + len(advice_lines) * 33 + 30 suggestion_h = 120 for suggestion in suggestions: suggestion_h += len(self._wrap_text(draw, suggestion, 824, self.font_small)) * 40 + 12 summary_lines = self._wrap_text(draw, data.get("overall_summary") or "", 440, self.font_small) score_h = max(300, 168 + len(summary_lines) * 42 + 68) return max(POSTER_H, 70 + 42 + 96 + score_h + 32 + 422 + 72 + dim_h + suggestion_h + 180) def _draw_background(self, draw: ImageDraw.ImageDraw, poster_h: int) -> None: for x in range(0, POSTER_W, 64): draw.line((x, 0, x, poster_h), fill=(15, 34, 35), width=1) for y in range(0, poster_h, 64): draw.line((0, y, POSTER_W, y), fill=(15, 34, 35), width=1) draw.rectangle((0, 0, POSTER_W, 280), fill=(8, 21, 22)) draw.line((64, 232, POSTER_W - 64, 232), fill=CYAN, width=2) def _draw_score_card(self, poster: Image.Image, draw: ImageDraw.ImageDraw, y: int, score: int, data: dict) -> int: summary = (data.get("overall_summary") or "这份报告正在整理中。").replace("\n", "") summary_lines = self._wrap_text(draw, summary, 440, self.font_small) card_h = max(300, 168 + len(summary_lines) * 42 + 68) self._round_rect(draw, (64, y, POSTER_W - 64, y + card_h), 34, PANEL) draw.text((104, y + 40), "综合能量分", font=self.font_small, fill=MUTED) draw.text((104, y + 78), str(score), font=self.font_score, fill=CYAN) draw.text((250, y + 120), "/ 100", font=self.font_small, fill=DIM) self._multiline(draw, summary, 520, y + 42, 440, self.font_small, TEXT, 99, line_gap=10) keywords = data.get("lucky_keywords") or [] x = 104 ky = y + card_h - 68 for keyword in keywords: w = self._text_w(draw, keyword, self.font_tiny) + 34 if x + w > 480: break self._pill(draw, (x, ky, x + w, ky + 42), keyword, self.font_tiny, CYAN, "#06201c") x += w + 12 return y + card_h + 32 def _draw_palm_card(self, poster: Image.Image, draw: ImageDraw.ImageDraw, y: int, image: UploadedImage | None) -> int: card_h = 390 self._round_rect(draw, (64, y, POSTER_W - 64, y + card_h), 34, PANEL) draw.text((104, y + 34), "掌纹照片", font=self.font_small, fill=GOLD) box = (104, y + 86, 486, y + 344) palm = self._load_palm_image(image) if palm: palm = ImageOps.fit(palm.convert("RGB"), (box[2] - box[0], box[3] - box[1]), method=Image.Resampling.LANCZOS) mask = Image.new("L", palm.size, 0) mask_draw = ImageDraw.Draw(mask) mask_draw.rounded_rectangle((0, 0, palm.size[0], palm.size[1]), radius=26, fill=255) poster.paste(palm, box[:2], mask) else: self._round_rect(draw, box, 26, PANEL_2) draw.text((216, y + 190), "掌", font=self.font_score, fill=CYAN) draw.text((532, y + 94), "先生观察", font=self.font_medium, fill=TEXT) copy = "掌纹主线会被用于生成娱乐向解读。报告更适合当作近期生活、学习和工作节奏的提醒。" self._multiline(draw, copy, 532, y + 156, 390, self.font_small, MUTED, 4, line_gap=10) return y + card_h + 32 def _draw_dimensions(self, draw: ImageDraw.ImageDraw, y: int, data: dict) -> int: draw.text((64, y), "核心维度", font=self.font_medium, fill=TEXT) y += 62 dimensions = data.get("dimensions") or [] for idx, item in enumerate(dimensions, start=1): interpretation = item.get("interpretation") or "" advice = item.get("advice") or "" interpretation_lines = self._wrap_text(draw, interpretation, 850, self.font_tiny) advice_lines = self._wrap_text(draw, f"建议:{advice}", 850, self.font_tiny) card_h = 102 + len(interpretation_lines) * 33 + len(advice_lines) * 33 + 24 self._round_rect(draw, (64, y, POSTER_W - 64, y + card_h), 28, PANEL) draw.text((104, y + 28), f"0{idx}", font=self.font_tiny, fill=DIM) draw.text((156, y + 22), item.get("name", "维度"), font=self.font_medium, fill=TEXT) confidence = int((item.get("confidence") or 0) * 100) self._pill(draw, (820, y + 28, 976, y + 70), f"{confidence}%", self.font_tiny, CYAN, "#06201c") self._multiline(draw, interpretation, 104, y + 78, 850, self.font_tiny, MUTED, 99, line_gap=7) self._multiline(draw, f"建议:{advice}", 104, y + 78 + len(interpretation_lines) * 33 + 10, 850, self.font_tiny, "#eadcc1", 99, line_gap=7) y += card_h + 18 return y + 10 def _draw_suggestions(self, draw: ImageDraw.ImageDraw, y: int, data: dict) -> int: suggestions = data.get("suggestions") or [] scratch_lines = [self._wrap_text(draw, suggestion, 824, self.font_small) for suggestion in suggestions] bottom = y + 110 + sum(len(lines) * 40 + 12 for lines in scratch_lines) self._round_rect(draw, (64, y, POSTER_W - 64, bottom), 30, PANEL) draw.text((104, y + 30), "近期建议", font=self.font_medium, fill=GOLD) sy = y + 92 for suggestion in suggestions: draw.ellipse((104, sy + 10, 116, sy + 22), fill=CYAN) sy = self._multiline(draw, suggestion, 132, sy, 824, self.font_small, TEXT, 99, line_gap=8) + 12 return sy def _draw_footer(self, draw: ImageDraw.ImageDraw, poster_h: int) -> None: draw.line((64, poster_h - 118, POSTER_W - 64, poster_h - 118), fill=(34, 56, 58), width=1) draw.text((64, poster_h - 88), "赛博先生 · AI 命理实验室", font=self.font_small, fill=TEXT) draw.text((64, poster_h - 48), "仅供娱乐与自我反思,不构成现实决策建议", font=self.font_tiny, fill=DIM) draw.text((POSTER_W - 180, poster_h - 88), "掌", font=self.font_medium, fill=CYAN) def _score(self, data: dict) -> int: confidences = [item.get("confidence", 0) for item in data.get("dimensions", []) if isinstance(item, dict)] base = sum(confidences) / len(confidences) if confidences else 0.68 quality = (data.get("quality_check") or {}).get("confidence", 0.7) return max(60, min(96, round((base * 0.65 + quality * 0.35) * 100))) def _load_palm_image(self, image: UploadedImage | None) -> Image.Image | None: if not image: return None try: return Image.open(BytesIO(self.image_service.read_bytes(image.storage_key))) except Exception: return None def _round_rect(self, draw: ImageDraw.ImageDraw, xy: tuple[int, int, int, int], radius: int, fill: str) -> None: draw.rounded_rectangle(xy, radius=radius, fill=fill, outline=(0, 224, 184, 54), width=1) def _pill( self, draw: ImageDraw.ImageDraw, xy: tuple[int, int, int, int], text: str, font: ImageFont.ImageFont, fill: str, bg: str, ) -> None: draw.rounded_rectangle(xy, radius=(xy[3] - xy[1]) // 2, fill=bg, outline=fill, width=1) tw = self._text_w(draw, text, font) th = self._text_h(draw, text, font) draw.text((xy[0] + (xy[2] - xy[0] - tw) / 2, xy[1] + (xy[3] - xy[1] - th) / 2 - 1), text, font=font, fill=fill) def _multiline( self, draw: ImageDraw.ImageDraw, text: str, x: int, y: int, max_width: int, font: ImageFont.ImageFont, fill: str, max_lines: int, line_gap: int, ) -> int: lines = self._wrap_text(draw, text, max_width, font) if len(lines) > max_lines: lines = lines[:max_lines] lines[-1] = lines[-1].rstrip(",。,. ") + "..." line_h = self._text_h(draw, "国", font) + line_gap for i, line in enumerate(lines): draw.text((x, y + i * line_h), line, font=font, fill=fill) return y + len(lines) * line_h def _wrap_text(self, draw: ImageDraw.ImageDraw, text: str, max_width: int, font: ImageFont.ImageFont) -> list[str]: lines: list[str] = [] current = "" for char in text: candidate = current + char if self._text_w(draw, candidate, font) <= max_width: current = candidate else: if current: lines.append(current) current = char if current: lines.append(current) return lines or [""] def _text_w(self, draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> int: return int(draw.textbbox((0, 0), text, font=font)[2]) def _text_h(self, draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> int: box = draw.textbbox((0, 0), text, font=font) return int(box[3] - box[1]) def _font(size: int) -> ImageFont.ImageFont: candidates = [ "/System/Library/Fonts/Hiragino Sans GB.ttc", "/System/Library/Fonts/STHeiti Medium.ttc", "/System/Library/Fonts/Supplemental/Arial Unicode.ttf", "/System/Library/Fonts/Supplemental/Songti.ttc", ] for candidate in candidates: if Path(candidate).exists(): return ImageFont.truetype(candidate, size=size) return ImageFont.load_default(size=size) def _trim(text: str, max_chars: int) -> str: text = text.replace("\n", " ").strip() return text if len(text) <= max_chars else text[: max_chars - 1] + "…"