F1 circuit layouts with year-by-year SVGs — manually traced track variations
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

613 lignes
27KB

  1. """Plex-style F1 race + season poster rendering.
  2. Produces 1000x1500 PNGs (Plex's recommended TV-poster ratio) by compositing:
  3. - Top + bottom checker-flag strips
  4. - "FORMULA 1" wordmark (red)
  5. - Round / year ribbon
  6. - Grand Prix name (bold, large)
  7. - Rasterised track outline (white) for race posters
  8. - Race date
  9. Output is cached under cache/posters/ keyed by (season, round, layout_slug)
  10. so cold paths stay cheap.
  11. """
  12. import io
  13. import json
  14. import logging
  15. import math
  16. import urllib.request
  17. from enum import Enum
  18. from pathlib import Path
  19. from typing import Optional
  20. import cairosvg
  21. from PIL import Image, ImageDraw, ImageFont
  22. from circuits_service import CircuitService
  23. from ergast_service import ErgastService
  24. # Where season background images live. Hosted by the dilbert files-api module —
  25. # one landscape JPG per F1 season (key for the season-card grid in Dilbert's UI).
  26. SEASON_BG_URL_TEMPLATE = "https://files-api.novox.be/dilbert/sports/formula1/cars/{season}.jpg"
  27. class BackgroundStyle(str, Enum):
  28. """Selectable backdrop for race posters."""
  29. AERIAL = "aerial" # Esri World Imagery satellite tiles (default)
  30. OSM = "osm" # OpenStreetMap Carto street-map tiles
  31. DARK = "dark" # CartoDB Dark Matter — minimalist black-and-grey map
  32. LIGHT = "light" # CartoDB Positron — minimalist light/grey map
  33. NONE = "none" # Plain black, no map fetch — fast fallback
  34. # XYZ tile templates. None require API keys at our volume.
  35. TILE_PROVIDERS = {
  36. BackgroundStyle.AERIAL: {
  37. "url": "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
  38. "attribution": "Esri, Maxar, Earthstar Geographics",
  39. },
  40. BackgroundStyle.OSM: {
  41. "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
  42. "attribution": "© OpenStreetMap contributors",
  43. },
  44. BackgroundStyle.DARK: {
  45. "url": "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
  46. "attribution": "© CartoDB, © OpenStreetMap contributors",
  47. },
  48. BackgroundStyle.LIGHT: {
  49. "url": "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
  50. "attribution": "© CartoDB, © OpenStreetMap contributors",
  51. },
  52. }
  53. # Polite UA — both providers expect one and Esri throttles aggressively without it.
  54. TILE_USER_AGENT = "f1-circuits-posters/1.0 (+https://f1-circuits.zurag.be)"
  55. logger = logging.getLogger(__name__)
  56. POSTER_W, POSTER_H = 1000, 1500
  57. BG = (10, 10, 12)
  58. RED = (220, 18, 32) # F1 red
  59. WHITE = (245, 245, 245)
  60. GREY = (160, 160, 170)
  61. CHECKER_H = 60
  62. CHECKER_SQ = 30
  63. # Vertical pixel where we want the circuit's geographic centre to land. Picked so
  64. # the track ends up visually centred *in the visible aerial band* — between the
  65. # heading block at the top and the date strip at the bottom, not in the full
  66. # 1000x1500 poster middle (which would look high once the heading dim is over it).
  67. GEO_CENTER_Y = 935
  68. # Slim-image-friendly font candidates. The Dockerfile installs fonts-dejavu-core
  69. # so DejaVuSans-Bold is always present in production; the others cover common
  70. # local-dev paths (Arch Linux ships Liberation Sans at /usr/share/fonts/liberation).
  71. FONT_CANDIDATES = [
  72. "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Debian/Ubuntu
  73. "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", # Fedora
  74. "/usr/share/fonts/liberation/LiberationSans-Bold.ttf", # Arch
  75. "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
  76. ]
  77. class PostersService:
  78. def __init__(self, circuits: CircuitService, ergast: ErgastService):
  79. self.circuits = circuits
  80. self.ergast = ergast
  81. self.font_path = next((p for p in FONT_CANDIDATES if Path(p).exists()), None)
  82. if not self.font_path:
  83. logger.warning("No bundled font found — poster text will use PIL's default bitmap font")
  84. self.cache_dir = Path("cache/posters")
  85. self.cache_dir.mkdir(parents=True, exist_ok=True)
  86. # Pre-tuned zooms per circuit, keyed by slugified circuit name.
  87. # Falls back to DEFAULT_ZOOM when not found.
  88. self._zoom_by_circuit = self._load_zoom_lookup()
  89. DEFAULT_ZOOM = 15
  90. @staticmethod
  91. def _load_zoom_lookup() -> dict:
  92. """Index f1-locations.json by slugified circuit name for fast zoom lookup."""
  93. from ergast_service import slugify
  94. path = Path("f1-locations.json")
  95. if not path.exists():
  96. return {}
  97. try:
  98. data = json.loads(path.read_text())
  99. except Exception as e:
  100. logger.warning(f"Could not load f1-locations.json: {e}")
  101. return {}
  102. return {slugify(entry["name"]): entry for entry in data if "name" in entry}
  103. # ------------------------------------------------------------------ utils
  104. def _font(self, size: int) -> ImageFont.FreeTypeFont:
  105. if self.font_path:
  106. return ImageFont.truetype(self.font_path, size)
  107. return ImageFont.load_default()
  108. def _centered(self, draw: ImageDraw.ImageDraw, text: str, y: int, size: int, fill=WHITE) -> int:
  109. """Draw text centered horizontally; returns the drawn height."""
  110. font = self._font(size)
  111. # Shrink-to-fit if too wide
  112. while size > 18 and draw.textlength(text, font) > POSTER_W - 80:
  113. size -= 4
  114. font = self._font(size)
  115. w = draw.textlength(text, font)
  116. draw.text(((POSTER_W - w) / 2, y), text, font=font, fill=fill)
  117. bbox = draw.textbbox((0, 0), text, font=font)
  118. return bbox[3] - bbox[1]
  119. def _checker_strip(self, draw: ImageDraw.ImageDraw, y: int, height: int = CHECKER_H, sq: int = CHECKER_SQ):
  120. for col, x in enumerate(range(0, POSTER_W + sq, sq)):
  121. for row, yy in enumerate(range(y, y + height, sq)):
  122. fill = WHITE if (col + row) % 2 == 0 else BG
  123. draw.rectangle([x, yy, x + sq, yy + sq], fill=fill)
  124. # ---------------------------------------------------------- map tiles
  125. @staticmethod
  126. def _latlon_to_pixel(lat: float, lon: float, zoom: int) -> tuple[float, float]:
  127. """Standard Web-Mercator pixel coordinates (slippy-map convention, 256-px tiles)."""
  128. n = 2.0 ** zoom * 256
  129. x = (lon + 180.0) / 360.0 * n
  130. lat_rad = math.radians(lat)
  131. y = (1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n
  132. return x, y
  133. def _fetch_map_background(self, lat: float, lon: float, zoom: int, style: BackgroundStyle) -> Optional[Image.Image]:
  134. """Stitch enough 256-px tiles around (lat, lon) to fill the full POSTER_W x POSTER_H."""
  135. provider = TILE_PROVIDERS.get(style)
  136. if not provider:
  137. return None
  138. # Centre pixel and the slab we need to cover. Anchor the geographic
  139. # centre at GEO_CENTER_Y rather than POSTER_H/2 so the track lands in
  140. # the middle of the *visible* aerial band.
  141. cpx, cpy = self._latlon_to_pixel(lat, lon, zoom)
  142. left, top = int(cpx - POSTER_W / 2), int(cpy - GEO_CENTER_Y)
  143. tile_x_lo, tile_y_lo = left // 256, top // 256
  144. tile_x_hi = (left + POSTER_W + 255) // 256
  145. tile_y_hi = (top + POSTER_H + 255) // 256
  146. canvas_w = (tile_x_hi - tile_x_lo) * 256
  147. canvas_h = (tile_y_hi - tile_y_lo) * 256
  148. canvas = Image.new("RGB", (canvas_w, canvas_h), BG)
  149. any_ok = False
  150. for tx in range(tile_x_lo, tile_x_hi):
  151. for ty in range(tile_y_lo, tile_y_hi):
  152. url = provider["url"].format(z=zoom, x=tx, y=ty)
  153. try:
  154. req = urllib.request.Request(url, headers={"User-Agent": TILE_USER_AGENT})
  155. with urllib.request.urlopen(req, timeout=10) as resp:
  156. tile = Image.open(io.BytesIO(resp.read())).convert("RGB")
  157. canvas.paste(tile, ((tx - tile_x_lo) * 256, (ty - tile_y_lo) * 256))
  158. any_ok = True
  159. except Exception as e:
  160. logger.warning(f"Tile fetch failed {style.value} z={zoom} x={tx} y={ty}: {e}")
  161. if not any_ok:
  162. return None
  163. ox = left - tile_x_lo * 256
  164. oy = top - tile_y_lo * 256
  165. return canvas.crop((ox, oy, ox + POSTER_W, oy + POSTER_H))
  166. def _draw_track_projected(
  167. self,
  168. img: Image.Image,
  169. coords: list,
  170. lat0: float,
  171. lon0: float,
  172. zoom: int,
  173. ) -> bool:
  174. """Draw the track onto `img` by projecting each GeoJSON (lon, lat) point
  175. into pixel space using the same web-mercator math as the map background.
  176. This guarantees the white outline overlays the actual circuit in the
  177. underlying aerial / OSM tiles.
  178. Returns True on success, False if there's nothing usable to draw."""
  179. if not coords:
  180. return False
  181. cpx, cpy = self._latlon_to_pixel(lat0, lon0, zoom)
  182. # The cropped poster window's top-left absolute pixel — anchored so the
  183. # geographic centre lands at (POSTER_W/2, GEO_CENTER_Y), matching the
  184. # offset used by _fetch_map_background so the polyline overlays cleanly.
  185. win_left = cpx - POSTER_W / 2
  186. win_top = cpy - GEO_CENTER_Y
  187. pts: list[tuple[float, float]] = []
  188. for entry in coords:
  189. # GeoJSON LineString is [[lon, lat], ...]; tolerate (lon, lat) tuples too.
  190. if len(entry) < 2:
  191. continue
  192. lon, lat = float(entry[0]), float(entry[1])
  193. px, py = self._latlon_to_pixel(lat, lon, zoom)
  194. pts.append((px - win_left, py - win_top))
  195. if len(pts) < 2:
  196. return False
  197. d = ImageDraw.Draw(img)
  198. # Subtle dark stroke under, then the bright white line, so the outline
  199. # stays legible whether the bg is the bright Mediterranean or a dark
  200. # CartoDB tile.
  201. d.line(pts + [pts[0]], fill=(0, 0, 0), width=10, joint="curve")
  202. d.line(pts + [pts[0]], fill=WHITE, width=6, joint="curve")
  203. return True
  204. def _render_world_locator(self, lat: float, lon: float, size: int = 220) -> Optional[Image.Image]:
  205. """A small picture-in-picture map showing where on Earth this race is.
  206. Uses CartoDB Dark Matter at zoom 5 — tight enough that the host country
  207. fills most of the inset, while neighbours and coastlines still give the
  208. viewer a "here's the region" anchor. A marker (red dot + white ring)
  209. is drawn over the exact spot."""
  210. zoom = 5
  211. # Use the same stitcher with a custom canvas target.
  212. provider = TILE_PROVIDERS[BackgroundStyle.DARK]
  213. cpx, cpy = self._latlon_to_pixel(lat, lon, zoom)
  214. # Frame 700x700 px window centred on the location, then downscale to `size`.
  215. win = 700
  216. left, top = int(cpx - win / 2), int(cpy - win / 2)
  217. tx0, ty0 = left // 256, top // 256
  218. tx1, ty1 = (left + win + 255) // 256, (top + win + 255) // 256
  219. canvas = Image.new("RGB", ((tx1 - tx0) * 256, (ty1 - ty0) * 256), BG)
  220. any_ok = False
  221. for tx in range(tx0, tx1):
  222. for ty in range(ty0, ty1):
  223. # Wrap longitude tiles (world is cyclic at zoom 2 = 4 tiles wide)
  224. wrapped_x = tx % (2 ** zoom)
  225. if wrapped_x < 0:
  226. wrapped_x += 2 ** zoom
  227. if not (0 <= ty < 2 ** zoom):
  228. continue
  229. url = provider["url"].format(z=zoom, x=wrapped_x, y=ty)
  230. try:
  231. req = urllib.request.Request(url, headers={"User-Agent": TILE_USER_AGENT})
  232. with urllib.request.urlopen(req, timeout=10) as resp:
  233. tile = Image.open(io.BytesIO(resp.read())).convert("RGB")
  234. canvas.paste(tile, ((tx - tx0) * 256, (ty - ty0) * 256))
  235. any_ok = True
  236. except Exception as e:
  237. logger.warning(f"Locator tile fail {tx},{ty}: {e}")
  238. if not any_ok:
  239. return None
  240. ox, oy = left - tx0 * 256, top - ty0 * 256
  241. cropped = canvas.crop((ox, oy, ox + win, oy + win)).resize((size, size), Image.LANCZOS)
  242. # Marker: red filled circle + white ring at the exact spot (centre of the frame).
  243. d = ImageDraw.Draw(cropped, "RGBA")
  244. cx, cy = size // 2, size // 2
  245. d.ellipse([cx - 9, cy - 9, cx + 9, cy + 9], outline=(255, 255, 255, 255), width=2)
  246. d.ellipse([cx - 5, cy - 5, cx + 5, cy + 5], fill=RED + (255,))
  247. # Thin white frame around the inset itself.
  248. d.rectangle([0, 0, size - 1, size - 1], outline=(255, 255, 255, 200), width=2)
  249. return cropped
  250. # Visible aerial slot (between heading band and date strip) — target the
  251. # bbox to ~85% of it so there's some breathing room around the track.
  252. _BBOX_TARGET_W = int(POSTER_W * 0.85)
  253. _BBOX_TARGET_H = int((1300 - 540) * 0.85) # visible aerial slot height, scaled
  254. def _zoom_from_bbox(self, coords: list, max_zoom: int = 17, min_zoom: int = 10) -> int:
  255. """Pick the tightest zoom level at which the track's GeoJSON bbox still
  256. fits inside the visible aerial area. Walks from high → low zoom and
  257. returns the first zoom that fits — i.e. the most zoomed-in view that
  258. still shows the whole lap."""
  259. if len(coords) < 2:
  260. return self.DEFAULT_ZOOM
  261. lons = [float(c[0]) for c in coords if len(c) >= 2]
  262. lats = [float(c[1]) for c in coords if len(c) >= 2]
  263. if not lons or not lats:
  264. return self.DEFAULT_ZOOM
  265. lat_min, lat_max = min(lats), max(lats)
  266. lon_min, lon_max = min(lons), max(lons)
  267. for zoom in range(max_zoom, min_zoom - 1, -1):
  268. x_lo, y_hi = self._latlon_to_pixel(lat_min, lon_min, zoom) # SW (small x, large y)
  269. x_hi, y_lo = self._latlon_to_pixel(lat_max, lon_max, zoom) # NE (large x, small y)
  270. if (x_hi - x_lo) <= self._BBOX_TARGET_W and (y_hi - y_lo) <= self._BBOX_TARGET_H:
  271. return zoom
  272. return min_zoom
  273. def _resolve_circuit_geo(self, grand_prix_name: str, season: int) -> Optional[tuple[float, float, int]]:
  274. """Pull (lat, lon, zoom) for the circuit hosting this GP in this season.
  275. Centre comes from the GeoJSON bbox midpoint (geometrically accurate),
  276. zoom is computed from the bbox so a tight street circuit gets zoomed in
  277. and a sprawling road course is zoomed out — automatically, no hand-tuning.
  278. Falls back to Jolpica's circuit coordinate + f1-locations.json's hand-
  279. tuned zoom when GeoJSON coords aren't available for this layout."""
  280. races = self.ergast._fetch_season_schedule(season)
  281. race = next((r for r in races if r["raceName"] == grand_prix_name), None)
  282. if not race:
  283. return None
  284. layout = self.circuits.get_circuit_layout_by_ergast_data(grand_prix_name, season)
  285. if layout:
  286. try:
  287. layout.load_geo_json_data()
  288. coords = layout.coordinates
  289. except Exception:
  290. coords = None
  291. if coords and len(coords) >= 2:
  292. lons = [float(c[0]) for c in coords if len(c) >= 2]
  293. lats = [float(c[1]) for c in coords if len(c) >= 2]
  294. lat_c = (min(lats) + max(lats)) / 2
  295. lon_c = (min(lons) + max(lons)) / 2
  296. return lat_c, lon_c, self._zoom_from_bbox(coords)
  297. # Fallback path: Jolpica coordinate + hand-tuned zoom
  298. loc = race.get("Circuit", {}).get("Location", {})
  299. if "lat" not in loc or "long" not in loc:
  300. return None
  301. lat, lon = float(loc["lat"]), float(loc["long"])
  302. from ergast_service import slugify
  303. entry = self._zoom_by_circuit.get(slugify(race["Circuit"].get("circuitName", "")))
  304. zoom = entry["zoom"] if entry and "zoom" in entry else self.DEFAULT_ZOOM
  305. return lat, lon, zoom
  306. # ---------------------------------------------------------- track raster
  307. def _rasterise_track(self, svg_path: Path, target_w: int = 700) -> Optional[Image.Image]:
  308. try:
  309. png_bytes = cairosvg.svg2png(
  310. bytestring=svg_path.read_bytes(),
  311. output_width=target_w,
  312. )
  313. except Exception as e:
  314. logger.error(f"Failed to rasterise {svg_path}: {e}")
  315. return None
  316. track = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
  317. # Recolour all opaque pixels (black stroke in source) → white
  318. px = track.load()
  319. for y in range(track.height):
  320. for x in range(track.width):
  321. r, g, b, a = px[x, y]
  322. if a > 0:
  323. px[x, y] = (WHITE[0], WHITE[1], WHITE[2], a)
  324. return track
  325. # ------------------------------------------------------------------ race poster
  326. def render_race_poster(
  327. self,
  328. grand_prix_name: str,
  329. season: int,
  330. bg: BackgroundStyle = BackgroundStyle.AERIAL,
  331. ) -> Optional[bytes]:
  332. races = self.ergast._fetch_season_schedule(season)
  333. race = next((r for r in races if r["raceName"] == grand_prix_name), None)
  334. if not race:
  335. logger.error(f"Race not found: {grand_prix_name} {season}")
  336. return None
  337. round_num = int(race["round"])
  338. date = race.get("date", "")
  339. layout = self.circuits.get_circuit_layout_by_ergast_data(grand_prix_name, season)
  340. if not layout:
  341. logger.error(f"Layout not resolved for {grand_prix_name} {season}")
  342. return None
  343. cache_key = f"{season}-{round_num:02d}-{layout.slug}-{bg.value}.png"
  344. cache_path = self.cache_dir / cache_key
  345. if cache_path.exists():
  346. return cache_path.read_bytes()
  347. # Fetch map background if requested; fall back to flat black if anything fails.
  348. map_bg: Optional[Image.Image] = None
  349. if bg is not BackgroundStyle.NONE:
  350. geo = self._resolve_circuit_geo(grand_prix_name, season)
  351. if geo:
  352. lat, lon, zoom = geo
  353. map_bg = self._fetch_map_background(lat, lon, zoom, bg)
  354. if map_bg is not None:
  355. # Dim the underlying map so the white track + heading text stay legible.
  356. img = Image.alpha_composite(map_bg.convert("RGBA"), self._race_dark_overlay()).convert("RGB")
  357. else:
  358. img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
  359. draw = ImageDraw.Draw(img)
  360. # Top checker
  361. self._checker_strip(draw, 0)
  362. # FORMULA 1 wordmark
  363. self._centered(draw, "FORMULA 1", y=110, size=88, fill=RED)
  364. # Round / Year ribbon
  365. self._centered(draw, f"ROUND {round_num:02d} / {season}", y=220, size=34, fill=GREY)
  366. # GP name (drop "Grand Prix" suffix; render "GRAND PRIX" smaller below)
  367. name_short = grand_prix_name.replace("Grand Prix", "").strip().upper()
  368. self._centered(draw, name_short, y=300, size=110, fill=WHITE)
  369. self._centered(draw, "GRAND PRIX", y=440, size=44, fill=GREY)
  370. # Track outline.
  371. # When a map background is present we project the GeoJSON lat/lon points
  372. # using the same zoom/centre we used for the tiles — that way the white
  373. # outline is geographically aligned with the actual track in the photo.
  374. # Without a map (bg=NONE) there's nothing to align with so we fall back
  375. # to the hand-drawn SVG centred in the slot.
  376. projected = False
  377. if map_bg is not None:
  378. try:
  379. # GeoJSON is lazy-loaded — trigger the read before pulling coords.
  380. layout.load_geo_json_data()
  381. projected = self._draw_track_projected(
  382. img, layout.coordinates, *self._resolve_circuit_geo(grand_prix_name, season)
  383. )
  384. except Exception as e:
  385. logger.warning(f"GeoJSON track projection failed; falling back to SVG: {e}")
  386. if not projected:
  387. svg_path = Path("circuits") / layout.relative_svg_filepath
  388. if svg_path.exists():
  389. track = self._rasterise_track(svg_path, target_w=720)
  390. if track:
  391. slot_top, slot_bottom = 560, 1250
  392. tx = (POSTER_W - track.width) // 2
  393. ty = slot_top + ((slot_bottom - slot_top) - track.height) // 2
  394. img.paste(track, (tx, ty), track)
  395. # World locator inset (top-right) — picture-in-picture pointing at the
  396. # exact race location on the globe. Only meaningful when a map bg is on.
  397. # Sized + positioned so it tucks under the top checker and sits to the
  398. # right of the centred FORMULA 1 wordmark without overlapping.
  399. if bg is not BackgroundStyle.NONE:
  400. geo = self._resolve_circuit_geo(grand_prix_name, season)
  401. if geo:
  402. lat, lon, _ = geo
  403. locator = self._render_world_locator(lat, lon, size=140)
  404. if locator:
  405. margin = 25
  406. img.paste(locator, (POSTER_W - locator.width - margin, CHECKER_H + margin))
  407. # Date
  408. if date:
  409. self._centered(draw, date, y=POSTER_H - 170, size=32, fill=WHITE)
  410. # Bottom checker
  411. self._checker_strip(draw, POSTER_H - CHECKER_H)
  412. buf = io.BytesIO()
  413. img.save(buf, "PNG", optimize=True)
  414. data = buf.getvalue()
  415. cache_path.write_bytes(data)
  416. return data
  417. # ------------------------------------------------------------------ kometa metadata
  418. BASE_URL = "https://f1-circuits.zurag.be"
  419. def render_kometa_metadata(self, first_season: int = 1950, last_offset: int = 1) -> str:
  420. """Emit the Kometa metadata YAML for the Formula 1 library, covering
  421. every F1 season from `first_season` through current_year + last_offset.
  422. Per show: f1_season builder + url_poster pointing at the season hero.
  423. Per Plex season (== round): url_poster pointing at the race poster.
  424. Kometa silently skips shows / seasons that don't exist in the library."""
  425. from datetime import datetime
  426. from urllib.parse import quote
  427. last_season = datetime.utcnow().year + last_offset
  428. lines: list[str] = [
  429. "# Auto-generated by f1-circuits (/kometa/formula-1-metadata.yml).",
  430. "# Do not commit — fetch via metadata_files.url in Kometa config.",
  431. "metadata:",
  432. ]
  433. for season in range(first_season, last_season + 1):
  434. races = self.ergast._fetch_season_schedule(season)
  435. lines.append(f' "Season {season}":')
  436. lines.append(f" f1_season: {season}")
  437. lines.append(" round_prefix: true")
  438. lines.append(" shorten_gp: true")
  439. lines.append(f" url_poster: {self.BASE_URL}/posters/season/{season}.png")
  440. if not races:
  441. continue
  442. lines.append(" seasons:")
  443. for race in races:
  444. try:
  445. round_num = int(race["round"])
  446. name = race["raceName"]
  447. except (KeyError, ValueError):
  448. continue
  449. race_url = f"{self.BASE_URL}/posters/race/{quote(name)}/{season}.png"
  450. lines.append(f" {round_num}:")
  451. lines.append(f" url_poster: {race_url}")
  452. lines.append("")
  453. return "\n".join(lines)
  454. # ------------------------------------------------------------------ season poster
  455. def _fetch_season_background(self, season: int) -> Optional[Image.Image]:
  456. """Pull the season's hero photo from the dilbert files-api CDN."""
  457. url = SEASON_BG_URL_TEMPLATE.format(season=season)
  458. try:
  459. with urllib.request.urlopen(url, timeout=10) as resp:
  460. bg = Image.open(io.BytesIO(resp.read())).convert("RGB")
  461. except Exception as e:
  462. logger.warning(f"No season background for {season} ({url}): {e}")
  463. return None
  464. # Cover-fit into 1000x1500: scale so both dims meet/exceed target, centre-crop.
  465. sw, sh = bg.size
  466. scale = max(POSTER_W / sw, POSTER_H / sh)
  467. bg = bg.resize((int(sw * scale), int(sh * scale)), Image.LANCZOS)
  468. nw, nh = bg.size
  469. left = (nw - POSTER_W) // 2
  470. top = (nh - POSTER_H) // 2
  471. return bg.crop((left, top, left + POSTER_W, top + POSTER_H))
  472. def _race_dark_overlay(self) -> Image.Image:
  473. """Dimming overlay for race posters when a map background is present.
  474. Stronger at top (heading area) and middle (track), lighter elsewhere so
  475. the underlying map detail still reads through."""
  476. overlay = Image.new("RGBA", (POSTER_W, POSTER_H), (0, 0, 0, 0))
  477. ovr = overlay.load()
  478. for y in range(POSTER_H):
  479. if y < 540:
  480. # Strong dim under the FORMULA 1 / Round / GP-name block
  481. alpha = 200
  482. elif y < 1300:
  483. # Mid-section behind the track — light dim
  484. alpha = 110
  485. else:
  486. # Bottom (date strip + checker)
  487. alpha = 200
  488. for x in range(POSTER_W):
  489. ovr[x, y] = (0, 0, 0, alpha)
  490. return overlay
  491. def _vertical_dark_gradient(self) -> Image.Image:
  492. """A vertical alpha gradient that's darkest top + bottom — keeps the photo's
  493. midsection visible while making wordmark / year / footer text legible."""
  494. overlay = Image.new("RGBA", (POSTER_W, POSTER_H), (0, 0, 0, 0))
  495. ovr = overlay.load()
  496. for y in range(POSTER_H):
  497. if y < 500:
  498. # Strong dim at top for the wordmark + year, fading to nothing at y=500
  499. alpha = 220 - (y * 220 // 500)
  500. elif y > 1100:
  501. # Strong dim at bottom for "WORLD CHAMPIONSHIP" footer
  502. alpha = (y - 1100) * 220 // (POSTER_H - 1100)
  503. alpha = min(alpha, 220)
  504. else:
  505. # Mild dim through the middle to anchor text contrast
  506. alpha = 60
  507. for x in range(POSTER_W):
  508. ovr[x, y] = (0, 0, 0, alpha)
  509. return overlay
  510. def render_season_poster(self, season: int) -> bytes:
  511. cache_path = self.cache_dir / f"season-{season}.png"
  512. if cache_path.exists():
  513. return cache_path.read_bytes()
  514. bg = self._fetch_season_background(season)
  515. if bg is None:
  516. # Fallback to the all-black layout if the background isn't available.
  517. img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
  518. else:
  519. # Composite the dimming gradient over the hero photo for text legibility.
  520. img = Image.alpha_composite(bg.convert("RGBA"), self._vertical_dark_gradient()).convert("RGB")
  521. draw = ImageDraw.Draw(img)
  522. self._checker_strip(draw, 0)
  523. self._centered(draw, "FORMULA 1", y=110, size=88, fill=RED)
  524. self._centered(draw, str(season), y=240, size=240, fill=WHITE)
  525. self._centered(draw, "WORLD CHAMPIONSHIP", y=POSTER_H - 200, size=48, fill=WHITE)
  526. self._checker_strip(draw, POSTER_H - CHECKER_H)
  527. buf = io.BytesIO()
  528. img.save(buf, "PNG", optimize=True)
  529. data = buf.getvalue()
  530. cache_path.write_bytes(data)
  531. return data