응원가 커버를 팀 로고 합성본으로 교체

- /api/songs/cover/:matchId/:teamCode — Suno 앨범아트(감광 배경) 위에
  팀 로고 중앙 합성 (1200x630, OG 규격), 메모리 캐시 + task 캐시버스터
- 로고 소스: KBO=프론트 로컬 PNG(내부 프록시), MLB=mlbstatic spots PNG
- 플레이어 썸네일·/song OG 카드 모두 합성 커버 사용, pillow 의존성 추가

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jwkim 2026-08-25 10:26:49 +09:00
parent dbc26a4150
commit 434f282e6b
4 changed files with 118 additions and 8 deletions

View File

@ -186,6 +186,8 @@ class Settings(BaseSettings):
song_lineup_fallback_minutes_before: int = 40
# 생성 대상 점검·생성 상태 폴링 주기(초)
song_tick_seconds: int = 120
# 응원가 커버 합성 시 KBO 로고를 가져올 내부 프론트 주소 (docker 네트워크)
internal_frontend_base: str = "http://frontend"
# ── 외부 연동: 이메일 ─────────────────────────────────────
# 1순위: Azure Communication Services(ACS) Email — endpoint + accesskey.

View File

@ -73,11 +73,14 @@ async def song_share(
)
origin = settings.public_origin.rstrip("/")
# 썸네일: Suno 앨범아트(외부 CDN 절대 URL) → 없으면 기본 카드
cover = ""
# 썸네일: 합성 커버(Suno 아트 + 팀 로고, 1200x630) → 곡 없으면 기본 카드
if s and s.tracks:
cover = (s.tracks[0] or {}).get("imageUrl") or ""
img_url = cover if cover.startswith("http") else f"{origin}{DEFAULT_OG}?v=2"
img_url = (
f"{origin}/api/songs/cover/{html.escape(match_id)}/"
f"{html.escape(team_code)}?v={(s.task_id or '')[:8]}"
)
else:
img_url = f"{origin}{DEFAULT_OG}?v=2"
page_url = f"{origin}/song/{html.escape(match_id)}/{html.escape(team_code)}"
t = html.escape(title)

View File

@ -1,12 +1,14 @@
"""오늘의 응원가 API — 조회(공개) · Suno 콜백 싱크 · 관리자 수동 생성."""
"""오늘의 응원가 API — 조회(공개) · 커버 합성 · Suno 콜백 싱크 · 관리자 수동 생성."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..database import get_db
from ..models import Song
from ..services.songs import poll_generating, start_due_songs
@ -14,10 +16,22 @@ from .admin import require_admin
router = APIRouter(prefix="/api/songs", tags=["songs"])
log = logging.getLogger("triplepick.songs")
KST = timezone(timedelta(hours=9))
def _cover_path(s: Song) -> str:
# v= 캐시버스터: 업그레이드(재생성)로 task 가 바뀌면 새 커버로
return f"/api/songs/cover/{s.match_id}/{s.team_code}?v={(s.task_id or '')[:8]}"
def _song_out(s: Song) -> dict:
# Suno 는 생성당 2트랙을 주지만 노출은 팀당 1곡만 (첫 트랙).
# 커버는 Suno 아트 + 팀 로고 합성본으로 교체.
tracks = [
{**t, "imageUrl": _cover_path(s)} for t in (s.tracks or [])[:1]
]
return {
"matchId": s.match_id,
"league": s.league,
@ -25,11 +39,101 @@ def _song_out(s: Song) -> dict:
"teamName": s.team_name,
"title": s.title,
"lyrics": s.lyrics,
# Suno 는 생성당 2트랙을 주지만 노출은 팀당 1곡만 (첫 트랙)
"tracks": (s.tracks or [])[:1],
"tracks": tracks,
}
# ── 커버 합성 (Suno 아트 배경 + 팀 로고 중앙) ──────────────────
_COVER_W, _COVER_H = 1200, 630 # OG 권장 규격 — 플레이어 썸네일은 중앙 크롭
_cover_cache: dict[str, bytes] = {}
async def _fetch_bytes(url: str) -> bytes | None:
import httpx
try:
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as c:
r = await c.get(url)
r.raise_for_status()
return r.content
except Exception as e: # noqa: BLE001
log.warning("cover 소스 다운로드 실패 %s: %s", url, e)
return None
def _logo_url(s: Song) -> str | None:
if s.league == "kbo":
return f"{settings.internal_frontend_base}/assets/teams/kbo/{s.team_code.lower()}.png"
if s.league == "mlb":
from ..teams_baseball import MLB_TEAMS
mlb_id = (MLB_TEAMS.get(s.team_code) or {}).get("mlb_id")
if mlb_id:
return f"https://midfield.mlbstatic.com/v1/team/{mlb_id}/spots/500"
return None
def _compose(bg_bytes: bytes | None, logo_bytes: bytes | None) -> bytes:
import io
from PIL import Image, ImageEnhance
canvas = Image.new("RGB", (_COVER_W, _COVER_H), (16, 20, 26))
if bg_bytes:
try:
bg = Image.open(io.BytesIO(bg_bytes)).convert("RGB")
# cover-crop: 비율 유지 확대 후 중앙 크롭
scale = max(_COVER_W / bg.width, _COVER_H / bg.height)
bg = bg.resize((round(bg.width * scale), round(bg.height * scale)))
x = (bg.width - _COVER_W) // 2
y = (bg.height - _COVER_H) // 2
bg = bg.crop((x, y, x + _COVER_W, y + _COVER_H))
canvas = ImageEnhance.Brightness(bg).enhance(0.5) # 로고 대비용 감광
except Exception as e: # noqa: BLE001
log.warning("cover 배경 처리 실패: %s", e)
if logo_bytes:
try:
logo = Image.open(io.BytesIO(logo_bytes)).convert("RGBA")
h = 340
w = round(logo.width * h / logo.height)
if w > 560:
w, h = 560, round(logo.height * 560 / logo.width)
logo = logo.resize((w, h))
canvas.paste(logo, ((_COVER_W - w) // 2, (_COVER_H - h) // 2), logo)
except Exception as e: # noqa: BLE001
log.warning("cover 로고 처리 실패: %s", e)
out = io.BytesIO()
canvas.save(out, "JPEG", quality=88)
return out.getvalue()
@router.get("/cover/{match_id}/{team_code}")
async def song_cover(
match_id: str, team_code: str, db: AsyncSession = Depends(get_db)
) -> Response:
s = (
await db.execute(
select(Song).where(Song.match_id == match_id, Song.team_code == team_code)
)
).scalar_one_or_none()
if not s:
raise HTTPException(status_code=404, detail="SONG_NOT_FOUND")
key = f"{match_id}:{team_code}:{s.task_id}"
if key not in _cover_cache:
if len(_cover_cache) > 200:
_cover_cache.clear()
suno_img = ((s.tracks or [{}])[0] or {}).get("imageUrl") or ""
bg = await _fetch_bytes(suno_img) if suno_img.startswith("http") else None
logo_url = _logo_url(s)
logo = await _fetch_bytes(logo_url) if logo_url else None
_cover_cache[key] = _compose(bg, logo)
return Response(
content=_cover_cache[key],
media_type="image/jpeg",
headers={"Cache-Control": "public, max-age=86400"},
)
@router.get("/today")
async def today_songs(league: str = "", db: AsyncSession = Depends(get_db)) -> list[dict]:
"""오늘의 응원가 목록 — league 미지정 시 전 리그(kbo+mlb)."""

View File

@ -14,3 +14,4 @@ python-jose[cryptography]==3.5.0
anthropic==0.69.0
openai==1.59.6
google-genai==0.8.0
pillow==11.1.0