revert: 카드 썸네일(투표 픽 스코어) 기능 제거 — 16bd01f 상태로 복귀
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>develop
parent
198f20a0f6
commit
7325c96f2c
|
|
@ -9,7 +9,6 @@ WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
fonts-nanum \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ from .routers import (
|
||||||
comments,
|
comments,
|
||||||
leaderboard,
|
leaderboard,
|
||||||
matches,
|
matches,
|
||||||
og_image,
|
|
||||||
predictions,
|
predictions,
|
||||||
share,
|
share,
|
||||||
visits,
|
visits,
|
||||||
|
|
@ -57,7 +56,6 @@ app.include_router(admin.router)
|
||||||
app.include_router(visits.router)
|
app.include_router(visits.router)
|
||||||
# 공유 미리보기(OG) 프리렌더 — nginx 가 크롤러 UA 의 /match/:id 만 여기로 보낸다.
|
# 공유 미리보기(OG) 프리렌더 — nginx 가 크롤러 UA 의 /match/:id 만 여기로 보낸다.
|
||||||
app.include_router(share.router)
|
app.include_router(share.router)
|
||||||
app.include_router(og_image.router)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
|
||||||
)
|
|
||||||
|
|
@ -51,9 +51,7 @@ def _team_label(code: str | None) -> str:
|
||||||
|
|
||||||
|
|
||||||
@router.get("/match/{match_id}", response_class=HTMLResponse)
|
@router.get("/match/{match_id}", response_class=HTMLResponse)
|
||||||
async def match_share(
|
async def match_share(match_id: str) -> HTMLResponse:
|
||||||
match_id: str, sa: int | None = None, sb: int | None = None
|
|
||||||
) -> HTMLResponse:
|
|
||||||
a, b = _parse_codes(match_id)
|
a, b = _parse_codes(match_id)
|
||||||
|
|
||||||
image = DEFAULT_OG
|
image = DEFAULT_OG
|
||||||
|
|
@ -65,30 +63,15 @@ async def match_share(
|
||||||
is_custom = True
|
is_custom = True
|
||||||
|
|
||||||
la, lb = _team_label(a), _team_label(b)
|
la, lb = _team_label(a), _team_label(b)
|
||||||
sa_disp, sb_disp = sa, sb
|
# 한국은 항상 왼쪽으로 표기(프론트 화면 규칙과 동일).
|
||||||
# 한국은 항상 왼쪽으로 표기(프론트 화면 규칙과 동일). 라벨·스코어 함께 정렬.
|
|
||||||
if b == "KOR" and a != "KOR":
|
if b == "KOR" and a != "KOR":
|
||||||
la, lb = lb, la
|
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:
|
if la and lb:
|
||||||
title = (
|
title = f"TriplePick 2026 — {la} vs {lb} AI 승부예측"
|
||||||
f"내 예측 {la} {sa_disp}-{sb_disp} {lb} — TriplePick"
|
|
||||||
if has_pick
|
|
||||||
else f"TriplePick 2026 — {la} vs {lb} AI 승부예측"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
title = "TriplePick 2026 — AI 2026 글로벌 축구 축전 승부예측"
|
title = "TriplePick 2026 — AI 2026 글로벌 축구 축전 승부예측"
|
||||||
desc = "AI 셋이 매 경기를 서로 다르게 예측합니다. 당신의 픽을 찍고 AI와 겨뤄보세요."
|
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("/")
|
origin = settings.public_origin.rstrip("/")
|
||||||
page_url = f"{origin}/match/{html.escape(match_id)}"
|
page_url = f"{origin}/match/{html.escape(match_id)}"
|
||||||
img_url = f"{origin}{image}"
|
img_url = f"{origin}{image}"
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,3 @@ httpx==0.27.2
|
||||||
anthropic==0.69.0
|
anthropic==0.69.0
|
||||||
openai==1.59.6
|
openai==1.59.6
|
||||||
google-genai==0.8.0
|
google-genai==0.8.0
|
||||||
Pillow==11.0.0
|
|
||||||
|
|
|
||||||
|
|
@ -151,16 +151,9 @@ export default function Arena({
|
||||||
: `나는 ${me}. AI 셋과 다 다른 선택! 너는? — TriplePick`;
|
: `나는 ${me}. AI 셋과 다 다른 선택! 너는? — TriplePick`;
|
||||||
}, [outcome, scoreA, scoreB, matched, aShort, bShort, lang]);
|
}, [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 () => {
|
const copyLink = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(`${shareText}\n${pickShareUrl}`);
|
await navigator.clipboard.writeText(`${shareText}\n${shareUrl}`);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1800);
|
setTimeout(() => setCopied(false), 1800);
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -173,7 +166,7 @@ export default function Arena({
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: `${leftShort} vs ${rightShort} — TriplePick`,
|
title: `${leftShort} vs ${rightShort} — TriplePick`,
|
||||||
text: shareText,
|
text: shareText,
|
||||||
url: pickShareUrl,
|
url: shareUrl,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
/* cancelled */
|
/* cancelled */
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue