응원가 커버를 팀 로고 합성본으로 교체
- /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:
parent
dbc26a4150
commit
434f282e6b
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)."""
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user