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
|
||||
.nova
|
||||
.nova__pycache__/
|
||||
|
||||
+32
-2
@@ -1,11 +1,13 @@
|
||||
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 pathlib import Path
|
||||
import logging
|
||||
|
||||
# Import our CircuitService
|
||||
from circuits_service import CircuitService, Format
|
||||
from ergast_service import ErgastService
|
||||
from posters_service import PostersService
|
||||
from models.track_layout import TrackLayout
|
||||
|
||||
# Initialize FastAPI app
|
||||
@@ -30,6 +32,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize CircuitService
|
||||
circuit_service = CircuitService()
|
||||
ergast_service = ErgastService(circuit_service)
|
||||
posters_service = PostersService(circuit_service, ergast_service)
|
||||
|
||||
def get_content_disposition(format: Format) -> str:
|
||||
if format == Format.PNG:
|
||||
@@ -59,10 +63,36 @@ async def root():
|
||||
"/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}/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")
|
||||
async def get_countries():
|
||||
"""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
|
||||
|
||||
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 circuits/ ./circuits/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user