From 6474e109f1cf5d5c54f6d6a78fa294a7759799ea Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 15 Jun 2026 02:28:03 +0200 Subject: [PATCH] Add 1920x1080 session thumbs with track overlay + episode YAML entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit posters_service: - Standard / sprint episode-index → (label, slug) maps mirroring Kometa's F1 builder numbering - _fetch_map_landscape: aerial tile stitcher in 1920x1080 - render_session_thumb: GP aerial bg + projected track outline + GP name + session label with red accent bar - _draw_track_projected now takes canvas_w / geo_center_y so it can be reused for both 2:3 posters and 16:9 thumbs - render_kometa_metadata now emits an episodes: block per Plex season pointing every Kometa-numbered episode at the matching session thumb; sprint vs standard format is detected from the Jolpica race entry cdn-api: - GET /posters/session/{name}/{year}/{slug}.png returns the thumb --- cdn-api.py | 19 +++++ posters_service.py | 179 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 193 insertions(+), 5 deletions(-) diff --git a/cdn-api.py b/cdn-api.py index 3ed5129..6d0a3e2 100644 --- a/cdn-api.py +++ b/cdn-api.py @@ -100,6 +100,25 @@ async def get_season_poster(season: int): ) +@app.get("/posters/session/{grand_prix_name}/{season}/{session_slug}.png") +async def get_session_thumb(grand_prix_name: str, season: int, session_slug: str): + """Plex episode-thumb (1920x1080) for a specific session of a race weekend. + + `session_slug` is one of: fp1, fp2, fp3, pre-qualifying, qualifying, + post-qualifying, pre-race, race, post-race, highlights, pre-sprint, + sprint, post-sprint, pre-sprint-qualifying, sprint-qualifying, + post-sprint-qualifying, bonus. + """ + png = posters_service.render_session_thumb(grand_prix_name, season, session_slug) + if not png: + raise HTTPException(status_code=404, detail=f"No session thumb for {grand_prix_name} {season} {session_slug}") + return FastAPIResponse( + content=png, + media_type="image/png", + headers={"Cache-Control": "public, max-age=31536000"}, + ) + + @app.get("/kometa/formula-1-metadata.yml") async def get_kometa_metadata(): """Generate a Kometa-compatible metadata YAML for the Formula 1 library. diff --git a/posters_service.py b/posters_service.py index 4a122af..9df3981 100644 --- a/posters_service.py +++ b/posters_service.py @@ -67,11 +67,46 @@ 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 @@ -203,22 +238,26 @@ class PostersService: 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) - # 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 + win_left = cpx - canvas_w / 2 + win_top = cpy - geo_center_y pts: list[tuple[float, float]] = [] for entry in coords: @@ -483,6 +522,123 @@ class PostersService: 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" @@ -522,6 +678,19 @@ class PostersService: 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)