diff --git a/backend/Dockerfile b/backend/Dockerfile index f995922..716e187 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,7 +9,6 @@ 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 698569f..abb8bc9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,7 +18,6 @@ from .routers import ( comments, leaderboard, matches, - og_image, predictions, share, visits, @@ -57,7 +56,6 @@ 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 deleted file mode 100644 index 03f66c3..0000000 --- a/backend/app/routers/og_image.py +++ /dev/null @@ -1,111 +0,0 @@ -"""경기 공유용 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()} -# fonts-nanum 에 항상 포함되는 Bold 만 사용(ExtraBold 는 패키지에 없을 수 있어 폴백→깨짐). -_FONT_BOLD = "/usr/share/fonts/truetype/nanum/NanumGothicBold.ttf" -_FONT_REG = "/usr/share/fonts/truetype/nanum/NanumGothic.ttf" - - -def _font_any(size: int) -> "ImageFont.FreeTypeFont": - """Bold → Regular → 기본 순으로 시도(경로 누락 시에도 한글 유지).""" - for path in (_FONT_BOLD, _FONT_REG): - try: - return ImageFont.truetype(path, size) - except Exception: # noqa: BLE001 - continue - return ImageFont.load_default() - -W, H = 1200, 630 -BG = (14, 17, 22) -WHITE = (240, 244, 248) -GREEN = (61, 224, 138) -SUB = (150, 160, 172) - - -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) - - f_team = _font_any(120) - f_score = _font_any(134) - - def text_w(txt: str, font: ImageFont.FreeTypeFont) -> int: - box = d.textbbox((0, 0), txt, font=font) - return box[2] - box[0] - - # 중앙에 결과만: 한국 2 : 0 남아공 (스코어 그린) — 가로·세로 정중앙 - gap = 50 - 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 - score_h = d.textbbox((0, 0), score_txt, font=f_score)[3] - team_h = d.textbbox((0, 0), la or "가", font=f_team)[3] - y_score = (H - score_h) // 2 - y_team = (H - team_h) // 2 - 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) - - 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 492f7f7..b483595 100644 --- a/backend/app/routers/share.py +++ b/backend/app/routers/share.py @@ -51,9 +51,7 @@ def _team_label(code: str | None) -> str: @router.get("/match/{match_id}", response_class=HTMLResponse) -async def match_share( - match_id: str, sa: int | None = None, sb: int | None = None -) -> HTMLResponse: +async def match_share(match_id: str) -> HTMLResponse: a, b = _parse_codes(match_id) image = DEFAULT_OG @@ -65,30 +63,15 @@ async def match_share( 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"내 예측 {la} {sa_disp}-{sb_disp} {lb} — TriplePick" - if has_pick - else f"TriplePick 2026 — {la} vs {lb} AI 승부예측" - ) + title = 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 a34f2b7..e43ee4a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,4 +13,3 @@ 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 5b34d91..ddccfde 100644 --- a/frontend/src/components/Arena.tsx +++ b/frontend/src/components/Arena.tsx @@ -151,16 +151,9 @@ 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${pickShareUrl}`); + await navigator.clipboard.writeText(`${shareText}\n${shareUrl}`); setCopied(true); setTimeout(() => setCopied(false), 1800); } catch { @@ -173,7 +166,7 @@ export default function Arena({ await navigator.share({ title: `${leftShort} vs ${rightShort} — TriplePick`, text: shareText, - url: pickShareUrl, + url: shareUrl, }); } catch { /* cancelled */