From 39766bae55bae19f43f63a7280c9187648df29bd Mon Sep 17 00:00:00 2001 From: jwkim Date: Tue, 23 Jun 2026 14:19:31 +0900 Subject: [PATCH] =?UTF-8?q?=ED=95=9C=EA=B5=AD=20=EB=82=A8=EC=95=84?= =?UTF-8?q?=EA=B3=B5=20=EA=B2=BD=EA=B8=B0=20=EA=B3=B5=EC=9C=A0=20=EC=8D=B8?= =?UTF-8?q?=EB=84=A4=EC=9D=BC=EC=97=90=20=ED=88=AC=ED=91=9C=20=ED=94=BD=20?= =?UTF-8?q?=EC=8A=A4=EC=BD=94=EC=96=B4=20=EC=B9=B4=EB=93=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 1 + backend/app/main.py | 2 + backend/app/routers/og_image.py | 118 ++++++++++++++++++++++++++++++ backend/app/routers/share.py | 23 +++++- backend/requirements.txt | 1 + frontend/src/components/Arena.tsx | 11 ++- 6 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 backend/app/routers/og_image.py diff --git a/backend/Dockerfile b/backend/Dockerfile index 716e187..f995922 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,6 +9,7 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ + fonts-nanum \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/backend/app/main.py b/backend/app/main.py index abb8bc9..698569f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,7 @@ from .routers import ( comments, leaderboard, matches, + og_image, predictions, share, visits, @@ -56,6 +57,7 @@ app.include_router(admin.router) app.include_router(visits.router) # 공유 미리보기(OG) 프리렌더 — nginx 가 크롤러 UA 의 /match/:id 만 여기로 보낸다. app.include_router(share.router) +app.include_router(og_image.router) @app.get("/api/health") diff --git a/backend/app/routers/og_image.py b/backend/app/routers/og_image.py new file mode 100644 index 0000000..f8a47f1 --- /dev/null +++ b/backend/app/routers/og_image.py @@ -0,0 +1,118 @@ +"""경기 공유용 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"}, + ) diff --git a/backend/app/routers/share.py b/backend/app/routers/share.py index b483595..492f7f7 100644 --- a/backend/app/routers/share.py +++ b/backend/app/routers/share.py @@ -51,7 +51,9 @@ def _team_label(code: str | None) -> str: @router.get("/match/{match_id}", response_class=HTMLResponse) -async def match_share(match_id: str) -> HTMLResponse: +async def match_share( + match_id: str, sa: int | None = None, sb: int | None = None +) -> HTMLResponse: a, b = _parse_codes(match_id) image = DEFAULT_OG @@ -63,15 +65,30 @@ async def match_share(match_id: str) -> HTMLResponse: is_custom = True la, lb = _team_label(a), _team_label(b) - # 한국은 항상 왼쪽으로 표기(프론트 화면 규칙과 동일). + sa_disp, sb_disp = sa, sb + # 한국은 항상 왼쪽으로 표기(프론트 화면 규칙과 동일). 라벨·스코어 함께 정렬. if b == "KOR" and a != "KOR": la, lb = lb, la + sa_disp, sb_disp = sb, sa + + # 픽 스코어 카드는 한국-남아공 경기 전용. + is_kor_rsa = bool(a and b) and frozenset({a, b}) == frozenset({"KOR", "RSA"}) + has_pick = is_kor_rsa and sa is not None and sb is not None if la and lb: - title = f"TriplePick 2026 — {la} vs {lb} AI 승부예측" + title = ( + f"내 예측 {la} {sa_disp}-{sb_disp} {lb} — TriplePick" + if has_pick + else f"TriplePick 2026 — {la} vs {lb} AI 승부예측" + ) else: title = "TriplePick 2026 — AI 2026 글로벌 축구 축전 승부예측" desc = "AI 셋이 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요." + # 픽 스코어가 있으면 동적 OG 카드(스코어 썸네일)로 교체 — 커스텀 이미지보다 우선. + if has_pick: + image = f"/api/og/match/{html.escape(match_id)}.png?sa={sa}&sb={sb}" + is_custom = False # 1200x630 규격 → og:image:width/height 포함 + origin = settings.public_origin.rstrip("/") page_url = f"{origin}/match/{html.escape(match_id)}" img_url = f"{origin}{image}" diff --git a/backend/requirements.txt b/backend/requirements.txt index e43ee4a..a34f2b7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,3 +13,4 @@ httpx==0.27.2 anthropic==0.69.0 openai==1.59.6 google-genai==0.8.0 +Pillow==11.0.0 diff --git a/frontend/src/components/Arena.tsx b/frontend/src/components/Arena.tsx index ddccfde..5b34d91 100644 --- a/frontend/src/components/Arena.tsx +++ b/frontend/src/components/Arena.tsx @@ -151,9 +151,16 @@ export default function Arena({ : `나는 ${me}. AI 셋과 다 다른 선택! 너는? — TriplePick`; }, [outcome, scoreA, scoreB, matched, aShort, bShort, lang]); + // 공유 URL — 픽 스코어 썸네일은 한국-남아공 경기 전용. 그 경기만 쿼리를 붙인다. + const codes = [match.teamA.code, match.teamB.code]; + const isKorRsa = codes.includes("KOR") && codes.includes("RSA"); + const pickShareUrl = isKorRsa + ? `${shareUrl}?sa=${scoreA}&sb=${scoreB}` + : shareUrl; + const copyLink = async () => { try { - await navigator.clipboard.writeText(`${shareText}\n${shareUrl}`); + await navigator.clipboard.writeText(`${shareText}\n${pickShareUrl}`); setCopied(true); setTimeout(() => setCopied(false), 1800); } catch { @@ -166,7 +173,7 @@ export default function Arena({ await navigator.share({ title: `${leftShort} vs ${rightShort} — TriplePick`, text: shareText, - url: shareUrl, + url: pickShareUrl, }); } catch { /* cancelled */