From 847ae2bae1bd717e039ee43a28d336db6f167c93 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 13 Jun 2026 20:57:28 +0200 Subject: [PATCH] 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. --- .gitignore | 2 +- cdn-api.py | 34 ++++++++- posters_service.py | 183 +++++++++++++++++++++++++++++++++++++++++++++ service/Dockerfile | 20 ++++- 4 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 posters_service.py diff --git a/.gitignore b/.gitignore index 41fa543..4ddb99e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ qgis -.nova \ No newline at end of file +.nova__pycache__/ diff --git a/cdn-api.py b/cdn-api.py index e1c0841..b9c9ae8 100644 --- a/cdn-api.py +++ b/cdn-api.py @@ -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""" diff --git a/posters_service.py b/posters_service.py new file mode 100644 index 0000000..f272e23 --- /dev/null +++ b/posters_service.py @@ -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 diff --git a/service/Dockerfile b/service/Dockerfile index 7764488..859a35f 100644 --- a/service/Dockerfile +++ b/service/Dockerfile @@ -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/