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

184 строки
6.8KB

  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. from pathlib import Path
  15. from typing import Optional
  16. import cairosvg
  17. from PIL import Image, ImageDraw, ImageFont
  18. from circuits_service import CircuitService
  19. from ergast_service import ErgastService
  20. logger = logging.getLogger(__name__)
  21. POSTER_W, POSTER_H = 1000, 1500
  22. BG = (10, 10, 12)
  23. RED = (220, 18, 32) # F1 red
  24. WHITE = (245, 245, 245)
  25. GREY = (160, 160, 170)
  26. CHECKER_H = 60
  27. CHECKER_SQ = 30
  28. # Slim-image-friendly font candidates. The Dockerfile installs fonts-dejavu-core
  29. # so DejaVuSans-Bold is always present; the others are fallbacks for local dev.
  30. FONT_CANDIDATES = [
  31. "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
  32. "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
  33. "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
  34. ]
  35. class PostersService:
  36. def __init__(self, circuits: CircuitService, ergast: ErgastService):
  37. self.circuits = circuits
  38. self.ergast = ergast
  39. self.font_path = next((p for p in FONT_CANDIDATES if Path(p).exists()), None)
  40. if not self.font_path:
  41. logger.warning("No bundled font found — poster text will use PIL's default bitmap font")
  42. self.cache_dir = Path("cache/posters")
  43. self.cache_dir.mkdir(parents=True, exist_ok=True)
  44. # ------------------------------------------------------------------ utils
  45. def _font(self, size: int) -> ImageFont.FreeTypeFont:
  46. if self.font_path:
  47. return ImageFont.truetype(self.font_path, size)
  48. return ImageFont.load_default()
  49. def _centered(self, draw: ImageDraw.ImageDraw, text: str, y: int, size: int, fill=WHITE) -> int:
  50. """Draw text centered horizontally; returns the drawn height."""
  51. font = self._font(size)
  52. # Shrink-to-fit if too wide
  53. while size > 18 and draw.textlength(text, font) > POSTER_W - 80:
  54. size -= 4
  55. font = self._font(size)
  56. w = draw.textlength(text, font)
  57. draw.text(((POSTER_W - w) / 2, y), text, font=font, fill=fill)
  58. bbox = draw.textbbox((0, 0), text, font=font)
  59. return bbox[3] - bbox[1]
  60. def _checker_strip(self, draw: ImageDraw.ImageDraw, y: int, height: int = CHECKER_H, sq: int = CHECKER_SQ):
  61. for col, x in enumerate(range(0, POSTER_W + sq, sq)):
  62. for row, yy in enumerate(range(y, y + height, sq)):
  63. fill = WHITE if (col + row) % 2 == 0 else BG
  64. draw.rectangle([x, yy, x + sq, yy + sq], fill=fill)
  65. def _rasterise_track(self, svg_path: Path, target_w: int = 700) -> Optional[Image.Image]:
  66. try:
  67. png_bytes = cairosvg.svg2png(
  68. bytestring=svg_path.read_bytes(),
  69. output_width=target_w,
  70. )
  71. except Exception as e:
  72. logger.error(f"Failed to rasterise {svg_path}: {e}")
  73. return None
  74. track = Image.open(io.BytesIO(png_bytes)).convert("RGBA")
  75. # Recolour all opaque pixels (black stroke in source) → white
  76. px = track.load()
  77. for y in range(track.height):
  78. for x in range(track.width):
  79. r, g, b, a = px[x, y]
  80. if a > 0:
  81. px[x, y] = (WHITE[0], WHITE[1], WHITE[2], a)
  82. return track
  83. # ------------------------------------------------------------------ race poster
  84. def render_race_poster(self, grand_prix_name: str, season: int) -> Optional[bytes]:
  85. races = self.ergast._fetch_season_schedule(season)
  86. race = next((r for r in races if r["raceName"] == grand_prix_name), None)
  87. if not race:
  88. logger.error(f"Race not found: {grand_prix_name} {season}")
  89. return None
  90. round_num = int(race["round"])
  91. date = race.get("date", "")
  92. layout = self.circuits.get_circuit_layout_by_ergast_data(grand_prix_name, season)
  93. if not layout:
  94. logger.error(f"Layout not resolved for {grand_prix_name} {season}")
  95. return None
  96. cache_key = f"{season}-{round_num:02d}-{layout.slug}.png"
  97. cache_path = self.cache_dir / cache_key
  98. if cache_path.exists():
  99. return cache_path.read_bytes()
  100. img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
  101. draw = ImageDraw.Draw(img)
  102. # Top checker
  103. self._checker_strip(draw, 0)
  104. # FORMULA 1 wordmark
  105. self._centered(draw, "FORMULA 1", y=110, size=88, fill=RED)
  106. # Round / Year ribbon
  107. self._centered(draw, f"ROUND {round_num:02d} / {season}", y=220, size=34, fill=GREY)
  108. # GP name (drop "Grand Prix" suffix; render "GRAND PRIX" smaller below)
  109. name_short = grand_prix_name.replace("Grand Prix", "").strip().upper()
  110. self._centered(draw, name_short, y=300, size=110, fill=WHITE)
  111. self._centered(draw, "GRAND PRIX", y=440, size=44, fill=GREY)
  112. # Track outline
  113. svg_path = Path("circuits") / layout.relative_svg_filepath
  114. if svg_path.exists():
  115. track = self._rasterise_track(svg_path, target_w=720)
  116. if track:
  117. # Centre between y=560 and y=1250
  118. slot_top, slot_bottom = 560, 1250
  119. tx = (POSTER_W - track.width) // 2
  120. ty = slot_top + ((slot_bottom - slot_top) - track.height) // 2
  121. img.paste(track, (tx, ty), track)
  122. # Date
  123. if date:
  124. self._centered(draw, date, y=POSTER_H - 170, size=32, fill=WHITE)
  125. # Bottom checker
  126. self._checker_strip(draw, POSTER_H - CHECKER_H)
  127. buf = io.BytesIO()
  128. img.save(buf, "PNG", optimize=True)
  129. data = buf.getvalue()
  130. cache_path.write_bytes(data)
  131. return data
  132. # ------------------------------------------------------------------ season poster
  133. def render_season_poster(self, season: int) -> bytes:
  134. cache_path = self.cache_dir / f"season-{season}.png"
  135. if cache_path.exists():
  136. return cache_path.read_bytes()
  137. img = Image.new("RGB", (POSTER_W, POSTER_H), BG)
  138. draw = ImageDraw.Draw(img)
  139. self._checker_strip(draw, 0)
  140. self._centered(draw, "FORMULA 1", y=240, size=120, fill=RED)
  141. self._centered(draw, str(season), y=440, size=420, fill=WHITE)
  142. self._centered(draw, "WORLD CHAMPIONSHIP", y=1100, size=56, fill=GREY)
  143. self._checker_strip(draw, POSTER_H - CHECKER_H)
  144. buf = io.BytesIO()
  145. img.save(buf, "PNG", optimize=True)
  146. data = buf.getvalue()
  147. cache_path.write_bytes(data)
  148. return data