Author SHA1 Message Date
jschoubben 6474e109f1 Add 1920x1080 session thumbs with track overlay + episode YAML entries
posters_service:
- Standard / sprint episode-index → (label, slug) maps mirroring Kometa's
  F1 builder numbering
- _fetch_map_landscape: aerial tile stitcher in 1920x1080
- render_session_thumb: GP aerial bg + projected track outline + GP name
  + session label with red accent bar
- _draw_track_projected now takes canvas_w / geo_center_y so it can be
  reused for both 2:3 posters and 16:9 thumbs
- render_kometa_metadata now emits an episodes: block per Plex season
  pointing every Kometa-numbered episode at the matching session thumb;
  sprint vs standard format is detected from the Jolpica race entry

cdn-api:
- GET /posters/session/{name}/{year}/{slug}.png returns the thumb
2026-06-15 02:28:03 +02:00
jschoubben 9430d499cc Map backgrounds (aerial/osm/dark/light), bbox zoom, world locator, kometa YAML endpoint
posters_service:
- BackgroundStyle enum + tile stitcher for race posters
  (Esri aerial / OSM Carto / CartoDB dark / CartoDB light / none)
- _resolve_circuit_geo: GeoJSON bbox centre + auto-zoom (no hand tuning),
  falls back to Jolpica lat/lon + f1-locations.json zoom
- _draw_track_projected: project the layout's GeoJSON points into the
  same window as the tile background, so the white outline aligns with
  the actual track (the hand-drawn SVGs had arbitrary orientations)
- _render_world_locator: 140x140 CartoDB Dark inset at zoom 5 with a
  red marker pinning the circuit on the continent
- _race_dark_overlay: dimming so the heading + footer stay legible
  against any underlying tiles
- render_kometa_metadata: emits a Kometa-compatible YAML covering
  every season 1950..current+1 with per-show + per-round url_poster

cdn-api:
- GET /posters/race/{name}/{year}.png supports ?bg= query param
- GET /kometa/formula-1-metadata.yml serves the generated config so
  Kometa pulls it via metadata_files.url (auto-updates with schedule)
2026-06-14 17:42:02 +02:00
jschoubben 99ac83c706 Use season hero photos as poster bg + switch image to Alpine
posters_service.render_season_poster() now fetches /dilbert/sports/
formula1/cars/{year}.jpg from the files-api CDN, centre-crops to 2:3,
and composites a vertical dark gradient so the FORMULA 1 / year /
WORLD CHAMPIONSHIP text stays legible over any photo.

Dockerfile:
  - python:3.12-slim → python:3.12-alpine (smaller, no fastf1 leftover)
  - fonts-dejavu-core → ttf-liberation: same font Arch dev ships, so
    posters render byte-identical on dev and in the container.
  - Drop fastf1 (unused in the served code path); add numpy explicitly
    because models/geo_json/geometry.py imports it at module load.
2026-06-13 22:42:16 +02:00
jschoubben f4c6a56d1c Merge pull request 'Add Plex-ready race + season poster endpoints' (#1) from feat/plex-posters into master 2026-06-13 21:51:42 +02:00
3 changed files with 689 additions and 42 deletions
+52 -5
View File
@@ -1,5 +1,5 @@
from fastapi import FastAPI, HTTPException, Response
from fastapi.responses import FileResponse, Response as FastAPIResponse
from fastapi.responses import FileResponse, Response as FastAPIResponse, PlainTextResponse
from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path
import logging
@@ -7,7 +7,7 @@ import logging
# Import our CircuitService
from circuits_service import CircuitService, Format
from ergast_service import ErgastService
from posters_service import PostersService
from posters_service import PostersService, BackgroundStyle
from models.track_layout import TrackLayout
# Initialize FastAPI app
@@ -71,9 +71,15 @@ async def root():
@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)
async def get_race_poster(grand_prix_name: str, season: int, bg: BackgroundStyle = BackgroundStyle.AERIAL):
"""Plex-ready 1000x1500 race poster (PNG). Cached on disk per (race, bg).
`?bg=` selects the backdrop:
- aerial (default) — Esri World Imagery satellite at the circuit's coords
- osm — OpenStreetMap Carto street-map at the circuit's coords
- none — plain black background (skip tile fetch)
"""
png = posters_service.render_race_poster(grand_prix_name, season, bg=bg)
if not png:
raise HTTPException(status_code=404, detail=f"No poster available for {grand_prix_name} {season}")
return FastAPIResponse(
@@ -93,6 +99,47 @@ async def get_season_poster(season: int):
headers={"Cache-Control": "public, max-age=31536000"},
)
@app.get("/posters/session/{grand_prix_name}/{season}/{session_slug}.png")
async def get_session_thumb(grand_prix_name: str, season: int, session_slug: str):
"""Plex episode-thumb (1920x1080) for a specific session of a race weekend.
`session_slug` is one of: fp1, fp2, fp3, pre-qualifying, qualifying,
post-qualifying, pre-race, race, post-race, highlights, pre-sprint,
sprint, post-sprint, pre-sprint-qualifying, sprint-qualifying,
post-sprint-qualifying, bonus.
"""
png = posters_service.render_session_thumb(grand_prix_name, season, session_slug)
if not png:
raise HTTPException(status_code=404, detail=f"No session thumb for {grand_prix_name} {season} {session_slug}")
return FastAPIResponse(
content=png,
media_type="image/png",
headers={"Cache-Control": "public, max-age=31536000"},
)
@app.get("/kometa/formula-1-metadata.yml")
async def get_kometa_metadata():
"""Generate a Kometa-compatible metadata YAML for the Formula 1 library.
One entry per F1 season from 1950..current+1. Each entry:
- uses Kometa's f1_season builder for round/episode names
- sets url_poster on the show (per-season hero from files-api)
- sets url_poster on each Plex 'season' (== each F1 round) from the
f1-circuits race poster endpoint
Kometa harmlessly skips shows / seasons that don't exist in the Plex
library, so we can emit the full schedule and let Kometa do the matching.
"""
yaml = posters_service.render_kometa_metadata()
return PlainTextResponse(
content=yaml,
media_type="text/yaml; charset=utf-8",
# YAML changes only when the Jolpica schedule changes — cache 1h.
headers={"Cache-Control": "public, max-age=3600"},
)
@app.get("/circuits")
async def get_countries():
"""Get list of all available countries with their slugs"""
+619 -21
View File
@@ -13,7 +13,11 @@ so cold paths stay cheap.
"""
import io
import json
import logging
import math
import urllib.request
from enum import Enum
from pathlib import Path
from typing import Optional
@@ -23,22 +27,102 @@ from PIL import Image, ImageDraw, ImageFont
from circuits_service import CircuitService
from ergast_service import ErgastService
# Where season background images live. Hosted by the dilbert files-api module —
# one landscape JPG per F1 season (key for the season-card grid in Dilbert's UI).
SEASON_BG_URL_TEMPLATE = "https://files-api.novox.be/dilbert/sports/formula1/cars/{season}.jpg"
class BackgroundStyle(str, Enum):
"""Selectable backdrop for race posters."""
AERIAL = "aerial" # Esri World Imagery satellite tiles (default)
OSM = "osm" # OpenStreetMap Carto street-map tiles
DARK = "dark" # CartoDB Dark Matter — minimalist black-and-grey map
LIGHT = "light" # CartoDB Positron — minimalist light/grey map
NONE = "none" # Plain black, no map fetch — fast fallback
# XYZ tile templates. None require API keys at our volume.
TILE_PROVIDERS = {
BackgroundStyle.AERIAL: {
"url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
"attribution": "Esri, Maxar, Earthstar Geographics",
},
BackgroundStyle.OSM: {
"url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"attribution": "© OpenStreetMap contributors",
},
BackgroundStyle.DARK: {
"url": "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
"attribution": "© CartoDB, © OpenStreetMap contributors",
},
BackgroundStyle.LIGHT: {
"url": "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
"attribution": "© CartoDB, © OpenStreetMap contributors",
},
}
# Polite UA — both providers expect one and Esri throttles aggressively without it.
TILE_USER_AGENT = "f1-circuits-posters/1.0 (+https://f1-circuits.zurag.be)"
logger = logging.getLogger(__name__)
POSTER_W, POSTER_H = 1000, 1500
THUMB_W, THUMB_H = 1920, 1080 # Plex episode-thumb ratio
BG = (10, 10, 12)
RED = (220, 18, 32) # F1 red
WHITE = (245, 245, 245)
GREY = (160, 160, 170)
# Kometa F1 builder episode numbering — keep in sync with Kometa's modules/meta.py.
# Each value is (display_label, url_slug) used for thumb generation + filename.
_SESSION_STD = {
1: ("Free Practice 1", "fp1"),
2: ("Free Practice 2", "fp2"),
3: ("Free Practice 3", "fp3"),
4: ("Pre-Qualifying Buildup", "pre-qualifying"),
5: ("Qualifying", "qualifying"),
6: ("Post-Qualifying", "post-qualifying"),
7: ("Pre-Race Buildup", "pre-race"),
8: ("Race", "race"),
9: ("Post-Race", "post-race"),
10: ("Highlights", "highlights"),
}
_SESSION_SPRINT = {
1: ("Free Practice 1", "fp1"),
2: ("Pre-Sprint-Qualifying Buildup", "pre-sprint-qualifying"),
3: ("Sprint Qualifying", "sprint-qualifying"),
4: ("Post-Sprint-Qualifying", "post-sprint-qualifying"),
5: ("Pre-Sprint Buildup", "pre-sprint"),
6: ("Sprint", "sprint"),
7: ("Post-Sprint", "post-sprint"),
8: ("Pre-Qualifying Buildup", "pre-qualifying"),
9: ("Qualifying", "qualifying"),
10: ("Post-Qualifying", "post-qualifying"),
11: ("Pre-Race Buildup", "pre-race"),
12: ("Race", "race"),
13: ("Post-Race", "post-race"),
14: ("Highlights", "highlights"),
15: ("Bonus Content", "bonus"),
}
# Display-label lookup for the thumb renderer (slug → label, deduped across formats).
SESSION_LABEL_BY_SLUG = {slug: label for d in (_SESSION_STD, _SESSION_SPRINT) for label, slug in d.values()}
CHECKER_H = 60
CHECKER_SQ = 30
# Vertical pixel where we want the circuit's geographic centre to land. Picked so
# the track ends up visually centred *in the visible aerial band* — between the
# heading block at the top and the date strip at the bottom, not in the full
# 1000x1500 poster middle (which would look high once the heading dim is over it).
GEO_CENTER_Y = 935
# Slim-image-friendly font candidates. The Dockerfile installs fonts-dejavu-core
# so DejaVuSans-Bold is always present; the others are fallbacks for local dev.
# so DejaVuSans-Bold is always present in production; the others cover common
# local-dev paths (Arch Linux ships Liberation Sans at /usr/share/fonts/liberation).
FONT_CANDIDATES = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Debian/Ubuntu
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", # Fedora
"/usr/share/fonts/liberation/LiberationSans-Bold.ttf", # Arch
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
]
@@ -52,6 +136,25 @@ class PostersService:
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)
# Pre-tuned zooms per circuit, keyed by slugified circuit name.
# Falls back to DEFAULT_ZOOM when not found.
self._zoom_by_circuit = self._load_zoom_lookup()
DEFAULT_ZOOM = 15
@staticmethod
def _load_zoom_lookup() -> dict:
"""Index f1-locations.json by slugified circuit name for fast zoom lookup."""
from ergast_service import slugify
path = Path("f1-locations.json")
if not path.exists():
return {}
try:
data = json.loads(path.read_text())
except Exception as e:
logger.warning(f"Could not load f1-locations.json: {e}")
return {}
return {slugify(entry["name"]): entry for entry in data if "name" in entry}
# ------------------------------------------------------------------ utils
@@ -78,6 +181,218 @@ class PostersService:
fill = WHITE if (col + row) % 2 == 0 else BG
draw.rectangle([x, yy, x + sq, yy + sq], fill=fill)
# ---------------------------------------------------------- map tiles
@staticmethod
def _latlon_to_pixel(lat: float, lon: float, zoom: int) -> tuple[float, float]:
"""Standard Web-Mercator pixel coordinates (slippy-map convention, 256-px tiles)."""
n = 2.0 ** zoom * 256
x = (lon + 180.0) / 360.0 * n
lat_rad = math.radians(lat)
y = (1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n
return x, y
def _fetch_map_background(self, lat: float, lon: float, zoom: int, style: BackgroundStyle) -> Optional[Image.Image]:
"""Stitch enough 256-px tiles around (lat, lon) to fill the full POSTER_W x POSTER_H."""
provider = TILE_PROVIDERS.get(style)
if not provider:
return None
# Centre pixel and the slab we need to cover. Anchor the geographic
# centre at GEO_CENTER_Y rather than POSTER_H/2 so the track lands in
# the middle of the *visible* aerial band.
cpx, cpy = self._latlon_to_pixel(lat, lon, zoom)
left, top = int(cpx - POSTER_W / 2), int(cpy - GEO_CENTER_Y)
tile_x_lo, tile_y_lo = left // 256, top // 256
tile_x_hi = (left + POSTER_W + 255) // 256
tile_y_hi = (top + POSTER_H + 255) // 256
canvas_w = (tile_x_hi - tile_x_lo) * 256
canvas_h = (tile_y_hi - tile_y_lo) * 256
canvas = Image.new("RGB", (canvas_w, canvas_h), BG)
any_ok = False
for tx in range(tile_x_lo, tile_x_hi):
for ty in range(tile_y_lo, tile_y_hi):
url = provider["url"].format(z=zoom, x=tx, y=ty)
try:
req = urllib.request.Request(url, headers={"User-Agent": TILE_USER_AGENT})
with urllib.request.urlopen(req, timeout=10) as resp:
tile = Image.open(io.BytesIO(resp.read())).convert("RGB")
canvas.paste(tile, ((tx - tile_x_lo) * 256, (ty - tile_y_lo) * 256))
any_ok = True
except Exception as e:
logger.warning(f"Tile fetch failed {style.value} z={zoom} x={tx} y={ty}: {e}")
if not any_ok:
return None
ox = left - tile_x_lo * 256
oy = top - tile_y_lo * 256
return canvas.crop((ox, oy, ox + POSTER_W, oy + POSTER_H))
def _draw_track_projected(
self,
img: Image.Image,
coords: list,
lat0: float,
lon0: float,
zoom: int,
canvas_w: int = POSTER_W,
geo_center_y: int = GEO_CENTER_Y,
) -> bool:
"""Draw the track onto `img` by projecting each GeoJSON (lon, lat) point
into pixel space using the same web-mercator math as the map background.
This guarantees the white outline overlays the actual circuit in the
underlying aerial / OSM tiles.
canvas_w / geo_center_y must match the dimensions/offset used to fetch
the map background so the polyline aligns with the actual track in the
rendered tiles. Defaults match the race poster; pass THUMB_W / THUMB_H/2
for landscape session thumbs.
Returns True on success, False if there's nothing usable to draw."""
if not coords:
return False
cpx, cpy = self._latlon_to_pixel(lat0, lon0, zoom)
win_left = cpx - canvas_w / 2
win_top = cpy - geo_center_y
pts: list[tuple[float, float]] = []
for entry in coords:
# GeoJSON LineString is [[lon, lat], ...]; tolerate (lon, lat) tuples too.
if len(entry) < 2:
continue
lon, lat = float(entry[0]), float(entry[1])
px, py = self._latlon_to_pixel(lat, lon, zoom)
pts.append((px - win_left, py - win_top))
if len(pts) < 2:
return False
d = ImageDraw.Draw(img)
# Subtle dark stroke under, then the bright white line, so the outline
# stays legible whether the bg is the bright Mediterranean or a dark
# CartoDB tile.
d.line(pts + [pts[0]], fill=(0, 0, 0), width=10, joint="curve")
d.line(pts + [pts[0]], fill=WHITE, width=6, joint="curve")
return True
def _render_world_locator(self, lat: float, lon: float, size: int = 220) -> Optional[Image.Image]:
"""A small picture-in-picture map showing where on Earth this race is.
Uses CartoDB Dark Matter at zoom 5 — tight enough that the host country
fills most of the inset, while neighbours and coastlines still give the
viewer a "here's the region" anchor. A marker (red dot + white ring)
is drawn over the exact spot."""
zoom = 5
# Use the same stitcher with a custom canvas target.
provider = TILE_PROVIDERS[BackgroundStyle.DARK]
cpx, cpy = self._latlon_to_pixel(lat, lon, zoom)
# Frame 700x700 px window centred on the location, then downscale to `size`.
win = 700
left, top = int(cpx - win / 2), int(cpy - win / 2)
tx0, ty0 = left // 256, top // 256
tx1, ty1 = (left + win + 255) // 256, (top + win + 255) // 256
canvas = Image.new("RGB", ((tx1 - tx0) * 256, (ty1 - ty0) * 256), BG)
any_ok = False
for tx in range(tx0, tx1):
for ty in range(ty0, ty1):
# Wrap longitude tiles (world is cyclic at zoom 2 = 4 tiles wide)
wrapped_x = tx % (2 ** zoom)
if wrapped_x < 0:
wrapped_x += 2 ** zoom
if not (0 <= ty < 2 ** zoom):
continue
url = provider["url"].format(z=zoom, x=wrapped_x, y=ty)
try:
req = urllib.request.Request(url, headers={"User-Agent": TILE_USER_AGENT})
with urllib.request.urlopen(req, timeout=10) as resp:
tile = Image.open(io.BytesIO(resp.read())).convert("RGB")
canvas.paste(tile, ((tx - tx0) * 256, (ty - ty0) * 256))
any_ok = True
except Exception as e:
logger.warning(f"Locator tile fail {tx},{ty}: {e}")
if not any_ok:
return None
ox, oy = left - tx0 * 256, top - ty0 * 256
cropped = canvas.crop((ox, oy, ox + win, oy + win)).resize((size, size), Image.LANCZOS)
# Marker: red filled circle + white ring at the exact spot (centre of the frame).
d = ImageDraw.Draw(cropped, "RGBA")
cx, cy = size // 2, size // 2
d.ellipse([cx - 9, cy - 9, cx + 9, cy + 9], outline=(255, 255, 255, 255), width=2)
d.ellipse([cx - 5, cy - 5, cx + 5, cy + 5], fill=RED + (255,))
# Thin white frame around the inset itself.
d.rectangle([0, 0, size - 1, size - 1], outline=(255, 255, 255, 200), width=2)
return cropped
# Visible aerial slot (between heading band and date strip) — target the
# bbox to ~85% of it so there's some breathing room around the track.
_BBOX_TARGET_W = int(POSTER_W * 0.85)
_BBOX_TARGET_H = int((1300 - 540) * 0.85) # visible aerial slot height, scaled
def _zoom_from_bbox(self, coords: list, max_zoom: int = 17, min_zoom: int = 10) -> int:
"""Pick the tightest zoom level at which the track's GeoJSON bbox still
fits inside the visible aerial area. Walks from high → low zoom and
returns the first zoom that fits — i.e. the most zoomed-in view that
still shows the whole lap."""
if len(coords) < 2:
return self.DEFAULT_ZOOM
lons = [float(c[0]) for c in coords if len(c) >= 2]
lats = [float(c[1]) for c in coords if len(c) >= 2]
if not lons or not lats:
return self.DEFAULT_ZOOM
lat_min, lat_max = min(lats), max(lats)
lon_min, lon_max = min(lons), max(lons)
for zoom in range(max_zoom, min_zoom - 1, -1):
x_lo, y_hi = self._latlon_to_pixel(lat_min, lon_min, zoom) # SW (small x, large y)
x_hi, y_lo = self._latlon_to_pixel(lat_max, lon_max, zoom) # NE (large x, small y)
if (x_hi - x_lo) <= self._BBOX_TARGET_W and (y_hi - y_lo) <= self._BBOX_TARGET_H:
return zoom
return min_zoom
def _resolve_circuit_geo(self, grand_prix_name: str, season: int) -> Optional[tuple[float, float, int]]:
"""Pull (lat, lon, zoom) for the circuit hosting this GP in this season.
Centre comes from the GeoJSON bbox midpoint (geometrically accurate),
zoom is computed from the bbox so a tight street circuit gets zoomed in
and a sprawling road course is zoomed out — automatically, no hand-tuning.
Falls back to Jolpica's circuit coordinate + f1-locations.json's hand-
tuned zoom when GeoJSON coords aren't available for this layout."""
races = self.ergast._fetch_season_schedule(season)
race = next((r for r in races if r["raceName"] == grand_prix_name), None)
if not race:
return None
layout = self.circuits.get_circuit_layout_by_ergast_data(grand_prix_name, season)
if layout:
try:
layout.load_geo_json_data()
coords = layout.coordinates
except Exception:
coords = None
if coords and len(coords) >= 2:
lons = [float(c[0]) for c in coords if len(c) >= 2]
lats = [float(c[1]) for c in coords if len(c) >= 2]
lat_c = (min(lats) + max(lats)) / 2
lon_c = (min(lons) + max(lons)) / 2
return lat_c, lon_c, self._zoom_from_bbox(coords)
# Fallback path: Jolpica coordinate + hand-tuned zoom
loc = race.get("Circuit", {}).get("Location", {})
if "lat" not in loc or "long" not in loc:
return None
lat, lon = float(loc["lat"]), float(loc["long"])
from ergast_service import slugify
entry = self._zoom_by_circuit.get(slugify(race["Circuit"].get("circuitName", "")))
zoom = entry["zoom"] if entry and "zoom" in entry else self.DEFAULT_ZOOM
return lat, lon, zoom
# ---------------------------------------------------------- track raster
def _rasterise_track(self, svg_path: Path, target_w: int = 700) -> Optional[Image.Image]:
try:
png_bytes = cairosvg.svg2png(
@@ -99,7 +414,12 @@ class PostersService:
# ------------------------------------------------------------------ race poster
def render_race_poster(self, grand_prix_name: str, season: int) -> Optional[bytes]:
def render_race_poster(
self,
grand_prix_name: str,
season: int,
bg: BackgroundStyle = BackgroundStyle.AERIAL,
) -> 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:
@@ -114,12 +434,25 @@ class PostersService:
logger.error(f"Layout not resolved for {grand_prix_name} {season}")
return None
cache_key = f"{season}-{round_num:02d}-{layout.slug}.png"
cache_key = f"{season}-{round_num:02d}-{layout.slug}-{bg.value}.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)
# Fetch map background if requested; fall back to flat black if anything fails.
map_bg: Optional[Image.Image] = None
if bg is not BackgroundStyle.NONE:
geo = self._resolve_circuit_geo(grand_prix_name, season)
if geo:
lat, lon, zoom = geo
map_bg = self._fetch_map_background(lat, lon, zoom, bg)
if map_bg is not None:
# Dim the underlying map so the white track + heading text stay legible.
img = Image.alpha_composite(map_bg.convert("RGBA"), self._race_dark_overlay()).convert("RGB")
else:
img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
draw = ImageDraw.Draw(img)
# Top checker
@@ -136,16 +469,45 @@ class PostersService:
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)
# Track outline.
# When a map background is present we project the GeoJSON lat/lon points
# using the same zoom/centre we used for the tiles — that way the white
# outline is geographically aligned with the actual track in the photo.
# Without a map (bg=NONE) there's nothing to align with so we fall back
# to the hand-drawn SVG centred in the slot.
projected = False
if map_bg is not None:
try:
# GeoJSON is lazy-loaded — trigger the read before pulling coords.
layout.load_geo_json_data()
projected = self._draw_track_projected(
img, layout.coordinates, *self._resolve_circuit_geo(grand_prix_name, season)
)
except Exception as e:
logger.warning(f"GeoJSON track projection failed; falling back to SVG: {e}")
if not projected:
svg_path = Path("circuits") / layout.relative_svg_filepath
if svg_path.exists():
track = self._rasterise_track(svg_path, target_w=720)
if track:
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)
# World locator inset (top-right) — picture-in-picture pointing at the
# exact race location on the globe. Only meaningful when a map bg is on.
# Sized + positioned so it tucks under the top checker and sits to the
# right of the centred FORMULA 1 wordmark without overlapping.
if bg is not BackgroundStyle.NONE:
geo = self._resolve_circuit_geo(grand_prix_name, season)
if geo:
lat, lon, _ = geo
locator = self._render_world_locator(lat, lon, size=140)
if locator:
margin = 25
img.paste(locator, (POSTER_W - locator.width - margin, CHECKER_H + margin))
# Date
if date:
@@ -160,20 +522,256 @@ class PostersService:
cache_path.write_bytes(data)
return data
# ------------------------------------------------------------------ session thumb (16:9)
def _fetch_map_landscape(self, lat: float, lon: float, zoom: int, style: BackgroundStyle) -> Optional[Image.Image]:
"""Like _fetch_map_background but for THUMB_W x THUMB_H (1920x1080).
Centred so the geographic point lands at the visual midpoint."""
provider = TILE_PROVIDERS.get(style)
if not provider:
return None
cpx, cpy = self._latlon_to_pixel(lat, lon, zoom)
left, top = int(cpx - THUMB_W / 2), int(cpy - THUMB_H / 2)
tx0, ty0 = left // 256, top // 256
tx1, ty1 = (left + THUMB_W + 255) // 256, (top + THUMB_H + 255) // 256
canvas = Image.new("RGB", ((tx1 - tx0) * 256, (ty1 - ty0) * 256), BG)
any_ok = False
for tx in range(tx0, tx1):
for ty in range(ty0, ty1):
url = provider["url"].format(z=zoom, x=tx, y=ty)
try:
req = urllib.request.Request(url, headers={"User-Agent": TILE_USER_AGENT})
with urllib.request.urlopen(req, timeout=10) as resp:
tile = Image.open(io.BytesIO(resp.read())).convert("RGB")
canvas.paste(tile, ((tx - tx0) * 256, (ty - ty0) * 256))
any_ok = True
except Exception as e:
logger.warning(f"Landscape tile fail {style.value} z={zoom} x={tx} y={ty}: {e}")
if not any_ok:
return None
ox, oy = left - tx0 * 256, top - ty0 * 256
return canvas.crop((ox, oy, ox + THUMB_W, oy + THUMB_H))
def render_session_thumb(self, grand_prix_name: str, season: int, session_slug: str) -> Optional[bytes]:
"""1920x1080 Plex episode-thumb. Aerial of the circuit + GP name + session label."""
label = SESSION_LABEL_BY_SLUG.get(session_slug)
if not label:
return None
races = self.ergast._fetch_season_schedule(season)
race = next((r for r in races if r["raceName"] == grand_prix_name), None)
if not race:
return None
round_num = int(race["round"])
layout = self.circuits.get_circuit_layout_by_ergast_data(grand_prix_name, season)
if not layout:
return None
cache_key = f"thumb-{season}-{round_num:02d}-{layout.slug}-{session_slug}.png"
cache_path = self.cache_dir / cache_key
if cache_path.exists():
return cache_path.read_bytes()
# Same per-circuit geo (lat, lon, bbox-derived zoom) as the race poster,
# so the thumb shows the same place at the same zoom level.
geo = self._resolve_circuit_geo(grand_prix_name, season)
map_bg = self._fetch_map_landscape(*geo, BackgroundStyle.AERIAL) if geo else None
img = Image.new("RGB", (THUMB_W, THUMB_H), BG)
if map_bg is not None:
# Heavier dim than the poster — 16:9 fills the player thumb at small
# sizes, so we need solid contrast for the overlaid label text.
overlay = Image.new("RGBA", (THUMB_W, THUMB_H), (0, 0, 0, 140))
img = Image.alpha_composite(map_bg.convert("RGBA"), overlay).convert("RGB")
# Project the track polyline onto the landscape canvas — same math
# as the race poster but with THUMB_W / THUMB_H/2 so it overlays the
# actual circuit visible in the aerial.
try:
layout.load_geo_json_data()
self._draw_track_projected(
img, layout.coordinates, *geo,
canvas_w=THUMB_W, geo_center_y=THUMB_H // 2,
)
except Exception as e:
logger.warning(f"Session-thumb track projection failed: {e}")
draw = ImageDraw.Draw(img)
# Two left-aligned text columns: GP name top-left, session label bottom-left.
# Round/year accent in red between them.
x_margin = 80
name_short = grand_prix_name.replace("Grand Prix", "").strip().upper()
# FORMULA 1 small wordmark top-left
font_wm = self._font(40)
draw.text((x_margin, 60), "FORMULA 1", fill=RED, font=font_wm)
# Round/year ribbon
font_ribbon = self._font(28)
draw.text((x_margin, 120), f"ROUND {round_num:02d} / {season}", fill=GREY, font=font_ribbon)
# GP name — fit to 1300px max width
gp_size = 120
font_gp = self._font(gp_size)
while gp_size > 60 and draw.textlength(name_short, font_gp) > 1300:
gp_size -= 10
font_gp = self._font(gp_size)
draw.text((x_margin, 180), name_short, fill=WHITE, font=font_gp)
draw.text((x_margin, 180 + gp_size + 5), "GRAND PRIX", fill=GREY, font=self._font(36))
# Session label — bottom-left, very prominent
sess_size = 90
font_sess = self._font(sess_size)
while sess_size > 50 and draw.textlength(label.upper(), font_sess) > 1300:
sess_size -= 6
font_sess = self._font(sess_size)
sess_label = label.upper()
sess_y = THUMB_H - 130 - sess_size
draw.text((x_margin, sess_y), sess_label, fill=WHITE, font=font_sess)
# Thin red accent bar above the session label
draw.rectangle([x_margin, sess_y - 16, x_margin + 120, sess_y - 8], fill=RED)
buf = io.BytesIO()
img.save(buf, "PNG", optimize=True)
data = buf.getvalue()
cache_path.write_bytes(data)
return data
# ------------------------------------------------------------------ kometa metadata
BASE_URL = "https://f1-circuits.zurag.be"
def render_kometa_metadata(self, first_season: int = 1950, last_offset: int = 1) -> str:
"""Emit the Kometa metadata YAML for the Formula 1 library, covering
every F1 season from `first_season` through current_year + last_offset.
Per show: f1_season builder + url_poster pointing at the season hero.
Per Plex season (== round): url_poster pointing at the race poster.
Kometa silently skips shows / seasons that don't exist in the library."""
from datetime import datetime
from urllib.parse import quote
last_season = datetime.utcnow().year + last_offset
lines: list[str] = [
"# Auto-generated by f1-circuits (/kometa/formula-1-metadata.yml).",
"# Do not commit — fetch via metadata_files.url in Kometa config.",
"metadata:",
]
for season in range(first_season, last_season + 1):
races = self.ergast._fetch_season_schedule(season)
lines.append(f' "Season {season}":')
lines.append(f" f1_season: {season}")
lines.append(" round_prefix: true")
lines.append(" shorten_gp: true")
lines.append(f" url_poster: {self.BASE_URL}/posters/season/{season}.png")
if not races:
continue
lines.append(" seasons:")
for race in races:
try:
round_num = int(race["round"])
name = race["raceName"]
except (KeyError, ValueError):
continue
race_url = f"{self.BASE_URL}/posters/race/{quote(name)}/{season}.png"
lines.append(f" {round_num}:")
lines.append(f" url_poster: {race_url}")
# Per-episode session thumbs. Detect sprint format from the
# presence of a Sprint section in the Jolpica race entry; the
# F1 builder maps each format to its own episode numbering and
# we follow the same map here so each Plex episode lands on a
# correctly-labelled session thumb.
is_sprint = any(k.lower().startswith("sprint") for k in race.keys())
session_map = _SESSION_SPRINT if is_sprint else _SESSION_STD
lines.append(" episodes:")
for ep_num, (_label, slug) in session_map.items():
thumb_url = f"{self.BASE_URL}/posters/session/{quote(name)}/{season}/{slug}.png"
lines.append(f" {ep_num}:")
lines.append(f" url_poster: {thumb_url}")
lines.append("")
return "\n".join(lines)
# ------------------------------------------------------------------ season poster
def _fetch_season_background(self, season: int) -> Optional[Image.Image]:
"""Pull the season's hero photo from the dilbert files-api CDN."""
url = SEASON_BG_URL_TEMPLATE.format(season=season)
try:
with urllib.request.urlopen(url, timeout=10) as resp:
bg = Image.open(io.BytesIO(resp.read())).convert("RGB")
except Exception as e:
logger.warning(f"No season background for {season} ({url}): {e}")
return None
# Cover-fit into 1000x1500: scale so both dims meet/exceed target, centre-crop.
sw, sh = bg.size
scale = max(POSTER_W / sw, POSTER_H / sh)
bg = bg.resize((int(sw * scale), int(sh * scale)), Image.LANCZOS)
nw, nh = bg.size
left = (nw - POSTER_W) // 2
top = (nh - POSTER_H) // 2
return bg.crop((left, top, left + POSTER_W, top + POSTER_H))
def _race_dark_overlay(self) -> Image.Image:
"""Dimming overlay for race posters when a map background is present.
Stronger at top (heading area) and middle (track), lighter elsewhere so
the underlying map detail still reads through."""
overlay = Image.new("RGBA", (POSTER_W, POSTER_H), (0, 0, 0, 0))
ovr = overlay.load()
for y in range(POSTER_H):
if y < 540:
# Strong dim under the FORMULA 1 / Round / GP-name block
alpha = 200
elif y < 1300:
# Mid-section behind the track — light dim
alpha = 110
else:
# Bottom (date strip + checker)
alpha = 200
for x in range(POSTER_W):
ovr[x, y] = (0, 0, 0, alpha)
return overlay
def _vertical_dark_gradient(self) -> Image.Image:
"""A vertical alpha gradient that's darkest top + bottom — keeps the photo's
midsection visible while making wordmark / year / footer text legible."""
overlay = Image.new("RGBA", (POSTER_W, POSTER_H), (0, 0, 0, 0))
ovr = overlay.load()
for y in range(POSTER_H):
if y < 500:
# Strong dim at top for the wordmark + year, fading to nothing at y=500
alpha = 220 - (y * 220 // 500)
elif y > 1100:
# Strong dim at bottom for "WORLD CHAMPIONSHIP" footer
alpha = (y - 1100) * 220 // (POSTER_H - 1100)
alpha = min(alpha, 220)
else:
# Mild dim through the middle to anchor text contrast
alpha = 60
for x in range(POSTER_W):
ovr[x, y] = (0, 0, 0, alpha)
return overlay
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)
bg = self._fetch_season_background(season)
if bg is None:
# Fallback to the all-black layout if the background isn't available.
img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
else:
# Composite the dimming gradient over the hero photo for text legibility.
img = Image.alpha_composite(bg.convert("RGBA"), self._vertical_dark_gradient()).convert("RGB")
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._centered(draw, "FORMULA 1", y=110, size=88, fill=RED)
self._centered(draw, str(season), y=240, size=240, fill=WHITE)
self._centered(draw, "WORLD CHAMPIONSHIP", y=POSTER_H - 200, size=48, fill=WHITE)
self._checker_strip(draw, POSTER_H - CHECKER_H)
buf = io.BytesIO()
+18 -16
View File
@@ -1,24 +1,26 @@
FROM python:3.12-slim
FROM python:3.12-alpine
WORKDIR /app
# 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/*
# Runtime deps:
# - cairo provides libcairo so cairosvg can rasterise SVG → PNG.
# - ttf-liberation gives Liberation Sans Bold at
# /usr/share/fonts/liberation/LiberationSans-Bold.ttf — exactly where Arch
# installs it, so dev (shanks) and prod (this container) render with the
# same font and the posters come out identical.
RUN apk add --no-cache \
cairo \
ttf-liberation
# All pip deps ship musllinux wheels — no build-deps needed.
# numpy is pulled in by models/geo_json/geometry.py for vector ops at import time.
RUN pip install --no-cache-dir \
fastapi \
uvicorn \
fastf1 \
python-slugify \
pillow \
cairosvg
fastapi \
uvicorn \
python-slugify \
pillow \
cairosvg \
numpy
COPY cdn-api.py circuits_service.py ergast_service.py posters_service.py ./
COPY models/ ./models/