mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
* 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>
243 lines
11 KiB
Markdown
243 lines
11 KiB
Markdown
# Creating Skins
|
||
|
||
A skin restyles a sports scoreboard (live / recent / upcoming) without
|
||
forking the plugin: the plugin keeps fetching data, scheduling, caching, and
|
||
doing vegas mode; your skin only draws. Architecture background:
|
||
[SKIN_SYSTEM.md](SKIN_SYSTEM.md).
|
||
|
||
## Quick start
|
||
|
||
```bash
|
||
cp -r skins/example-classic-baseball skins/my-skin
|
||
# edit skins/my-skin/skin.json -> set id ("my-skin"), name, author, class_name
|
||
# edit skins/my-skin/skin.py -> rename the class, start restyling
|
||
python scripts/validate_skin.py --skin my-skin
|
||
```
|
||
|
||
The validator renders your skin against bundled fixture games at several
|
||
panel sizes with **no hardware, no network, no running service**, saves PNGs
|
||
(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate:
|
||
edit → validate → look at the PNGs.
|
||
|
||
To see it on your matrix, add to your plugin's section in `config/config.json`:
|
||
|
||
```json
|
||
"baseball-scoreboard": {
|
||
"skin": "my-skin",
|
||
"skin_options": { }
|
||
}
|
||
```
|
||
|
||
or pick it from the **Visual Skin** dropdown in the web UI (it appears once a
|
||
matching skin is installed). `"skin"` also accepts a per-mode mapping:
|
||
`{"live": "my-skin", "recent": "built-in"}`.
|
||
|
||
## The manifest (`skin.json`)
|
||
|
||
```json
|
||
{
|
||
"id": "my-skin",
|
||
"name": "My Skin",
|
||
"version": "1.0.0",
|
||
"author": "you",
|
||
"description": "What it looks like",
|
||
"skin_api_version": "1.0.0",
|
||
"targets": {
|
||
"sports": ["baseball"],
|
||
"sport_keys": ["mlb", "milb"],
|
||
"plugins": []
|
||
},
|
||
"entry_point": "skin.py",
|
||
"class_name": "MySkin",
|
||
"modes": ["live", "recent", "upcoming"],
|
||
"preview": "preview.png"
|
||
}
|
||
```
|
||
|
||
Field notes: `id` must equal the directory name; `skin_api_version`'s major
|
||
version must match the host's `SKIN_API_VERSION` or the skin is refused at
|
||
load; `targets` takes sport families (`sports`), exact sport keys
|
||
(`sport_keys`), and/or exact plugin ids (`plugins`) — any match applies.
|
||
|
||
## The renderer (`skin.py`)
|
||
|
||
```python
|
||
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
|
||
|
||
class MySkin(ScoreboardSkin):
|
||
def render_live(self, ctx: SkinContext, game: dict) -> bool:
|
||
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
||
fit = ctx.layout.fit_text(score, ctx.layout.bounds)
|
||
ctx.draw_fit(fit, ctx.layout.bounds)
|
||
return True # True = "I drew it"; False = use the built-in layout
|
||
```
|
||
|
||
Implement only the modes you care about — anything else falls back to the
|
||
plugin's built-in rendering. Return `False` to decline a specific game (e.g.
|
||
a layout that only makes sense while a game is live).
|
||
|
||
### The rules (they keep your skin from breaking the display)
|
||
|
||
1. **Draw only onto `ctx.canvas`** (via the helpers or `ctx.draw`). Never
|
||
reassign `ctx.canvas`, never touch the display or call any update method.
|
||
2. **No I/O in render paths.** No network, no file loads per frame —
|
||
`render_live` runs every display pass, and a slow render stalls the whole
|
||
matrix (the host warns at >150 ms). Use `ctx.load_logo` (cached) and
|
||
`cache_key=` for images.
|
||
3. **Derive everything from `(ctx, game)`.** Skins must be stateless: the
|
||
live/recent/upcoming modes each get their own instance.
|
||
4. **Always `.get()` optional keys.** Only the guaranteed keys below are
|
||
promised to exist.
|
||
5. **Never hardcode pixel positions for the panel.** Use `ctx.width`/
|
||
`ctx.height`, `ctx.layout` regions and `fit_text` — your skin will be run
|
||
at sizes you didn't test (64x32, 128x64, vegas cards).
|
||
6. **No third-party dependencies.** Stdlib + PIL + what `ctx` provides.
|
||
|
||
A skin that raises 3 renders in a row is disabled until the service restarts
|
||
(the built-in layout takes over), so a bug is cosmetic — but check your logs.
|
||
|
||
## SkinContext reference
|
||
|
||
| Member | What it is |
|
||
|---|---|
|
||
| `ctx.canvas` / `ctx.draw` | Fresh RGB `PIL.Image` at display size + its `ImageDraw` (raw-PIL escape hatch) |
|
||
| `ctx.width`, `ctx.height` | Canvas size — the only size truth |
|
||
| `ctx.layout` | `LayoutContext` (see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)): `bounds`, `fit_text`, `fit_text_proportional`, `fit_image`, `px`, `by_tier` |
|
||
| `ctx.draw_fit(fit, box, color, align, valign)` | Draw a `fit_text` result aligned in a `Region` (handles BDF fonts) |
|
||
| `ctx.draw_text(text, x, y, color, font)` | Positioned text (handles BDF fonts) |
|
||
| `ctx.draw_image(img, box, mode, align, valign, cache_key)` | Fit + paste an image with alpha; no-ops on `None` |
|
||
| `ctx.load_logo("home" \| "away")` | Team logo as RGBA, or `None` (always handle `None`). Cached after first use; see note below |
|
||
| `ctx.draw_text_outlined(text, (x, y), font, fill, outline_color)` | The classic scorebug outlined text (TTF fonts only) |
|
||
| `ctx.fonts` | The host's font dict — keys `score`, `time`, `team`, `status`, `detail`, `rank` |
|
||
| `ctx.options` | Your user's `skin_options` from config |
|
||
| `ctx.sport`, `ctx.view_model_version`, `ctx.logger` | Context metadata + logger |
|
||
|
||
**A note on `ctx.load_logo` vs the no-I/O rule:** `load_logo` is the one
|
||
sanctioned exception. It goes through the host's logo cache — after the
|
||
first call per team it's a pure in-memory lookup. If a logo file is missing
|
||
on disk, the *first* call may download it, exactly like the built-in
|
||
renderer does for the same game (a skin is never worse than built-in here).
|
||
Always pass a stable `cache_key` when drawing it, never load image files
|
||
yourself in a render path, and always handle `None`.
|
||
|
||
The default layout idiom — carve regions, then fit text into them:
|
||
|
||
```python
|
||
from src.adaptive_layout import scoreboard_regions
|
||
|
||
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')}")
|
||
fit = ctx.layout.fit_text("3-5", regions.score_area)
|
||
ctx.draw_fit(fit, regions.score_area)
|
||
```
|
||
|
||
`Region` supports `split_h`/`split_v`/`inset`/`top_band`/`bottom_band`/
|
||
`left_col`/`right_col` for custom carves. Raw `ctx.draw.rectangle/polygon/
|
||
ellipse/...` is always available for custom marks (see the bases diamond in
|
||
the example skin).
|
||
|
||
## The game view model
|
||
|
||
Guaranteed for every sport (view model v1.0 — renaming these breaks skins and
|
||
is treated as a breaking change upstream):
|
||
|
||
| Key | Notes |
|
||
|---|---|
|
||
| `id` | Event id (string) |
|
||
| `status_text` | Display-ready status, e.g. `"Final"`, `"7:30 PM"`, `"Bot 7th"` |
|
||
| `is_live`, `is_final`, `is_upcoming`, `is_halftime` | Booleans |
|
||
| `game_date`, `game_time` | Pre-formatted local date/time strings |
|
||
| `start_time_utc` | UTC `datetime` |
|
||
| `home_abbr`, `away_abbr` | Team abbreviations (can be 2–5 chars — fit, don't assume) |
|
||
| `home_id`, `away_id` | Team ids |
|
||
| `home_score`, `away_score` | **Strings**, not ints |
|
||
| `home_record`, `away_record` | `"58-33"` or `""` (0-0 records are blanked) |
|
||
| `home_logo_path`, `away_logo_path` | Prefer `ctx.load_logo` over touching these |
|
||
|
||
Sport extras (present for that sport, still `.get()` defensively):
|
||
|
||
- **baseball**: `inning` (int), `inning_half` (`"top"`/`"bottom"`), `balls`,
|
||
`strikes`, `outs` (ints), `bases_occupied` (`[first, second, third]`
|
||
booleans), `series_summary` (str)
|
||
- **football**: `period`, `period_text`, `clock`, `home_timeouts`,
|
||
`away_timeouts`, `down_distance_text`, `down_distance_text_long`,
|
||
`is_redzone`, `possession`, `possession_indicator` (`"home"`/`"away"`),
|
||
`scoring_event`
|
||
- **basketball**: `period`, `period_text`, `clock`
|
||
- **hockey**: `period`, `period_text`, `clock`, `power_play`, `penalties`,
|
||
`home_shots`, `away_shots`
|
||
|
||
Optional everywhere (only when the user enabled the feature): `odds` (dict),
|
||
`series_summary`, rankings-related fields.
|
||
|
||
Fixture copies of these dicts live in `src/skin_system/fixtures/` — that's
|
||
exactly what the validator feeds your skin.
|
||
|
||
## Vegas mode
|
||
|
||
You get vegas support for free: vegas captures the normal display output,
|
||
which is already your skin's rendering. Optionally implement
|
||
`render_vegas_card(ctx, game)` to return a purpose-built card at
|
||
`ctx.width x ctx.height` (sizes vary — never assume 128x32).
|
||
|
||
## Building a skin with Claude Code
|
||
|
||
Skins are ideal Claude Code projects: small, isolated, and verifiable with
|
||
one command. Paste this to start:
|
||
|
||
> You are building a **display skin** for LEDMatrix — a visual overlay for a
|
||
> sports scoreboard on a small LED matrix (commonly 128x32 or 64x32 pixels).
|
||
> First read `docs/CREATING_SKINS.md` and the reference skin in
|
||
> `skins/example-classic-baseball/`.
|
||
>
|
||
> Rules:
|
||
> - Create/modify files ONLY under `skins/<my-skin-id>/`. Do NOT modify
|
||
> anything in `src/`, `scripts/`, the plugins, or any other skin.
|
||
> - Render only from the `game` dict and `ctx` helpers. No network calls, no
|
||
> per-frame file I/O, no new pip dependencies, no touching the display —
|
||
> draw onto `ctx.canvas` and return True.
|
||
> - Use `ctx.layout` regions and `fit_text` for positioning so the skin works
|
||
> at any panel size; use `.get()` for every optional game key.
|
||
> - After every change run
|
||
> `python scripts/validate_skin.py --skin <my-skin-id>` and LOOK at the
|
||
> PNGs it writes to `skin_renders/` (the `_x4.png` files are easiest to
|
||
> read). Iterate until it passes and looks right at both 128x32 and 64x32.
|
||
>
|
||
> What I want it to look like: <describe your layout — where logos, score,
|
||
> status go; colors; what shows during live vs upcoming vs final>
|
||
|
||
Tips that keep Claude (and you) out of trouble:
|
||
|
||
- One mode at a time: get `render_live` right before touching the others —
|
||
unimplemented modes automatically use the built-in look.
|
||
- Ask for edge-case renders: long team abbreviations, missing logos
|
||
(`ctx.load_logo` returning `None`), 0-0 records, extra innings/OT.
|
||
- If the render looks cramped at 64x32, ask Claude to use
|
||
`ctx.layout.by_tier(...)` to drop elements on small panels rather than
|
||
shrinking everything.
|
||
- Never let it "fix" a problem by editing `src/` — if the skin can't do
|
||
something within its directory, that's a feature request, not a workaround.
|
||
|
||
## Pre-publish checklist
|
||
|
||
- [ ] `python scripts/validate_skin.py --skin <id> --size 128x32 --size 64x32 --size 128x64` passes
|
||
- [ ] Looked at every PNG in `skin_renders/` — nothing clipped or overlapping
|
||
- [ ] Handles a missing logo (`None`) without crashing — temporarily point a
|
||
fixture's logo path at a nonexistent file to test
|
||
- [ ] Long abbreviations (`"TA&M"`, 4–5 chars) don't overflow
|
||
- [ ] No render warning above the time budget
|
||
- [ ] `skin.json`: `id` matches the directory, `version` set,
|
||
`skin_api_version` matches the host, targets correct
|
||
- [ ] `preview.png` added (grab your favorite `_x4` render)
|
||
- [ ] Tested on real hardware if you have it — a Pi is much slower than your
|
||
dev machine
|
||
|
||
Distribute by publishing the directory as a git repo (users
|
||
`git clone <repo> skins/<id>`), or submit it to the plugin registry as an
|
||
entry with `"type": "skin"` (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
|
||
|
||
**Trust note:** a skin is Python running inside the display service — the
|
||
same trust level as a plugin. Review code before installing skins from
|
||
others.
|