Browse Source

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.
feat/plex-posters
jochen 2 weeks ago
parent
commit
847ae2bae1
4 changed files with 234 additions and 5 deletions
  1. +1
    -1
      .gitignore
  2. +32
    -2
      cdn-api.py
  3. +183
    -0
      posters_service.py
  4. +18
    -2
      service/Dockerfile

+ 1
- 1
.gitignore View File

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

+ 32
- 2
cdn-api.py View File

@@ -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"""


+ 183
- 0
posters_service.py View File

@@ -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 View File

@@ -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/



Loading…
Cancel
Save