|
|
|
@@ -0,0 +1,183 @@ |
|
|
|
"""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 |