people-reading/backend/app/services/share_poster_service.py
2026-05-12 20:50:15 +08:00

356 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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] + ""