119 lines
3.8 KiB
Python
119 lines
3.8 KiB
Python
"""경기 공유용 OG 카드 이미지 동적 생성 — 유저가 투표한 픽 스코어를 그려서 반환.
|
|
|
|
공유 URL 에 ?sa=&sb= (팀A/팀B 픽 스코어) 가 붙어오면, 그 점수로 카드를 그린다.
|
|
share.py 가 og:image 를 여기로 가리키고, 카카오/트위터 등 크롤러가 이 PNG 를 가져간다.
|
|
스코어가 없으면 'VS' 카드. 한국은 항상 좌측(프론트/공유 규칙과 동일).
|
|
|
|
폰트: 컨테이너에 fonts-nanum 설치(Dockerfile). 없으면 기본 폰트로 폴백(한글 깨질 수 있음).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from fastapi.responses import Response
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from ..database import get_db
|
|
from ..models import Match
|
|
from ..schedule_data import TEAMS
|
|
|
|
router = APIRouter(prefix="/api/og", tags=["og"])
|
|
|
|
_KOR_NAME = {code: t["shortName"] for code, t in TEAMS.items()}
|
|
_FONT_BOLD = "/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf"
|
|
_FONT_EBOLD = "/usr/share/fonts/truetype/nanum/NanumGothicExtraBold.ttf"
|
|
|
|
W, H = 1200, 630
|
|
BG = (14, 17, 22)
|
|
WHITE = (240, 244, 248)
|
|
GREEN = (61, 224, 138)
|
|
SUB = (150, 160, 172)
|
|
|
|
|
|
def _font(path: str, size: int) -> ImageFont.FreeTypeFont:
|
|
try:
|
|
return ImageFont.truetype(path, size)
|
|
except Exception: # noqa: BLE001
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _label(code: str | None) -> str:
|
|
if not code:
|
|
return ""
|
|
return _KOR_NAME.get(code, code)
|
|
|
|
|
|
def _parse_codes(match_id: str) -> tuple[str | None, str | None]:
|
|
parts = match_id.split("_")
|
|
return (parts[1], parts[2]) if len(parts) >= 3 else (None, None)
|
|
|
|
|
|
@router.get("/match/{match_id}.png")
|
|
async def og_match_png(
|
|
match_id: str,
|
|
sa: int | None = None,
|
|
sb: int | None = None,
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Response:
|
|
m = await db.get(Match, match_id)
|
|
if m:
|
|
a_code, b_code = m.team_a_code, m.team_b_code
|
|
la, lb = m.team_a_short, m.team_b_short
|
|
else:
|
|
a_code, b_code = _parse_codes(match_id)
|
|
la, lb = _label(a_code), _label(b_code)
|
|
|
|
# 한국 항상 좌측 — 라벨과 스코어를 함께 뒤집는다.
|
|
if b_code == "KOR" and a_code != "KOR":
|
|
la, lb = lb, la
|
|
sa, sb = sb, sa
|
|
|
|
has_score = sa is not None and sb is not None
|
|
score_txt = f"{sa} : {sb}" if has_score else "VS"
|
|
|
|
img = Image.new("RGB", (W, H), BG)
|
|
d = ImageDraw.Draw(img)
|
|
# 상단 그린 악센트 바
|
|
d.rectangle([0, 0, W, 8], fill=GREEN)
|
|
|
|
f_top = _font(_FONT_BOLD, 44)
|
|
f_team = _font(_FONT_EBOLD, 96)
|
|
f_score = _font(_FONT_EBOLD, 110)
|
|
f_foot = _font(_FONT_BOLD, 34)
|
|
|
|
def text_w(txt: str, font: ImageFont.FreeTypeFont) -> int:
|
|
box = d.textbbox((0, 0), txt, font=font)
|
|
return box[2] - box[0]
|
|
|
|
def center(txt: str, font: ImageFont.FreeTypeFont, y: int, fill) -> None:
|
|
d.text(((W - text_w(txt, font)) / 2, y), txt, font=font, fill=fill)
|
|
|
|
# 상단 라벨
|
|
center("TriplePick · 내 예측", f_top, 78, SUB)
|
|
|
|
# 중앙: 한국 2 : 0 남아공 (스코어 그린)
|
|
gap = 44
|
|
wa, ws, wb = text_w(la, f_team), text_w(score_txt, f_score), text_w(lb, f_team)
|
|
total = wa + gap + ws + gap + wb
|
|
x = (W - total) / 2
|
|
y_team = 250
|
|
y_score = y_team - 8 # 스코어가 더 커서 베이스라인 대략 맞춤
|
|
d.text((x, y_team), la, font=f_team, fill=WHITE)
|
|
x += wa + gap
|
|
d.text((x, y_score), score_txt, font=f_score, fill=GREEN)
|
|
x += ws + gap
|
|
d.text((x, y_team), lb, font=f_team, fill=WHITE)
|
|
|
|
# 하단 도메인
|
|
center("triplepick.o2o.kr", f_foot, H - 78, SUB)
|
|
|
|
buf = io.BytesIO()
|
|
img.save(buf, format="PNG")
|
|
return Response(
|
|
content=buf.getvalue(),
|
|
media_type="image/png",
|
|
headers={"Cache-Control": "public, max-age=600"},
|
|
)
|