356 lines
17 KiB
Python
356 lines
17 KiB
Python
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] + "…"
|