diff --git a/backend/app/config.py b/backend/app/config.py index 1f940c6..bf2bc06 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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. diff --git a/backend/app/routers/share.py b/backend/app/routers/share.py index be2132d..c967129 100644 --- a/backend/app/routers/share.py +++ b/backend/app/routers/share.py @@ -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) diff --git a/backend/app/routers/songs.py b/backend/app/routers/songs.py index 36e0f2c..c4ca447 100644 --- a/backend/app/routers/songs.py +++ b/backend/app/routers/songs.py @@ -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).""" diff --git a/backend/requirements.txt b/backend/requirements.txt index c39ff5e..05ed0fd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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