Add Plex-ready race + season poster endpoints
GET /posters/race/{grand_prix_name}/{season}.png
GET /posters/season/{season}.png
1000x1500 PNGs composed with Pillow + cairosvg:
checkered-flag strips top/bottom, F1 wordmark, round/year ribbon,
GP name, rasterised track outline (white), race date. Season poster
is wordmark + giant year + 'WORLD CHAMPIONSHIP'. Cached under
cache/posters/ so cold renders happen once.
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user