|
- """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
- 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
-
- 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; the others are fallbacks for local dev.
- FONT_CANDIDATES = [
- "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
- "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
- "/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 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()
-
- img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
- draw = ImageDraw.Draw(img)
-
- self._checker_strip(draw, 0)
- self._centered(draw, "FORMULA 1", y=240, size=120, fill=RED)
- self._centered(draw, str(season), y=440, size=420, fill=WHITE)
- self._centered(draw, "WORLD CHAMPIONSHIP", y=1100, size=56, fill=GREY)
- 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
|