|
|
|
@@ -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() |
|
|
|
|