F1 circuit layouts with year-by-year SVGs — manually traced track variations
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

782 рядки
35KB

  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. THUMB_W, THUMB_H = 1920, 1080 # Plex episode-thumb ratio
  58. BG = (10, 10, 12)
  59. RED = (220, 18, 32) # F1 red
  60. WHITE = (245, 245, 245)
  61. GREY = (160, 160, 170)
  62. # Kometa F1 builder episode numbering — keep in sync with Kometa's modules/meta.py.
  63. # Each value is (display_label, url_slug) used for thumb generation + filename.
  64. _SESSION_STD = {
  65. 1: ("Free Practice 1", "fp1"),
  66. 2: ("Free Practice 2", "fp2"),
  67. 3: ("Free Practice 3", "fp3"),
  68. 4: ("Pre-Qualifying Buildup", "pre-qualifying"),
  69. 5: ("Qualifying", "qualifying"),
  70. 6: ("Post-Qualifying", "post-qualifying"),
  71. 7: ("Pre-Race Buildup", "pre-race"),
  72. 8: ("Race", "race"),
  73. 9: ("Post-Race", "post-race"),
  74. 10: ("Highlights", "highlights"),
  75. }
  76. _SESSION_SPRINT = {
  77. 1: ("Free Practice 1", "fp1"),
  78. 2: ("Pre-Sprint-Qualifying Buildup", "pre-sprint-qualifying"),
  79. 3: ("Sprint Qualifying", "sprint-qualifying"),
  80. 4: ("Post-Sprint-Qualifying", "post-sprint-qualifying"),
  81. 5: ("Pre-Sprint Buildup", "pre-sprint"),
  82. 6: ("Sprint", "sprint"),
  83. 7: ("Post-Sprint", "post-sprint"),
  84. 8: ("Pre-Qualifying Buildup", "pre-qualifying"),
  85. 9: ("Qualifying", "qualifying"),
  86. 10: ("Post-Qualifying", "post-qualifying"),
  87. 11: ("Pre-Race Buildup", "pre-race"),
  88. 12: ("Race", "race"),
  89. 13: ("Post-Race", "post-race"),
  90. 14: ("Highlights", "highlights"),
  91. 15: ("Bonus Content", "bonus"),
  92. }
  93. # Display-label lookup for the thumb renderer (slug → label, deduped across formats).
  94. SESSION_LABEL_BY_SLUG = {slug: label for d in (_SESSION_STD, _SESSION_SPRINT) for label, slug in d.values()}
  95. CHECKER_H = 60
  96. CHECKER_SQ = 30
  97. # Vertical pixel where we want the circuit's geographic centre to land. Picked so
  98. # the track ends up visually centred *in the visible aerial band* — between the
  99. # heading block at the top and the date strip at the bottom, not in the full
  100. # 1000x1500 poster middle (which would look high once the heading dim is over it).
  101. GEO_CENTER_Y = 935
  102. # Slim-image-friendly font candidates. The Dockerfile installs fonts-dejavu-core
  103. # so DejaVuSans-Bold is always present in production; the others cover common
  104. # local-dev paths (Arch Linux ships Liberation Sans at /usr/share/fonts/liberation).
  105. FONT_CANDIDATES = [
  106. "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Debian/Ubuntu
  107. "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", # Fedora
  108. "/usr/share/fonts/liberation/LiberationSans-Bold.ttf", # Arch
  109. "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
  110. ]
  111. class PostersService:
  112. def __init__(self, circuits: CircuitService, ergast: ErgastService):
  113. self.circuits = circuits
  114. self.ergast = ergast
  115. self.font_path = next((p for p in FONT_CANDIDATES if Path(p).exists()), None)
  116. if not self.font_path:
  117. logger.warning("No bundled font found — poster text will use PIL's default bitmap font")
  118. self.cache_dir = Path("cache/posters")
  119. self.cache_dir.mkdir(parents=True, exist_ok=True)
  120. # Pre-tuned zooms per circuit, keyed by slugified circuit name.
  121. # Falls back to DEFAULT_ZOOM when not found.
  122. self._zoom_by_circuit = self._load_zoom_lookup()
  123. DEFAULT_ZOOM = 15
  124. @staticmethod
  125. def _load_zoom_lookup() -> dict:
  126. """Index f1-locations.json by slugified circuit name for fast zoom lookup."""
  127. from ergast_service import slugify
  128. path = Path("f1-locations.json")
  129. if not path.exists():
  130. return {}
  131. try:
  132. data = json.loads(path.read_text())
  133. except Exception as e:
  134. logger.warning(f"Could not load f1-locations.json: {e}")
  135. return {}
  136. return {slugify(entry["name"]): entry for entry in data if "name" in entry}
  137. # ------------------------------------------------------------------ utils
  138. def _font(self, size: int) -> ImageFont.FreeTypeFont:
  139. if self.font_path:
  140. return ImageFont.truetype(self.font_path, size)
  141. return ImageFont.load_default()
  142. def _centered(self, draw: ImageDraw.ImageDraw, text: str, y: int, size: int, fill=WHITE) -> int:
  143. """Draw text centered horizontally; returns the drawn height."""
  144. font = self._font(size)
  145. # Shrink-to-fit if too wide
  146. while size > 18 and draw.textlength(text, font) > POSTER_W - 80:
  147. size -= 4
  148. font = self._font(size)
  149. w = draw.textlength(text, font)
  150. draw.text(((POSTER_W - w) / 2, y), text, font=font, fill=fill)
  151. bbox = draw.textbbox((0, 0), text, font=font)
  152. return bbox[3] - bbox[1]
  153. def _checker_strip(self, draw: ImageDraw.ImageDraw, y: int, height: int = CHECKER_H, sq: int = CHECKER_SQ):
  154. for col, x in enumerate(range(0, POSTER_W + sq, sq)):
  155. for row, yy in enumerate(range(y, y + height, sq)):
  156. fill = WHITE if (col + row) % 2 == 0 else BG
  157. draw.rectangle([x, yy, x + sq, yy + sq], fill=fill)
  158. # ---------------------------------------------------------- map tiles
  159. @staticmethod
  160. def _latlon_to_pixel(lat: float, lon: float, zoom: int) -> tuple[float, float]:
  161. """Standard Web-Mercator pixel coordinates (slippy-map convention, 256-px tiles)."""
  162. n = 2.0 ** zoom * 256
  163. x = (lon + 180.0) / 360.0 * n
  164. lat_rad = math.radians(lat)
  165. y = (1.0 - math.log(math.tan(lat_rad) + 1.0 / math.cos(lat_rad)) / math.pi) / 2.0 * n
  166. return x, y
  167. def _fetch_map_background(self, lat: float, lon: float, zoom: int, style: BackgroundStyle) -> Optional[Image.Image]:
  168. """Stitch enough 256-px tiles around (lat, lon) to fill the full POSTER_W x POSTER_H."""
  169. provider = TILE_PROVIDERS.get(style)
  170. if not provider:
  171. return None
  172. # Centre pixel and the slab we need to cover. Anchor the geographic
  173. # centre at GEO_CENTER_Y rather than POSTER_H/2 so the track lands in
  174. # the middle of the *visible* aerial band.
  175. cpx, cpy = self._latlon_to_pixel(lat, lon, zoom)
  176. left, top = int(cpx - POSTER_W / 2), int(cpy - GEO_CENTER_Y)
  177. tile_x_lo, tile_y_lo = left // 256, top // 256
  178. tile_x_hi = (left + POSTER_W + 255) // 256
  179. tile_y_hi = (top + POSTER_H + 255) // 256
  180. canvas_w = (tile_x_hi - tile_x_lo) * 256
  181. canvas_h = (tile_y_hi - tile_y_lo) * 256
  182. canvas = Image.new("RGB", (canvas_w, canvas_h), BG)
  183. any_ok = False
  184. for tx in range(tile_x_lo, tile_x_hi):
  185. for ty in range(tile_y_lo, tile_y_hi):
  186. url = provider["url"].format(z=zoom, x=tx, y=ty)
  187. try:
  188. req = urllib.request.Request(url, headers={"User-Agent": TILE_USER_AGENT})
  189. with urllib.request.urlopen(req, timeout=10) as resp:
  190. tile = Image.open(io.BytesIO(resp.read())).convert("RGB")
  191. canvas.paste(tile, ((tx - tile_x_lo) * 256, (ty - tile_y_lo) * 256))
  192. any_ok = True
  193. except Exception as e:
  194. logger.warning(f"Tile fetch failed {style.value} z={zoom} x={tx} y={ty}: {e}")
  195. if not any_ok:
  196. return None
  197. ox = left - tile_x_lo * 256
  198. oy = top - tile_y_lo * 256
  199. return canvas.crop((ox, oy, ox + POSTER_W, oy + POSTER_H))
  200. def _draw_track_projected(
  201. self,
  202. img: Image.Image,
  203. coords: list,
  204. lat0: float,
  205. lon0: float,
  206. zoom: int,
  207. canvas_w: int = POSTER_W,
  208. geo_center_y: int = GEO_CENTER_Y,
  209. ) -> bool:
  210. """Draw the track onto `img` by projecting each GeoJSON (lon, lat) point
  211. into pixel space using the same web-mercator math as the map background.
  212. This guarantees the white outline overlays the actual circuit in the
  213. underlying aerial / OSM tiles.
  214. canvas_w / geo_center_y must match the dimensions/offset used to fetch
  215. the map background so the polyline aligns with the actual track in the
  216. rendered tiles. Defaults match the race poster; pass THUMB_W / THUMB_H/2
  217. for landscape session thumbs.
  218. Returns True on success, False if there's nothing usable to draw."""
  219. if not coords:
  220. return False
  221. cpx, cpy = self._latlon_to_pixel(lat0, lon0, zoom)
  222. win_left = cpx - canvas_w / 2
  223. win_top = cpy - geo_center_y
  224. pts: list[tuple[float, float]] = []
  225. for entry in coords:
  226. # GeoJSON LineString is [[lon, lat], ...]; tolerate (lon, lat) tuples too.
  227. if len(entry) < 2:
  228. continue
  229. lon, lat = float(entry[0]), float(entry[1])
  230. px, py = self._latlon_to_pixel(lat, lon, zoom)
  231. pts.append((px - win_left, py - win_top))
  232. if len(pts) < 2:
  233. return False
  234. d = ImageDraw.Draw(img)
  235. # Subtle dark stroke under, then the bright white line, so the outline
  236. # stays legible whether the bg is the bright Mediterranean or a dark
  237. # CartoDB tile.
  238. d.line(pts + [pts[0]], fill=(0, 0, 0), width=10, joint="curve")
  239. d.line(pts + [pts[0]], fill=WHITE, width=6, joint="curve")
  240. return True
  241. def _render_world_locator(self, lat: float, lon: float, size: int = 220) -> Optional[Image.Image]:
  242. """A small picture-in-picture map showing where on Earth this race is.
  243. Uses CartoDB Dark Matter at zoom 5 — tight enough that the host country
  244. fills most of the inset, while neighbours and coastlines still give the
  245. viewer a "here's the region" anchor. A marker (red dot + white ring)
  246. is drawn over the exact spot."""
  247. zoom = 5
  248. # Use the same stitcher with a custom canvas target.
  249. provider = TILE_PROVIDERS[BackgroundStyle.DARK]
  250. cpx, cpy = self._latlon_to_pixel(lat, lon, zoom)
  251. # Frame 700x700 px window centred on the location, then downscale to `size`.
  252. win = 700
  253. left, top = int(cpx - win / 2), int(cpy - win / 2)
  254. tx0, ty0 = left // 256, top // 256
  255. tx1, ty1 = (left + win + 255) // 256, (top + win + 255) // 256
  256. canvas = Image.new("RGB", ((tx1 - tx0) * 256, (ty1 - ty0) * 256), BG)
  257. any_ok = False
  258. for tx in range(tx0, tx1):
  259. for ty in range(ty0, ty1):
  260. # Wrap longitude tiles (world is cyclic at zoom 2 = 4 tiles wide)
  261. wrapped_x = tx % (2 ** zoom)
  262. if wrapped_x < 0:
  263. wrapped_x += 2 ** zoom
  264. if not (0 <= ty < 2 ** zoom):
  265. continue
  266. url = provider["url"].format(z=zoom, x=wrapped_x, y=ty)
  267. try:
  268. req = urllib.request.Request(url, headers={"User-Agent": TILE_USER_AGENT})
  269. with urllib.request.urlopen(req, timeout=10) as resp:
  270. tile = Image.open(io.BytesIO(resp.read())).convert("RGB")
  271. canvas.paste(tile, ((tx - tx0) * 256, (ty - ty0) * 256))
  272. any_ok = True
  273. except Exception as e:
  274. logger.warning(f"Locator tile fail {tx},{ty}: {e}")
  275. if not any_ok:
  276. return None
  277. ox, oy = left - tx0 * 256, top - ty0 * 256
  278. cropped = canvas.crop((ox, oy, ox + win, oy + win)).resize((size, size), Image.LANCZOS)
  279. # Marker: red filled circle + white ring at the exact spot (centre of the frame).
  280. d = ImageDraw.Draw(cropped, "RGBA")
  281. cx, cy = size // 2, size // 2
  282. d.ellipse([cx - 9, cy - 9, cx + 9, cy + 9], outline=(255, 255, 255, 255), width=2)
  283. d.ellipse([cx - 5, cy - 5, cx + 5, cy + 5], fill=RED + (255,))
  284. # Thin white frame around the inset itself.
  285. d.rectangle([0, 0, size - 1, size - 1], outline=(255, 255, 255, 200), width=2)
  286. return cropped
  287. # Visible aerial slot (between heading band and date strip) — target the
  288. # bbox to ~85% of it so there's some breathing room around the track.
  289. _BBOX_TARGET_W = int(POSTER_W * 0.85)
  290. _BBOX_TARGET_H = int((1300 - 540) * 0.85) # visible aerial slot height, scaled
  291. def _zoom_from_bbox(self, coords: list, max_zoom: int = 17, min_zoom: int = 10) -> int:
  292. """Pick the tightest zoom level at which the track's GeoJSON bbox still
  293. fits inside the visible aerial area. Walks from high → low zoom and
  294. returns the first zoom that fits — i.e. the most zoomed-in view that
  295. still shows the whole lap."""
  296. if len(coords) < 2:
  297. return self.DEFAULT_ZOOM
  298. lons = [float(c[0]) for c in coords if len(c) >= 2]
  299. lats = [float(c[1]) for c in coords if len(c) >= 2]
  300. if not lons or not lats:
  301. return self.DEFAULT_ZOOM
  302. lat_min, lat_max = min(lats), max(lats)
  303. lon_min, lon_max = min(lons), max(lons)
  304. for zoom in range(max_zoom, min_zoom - 1, -1):
  305. x_lo, y_hi = self._latlon_to_pixel(lat_min, lon_min, zoom) # SW (small x, large y)
  306. x_hi, y_lo = self._latlon_to_pixel(lat_max, lon_max, zoom) # NE (large x, small y)
  307. if (x_hi - x_lo) <= self._BBOX_TARGET_W and (y_hi - y_lo) <= self._BBOX_TARGET_H:
  308. return zoom
  309. return min_zoom
  310. def _resolve_circuit_geo(self, grand_prix_name: str, season: int) -> Optional[tuple[float, float, int]]:
  311. """Pull (lat, lon, zoom) for the circuit hosting this GP in this season.
  312. Centre comes from the GeoJSON bbox midpoint (geometrically accurate),
  313. zoom is computed from the bbox so a tight street circuit gets zoomed in
  314. and a sprawling road course is zoomed out — automatically, no hand-tuning.
  315. Falls back to Jolpica's circuit coordinate + f1-locations.json's hand-
  316. tuned zoom when GeoJSON coords aren't available for this layout."""
  317. races = self.ergast._fetch_season_schedule(season)
  318. race = next((r for r in races if r["raceName"] == grand_prix_name), None)
  319. if not race:
  320. return None
  321. layout = self.circuits.get_circuit_layout_by_ergast_data(grand_prix_name, season)
  322. if layout:
  323. try:
  324. layout.load_geo_json_data()
  325. coords = layout.coordinates
  326. except Exception:
  327. coords = None
  328. if coords and len(coords) >= 2:
  329. lons = [float(c[0]) for c in coords if len(c) >= 2]
  330. lats = [float(c[1]) for c in coords if len(c) >= 2]
  331. lat_c = (min(lats) + max(lats)) / 2
  332. lon_c = (min(lons) + max(lons)) / 2
  333. return lat_c, lon_c, self._zoom_from_bbox(coords)
  334. # Fallback path: Jolpica coordinate + hand-tuned zoom
  335. loc = race.get("Circuit", {}).get("Location", {})
  336. if "lat" not in loc or "long" not in loc:
  337. return None
  338. lat, lon = float(loc["lat"]), float(loc["long"])
  339. from ergast_service import slugify
  340. entry = self._zoom_by_circuit.get(slugify(race["Circuit"].get("circuitName", "")))
  341. zoom = entry["zoom"] if entry and "zoom" in entry else self.DEFAULT_ZOOM
  342. return lat, lon, zoom
  343. # ---------------------------------------------------------- track raster
  344. def _rasterise_track(self, svg_path: Path, target_w: int = 700) -> Optional[Image.Image]:
  345. try:
  346. png_bytes = cairosvg.svg2png(
  347. bytestring=svg_path.read_bytes(),
  348. output_width=target_w,
  349. )
  350. except Exception as e:
  351. logger.error(f"Failed to rasterise {svg_path}: {e}")
  352. return None
  353. track = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
  354. # Recolour all opaque pixels (black stroke in source) → white
  355. px = track.load()
  356. for y in range(track.height):
  357. for x in range(track.width):
  358. r, g, b, a = px[x, y]
  359. if a > 0:
  360. px[x, y] = (WHITE[0], WHITE[1], WHITE[2], a)
  361. return track
  362. # ------------------------------------------------------------------ race poster
  363. def render_race_poster(
  364. self,
  365. grand_prix_name: str,
  366. season: int,
  367. bg: BackgroundStyle = BackgroundStyle.AERIAL,
  368. ) -> Optional[bytes]:
  369. races = self.ergast._fetch_season_schedule(season)
  370. race = next((r for r in races if r["raceName"] == grand_prix_name), None)
  371. if not race:
  372. logger.error(f"Race not found: {grand_prix_name} {season}")
  373. return None
  374. round_num = int(race["round"])
  375. date = race.get("date", "")
  376. layout = self.circuits.get_circuit_layout_by_ergast_data(grand_prix_name, season)
  377. if not layout:
  378. logger.error(f"Layout not resolved for {grand_prix_name} {season}")
  379. return None
  380. cache_key = f"{season}-{round_num:02d}-{layout.slug}-{bg.value}.png"
  381. cache_path = self.cache_dir / cache_key
  382. if cache_path.exists():
  383. return cache_path.read_bytes()
  384. # Fetch map background if requested; fall back to flat black if anything fails.
  385. map_bg: Optional[Image.Image] = None
  386. if bg is not BackgroundStyle.NONE:
  387. geo = self._resolve_circuit_geo(grand_prix_name, season)
  388. if geo:
  389. lat, lon, zoom = geo
  390. map_bg = self._fetch_map_background(lat, lon, zoom, bg)
  391. if map_bg is not None:
  392. # Dim the underlying map so the white track + heading text stay legible.
  393. img = Image.alpha_composite(map_bg.convert("RGBA"), self._race_dark_overlay()).convert("RGB")
  394. else:
  395. img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
  396. draw = ImageDraw.Draw(img)
  397. # Top checker
  398. self._checker_strip(draw, 0)
  399. # FORMULA 1 wordmark
  400. self._centered(draw, "FORMULA 1", y=110, size=88, fill=RED)
  401. # Round / Year ribbon
  402. self._centered(draw, f"ROUND {round_num:02d} / {season}", y=220, size=34, fill=GREY)
  403. # GP name (drop "Grand Prix" suffix; render "GRAND PRIX" smaller below)
  404. name_short = grand_prix_name.replace("Grand Prix", "").strip().upper()
  405. self._centered(draw, name_short, y=300, size=110, fill=WHITE)
  406. self._centered(draw, "GRAND PRIX", y=440, size=44, fill=GREY)
  407. # Track outline.
  408. # When a map background is present we project the GeoJSON lat/lon points
  409. # using the same zoom/centre we used for the tiles — that way the white
  410. # outline is geographically aligned with the actual track in the photo.
  411. # Without a map (bg=NONE) there's nothing to align with so we fall back
  412. # to the hand-drawn SVG centred in the slot.
  413. projected = False
  414. if map_bg is not None:
  415. try:
  416. # GeoJSON is lazy-loaded — trigger the read before pulling coords.
  417. layout.load_geo_json_data()
  418. projected = self._draw_track_projected(
  419. img, layout.coordinates, *self._resolve_circuit_geo(grand_prix_name, season)
  420. )
  421. except Exception as e:
  422. logger.warning(f"GeoJSON track projection failed; falling back to SVG: {e}")
  423. if not projected:
  424. svg_path = Path("circuits") / layout.relative_svg_filepath
  425. if svg_path.exists():
  426. track = self._rasterise_track(svg_path, target_w=720)
  427. if track:
  428. slot_top, slot_bottom = 560, 1250
  429. tx = (POSTER_W - track.width) // 2
  430. ty = slot_top + ((slot_bottom - slot_top) - track.height) // 2
  431. img.paste(track, (tx, ty), track)
  432. # World locator inset (top-right) — picture-in-picture pointing at the
  433. # exact race location on the globe. Only meaningful when a map bg is on.
  434. # Sized + positioned so it tucks under the top checker and sits to the
  435. # right of the centred FORMULA 1 wordmark without overlapping.
  436. if bg is not BackgroundStyle.NONE:
  437. geo = self._resolve_circuit_geo(grand_prix_name, season)
  438. if geo:
  439. lat, lon, _ = geo
  440. locator = self._render_world_locator(lat, lon, size=140)
  441. if locator:
  442. margin = 25
  443. img.paste(locator, (POSTER_W - locator.width - margin, CHECKER_H + margin))
  444. # Date
  445. if date:
  446. self._centered(draw, date, y=POSTER_H - 170, size=32, fill=WHITE)
  447. # Bottom checker
  448. self._checker_strip(draw, POSTER_H - CHECKER_H)
  449. buf = io.BytesIO()
  450. img.save(buf, "PNG", optimize=True)
  451. data = buf.getvalue()
  452. cache_path.write_bytes(data)
  453. return data
  454. # ------------------------------------------------------------------ session thumb (16:9)
  455. def _fetch_map_landscape(self, lat: float, lon: float, zoom: int, style: BackgroundStyle) -> Optional[Image.Image]:
  456. """Like _fetch_map_background but for THUMB_W x THUMB_H (1920x1080).
  457. Centred so the geographic point lands at the visual midpoint."""
  458. provider = TILE_PROVIDERS.get(style)
  459. if not provider:
  460. return None
  461. cpx, cpy = self._latlon_to_pixel(lat, lon, zoom)
  462. left, top = int(cpx - THUMB_W / 2), int(cpy - THUMB_H / 2)
  463. tx0, ty0 = left // 256, top // 256
  464. tx1, ty1 = (left + THUMB_W + 255) // 256, (top + THUMB_H + 255) // 256
  465. canvas = Image.new("RGB", ((tx1 - tx0) * 256, (ty1 - ty0) * 256), BG)
  466. any_ok = False
  467. for tx in range(tx0, tx1):
  468. for ty in range(ty0, ty1):
  469. url = provider["url"].format(z=zoom, x=tx, y=ty)
  470. try:
  471. req = urllib.request.Request(url, headers={"User-Agent": TILE_USER_AGENT})
  472. with urllib.request.urlopen(req, timeout=10) as resp:
  473. tile = Image.open(io.BytesIO(resp.read())).convert("RGB")
  474. canvas.paste(tile, ((tx - tx0) * 256, (ty - ty0) * 256))
  475. any_ok = True
  476. except Exception as e:
  477. logger.warning(f"Landscape tile fail {style.value} z={zoom} x={tx} y={ty}: {e}")
  478. if not any_ok:
  479. return None
  480. ox, oy = left - tx0 * 256, top - ty0 * 256
  481. return canvas.crop((ox, oy, ox + THUMB_W, oy + THUMB_H))
  482. def render_session_thumb(self, grand_prix_name: str, season: int, session_slug: str) -> Optional[bytes]:
  483. """1920x1080 Plex episode-thumb. Aerial of the circuit + GP name + session label."""
  484. label = SESSION_LABEL_BY_SLUG.get(session_slug)
  485. if not label:
  486. return None
  487. races = self.ergast._fetch_season_schedule(season)
  488. race = next((r for r in races if r["raceName"] == grand_prix_name), None)
  489. if not race:
  490. return None
  491. round_num = int(race["round"])
  492. layout = self.circuits.get_circuit_layout_by_ergast_data(grand_prix_name, season)
  493. if not layout:
  494. return None
  495. cache_key = f"thumb-{season}-{round_num:02d}-{layout.slug}-{session_slug}.png"
  496. cache_path = self.cache_dir / cache_key
  497. if cache_path.exists():
  498. return cache_path.read_bytes()
  499. # Same per-circuit geo (lat, lon, bbox-derived zoom) as the race poster,
  500. # so the thumb shows the same place at the same zoom level.
  501. geo = self._resolve_circuit_geo(grand_prix_name, season)
  502. map_bg = self._fetch_map_landscape(*geo, BackgroundStyle.AERIAL) if geo else None
  503. img = Image.new("RGB", (THUMB_W, THUMB_H), BG)
  504. if map_bg is not None:
  505. # Heavier dim than the poster — 16:9 fills the player thumb at small
  506. # sizes, so we need solid contrast for the overlaid label text.
  507. overlay = Image.new("RGBA", (THUMB_W, THUMB_H), (0, 0, 0, 140))
  508. img = Image.alpha_composite(map_bg.convert("RGBA"), overlay).convert("RGB")
  509. # Project the track polyline onto the landscape canvas — same math
  510. # as the race poster but with THUMB_W / THUMB_H/2 so it overlays the
  511. # actual circuit visible in the aerial.
  512. try:
  513. layout.load_geo_json_data()
  514. self._draw_track_projected(
  515. img, layout.coordinates, *geo,
  516. canvas_w=THUMB_W, geo_center_y=THUMB_H // 2,
  517. )
  518. except Exception as e:
  519. logger.warning(f"Session-thumb track projection failed: {e}")
  520. draw = ImageDraw.Draw(img)
  521. # Two left-aligned text columns: GP name top-left, session label bottom-left.
  522. # Round/year accent in red between them.
  523. x_margin = 80
  524. name_short = grand_prix_name.replace("Grand Prix", "").strip().upper()
  525. # FORMULA 1 small wordmark top-left
  526. font_wm = self._font(40)
  527. draw.text((x_margin, 60), "FORMULA 1", fill=RED, font=font_wm)
  528. # Round/year ribbon
  529. font_ribbon = self._font(28)
  530. draw.text((x_margin, 120), f"ROUND {round_num:02d} / {season}", fill=GREY, font=font_ribbon)
  531. # GP name — fit to 1300px max width
  532. gp_size = 120
  533. font_gp = self._font(gp_size)
  534. while gp_size > 60 and draw.textlength(name_short, font_gp) > 1300:
  535. gp_size -= 10
  536. font_gp = self._font(gp_size)
  537. draw.text((x_margin, 180), name_short, fill=WHITE, font=font_gp)
  538. draw.text((x_margin, 180 + gp_size + 5), "GRAND PRIX", fill=GREY, font=self._font(36))
  539. # Session label — bottom-left, very prominent
  540. sess_size = 90
  541. font_sess = self._font(sess_size)
  542. while sess_size > 50 and draw.textlength(label.upper(), font_sess) > 1300:
  543. sess_size -= 6
  544. font_sess = self._font(sess_size)
  545. sess_label = label.upper()
  546. sess_y = THUMB_H - 130 - sess_size
  547. draw.text((x_margin, sess_y), sess_label, fill=WHITE, font=font_sess)
  548. # Thin red accent bar above the session label
  549. draw.rectangle([x_margin, sess_y - 16, x_margin + 120, sess_y - 8], fill=RED)
  550. buf = io.BytesIO()
  551. img.save(buf, "PNG", optimize=True)
  552. data = buf.getvalue()
  553. cache_path.write_bytes(data)
  554. return data
  555. # ------------------------------------------------------------------ kometa metadata
  556. BASE_URL = "https://f1-circuits.zurag.be"
  557. def render_kometa_metadata(self, first_season: int = 1950, last_offset: int = 1) -> str:
  558. """Emit the Kometa metadata YAML for the Formula 1 library, covering
  559. every F1 season from `first_season` through current_year + last_offset.
  560. Per show: f1_season builder + url_poster pointing at the season hero.
  561. Per Plex season (== round): url_poster pointing at the race poster.
  562. Kometa silently skips shows / seasons that don't exist in the library."""
  563. from datetime import datetime
  564. from urllib.parse import quote
  565. last_season = datetime.utcnow().year + last_offset
  566. lines: list[str] = [
  567. "# Auto-generated by f1-circuits (/kometa/formula-1-metadata.yml).",
  568. "# Do not commit — fetch via metadata_files.url in Kometa config.",
  569. "metadata:",
  570. ]
  571. for season in range(first_season, last_season + 1):
  572. races = self.ergast._fetch_season_schedule(season)
  573. lines.append(f' "Season {season}":')
  574. lines.append(f" f1_season: {season}")
  575. lines.append(" round_prefix: true")
  576. lines.append(" shorten_gp: true")
  577. lines.append(f" url_poster: {self.BASE_URL}/posters/season/{season}.png")
  578. if not races:
  579. continue
  580. lines.append(" seasons:")
  581. for race in races:
  582. try:
  583. round_num = int(race["round"])
  584. name = race["raceName"]
  585. except (KeyError, ValueError):
  586. continue
  587. race_url = f"{self.BASE_URL}/posters/race/{quote(name)}/{season}.png"
  588. lines.append(f" {round_num}:")
  589. lines.append(f" url_poster: {race_url}")
  590. # Per-episode session thumbs. Detect sprint format from the
  591. # presence of a Sprint section in the Jolpica race entry; the
  592. # F1 builder maps each format to its own episode numbering and
  593. # we follow the same map here so each Plex episode lands on a
  594. # correctly-labelled session thumb.
  595. is_sprint = any(k.lower().startswith("sprint") for k in race.keys())
  596. session_map = _SESSION_SPRINT if is_sprint else _SESSION_STD
  597. lines.append(" episodes:")
  598. for ep_num, (_label, slug) in session_map.items():
  599. thumb_url = f"{self.BASE_URL}/posters/session/{quote(name)}/{season}/{slug}.png"
  600. lines.append(f" {ep_num}:")
  601. lines.append(f" url_poster: {thumb_url}")
  602. lines.append("")
  603. return "\n".join(lines)
  604. # ------------------------------------------------------------------ season poster
  605. def _fetch_season_background(self, season: int) -> Optional[Image.Image]:
  606. """Pull the season's hero photo from the dilbert files-api CDN."""
  607. url = SEASON_BG_URL_TEMPLATE.format(season=season)
  608. try:
  609. with urllib.request.urlopen(url, timeout=10) as resp:
  610. bg = Image.open(io.BytesIO(resp.read())).convert("RGB")
  611. except Exception as e:
  612. logger.warning(f"No season background for {season} ({url}): {e}")
  613. return None
  614. # Cover-fit into 1000x1500: scale so both dims meet/exceed target, centre-crop.
  615. sw, sh = bg.size
  616. scale = max(POSTER_W / sw, POSTER_H / sh)
  617. bg = bg.resize((int(sw * scale), int(sh * scale)), Image.LANCZOS)
  618. nw, nh = bg.size
  619. left = (nw - POSTER_W) // 2
  620. top = (nh - POSTER_H) // 2
  621. return bg.crop((left, top, left + POSTER_W, top + POSTER_H))
  622. def _race_dark_overlay(self) -> Image.Image:
  623. """Dimming overlay for race posters when a map background is present.
  624. Stronger at top (heading area) and middle (track), lighter elsewhere so
  625. the underlying map detail still reads through."""
  626. overlay = Image.new("RGBA", (POSTER_W, POSTER_H), (0, 0, 0, 0))
  627. ovr = overlay.load()
  628. for y in range(POSTER_H):
  629. if y < 540:
  630. # Strong dim under the FORMULA 1 / Round / GP-name block
  631. alpha = 200
  632. elif y < 1300:
  633. # Mid-section behind the track — light dim
  634. alpha = 110
  635. else:
  636. # Bottom (date strip + checker)
  637. alpha = 200
  638. for x in range(POSTER_W):
  639. ovr[x, y] = (0, 0, 0, alpha)
  640. return overlay
  641. def _vertical_dark_gradient(self) -> Image.Image:
  642. """A vertical alpha gradient that's darkest top + bottom — keeps the photo's
  643. midsection visible while making wordmark / year / footer text legible."""
  644. overlay = Image.new("RGBA", (POSTER_W, POSTER_H), (0, 0, 0, 0))
  645. ovr = overlay.load()
  646. for y in range(POSTER_H):
  647. if y < 500:
  648. # Strong dim at top for the wordmark + year, fading to nothing at y=500
  649. alpha = 220 - (y * 220 // 500)
  650. elif y > 1100:
  651. # Strong dim at bottom for "WORLD CHAMPIONSHIP" footer
  652. alpha = (y - 1100) * 220 // (POSTER_H - 1100)
  653. alpha = min(alpha, 220)
  654. else:
  655. # Mild dim through the middle to anchor text contrast
  656. alpha = 60
  657. for x in range(POSTER_W):
  658. ovr[x, y] = (0, 0, 0, alpha)
  659. return overlay
  660. def render_season_poster(self, season: int) -> bytes:
  661. cache_path = self.cache_dir / f"season-{season}.png"
  662. if cache_path.exists():
  663. return cache_path.read_bytes()
  664. bg = self._fetch_season_background(season)
  665. if bg is None:
  666. # Fallback to the all-black layout if the background isn't available.
  667. img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
  668. else:
  669. # Composite the dimming gradient over the hero photo for text legibility.
  670. img = Image.alpha_composite(bg.convert("RGBA"), self._vertical_dark_gradient()).convert("RGB")
  671. draw = ImageDraw.Draw(img)
  672. self._checker_strip(draw, 0)
  673. self._centered(draw, "FORMULA 1", y=110, size=88, fill=RED)
  674. self._centered(draw, str(season), y=240, size=240, fill=WHITE)
  675. self._centered(draw, "WORLD CHAMPIONSHIP", y=POSTER_H - 200, size=48, fill=WHITE)
  676. self._checker_strip(draw, POSTER_H - CHECKER_H)
  677. buf = io.BytesIO()
  678. img.save(buf, "PNG", optimize=True)
  679. data = buf.getvalue()
  680. cache_path.write_bytes(data)
  681. return data