F1 circuit layouts with year-by-year SVGs — manually traced track variations
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

235 строки
9.4KB

  1. """Plex-style F1 race + season poster rendering.
  2. Produces 1000x1500 PNGs (Plex's recommended TV-poster ratio) by compositing:
  3. - Top + bottom checker-flag strips
  4. - "FORMULA 1" wordmark (red)
  5. - Round / year ribbon
  6. - Grand Prix name (bold, large)
  7. - Rasterised track outline (white) for race posters
  8. - Race date
  9. Output is cached under cache/posters/ keyed by (season, round, layout_slug)
  10. so cold paths stay cheap.
  11. """
  12. import io
  13. import logging
  14. import urllib.request
  15. from pathlib import Path
  16. from typing import Optional
  17. import cairosvg
  18. from PIL import Image, ImageDraw, ImageFont
  19. from circuits_service import CircuitService
  20. from ergast_service import ErgastService
  21. # Where season background images live. Hosted by the dilbert files-api module —
  22. # one landscape JPG per F1 season (key for the season-card grid in Dilbert's UI).
  23. SEASON_BG_URL_TEMPLATE = "https://files-api.novox.be/dilbert/sports/formula1/cars/{season}.jpg"
  24. logger = logging.getLogger(__name__)
  25. POSTER_W, POSTER_H = 1000, 1500
  26. BG = (10, 10, 12)
  27. RED = (220, 18, 32) # F1 red
  28. WHITE = (245, 245, 245)
  29. GREY = (160, 160, 170)
  30. CHECKER_H = 60
  31. CHECKER_SQ = 30
  32. # Slim-image-friendly font candidates. The Dockerfile installs fonts-dejavu-core
  33. # so DejaVuSans-Bold is always present in production; the others cover common
  34. # local-dev paths (Arch Linux ships Liberation Sans at /usr/share/fonts/liberation).
  35. FONT_CANDIDATES = [
  36. "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", # Debian/Ubuntu
  37. "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", # Fedora
  38. "/usr/share/fonts/liberation/LiberationSans-Bold.ttf", # Arch
  39. "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
  40. ]
  41. class PostersService:
  42. def __init__(self, circuits: CircuitService, ergast: ErgastService):
  43. self.circuits = circuits
  44. self.ergast = ergast
  45. self.font_path = next((p for p in FONT_CANDIDATES if Path(p).exists()), None)
  46. if not self.font_path:
  47. logger.warning("No bundled font found — poster text will use PIL's default bitmap font")
  48. self.cache_dir = Path("cache/posters")
  49. self.cache_dir.mkdir(parents=True, exist_ok=True)
  50. # ------------------------------------------------------------------ utils
  51. def _font(self, size: int) -> ImageFont.FreeTypeFont:
  52. if self.font_path:
  53. return ImageFont.truetype(self.font_path, size)
  54. return ImageFont.load_default()
  55. def _centered(self, draw: ImageDraw.ImageDraw, text: str, y: int, size: int, fill=WHITE) -> int:
  56. """Draw text centered horizontally; returns the drawn height."""
  57. font = self._font(size)
  58. # Shrink-to-fit if too wide
  59. while size > 18 and draw.textlength(text, font) > POSTER_W - 80:
  60. size -= 4
  61. font = self._font(size)
  62. w = draw.textlength(text, font)
  63. draw.text(((POSTER_W - w) / 2, y), text, font=font, fill=fill)
  64. bbox = draw.textbbox((0, 0), text, font=font)
  65. return bbox[3] - bbox[1]
  66. def _checker_strip(self, draw: ImageDraw.ImageDraw, y: int, height: int = CHECKER_H, sq: int = CHECKER_SQ):
  67. for col, x in enumerate(range(0, POSTER_W + sq, sq)):
  68. for row, yy in enumerate(range(y, y + height, sq)):
  69. fill = WHITE if (col + row) % 2 == 0 else BG
  70. draw.rectangle([x, yy, x + sq, yy + sq], fill=fill)
  71. def _rasterise_track(self, svg_path: Path, target_w: int = 700) -> Optional[Image.Image]:
  72. try:
  73. png_bytes = cairosvg.svg2png(
  74. bytestring=svg_path.read_bytes(),
  75. output_width=target_w,
  76. )
  77. except Exception as e:
  78. logger.error(f"Failed to rasterise {svg_path}: {e}")
  79. return None
  80. track = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
  81. # Recolour all opaque pixels (black stroke in source) → white
  82. px = track.load()
  83. for y in range(track.height):
  84. for x in range(track.width):
  85. r, g, b, a = px[x, y]
  86. if a > 0:
  87. px[x, y] = (WHITE[0], WHITE[1], WHITE[2], a)
  88. return track
  89. # ------------------------------------------------------------------ race poster
  90. def render_race_poster(self, grand_prix_name: str, season: int) -> Optional[bytes]:
  91. races = self.ergast._fetch_season_schedule(season)
  92. race = next((r for r in races if r["raceName"] == grand_prix_name), None)
  93. if not race:
  94. logger.error(f"Race not found: {grand_prix_name} {season}")
  95. return None
  96. round_num = int(race["round"])
  97. date = race.get("date", "")
  98. layout = self.circuits.get_circuit_layout_by_ergast_data(grand_prix_name, season)
  99. if not layout:
  100. logger.error(f"Layout not resolved for {grand_prix_name} {season}")
  101. return None
  102. cache_key = f"{season}-{round_num:02d}-{layout.slug}.png"
  103. cache_path = self.cache_dir / cache_key
  104. if cache_path.exists():
  105. return cache_path.read_bytes()
  106. img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
  107. draw = ImageDraw.Draw(img)
  108. # Top checker
  109. self._checker_strip(draw, 0)
  110. # FORMULA 1 wordmark
  111. self._centered(draw, "FORMULA 1", y=110, size=88, fill=RED)
  112. # Round / Year ribbon
  113. self._centered(draw, f"ROUND {round_num:02d} / {season}", y=220, size=34, fill=GREY)
  114. # GP name (drop "Grand Prix" suffix; render "GRAND PRIX" smaller below)
  115. name_short = grand_prix_name.replace("Grand Prix", "").strip().upper()
  116. self._centered(draw, name_short, y=300, size=110, fill=WHITE)
  117. self._centered(draw, "GRAND PRIX", y=440, size=44, fill=GREY)
  118. # Track outline
  119. svg_path = Path("circuits") / layout.relative_svg_filepath
  120. if svg_path.exists():
  121. track = self._rasterise_track(svg_path, target_w=720)
  122. if track:
  123. # Centre between y=560 and y=1250
  124. slot_top, slot_bottom = 560, 1250
  125. tx = (POSTER_W - track.width) // 2
  126. ty = slot_top + ((slot_bottom - slot_top) - track.height) // 2
  127. img.paste(track, (tx, ty), track)
  128. # Date
  129. if date:
  130. self._centered(draw, date, y=POSTER_H - 170, size=32, fill=WHITE)
  131. # Bottom checker
  132. self._checker_strip(draw, POSTER_H - CHECKER_H)
  133. buf = io.BytesIO()
  134. img.save(buf, "PNG", optimize=True)
  135. data = buf.getvalue()
  136. cache_path.write_bytes(data)
  137. return data
  138. # ------------------------------------------------------------------ season poster
  139. def _fetch_season_background(self, season: int) -> Optional[Image.Image]:
  140. """Pull the season's hero photo from the dilbert files-api CDN."""
  141. url = SEASON_BG_URL_TEMPLATE.format(season=season)
  142. try:
  143. with urllib.request.urlopen(url, timeout=10) as resp:
  144. bg = Image.open(io.BytesIO(resp.read())).convert("RGB")
  145. except Exception as e:
  146. logger.warning(f"No season background for {season} ({url}): {e}")
  147. return None
  148. # Cover-fit into 1000x1500: scale so both dims meet/exceed target, centre-crop.
  149. sw, sh = bg.size
  150. scale = max(POSTER_W / sw, POSTER_H / sh)
  151. bg = bg.resize((int(sw * scale), int(sh * scale)), Image.LANCZOS)
  152. nw, nh = bg.size
  153. left = (nw - POSTER_W) // 2
  154. top = (nh - POSTER_H) // 2
  155. return bg.crop((left, top, left + POSTER_W, top + POSTER_H))
  156. def _vertical_dark_gradient(self) -> Image.Image:
  157. """A vertical alpha gradient that's darkest top + bottom — keeps the photo's
  158. midsection visible while making wordmark / year / footer text legible."""
  159. overlay = Image.new("RGBA", (POSTER_W, POSTER_H), (0, 0, 0, 0))
  160. ovr = overlay.load()
  161. for y in range(POSTER_H):
  162. if y < 500:
  163. # Strong dim at top for the wordmark + year, fading to nothing at y=500
  164. alpha = 220 - (y * 220 // 500)
  165. elif y > 1100:
  166. # Strong dim at bottom for "WORLD CHAMPIONSHIP" footer
  167. alpha = (y - 1100) * 220 // (POSTER_H - 1100)
  168. alpha = min(alpha, 220)
  169. else:
  170. # Mild dim through the middle to anchor text contrast
  171. alpha = 60
  172. for x in range(POSTER_W):
  173. ovr[x, y] = (0, 0, 0, alpha)
  174. return overlay
  175. def render_season_poster(self, season: int) -> bytes:
  176. cache_path = self.cache_dir / f"season-{season}.png"
  177. if cache_path.exists():
  178. return cache_path.read_bytes()
  179. bg = self._fetch_season_background(season)
  180. if bg is None:
  181. # Fallback to the all-black layout if the background isn't available.
  182. img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
  183. else:
  184. # Composite the dimming gradient over the hero photo for text legibility.
  185. img = Image.alpha_composite(bg.convert("RGBA"), self._vertical_dark_gradient()).convert("RGB")
  186. draw = ImageDraw.Draw(img)
  187. self._checker_strip(draw, 0)
  188. self._centered(draw, "FORMULA 1", y=110, size=88, fill=RED)
  189. self._centered(draw, str(season), y=240, size=240, fill=WHITE)
  190. self._centered(draw, "WORLD CHAMPIONSHIP", y=POSTER_H - 200, size=48, fill=WHITE)
  191. self._checker_strip(draw, POSTER_H - CHECKER_H)
  192. buf = io.BytesIO()
  193. img.save(buf, "PNG", optimize=True)
  194. data = buf.getvalue()
  195. cache_path.write_bytes(data)
  196. return data