Kaynağa Gözat

Merge pull request 'Add Plex-ready race + season poster endpoints' (#1) from feat/plex-posters into master

feat/season-poster-bg
jschoubben 2 hafta önce
ebeveyn
işleme
f4c6a56d1c
4 değiştirilmiş dosya ile 234 ekleme ve 5 silme
  1. +1
    -1
      .gitignore
  2. +32
    -2
      cdn-api.py
  3. +183
    -0
      posters_service.py
  4. +18
    -2
      service/Dockerfile

+ 1
- 1
.gitignore Dosyayı Görüntüle

@@ -1,2 +1,2 @@
qgis qgis
.nova
.nova__pycache__/

+ 32
- 2
cdn-api.py Dosyayı Görüntüle

@@ -1,11 +1,13 @@
from fastapi import FastAPI, HTTPException, Response from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, Response as FastAPIResponse
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path from pathlib import Path
import logging import logging


# Import our CircuitService # Import our CircuitService
from circuits_service import CircuitService, Format from circuits_service import CircuitService, Format
from ergast_service import ErgastService
from posters_service import PostersService
from models.track_layout import TrackLayout from models.track_layout import TrackLayout


# Initialize FastAPI app # Initialize FastAPI app
@@ -30,6 +32,8 @@ logger = logging.getLogger(__name__)


# Initialize CircuitService # Initialize CircuitService
circuit_service = CircuitService() circuit_service = CircuitService()
ergast_service = ErgastService(circuit_service)
posters_service = PostersService(circuit_service, ergast_service)


def get_content_disposition(format: Format) -> str: def get_content_disposition(format: Format) -> str:
if format == Format.PNG: if format == Format.PNG:
@@ -59,10 +63,36 @@ async def root():
"/circuits/{country_slug}/{city_slug}/{circuit_slug}/layout/{layout_slug}", "/circuits/{country_slug}/{city_slug}/{circuit_slug}/layout/{layout_slug}",
"/tracks/{country_slug}/{city_slug}/{circuit_slug}/year/{year}/{format}", "/tracks/{country_slug}/{city_slug}/{circuit_slug}/year/{year}/{format}",
"/tracks/{country_slug}/{city_slug}/{circuit_slug}/layout/{layout_slug}/{format}", "/tracks/{country_slug}/{city_slug}/{circuit_slug}/layout/{layout_slug}/{format}",
"/grand-prix/{grand_prix_name}/year/{year}"
"/grand-prix/{grand_prix_name}/year/{year}",
"/posters/race/{grand_prix_name}/{season}.png",
"/posters/season/{season}.png",
] ]
} }



@app.get("/posters/race/{grand_prix_name}/{season}.png")
async def get_race_poster(grand_prix_name: str, season: int):
"""Plex-ready 1000x1500 race poster (PNG). Cached on disk."""
png = posters_service.render_race_poster(grand_prix_name, season)
if not png:
raise HTTPException(status_code=404, detail=f"No poster available for {grand_prix_name} {season}")
return FastAPIResponse(
content=png,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000"},
)


@app.get("/posters/season/{season}.png")
async def get_season_poster(season: int):
"""Plex-ready 1000x1500 season poster (PNG). Cached on disk."""
png = posters_service.render_season_poster(season)
return FastAPIResponse(
content=png,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000"},
)

@app.get("/circuits") @app.get("/circuits")
async def get_countries(): async def get_countries():
"""Get list of all available countries with their slugs""" """Get list of all available countries with their slugs"""


+ 183
- 0
posters_service.py Dosyayı Görüntüle

@@ -0,0 +1,183 @@
"""Plex-style F1 race + season poster rendering.

Produces 1000x1500 PNGs (Plex's recommended TV-poster ratio) by compositing:
- Top + bottom checker-flag strips
- "FORMULA 1" wordmark (red)
- Round / year ribbon
- Grand Prix name (bold, large)
- Rasterised track outline (white) for race posters
- Race date

Output is cached under cache/posters/ keyed by (season, round, layout_slug)
so cold paths stay cheap.
"""

import io
import logging
from pathlib import Path
from typing import Optional

import cairosvg
from PIL import Image, ImageDraw, ImageFont

from circuits_service import CircuitService
from ergast_service import ErgastService

logger = logging.getLogger(__name__)

POSTER_W, POSTER_H = 1000, 1500
BG = (10, 10, 12)
RED = (220, 18, 32) # F1 red
WHITE = (245, 245, 245)
GREY = (160, 160, 170)

CHECKER_H = 60
CHECKER_SQ = 30

# Slim-image-friendly font candidates. The Dockerfile installs fonts-dejavu-core
# so DejaVuSans-Bold is always present; the others are fallbacks for local dev.
FONT_CANDIDATES = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
]


class PostersService:
def __init__(self, circuits: CircuitService, ergast: ErgastService):
self.circuits = circuits
self.ergast = ergast
self.font_path = next((p for p in FONT_CANDIDATES if Path(p).exists()), None)
if not self.font_path:
logger.warning("No bundled font found — poster text will use PIL's default bitmap font")
self.cache_dir = Path("cache/posters")
self.cache_dir.mkdir(parents=True, exist_ok=True)

# ------------------------------------------------------------------ utils

def _font(self, size: int) -> ImageFont.FreeTypeFont:
if self.font_path:
return ImageFont.truetype(self.font_path, size)
return ImageFont.load_default()

def _centered(self, draw: ImageDraw.ImageDraw, text: str, y: int, size: int, fill=WHITE) -> int:
"""Draw text centered horizontally; returns the drawn height."""
font = self._font(size)
# Shrink-to-fit if too wide
while size > 18 and draw.textlength(text, font) > POSTER_W - 80:
size -= 4
font = self._font(size)
w = draw.textlength(text, font)
draw.text(((POSTER_W - w) / 2, y), text, font=font, fill=fill)
bbox = draw.textbbox((0, 0), text, font=font)
return bbox[3] - bbox[1]

def _checker_strip(self, draw: ImageDraw.ImageDraw, y: int, height: int = CHECKER_H, sq: int = CHECKER_SQ):
for col, x in enumerate(range(0, POSTER_W + sq, sq)):
for row, yy in enumerate(range(y, y + height, sq)):
fill = WHITE if (col + row) % 2 == 0 else BG
draw.rectangle([x, yy, x + sq, yy + sq], fill=fill)

def _rasterise_track(self, svg_path: Path, target_w: int = 700) -> Optional[Image.Image]:
try:
png_bytes = cairosvg.svg2png(
bytestring=svg_path.read_bytes(),
output_width=target_w,
)
except Exception as e:
logger.error(f"Failed to rasterise {svg_path}: {e}")
return None
track = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
# Recolour all opaque pixels (black stroke in source) → white
px = track.load()
for y in range(track.height):
for x in range(track.width):
r, g, b, a = px[x, y]
if a > 0:
px[x, y] = (WHITE[0], WHITE[1], WHITE[2], a)
return track

# ------------------------------------------------------------------ race poster

def render_race_poster(self, grand_prix_name: str, season: int) -> Optional[bytes]:
races = self.ergast._fetch_season_schedule(season)
race = next((r for r in races if r["raceName"] == grand_prix_name), None)
if not race:
logger.error(f"Race not found: {grand_prix_name} {season}")
return None

round_num = int(race["round"])
date = race.get("date", "")

layout = self.circuits.get_circuit_layout_by_ergast_data(grand_prix_name, season)
if not layout:
logger.error(f"Layout not resolved for {grand_prix_name} {season}")
return None

cache_key = f"{season}-{round_num:02d}-{layout.slug}.png"
cache_path = self.cache_dir / cache_key
if cache_path.exists():
return cache_path.read_bytes()

img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
draw = ImageDraw.Draw(img)

# Top checker
self._checker_strip(draw, 0)

# FORMULA 1 wordmark
self._centered(draw, "FORMULA 1", y=110, size=88, fill=RED)

# Round / Year ribbon
self._centered(draw, f"ROUND {round_num:02d} / {season}", y=220, size=34, fill=GREY)

# GP name (drop "Grand Prix" suffix; render "GRAND PRIX" smaller below)
name_short = grand_prix_name.replace("Grand Prix", "").strip().upper()
self._centered(draw, name_short, y=300, size=110, fill=WHITE)
self._centered(draw, "GRAND PRIX", y=440, size=44, fill=GREY)

# Track outline
svg_path = Path("circuits") / layout.relative_svg_filepath
if svg_path.exists():
track = self._rasterise_track(svg_path, target_w=720)
if track:
# Centre between y=560 and y=1250
slot_top, slot_bottom = 560, 1250
tx = (POSTER_W - track.width) // 2
ty = slot_top + ((slot_bottom - slot_top) - track.height) // 2
img.paste(track, (tx, ty), track)

# Date
if date:
self._centered(draw, date, y=POSTER_H - 170, size=32, fill=WHITE)

# Bottom checker
self._checker_strip(draw, POSTER_H - CHECKER_H)

buf = io.BytesIO()
img.save(buf, "PNG", optimize=True)
data = buf.getvalue()
cache_path.write_bytes(data)
return data

# ------------------------------------------------------------------ season poster

def render_season_poster(self, season: int) -> bytes:
cache_path = self.cache_dir / f"season-{season}.png"
if cache_path.exists():
return cache_path.read_bytes()

img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
draw = ImageDraw.Draw(img)

self._checker_strip(draw, 0)
self._centered(draw, "FORMULA 1", y=240, size=120, fill=RED)
self._centered(draw, str(season), y=440, size=420, fill=WHITE)
self._centered(draw, "WORLD CHAMPIONSHIP", y=1100, size=56, fill=GREY)
self._checker_strip(draw, POSTER_H - CHECKER_H)

buf = io.BytesIO()
img.save(buf, "PNG", optimize=True)
data = buf.getvalue()
cache_path.write_bytes(data)
return data

+ 18
- 2
service/Dockerfile Dosyayı Görüntüle

@@ -2,9 +2,25 @@ FROM python:3.12-slim


WORKDIR /app WORKDIR /app


RUN pip install --no-cache-dir fastapi uvicorn fastf1 python-slugify
# Poster rendering needs:
# - libcairo2 (cairosvg ↔ Cairo bindings) for SVG → PNG raster
# - fonts-dejavu-core for the DejaVuSans-Bold font used by PostersService
# These add ~15 MB to the image but keep poster generation in-process and fast.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libcairo2 \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*


COPY cdn-api.py circuits_service.py ergast_service.py ./
RUN pip install --no-cache-dir \
fastapi \
uvicorn \
fastf1 \
python-slugify \
pillow \
cairosvg

COPY cdn-api.py circuits_service.py ergast_service.py posters_service.py ./
COPY models/ ./models/ COPY models/ ./models/
COPY circuits/ ./circuits/ COPY circuits/ ./circuits/




Yükleniyor…
İptal
Kaydet