mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* fix(display): pin one text layout engine, and give the 5x7 face a size Two ways a font could render differently on two machines running the same code, both found while diagnosing four plugins whose golden images passed on the machine that generated them and failed everywhere else. **Layout engine.** `ImageFont.truetype` picks its engine at load time: Raqm where the host Pillow was built with libraqm, Basic otherwise. The two round fractional glyph advances differently. `PressStart2P-Regular.ttf` at 8px has whole-pixel advances, so they agree — which is why most of the fleet matched everywhere and hid this. `4x6-font.ttf` at 6px does not: glyph positions drift cumulatively along a run, and the four plugins that draw body text in it (geochron, of-the-day, christmas-countdown, ledmatrix-weather's almanac) are exactly the four whose goldens travelled badly. Every core font load now goes through `src/common/font_layout.load_truetype`, which pins the Basic engine, so a render depends on the font file and the size and nothing else. Basic gives up complex-script shaping and kerning pairs; neither applies to bitmap-grid faces on an LED panel. Output is unchanged on a host without libraqm. **Zero font height.** `DisplayManager` built the 5x7 BDF face with `freetype.Face(path)` and never called `set_char_size`, so `face.size.height` stayed 0 and `get_font_height()` returned 0 for it — callers stacking rows by `prev_y + prev_height + gap` drew two lines on top of each other. The start-up line `Calendar font size: 0 pixels` has been printing the symptom all along. `font_manager._load_bdf_font` already called `set_char_size`, so whether measurement worked depended on which path loaded the face. `DisplayManager` now sets it too, and `get_font_height()` falls back to the strike the file declares rather than returning a zero line height. Fixes ChuckBuilds/ledmatrix-plugins#397 Refs ChuckBuilds/ledmatrix-plugins#371, #375, #378, #391 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(display): give the startup banner a rung that fits a full address at 64px CI caught what pinning the layout engine exposed rather than caused. `_fitting_font` walks PressStart2P then 4x6 at 6px, and "255.255.255.255" -- the widest thing the startup banner ever shows -- measures 66px at 4x6/6px against the 62 a 64x32 panel has to give. It used to squeak in only because the measurement depended on which layout engine the host Pillow happened to have; with the engine pinned it does not, so the rung the worst case actually needs is now in the ladder instead of implied: 4x6 at 5px, which measures 51. The fallback was wrong in the same place. When nothing in the ladder fit, it returned `self.font` -- the *widest* option, and precisely how "Initializing" came to run off the side of a 64px panel to begin with. It returns the narrowest face that loaded now. test/test_initializing_screen.py: 34 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(display): name the exceptions the BDF strike read can raise Codacy flagged the try/except/pass. It was already narrow in intent -- a malformed strike table on the measurement path must degrade to "size unknown" rather than take the display down -- but a bare `except Exception: pass` says neither of those things and hides a genuinely broken font behind a silent 8px fallback. It now catches what reading `available_sizes` can actually raise and logs which face failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore: drop logo PNGs the render harness downloaded into the worktree These are fetched at runtime by the logo cache; they are not source, and they rode in on a `git add -A` while I was running check_plugin.py against this branch. Nothing in the change needs them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
251 lines
10 KiB
Python
251 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Headless skin validator — render a skin against bundled fixture games at
|
|
multiple panel sizes without hardware, a network, or a running service.
|
|
|
|
python scripts/validate_skin.py --skin my-skin
|
|
python scripts/validate_skin.py --skin my-skin --sport baseball \
|
|
--size 128x32 --size 64x32 --output-dir /tmp/skin_renders
|
|
|
|
For each (mode x size) it checks: the manifest loads and its API version
|
|
matches, the render raises no exception, the canvas isn't blank, and the
|
|
render finishes inside a time budget (warn — the live renderer runs every
|
|
display-loop pass, and a Pi is far slower than your dev machine). PNGs are
|
|
saved (native plus 4x nearest-neighbor previews) so you can eyeball the
|
|
result. Exit code is non-zero when any check fails.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(PROJECT_ROOT))
|
|
|
|
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
|
|
|
from src.common.font_layout import load_truetype # noqa: E402
|
|
|
|
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
|
MODES = ("live", "recent", "upcoming")
|
|
SPORTS = ("baseball", "basketball", "football", "hockey")
|
|
RENDER_BUDGET_S = 0.100
|
|
|
|
|
|
class FixtureHost:
|
|
"""Stands in for a SportsCore instance: fonts, logger, logo loading,
|
|
outlined text — everything build_context needs, no network."""
|
|
|
|
def __init__(self, sport: str, skin_options: dict) -> None:
|
|
self.sport = sport
|
|
self.sport_key = sport
|
|
self.skin_options = skin_options
|
|
self.logger = logging.getLogger(f"validate_skin.{sport}")
|
|
self.fonts = self._load_fonts()
|
|
self._logo_cache = {}
|
|
self.display_manager = None # build_context is always given a size
|
|
|
|
def _load_fonts(self) -> dict:
|
|
"""Load the SportsCore font set (TTF, with PIL default fallback)."""
|
|
fonts = {}
|
|
try:
|
|
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
|
|
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
|
|
fonts['score'] = load_truetype(press, 10)
|
|
fonts['time'] = load_truetype(press, 8)
|
|
fonts['team'] = load_truetype(press, 8)
|
|
fonts['status'] = load_truetype(small, 6)
|
|
fonts['detail'] = load_truetype(small, 6)
|
|
fonts['rank'] = load_truetype(press, 10)
|
|
except IOError:
|
|
default = ImageFont.load_default()
|
|
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
|
|
fonts[key] = default
|
|
return fonts
|
|
|
|
def _load_and_resize_logo(self, team_id: str, team_abbrev: str,
|
|
logo_path, logo_url) -> "Image.Image | None":
|
|
"""Load a fixture logo from disk (no downloads), cached per team."""
|
|
if team_abbrev in self._logo_cache:
|
|
return self._logo_cache[team_abbrev]
|
|
path = Path(logo_path)
|
|
if not path.is_absolute():
|
|
path = PROJECT_ROOT / path
|
|
if not path.exists():
|
|
return None
|
|
logo = Image.open(path).convert('RGBA')
|
|
self._logo_cache[team_abbrev] = logo
|
|
return logo
|
|
|
|
def _draw_text_with_outline(self, draw: "ImageDraw.ImageDraw", text: str,
|
|
position: tuple, font,
|
|
fill: tuple = (255, 255, 255),
|
|
outline_color: tuple = (0, 0, 0)) -> None:
|
|
"""Classic outlined scorebug text, same as SportsCore's helper."""
|
|
x, y = position
|
|
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1),
|
|
(1, -1), (1, 0), (1, 1)]:
|
|
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
|
draw.text((x, y), text, font=font, fill=fill)
|
|
|
|
|
|
def load_fixture(sport: str, mode: str) -> dict:
|
|
with open(FIXTURES_DIR / f"{sport}_{mode}.json", encoding="utf-8") as f:
|
|
game = json.load(f)
|
|
# Real view models carry start_time_utc as a UTC datetime, not a string.
|
|
if isinstance(game.get("start_time_utc"), str):
|
|
from datetime import datetime
|
|
game["start_time_utc"] = datetime.fromisoformat(game["start_time_utc"])
|
|
return game
|
|
|
|
|
|
def parse_size(value: str) -> "tuple[int, int]":
|
|
try:
|
|
w_text, h_text = value.lower().split("x")
|
|
w, h = int(w_text), int(h_text)
|
|
except ValueError as exc:
|
|
raise argparse.ArgumentTypeError(f"size must look like 128x32, got {value!r}") from exc
|
|
if w <= 0 or h <= 0:
|
|
raise argparse.ArgumentTypeError(f"size dimensions must be positive, got {value!r}")
|
|
return w, h
|
|
|
|
|
|
def parse_options(value: str) -> dict:
|
|
try:
|
|
options = json.loads(value)
|
|
except json.JSONDecodeError as exc:
|
|
raise argparse.ArgumentTypeError(f"options must be valid JSON: {exc.msg}") from exc
|
|
if not isinstance(options, dict):
|
|
raise argparse.ArgumentTypeError("options must be a JSON object")
|
|
return options
|
|
|
|
|
|
def display_path(path: Path) -> str:
|
|
"""Repo-relative when inside the repo, absolute otherwise (--output-dir
|
|
may point anywhere, e.g. /tmp/skin_renders)."""
|
|
try:
|
|
return str(path.relative_to(PROJECT_ROOT))
|
|
except ValueError:
|
|
return str(path)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--skin", required=True, help="skin id (directory name under skins/)")
|
|
parser.add_argument("--sport", choices=SPORTS,
|
|
help="fixture sport (default: first sport the skin targets, else baseball)")
|
|
parser.add_argument("--size", action="append", type=parse_size, dest="sizes",
|
|
metavar="WxH", help="panel size to render at (repeatable; default 128x32 and 64x32)")
|
|
parser.add_argument("--output-dir", type=Path,
|
|
default=PROJECT_ROOT / "skin_renders",
|
|
help="where rendered PNGs are written")
|
|
parser.add_argument("--options", type=parse_options, default={},
|
|
help="skin_options JSON to pass the skin")
|
|
args = parser.parse_args()
|
|
sizes = args.sizes or [(128, 32), (64, 32)]
|
|
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
|
|
from src.skin_system import skin_runtime
|
|
from src.skin_system.skin_base import SKIN_API_VERSION
|
|
|
|
skins = skin_runtime.discover_skins()
|
|
manifest = skins.get(args.skin)
|
|
if manifest is None:
|
|
print(f"FAIL: skin '{args.skin}' not found under {skin_runtime.get_skins_directory()}")
|
|
if skins:
|
|
print(f" installed skins: {', '.join(sorted(skins))}")
|
|
return 1
|
|
|
|
sport = args.sport
|
|
if sport is None:
|
|
declared = skin_runtime.skin_targets(manifest)[0]
|
|
sport = next((s for s in declared if s in SPORTS), "baseball")
|
|
|
|
skin = skin_runtime.load_skin(args.skin, sport=sport, sport_key=sport,
|
|
options=args.options)
|
|
if skin is None:
|
|
print(f"FAIL: skin '{args.skin}' did not load "
|
|
f"(see log above; host API is {SKIN_API_VERSION})")
|
|
return 1
|
|
|
|
host = FixtureHost(sport, args.options)
|
|
args.output_dir.mkdir(parents=True, exist_ok=True)
|
|
failures = 0
|
|
rendered = 0
|
|
|
|
for mode in MODES:
|
|
game = load_fixture(sport, mode)
|
|
render = getattr(skin, f"render_{mode}")
|
|
for width, height in sizes:
|
|
label = f"{mode}@{width}x{height}"
|
|
try:
|
|
# Warm-up render absorbs one-time font/image loads, second
|
|
# render is the one timed against the budget.
|
|
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
|
handled = render(ctx, dict(game))
|
|
if handled:
|
|
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
|
started = time.monotonic()
|
|
handled = render(ctx, dict(game))
|
|
elapsed = time.monotonic() - started
|
|
else:
|
|
elapsed = 0.0
|
|
except Exception as e:
|
|
print(f"FAIL {label}: render raised {type(e).__name__}: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
failures += 1
|
|
continue
|
|
|
|
if not handled:
|
|
print(f"skip {label}: render_{mode} returned False (built-in renderer would be used)")
|
|
continue
|
|
|
|
if ctx.canvas.size != (width, height):
|
|
print(f"FAIL {label}: canvas was replaced/resized to {ctx.canvas.size} — draw onto ctx.canvas, never reassign it")
|
|
failures += 1
|
|
continue
|
|
if ctx.canvas.convert("L").getbbox() is None:
|
|
print(f"FAIL {label}: canvas is blank — render returned True but drew nothing")
|
|
failures += 1
|
|
continue
|
|
if elapsed > RENDER_BUDGET_S:
|
|
print(f"WARN {label}: render took {elapsed * 1000:.0f}ms "
|
|
f"(budget {RENDER_BUDGET_S * 1000:.0f}ms; a Pi is much slower than this machine)")
|
|
|
|
out = args.output_dir / f"{args.skin}_{sport}_{mode}_{width}x{height}.png"
|
|
ctx.canvas.save(out)
|
|
preview = ctx.canvas.resize((width * 4, height * 4), Image.NEAREST)
|
|
preview.save(out.with_name(out.stem + "_x4.png"))
|
|
print(f"ok {label}: {elapsed * 1000:.0f}ms -> {display_path(out)}")
|
|
rendered += 1
|
|
|
|
# Vegas card, once per mode at the first size (optional API)
|
|
try:
|
|
width, height = sizes[0]
|
|
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
|
card = skin.render_vegas_card(ctx, dict(game))
|
|
if card is not None:
|
|
out = args.output_dir / f"{args.skin}_{sport}_{mode}_vegas.png"
|
|
card.save(out)
|
|
print(f"ok {mode} vegas card -> {display_path(out)}")
|
|
except Exception as e:
|
|
print(f"FAIL {mode} vegas card: {type(e).__name__}: {e}")
|
|
failures += 1
|
|
|
|
if rendered == 0 and failures == 0:
|
|
print(f"FAIL: skin '{args.skin}' rendered nothing — no render_<mode> returned True")
|
|
return 1
|
|
print(f"\n{'FAILED' if failures else 'PASSED'}: {rendered} renders, {failures} failures "
|
|
f"(PNGs in {args.output_dir})")
|
|
return 1 if failures else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|