diff --git a/posters_service.py b/posters_service.py index f272e23..9b4f935 100644 --- a/posters_service.py +++ b/posters_service.py @@ -14,6 +14,7 @@ so cold paths stay cheap. import io import logging +import urllib.request from pathlib import Path from typing import Optional @@ -23,6 +24,10 @@ 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 @@ -35,10 +40,12 @@ 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. +# 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", - "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", + "/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", ] @@ -162,18 +169,62 @@ class PostersService: # ------------------------------------------------------------------ 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() - img = Image.new("RGB", (POSTER_W, POSTER_H), BG) - draw = ImageDraw.Draw(img) + 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=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._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() diff --git a/service/Dockerfile b/service/Dockerfile index 859a35f..9cdeb11 100644 --- a/service/Dockerfile +++ b/service/Dockerfile @@ -1,24 +1,26 @@ -FROM python:3.12-slim +FROM python:3.12-alpine WORKDIR /app -# Poster rendering needs: -# - libcairo2 (cairosvg ↔ Cairo bindings) for SVG → PNG raster -# - fonts-dejavu-core for the DejaVuSans-Bold font used by PostersService -# These add ~15 MB to the image but keep poster generation in-process and fast. -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - libcairo2 \ - fonts-dejavu-core \ - && rm -rf /var/lib/apt/lists/* +# Runtime deps: +# - cairo provides libcairo so cairosvg can rasterise SVG → PNG. +# - ttf-liberation gives Liberation Sans Bold at +# /usr/share/fonts/liberation/LiberationSans-Bold.ttf — exactly where Arch +# installs it, so dev (shanks) and prod (this container) render with the +# same font and the posters come out identical. +RUN apk add --no-cache \ + cairo \ + ttf-liberation +# All pip deps ship musllinux wheels — no build-deps needed. +# numpy is pulled in by models/geo_json/geometry.py for vector ops at import time. RUN pip install --no-cache-dir \ - fastapi \ - uvicorn \ - fastf1 \ - python-slugify \ - pillow \ - cairosvg + fastapi \ + uvicorn \ + python-slugify \ + pillow \ + cairosvg \ + numpy COPY cdn-api.py circuits_service.py ergast_service.py posters_service.py ./ COPY models/ ./models/