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