"""Plex-style F1 race + season poster rendering. Produces 1000x1500 PNGs (Plex's recommended TV-poster ratio) by compositing: - Top + bottom checker-flag strips - "FORMULA 1" wordmark (red) - Round / year ribbon - Grand Prix name (bold, large) - Rasterised track outline (white) for race posters - Race date Output is cached under cache/posters/ keyed by (season, round, layout_slug) so cold paths stay cheap. """ import io import logging import urllib.request from pathlib import Path from typing import Optional import cairosvg from PIL import Image, ImageDraw, ImageFont from circuits_service import CircuitService from ergast_service import ErgastService # Where season background images live. Hosted by the dilbert files-api module — # 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" logger = logging.getLogger(__name__) POSTER_W, POSTER_H = 1000, 1500 BG = (10, 10, 12) RED = (220, 18, 32) # F1 red WHITE = (245, 245, 245) GREY = (160, 160, 170) CHECKER_H = 60 CHECKER_SQ = 30 # 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). FONT_CANDIDATES = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Debian/Ubuntu "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", # Fedora "/usr/share/fonts/liberation/LiberationSans-Bold.ttf", # Arch "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", ] class PostersService: def __init__(self, circuits: CircuitService, ergast: ErgastService): self.circuits = circuits self.ergast = ergast self.font_path = next((p for p in FONT_CANDIDATES if Path(p).exists()), None) if not self.font_path: 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) # ------------------------------------------------------------------ utils def _font(self, size: int) -> ImageFont.FreeTypeFont: if self.font_path: return ImageFont.truetype(self.font_path, size) return ImageFont.load_default() def _centered(self, draw: ImageDraw.ImageDraw, text: str, y: int, size: int, fill=WHITE) -> int: """Draw text centered horizontally; returns the drawn height.""" font = self._font(size) # Shrink-to-fit if too wide while size > 18 and draw.textlength(text, font) > POSTER_W - 80: size -= 4 font = self._font(size) w = draw.textlength(text, font) draw.text(((POSTER_W - w) / 2, y), text, font=font, fill=fill) bbox = draw.textbbox((0, 0), text, font=font) return bbox[3] - bbox[1] def _checker_strip(self, draw: ImageDraw.ImageDraw, y: int, height: int = CHECKER_H, sq: int = CHECKER_SQ): for col, x in enumerate(range(0, POSTER_W + sq, sq)): for row, yy in enumerate(range(y, y + height, sq)): fill = WHITE if (col + row) % 2 == 0 else BG draw.rectangle([x, yy, x + sq, yy + sq], fill=fill) def _rasterise_track(self, svg_path: Path, target_w: int = 700) -> Optional[Image.Image]: try: png_bytes = cairosvg.svg2png( bytestring=svg_path.read_bytes(), output_width=target_w, ) except Exception as e: logger.error(f"Failed to rasterise {svg_path}: {e}") return None track = Image.open(io.BytesIO(png_bytes)).convert("RGBA") # Recolour all opaque pixels (black stroke in source) → white px = track.load() for y in range(track.height): for x in range(track.width): r, g, b, a = px[x, y] if a > 0: px[x, y] = (WHITE[0], WHITE[1], WHITE[2], a) return track # ------------------------------------------------------------------ race poster def render_race_poster(self, grand_prix_name: str, season: int) -> 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: logger.error(f"Race not found: {grand_prix_name} {season}") return None round_num = int(race["round"]) date = race.get("date", "") layout = self.circuits.get_circuit_layout_by_ergast_data(grand_prix_name, season) if not layout: logger.error(f"Layout not resolved for {grand_prix_name} {season}") return None cache_key = f"{season}-{round_num:02d}-{layout.slug}.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) draw = ImageDraw.Draw(img) # Top checker self._checker_strip(draw, 0) # FORMULA 1 wordmark self._centered(draw, "FORMULA 1", y=110, size=88, fill=RED) # Round / Year ribbon self._centered(draw, f"ROUND {round_num:02d} / {season}", y=220, size=34, fill=GREY) # GP name (drop "Grand Prix" suffix; render "GRAND PRIX" smaller below) name_short = grand_prix_name.replace("Grand Prix", "").strip().upper() 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) # Date if date: self._centered(draw, date, y=POSTER_H - 170, size=32, fill=WHITE) # Bottom checker self._checker_strip(draw, POSTER_H - CHECKER_H) buf = io.BytesIO() img.save(buf, "PNG", optimize=True) data = buf.getvalue() cache_path.write_bytes(data) return data # ------------------------------------------------------------------ season poster def _fetch_season_background(self, season: int) -> Optional[Image.Image]: """Pull the season's hero photo from the dilbert files-api CDN.""" url = SEASON_BG_URL_TEMPLATE.format(season=season) try: with urllib.request.urlopen(url, timeout=10) as resp: bg = Image.open(io.BytesIO(resp.read())).convert("RGB") except Exception as e: logger.warning(f"No season background for {season} ({url}): {e}") return None # Cover-fit into 1000x1500: scale so both dims meet/exceed target, centre-crop. sw, sh = bg.size scale = max(POSTER_W / sw, POSTER_H / sh) bg = bg.resize((int(sw * scale), int(sh * scale)), Image.LANCZOS) nw, nh = bg.size left = (nw - POSTER_W) // 2 top = (nh - POSTER_H) // 2 return bg.crop((left, top, left + POSTER_W, top + POSTER_H)) 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.""" overlay = Image.new("RGBA", (POSTER_W, POSTER_H), (0, 0, 0, 0)) ovr = overlay.load() for y in range(POSTER_H): if y < 500: # Strong dim at top for the wordmark + year, fading to nothing at y=500 alpha = 220 - (y * 220 // 500) elif y > 1100: # Strong dim at bottom for "WORLD CHAMPIONSHIP" footer alpha = (y - 1100) * 220 // (POSTER_H - 1100) alpha = min(alpha, 220) else: # Mild dim through the middle to anchor text contrast alpha = 60 for x in range(POSTER_W): ovr[x, y] = (0, 0, 0, alpha) return overlay def render_season_poster(self, season: int) -> bytes: cache_path = self.cache_dir / f"season-{season}.png" if cache_path.exists(): return cache_path.read_bytes() bg = self._fetch_season_background(season) if bg is None: # Fallback to the all-black layout if the background isn't available. img = Image.new("RGB", (POSTER_W, POSTER_H), BG) else: # Composite the dimming gradient over the hero photo for text legibility. img = Image.alpha_composite(bg.convert("RGBA"), self._vertical_dark_gradient()).convert("RGB") draw = ImageDraw.Draw(img) self._checker_strip(draw, 0) self._centered(draw, "FORMULA 1", y=110, size=88, fill=RED) self._centered(draw, str(season), y=240, size=240, fill=WHITE) self._centered(draw, "WORLD CHAMPIONSHIP", y=POSTER_H - 200, size=48, fill=WHITE) self._checker_strip(draw, POSTER_H - CHECKER_H) buf = io.BytesIO() img.save(buf, "PNG", optimize=True) data = buf.getvalue() cache_path.write_bytes(data) return data