Add Plex-ready race + season poster endpoints
GET /posters/race/{grand_prix_name}/{season}.png
GET /posters/season/{season}.png
1000x1500 PNGs composed with Pillow + cairosvg:
checkered-flag strips top/bottom, F1 wordmark, round/year ribbon,
GP name, rasterised track outline (white), race date. Season poster
is wordmark + giant year + 'WORLD CHAMPIONSHIP'. Cached under
cache/posters/ so cold renders happen once.
This commit is contained in:
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
qgis
|
qgis
|
||||||
.nova
|
.nova__pycache__/
|
||||||
|
|||||||
+32
-2
@@ -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"""
|
||||||
|
|||||||
@@ -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
@@ -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/
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user