mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Add skin system: user-installable visual overlays for sports scoreboards (#419)
* Add skin system: user-installable visual overlays for sports scoreboards Skins restyle a scoreboard's live/recent/upcoming rendering while the host plugin keeps doing data fetching, scheduling, caching, live priority, and vegas mode — the anti-fork alternative for users who only want a different layout. - src/skin_system/: ScoreboardSkin API, SkinContext (canvas + adaptive layout + logo/font helpers), discovery/loading runtime with API major version gating and per-skin module namespacing - src/base_classes/sports.py: _render_game() seam at the three _draw_scorebug_layout call sites; skin-first with built-in fallback, 3-strikes session disable, slow-render warning; per-mode skin config - scripts/validate_skin.py: headless multi-mode/multi-size validator with bundled per-sport fixtures (no hardware or network needed) - skins/example-classic-baseball/: working reference skin - Web UI: served-schema Visual Skin dropdown (validation never enum-restricted, so uninstalled skins can't invalidate configs) and GET /api/v3/skins - Store: registry entries with type "skin" install to skins/ - docs/SKIN_SYSTEM.md (architecture), docs/CREATING_SKINS.md (author guide incl. Claude Code prompt), view-model contract locked by tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrCusPasy1qeUN5anK3aA1 * Address review feedback on skin system - skin_runtime: cache the entry module so the 2nd/3rd load of the same skin (live/recent/upcoming hosts) doesn't re-execute it with unbound sibling aliases; rebind cached sibling modules to their bare names around entry import and restore prior bindings after; include per- manifest mtimes in the discovery cache fingerprint so in-place skin updates are picked up - sports.py: count render_skin_card exceptions toward the 3-strike session disable - store_manager: validate skin ids (pattern + resolved-path containment in skins/), reject registry/manifest id mismatches, and stage+validate downloads in a temp sibling before replacing an existing skin - schema_manager: leave the schema untouched when the configured skin value is a per-mode mapping (a string dropdown could overwrite it) - validate_skin.py: reject non-positive sizes and non-object --options at parse time; support --output-dir outside the repo; type annotations - example skin: validate accent_color once at load with logged fallback - fixtures: pregame 0-0 scores in football/hockey upcoming fixtures - api /skins: rely on the self-invalidating discovery cache instead of force_refresh - docs: valid JSON manifest example, load_logo caching semantics spelled out, language ids on fenced blocks - tests: view-model contract test now exercises the real extractor; regression test for repeated same-skin loads with sibling modules Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrCusPasy1qeUN5anK3aA1 --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"id": "example-classic-baseball",
|
||||
"name": "Example: Classic Baseball",
|
||||
"version": "1.0.0",
|
||||
"author": "LEDMatrix",
|
||||
"description": "Reference skin: a restyled baseball scorebug demonstrating the skin API. Copy this directory to start your own skin.",
|
||||
"skin_api_version": "1.0.0",
|
||||
"targets": {
|
||||
"sports": [
|
||||
"baseball"
|
||||
],
|
||||
"sport_keys": [
|
||||
"mlb",
|
||||
"milb"
|
||||
]
|
||||
},
|
||||
"entry_point": "skin.py",
|
||||
"class_name": "ClassicBaseballSkin",
|
||||
"modes": [
|
||||
"live",
|
||||
"recent",
|
||||
"upcoming"
|
||||
],
|
||||
"preview": "preview.png"
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Example: Classic Baseball — the reference skin.
|
||||
|
||||
Shows the whole skin API surface on purpose: adaptive regions
|
||||
(scoreboard_regions), fitted text (ctx.layout.fit_text + ctx.draw_fit),
|
||||
logos (ctx.load_logo + ctx.draw_image), raw PIL (ctx.draw for the bases
|
||||
diamond), and per-user options (ctx.options). Everything is derived from
|
||||
ctx and the game dict — a skin holds no state, does no I/O, and never
|
||||
touches the display.
|
||||
|
||||
Copy this directory to skins/<your-skin-id>/, rename the class and the
|
||||
manifest fields, and run:
|
||||
|
||||
python scripts/validate_skin.py --skin <your-skin-id>
|
||||
"""
|
||||
|
||||
from src.adaptive_layout import LADDER_GRID, scoreboard_regions
|
||||
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
|
||||
|
||||
DEFAULT_ACCENT = (255, 200, 0)
|
||||
|
||||
|
||||
class ClassicBaseballSkin(ScoreboardSkin):
|
||||
"""Reference baseball skin: classic scorebug with bases/outs/count."""
|
||||
|
||||
def __init__(self, manifest: dict, options: dict):
|
||||
super().__init__(manifest, options)
|
||||
# Validate user options once at load time (fail fast, fall back
|
||||
# gracefully) rather than surprising every render.
|
||||
accent = self.options.get("accent_color", DEFAULT_ACCENT)
|
||||
if (isinstance(accent, (list, tuple)) and len(accent) == 3
|
||||
and all(isinstance(c, int) and 0 <= c <= 255 for c in accent)):
|
||||
self._accent_color = tuple(accent)
|
||||
else:
|
||||
import logging
|
||||
logging.getLogger(__name__).error(
|
||||
"accent_color must be three 0-255 integers, got %r; using default", accent)
|
||||
self._accent_color = DEFAULT_ACCENT
|
||||
|
||||
# -- shared pieces ----------------------------------------------------
|
||||
|
||||
def _accent(self, ctx: SkinContext) -> tuple:
|
||||
"""Users can recolor the skin from config via skin_options."""
|
||||
return self._accent_color
|
||||
|
||||
def _draw_card(self, ctx: SkinContext, game: dict, status: str,
|
||||
center_lines: list, detail: str) -> None:
|
||||
"""The common card: logos left/right, status on top, the given
|
||||
center content, detail along the bottom."""
|
||||
regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout)
|
||||
|
||||
ctx.draw_image(ctx.load_logo("away"), regions.away_slot,
|
||||
cache_key=f"logo:{game.get('away_abbr')}")
|
||||
ctx.draw_image(ctx.load_logo("home"), regions.home_slot,
|
||||
cache_key=f"logo:{game.get('home_abbr')}")
|
||||
|
||||
if status:
|
||||
fit = ctx.layout.fit_text(status, regions.status_band, LADDER_GRID)
|
||||
ctx.draw_fit(fit, regions.status_band, color=self._accent(ctx))
|
||||
|
||||
if center_lines:
|
||||
rows = regions.score_area.split_v(*[1] * len(center_lines))
|
||||
for line, row in zip(center_lines, rows):
|
||||
if line:
|
||||
fit = ctx.layout.fit_text(line, row, LADDER_GRID)
|
||||
ctx.draw_fit(fit, row)
|
||||
|
||||
if detail:
|
||||
fit = ctx.layout.fit_text(detail, regions.detail_band, LADDER_GRID)
|
||||
ctx.draw_fit(fit, regions.detail_band, color=(160, 160, 160))
|
||||
|
||||
def _draw_bases_and_outs(self, ctx: SkinContext, game: dict) -> None:
|
||||
"""Raw-PIL escape hatch: a bases diamond + out dots in the bottom
|
||||
band, sized from the layout scale so it works on any panel."""
|
||||
size = ctx.layout.px(3, minimum=2) # half-diagonal of one base
|
||||
gap = ctx.layout.px(1)
|
||||
cx = ctx.width // 2
|
||||
cy = ctx.height - (size * 2) - 1
|
||||
|
||||
bases = game.get("bases_occupied") or [False, False, False]
|
||||
# (dx, dy) per base: first (right), second (top), third (left)
|
||||
offsets = [(size + gap, 0), (0, -(size + gap)), (-(size + gap), 0)]
|
||||
for occupied, (dx, dy) in zip(bases, offsets):
|
||||
x, y = cx + dx, cy + dy
|
||||
diamond = [(x, y - size), (x + size, y), (x, y + size), (x - size, y)]
|
||||
if occupied:
|
||||
ctx.draw.polygon(diamond, fill=self._accent(ctx))
|
||||
else:
|
||||
ctx.draw.polygon(diamond, outline=(110, 110, 110))
|
||||
|
||||
outs = min(int(game.get("outs") or 0), 3)
|
||||
r = max(1, size - 1)
|
||||
for i in range(3):
|
||||
x = cx + (i - 1) * (2 * r + 2 * gap)
|
||||
y = ctx.height - r - 1
|
||||
dot = [x - r, y - r, x + r, y + r]
|
||||
if i < outs:
|
||||
ctx.draw.ellipse(dot, fill=(255, 255, 255))
|
||||
else:
|
||||
ctx.draw.ellipse(dot, outline=(110, 110, 110))
|
||||
|
||||
# -- the three modes --------------------------------------------------
|
||||
|
||||
def render_live(self, ctx: SkinContext, game: dict) -> bool:
|
||||
half = "▲" if game.get("inning_half") == "top" else "▼"
|
||||
inning = game.get("inning") or ""
|
||||
status = f"{half}{inning}" if inning else game.get("status_text", "")
|
||||
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
||||
count = f"{game.get('balls', 0)}-{game.get('strikes', 0)}"
|
||||
|
||||
self._draw_card(ctx, game, status, [score], "")
|
||||
self._draw_bases_and_outs(ctx, game)
|
||||
|
||||
# Ball-strike count in the top-left corner, over the away logo.
|
||||
fit = ctx.layout.fit_text(count, (ctx.width // 4, ctx.layout.px(8, minimum=6)), LADDER_GRID)
|
||||
ctx.draw_fit(fit, ctx.layout.bounds.top_band(fit.height + 1).left_col(fit.width + 2),
|
||||
color=(200, 200, 200))
|
||||
return True
|
||||
|
||||
def render_recent(self, ctx: SkinContext, game: dict) -> bool:
|
||||
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
||||
self._draw_card(ctx, game, game.get("status_text", "Final"),
|
||||
[score], game.get("series_summary", ""))
|
||||
return True
|
||||
|
||||
def render_upcoming(self, ctx: SkinContext, game: dict) -> bool:
|
||||
matchup = f"{game.get('away_abbr', '')}@{game.get('home_abbr', '')}"
|
||||
self._draw_card(ctx, game, game.get("game_date", ""),
|
||||
[matchup, game.get("game_time", "")],
|
||||
f"{game.get('away_record', '')} {game.get('home_record', '')}".strip())
|
||||
return True
|
||||
Reference in New Issue
Block a user