|
- """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 json
- import logging
- import math
- import urllib.request
- from enum import Enum
- 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
-
- # 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 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", # 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",
- ]
-
-
- 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)
- # 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
-
- 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)
-
- # ---------------------------------------------------------- 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(
- 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,
- 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:
- 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}-{bg.value}.png"
- cache_path = self.cache_dir / cache_key
- if cache_path.exists():
- return cache_path.read_bytes()
-
- # 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
- 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.
- # 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:
- 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
-
- # ------------------------------------------------------------------ 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()
-
- 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=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()
- img.save(buf, "PNG", optimize=True)
- data = buf.getvalue()
- cache_path.write_bytes(data)
- return data
|