From 99ac83c7067e16dd2be32dd04f8587df274545bb Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 13 Jun 2026 22:42:16 +0200 Subject: [PATCH 1/2] Use season hero photos as poster bg + switch image to Alpine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- posters_service.py | 67 ++++++++++++++++++++++++++++++++++++++++------ service/Dockerfile | 34 ++++++++++++----------- 2 files changed, 77 insertions(+), 24 deletions(-) diff --git a/posters_service.py b/posters_service.py index f272e23..9b4f935 100644 --- a/posters_service.py +++ b/posters_service.py @@ -14,6 +14,7 @@ so cold paths stay cheap. import io import logging +import urllib.request from pathlib import Path from typing import Optional @@ -23,6 +24,10 @@ 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" + logger = logging.getLogger(__name__) POSTER_W, POSTER_H = 1000, 1500 @@ -35,10 +40,12 @@ 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. +# 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", ] @@ -162,18 +169,62 @@ class PostersService: # ------------------------------------------------------------------ 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 _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() diff --git a/service/Dockerfile b/service/Dockerfile index 859a35f..9cdeb11 100644 --- a/service/Dockerfile +++ b/service/Dockerfile @@ -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/ From 9430d499cc61649f4cc2420f4012f6f4440fb426 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 14 Jun 2026 17:42:02 +0200 Subject: [PATCH 2/2] 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) --- cdn-api.py | 38 ++++- posters_service.py | 404 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 424 insertions(+), 18 deletions(-) diff --git a/cdn-api.py b/cdn-api.py index b9c9ae8..3ed5129 100644 --- a/cdn-api.py +++ b/cdn-api.py @@ -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,28 @@ async def get_season_poster(season: int): 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""" diff --git a/posters_service.py b/posters_service.py index 9b4f935..4a122af 100644 --- a/posters_service.py +++ b/posters_service.py @@ -13,8 +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 @@ -28,6 +31,39 @@ from ergast_service import ErgastService # 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 @@ -39,6 +75,12 @@ GREY = (160, 160, 170) 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 in production; the others cover common # local-dev paths (Arch Linux ships Liberation Sans at /usr/share/fonts/liberation). @@ -59,6 +101,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 @@ -85,6 +146,214 @@ 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, + ) -> 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. + + 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) + # The cropped poster window's top-left absolute pixel — anchored so the + # geographic centre lands at (POSTER_W/2, GEO_CENTER_Y), matching the + # offset used by _fetch_map_background so the polyline overlays cleanly. + win_left = cpx - POSTER_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( @@ -106,7 +375,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: @@ -121,12 +395,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 @@ -143,16 +430,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: @@ -167,6 +483,48 @@ class PostersService: 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}") + lines.append("") + return "\n".join(lines) + # ------------------------------------------------------------------ season poster def _fetch_season_background(self, season: int) -> Optional[Image.Image]: @@ -187,6 +545,26 @@ class PostersService: 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."""