|
- """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
|