|
|
|
@@ -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) |
|
|
|
|
|
|
|
|