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 (#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:
+110
-3
@@ -29,6 +29,10 @@ except ImportError:
|
||||
|
||||
|
||||
class SportsCore(ABC):
|
||||
# Which ScoreboardSkin render method this class's display path maps to.
|
||||
# SportsLive inherits the default; SportsUpcoming/SportsRecent override.
|
||||
SKIN_MODE = "live"
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||
self.logger = logger
|
||||
self.config = config
|
||||
@@ -99,6 +103,17 @@ class SportsCore(ABC):
|
||||
self.last_update = 0
|
||||
self.current_game = None
|
||||
self.fonts = self._load_fonts()
|
||||
|
||||
# Optional visual skin (see docs/SKIN_SYSTEM.md). "skin" is either a
|
||||
# skin id applied to all modes, or a per-mode mapping like
|
||||
# {"live": "retro", "recent": "built-in"}. Loaded lazily on first
|
||||
# render so a broken skin can never block startup.
|
||||
self._skin_config = self.mode_config.get("skin")
|
||||
self.skin_options = self.mode_config.get("skin_options", {}) or {}
|
||||
self._skin = None
|
||||
self._skin_load_attempted = False
|
||||
self._skin_failures = 0
|
||||
self._skin_slow_renders = 0
|
||||
|
||||
# Initialize dynamic team resolver and resolve favorite teams
|
||||
self.dynamic_resolver = DynamicTeamResolver()
|
||||
@@ -205,6 +220,95 @@ class SportsCore(ABC):
|
||||
self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True)
|
||||
|
||||
|
||||
def _resolve_skin_id(self) -> Optional[str]:
|
||||
"""The skin id configured for this instance's mode, or None for the
|
||||
built-in renderer. Accepts a plain id (all modes) or a per-mode
|
||||
mapping ({"live": "retro-baseball", "recent": "built-in"})."""
|
||||
skin_id = self._skin_config
|
||||
if isinstance(skin_id, dict):
|
||||
skin_id = skin_id.get(self.SKIN_MODE)
|
||||
if not skin_id or not isinstance(skin_id, str) or skin_id == "built-in":
|
||||
return None
|
||||
return skin_id
|
||||
|
||||
def _get_skin(self):
|
||||
"""Lazily load the configured skin once. Returns None (built-in
|
||||
renderer) when no skin is configured or loading failed."""
|
||||
if not self._skin_load_attempted:
|
||||
self._skin_load_attempted = True
|
||||
skin_id = self._resolve_skin_id()
|
||||
if skin_id:
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
self._skin = skin_runtime.load_skin(
|
||||
skin_id, sport=self.sport, sport_key=self.sport_key,
|
||||
options=self.skin_options)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to load skin '{skin_id}': {e}", exc_info=True)
|
||||
self._skin = None
|
||||
return self._skin
|
||||
|
||||
def _render_game(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Render one game: try the configured skin first, fall back to the
|
||||
built-in _draw_scorebug_layout. A skin that raises 3 times in a row
|
||||
is disabled for the rest of the session."""
|
||||
skin = self._get_skin()
|
||||
if skin is not None and self._skin_failures < 3:
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
ctx = skin_runtime.build_context(self, game)
|
||||
render = getattr(skin, f"render_{self.SKIN_MODE}")
|
||||
started = time.monotonic()
|
||||
handled = render(ctx, dict(game))
|
||||
elapsed = time.monotonic() - started
|
||||
if elapsed > 0.15 and self._skin_slow_renders < 5:
|
||||
self._skin_slow_renders += 1
|
||||
self.logger.warning(
|
||||
f"Skin '{self._resolve_skin_id()}' took {elapsed * 1000:.0f}ms to "
|
||||
f"render {self.SKIN_MODE} — slow renders stall the whole display loop")
|
||||
if handled:
|
||||
self._skin_failures = 0
|
||||
self.display_manager.image.paste(ctx.canvas, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
return
|
||||
except Exception:
|
||||
self._skin_failures += 1
|
||||
outcome = ("disabling skin for this session" if self._skin_failures >= 3
|
||||
else "falling back to built-in renderer")
|
||||
self.logger.error(
|
||||
f"Skin '{self._resolve_skin_id()}' failed rendering {self.SKIN_MODE} "
|
||||
f"({self._skin_failures}/3); {outcome}", exc_info=True)
|
||||
self._draw_scorebug_layout(game, force_clear)
|
||||
|
||||
def render_skin_card(self, game: Dict, size: tuple) -> Optional[Image.Image]:
|
||||
"""Render one game as a standalone card via the configured skin —
|
||||
for vegas mode and previews. Tries render_vegas_card at the given
|
||||
size, then the mode renderer on a card-sized canvas. Returns None
|
||||
when no skin is active or the skin declined, so callers can use
|
||||
their default rendering."""
|
||||
skin = self._get_skin()
|
||||
if skin is None or self._skin_failures >= 3:
|
||||
return None
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
ctx = skin_runtime.build_context(self, game, size=size)
|
||||
card = skin.render_vegas_card(ctx, dict(game))
|
||||
if card is not None:
|
||||
return card
|
||||
ctx = skin_runtime.build_context(self, game, size=size)
|
||||
render = getattr(skin, f"render_{self.SKIN_MODE}")
|
||||
if render(ctx, dict(game)):
|
||||
return ctx.canvas
|
||||
except Exception:
|
||||
# Card failures count toward the same 3-strike session disable
|
||||
# as display failures — a skin broken for vegas shouldn't get
|
||||
# to throw on every scroll tick forever.
|
||||
self._skin_failures += 1
|
||||
self.logger.error(
|
||||
f"Skin '{self._resolve_skin_id()}' card render failed "
|
||||
f"({self._skin_failures}/3)", exc_info=True)
|
||||
return None
|
||||
|
||||
def display(self, force_clear: bool = False) -> bool:
|
||||
"""Common display method for all NCAA FB managers""" # Updated docstring
|
||||
if not self.is_enabled: # Check if module is enabled
|
||||
@@ -229,7 +333,7 @@ class SportsCore(ABC):
|
||||
return False
|
||||
|
||||
try:
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
self._render_game(self.current_game, force_clear)
|
||||
# display_manager.update_display() should be called within subclass draw methods
|
||||
# or after calling display() in the main loop. Let's keep it out of the base display.
|
||||
return True
|
||||
@@ -646,6 +750,8 @@ class SportsCore(ABC):
|
||||
pass
|
||||
|
||||
class SportsUpcoming(SportsCore):
|
||||
SKIN_MODE = "upcoming"
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
self.upcoming_games = [] # Store all fetched upcoming games initially
|
||||
@@ -973,7 +1079,7 @@ class SportsUpcoming(SportsCore):
|
||||
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
||||
|
||||
if self.current_game:
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
self._render_game(self.current_game, force_clear)
|
||||
return True
|
||||
# update_display() is called within _draw_scorebug_layout for upcoming
|
||||
return False
|
||||
@@ -984,6 +1090,7 @@ class SportsUpcoming(SportsCore):
|
||||
|
||||
|
||||
class SportsRecent(SportsCore):
|
||||
SKIN_MODE = "recent"
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
@@ -1274,7 +1381,7 @@ class SportsRecent(SportsCore):
|
||||
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
||||
|
||||
if self.current_game:
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
self._render_game(self.current_game, force_clear)
|
||||
return True
|
||||
# update_display() is called within _draw_scorebug_layout for recent
|
||||
return False
|
||||
|
||||
@@ -284,6 +284,19 @@ class SchemaManager:
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Enable live priority takeover when plugin has live content"
|
||||
},
|
||||
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an
|
||||
# enum here: validation must keep passing when a configured
|
||||
# skin gets uninstalled (rendering falls back to built-in).
|
||||
# The install-dependent enum is injected only at serve time
|
||||
# (inject_skin_selector) for the web UI dropdown.
|
||||
"skin": {
|
||||
"type": ["string", "object", "null"],
|
||||
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
|
||||
},
|
||||
"skin_options": {
|
||||
"type": "object",
|
||||
"description": "Options passed through to the selected skin"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +367,53 @@ class SchemaManager:
|
||||
self.logger.error(error_msg)
|
||||
return False, [error_msg]
|
||||
|
||||
def inject_skin_selector(self, schema: Dict[str, Any], plugin_id: str,
|
||||
current_value: Any = None) -> Dict[str, Any]:
|
||||
"""Return a copy of a plugin's schema with a "skin" dropdown added
|
||||
when installed skins target this plugin (docs/SKIN_SYSTEM.md).
|
||||
|
||||
Serve-time only — validation never sees this enum, so a config
|
||||
referencing an uninstalled skin stays valid (rendering falls back
|
||||
to the built-in layout). The currently-configured value is always
|
||||
included in the enum for the same reason: the dropdown must be able
|
||||
to display a selection whose skin was removed.
|
||||
"""
|
||||
# A per-mode mapping ({"live": ..., "recent": ...}) can't be edited
|
||||
# through a string dropdown — injecting one would let the form save
|
||||
# a string over the mapping. Leave the schema alone; per-mode users
|
||||
# edit via the raw JSON config editor.
|
||||
if isinstance(current_value, dict):
|
||||
return schema
|
||||
|
||||
try:
|
||||
from src.skin_system import skin_runtime
|
||||
matching = skin_runtime.skins_for_plugin(plugin_id)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Skin discovery failed for {plugin_id}: {e}")
|
||||
return schema
|
||||
|
||||
choices = sorted(matching.keys())
|
||||
if isinstance(current_value, str) and current_value and \
|
||||
current_value != "built-in" and current_value not in choices:
|
||||
choices.append(current_value)
|
||||
if not choices:
|
||||
return schema
|
||||
|
||||
enhanced = copy.deepcopy(schema)
|
||||
enhanced.setdefault("properties", {})
|
||||
if "skin" not in enhanced["properties"]:
|
||||
names = {sid: (matching.get(sid, {}).get("name") or sid) for sid in choices}
|
||||
enhanced["properties"]["skin"] = {
|
||||
"type": "string",
|
||||
"title": "Visual Skin",
|
||||
"description": "Replace this scoreboard's look with an installed skin "
|
||||
"(data, scheduling, and vegas mode are unaffected)",
|
||||
"enum": ["built-in", *choices],
|
||||
"enumNames": ["Built-in", *(names[sid] for sid in choices)],
|
||||
"default": "built-in"
|
||||
}
|
||||
return enhanced
|
||||
|
||||
def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str:
|
||||
"""
|
||||
Format a validation error into a readable message.
|
||||
|
||||
@@ -1214,6 +1214,11 @@ class PluginStoreManager:
|
||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
||||
return False
|
||||
|
||||
# Visual skins share the registry but install to skins/, not to a
|
||||
# plugin directory (docs/SKIN_SYSTEM.md)
|
||||
if (plugin_info.get('type') or 'plugin') == 'skin':
|
||||
return self._install_skin_from_info(plugin_id, plugin_info, branch)
|
||||
|
||||
repo_url = plugin_info.get('repo')
|
||||
if not repo_url:
|
||||
self.logger.error(f"Plugin {plugin_id} missing repository URL")
|
||||
@@ -2254,19 +2259,171 @@ class PluginStoreManager:
|
||||
|
||||
return None
|
||||
|
||||
_SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
|
||||
|
||||
def _resolve_skin_target(self, skin_id: str) -> Optional[Path]:
|
||||
"""Validate an externally-supplied skin id and resolve it to a path
|
||||
strictly inside the skins directory. Returns None (after logging)
|
||||
for ids that are malformed or would escape the directory — registry
|
||||
entries and manifests are external input and must not be able to
|
||||
write or delete outside skins/."""
|
||||
from src.skin_system import skin_runtime
|
||||
|
||||
if not isinstance(skin_id, str) or not self._SKIN_ID_PATTERN.match(skin_id) \
|
||||
or '..' in skin_id:
|
||||
self.logger.error(f"Rejecting unsafe skin id: {skin_id!r}")
|
||||
return None
|
||||
skins_dir = skin_runtime.get_skins_directory().resolve()
|
||||
target = (skins_dir / skin_id).resolve()
|
||||
if target.parent != skins_dir:
|
||||
self.logger.error(f"Skin id {skin_id!r} escapes the skins directory; rejecting")
|
||||
return None
|
||||
return target
|
||||
|
||||
def _install_skin_from_info(self, skin_id: str, skin_info: Dict,
|
||||
branch: Optional[str] = None) -> bool:
|
||||
"""Install a registry entry of type "skin" into skins/<id>/.
|
||||
|
||||
Reuses the plugin download machinery (git / monorepo zip / archive)
|
||||
but validates skin.json instead of manifest.json and never installs
|
||||
dependencies — skins are render-only (stdlib + PIL + the provided
|
||||
SkinContext), which is also what keeps them safe to iterate on.
|
||||
|
||||
Downloads into a staging directory and validates there; the
|
||||
existing installation is only replaced after the new one passes,
|
||||
so a failed download or bad manifest can't destroy a working skin.
|
||||
"""
|
||||
from src.skin_system import skin_runtime
|
||||
from src.skin_system.skin_base import SKIN_API_VERSION
|
||||
|
||||
repo_url = skin_info.get('repo')
|
||||
if not repo_url:
|
||||
self.logger.error(f"Skin {skin_id} missing repository URL")
|
||||
return False
|
||||
|
||||
target = self._resolve_skin_target(skin_id)
|
||||
if target is None:
|
||||
return False
|
||||
skins_dir = target.parent
|
||||
skins_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Leading "_" keeps staging invisible to skin discovery
|
||||
staging = skins_dir / f"_staging-{skin_id}"
|
||||
if staging.exists() and not self._safe_remove_directory(staging):
|
||||
return False
|
||||
|
||||
subpath = skin_info.get('plugin_path')
|
||||
branch_candidates = self._distinct_sequence([
|
||||
branch,
|
||||
skin_info.get('branch'),
|
||||
skin_info.get('default_branch'),
|
||||
skin_info.get('last_commit_branch'),
|
||||
'main',
|
||||
'master'
|
||||
])
|
||||
|
||||
try:
|
||||
branch_used = None
|
||||
if subpath:
|
||||
for candidate in branch_candidates:
|
||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
||||
if self._install_from_monorepo(download_url, subpath, staging):
|
||||
branch_used = candidate
|
||||
break
|
||||
else:
|
||||
branch_used = self._install_via_git(repo_url, staging, branch_candidates)
|
||||
if branch_used is None and not staging.exists():
|
||||
for candidate in branch_candidates:
|
||||
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
||||
if self._install_via_download(download_url, staging):
|
||||
branch_used = candidate
|
||||
break
|
||||
|
||||
if branch_used is None and not staging.exists():
|
||||
self.logger.error(f"Failed to install skin {skin_id} via git or archive download")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(staging / 'skin.json', 'r', encoding='utf-8') as f:
|
||||
manifest = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
self.logger.error(f"Skin {skin_id} has no valid skin.json: {e}")
|
||||
return False
|
||||
|
||||
missing = [k for k in ('id', 'name', 'version', 'skin_api_version', 'class_name')
|
||||
if not manifest.get(k)]
|
||||
if missing:
|
||||
self.logger.error(f"Skin {skin_id} manifest missing fields: {missing}")
|
||||
return False
|
||||
|
||||
# Unlike plugins, a mismatched id is rejected rather than
|
||||
# renamed: the manifest id is external input, and the registry
|
||||
# id is what the user asked to install.
|
||||
if manifest['id'] != skin_id:
|
||||
self.logger.error(
|
||||
f"Skin manifest id {manifest['id']!r} doesn't match registry id "
|
||||
f"{skin_id!r}; not installing")
|
||||
return False
|
||||
|
||||
def _api_major(v):
|
||||
try:
|
||||
return int(str(v).split('.')[0])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
if _api_major(manifest['skin_api_version']) != _api_major(SKIN_API_VERSION):
|
||||
self.logger.error(
|
||||
f"Skin {skin_id} targets skin API {manifest['skin_api_version']} but this "
|
||||
f"LEDMatrix provides {SKIN_API_VERSION}; not installing")
|
||||
return False
|
||||
|
||||
# Validated — swap into place
|
||||
if target.exists() and not self._safe_remove_directory(target):
|
||||
self.logger.error(f"Could not replace existing skin directory: {target}")
|
||||
return False
|
||||
shutil.move(str(staging), str(target))
|
||||
skin_runtime.discover_skins(force_refresh=True)
|
||||
self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})")
|
||||
return True
|
||||
finally:
|
||||
if staging.exists():
|
||||
self._safe_remove_directory(staging)
|
||||
|
||||
def uninstall_skin(self, skin_id: str) -> bool:
|
||||
"""Remove an installed skin. Plugin configs referencing it keep
|
||||
validating; rendering falls back to the built-in layout."""
|
||||
from src.skin_system import skin_runtime
|
||||
|
||||
target = self._resolve_skin_target(skin_id)
|
||||
if target is None:
|
||||
return False
|
||||
if not target.exists():
|
||||
self.logger.info(f"Skin {skin_id} not found (already uninstalled)")
|
||||
return True
|
||||
if self._safe_remove_directory(target):
|
||||
skin_runtime.discover_skins(force_refresh=True)
|
||||
self.logger.info(f"Successfully uninstalled skin: {skin_id}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def uninstall_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Uninstall a plugin by removing its directory.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
|
||||
Returns:
|
||||
True if uninstalled successfully (or already not installed)
|
||||
"""
|
||||
plugin_path = self._find_plugin_path(plugin_id)
|
||||
|
||||
|
||||
if plugin_path is None or not plugin_path.exists():
|
||||
# A skin id passed to the plugin uninstall path (the store UI
|
||||
# uses one uninstall flow) removes the skin instead
|
||||
skin_target = self._resolve_skin_target(plugin_id) \
|
||||
if self._SKIN_ID_PATTERN.match(str(plugin_id)) else None
|
||||
if skin_target is not None and skin_target.exists():
|
||||
return self.uninstall_skin(plugin_id)
|
||||
self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)")
|
||||
return True # Already uninstalled, consider this success
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
Skin system: user-installable visual overlays for sports scoreboards.
|
||||
|
||||
A skin replaces only the rendering of a scoreboard (live / recent /
|
||||
upcoming) while the host plugin keeps doing data fetching, scheduling,
|
||||
caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md.
|
||||
"""
|
||||
|
||||
from src.skin_system.skin_base import (
|
||||
SKIN_API_VERSION,
|
||||
VIEW_MODEL_VERSION,
|
||||
ScoreboardSkin,
|
||||
SkinContext,
|
||||
)
|
||||
from src.skin_system.skin_runtime import (
|
||||
build_context,
|
||||
discover_skins,
|
||||
get_skins_directory,
|
||||
load_skin,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SKIN_API_VERSION",
|
||||
"VIEW_MODEL_VERSION",
|
||||
"ScoreboardSkin",
|
||||
"SkinContext",
|
||||
"build_context",
|
||||
"discover_skins",
|
||||
"get_skins_directory",
|
||||
"load_skin",
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Bot 7th",
|
||||
"is_live": true,
|
||||
"is_final": false,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "LAD",
|
||||
"home_id": "19",
|
||||
"home_score": "5",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "SF",
|
||||
"away_id": "26",
|
||||
"away_score": "3",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"status": "STATUS_IN_PROGRESS",
|
||||
"status_state": "in",
|
||||
"inning": 7,
|
||||
"inning_half": "bottom",
|
||||
"balls": 3,
|
||||
"strikes": 2,
|
||||
"outs": 2,
|
||||
"bases_occupied": [
|
||||
true,
|
||||
true,
|
||||
true
|
||||
],
|
||||
"start_time": "2026-07-16T23:05:00Z",
|
||||
"series_summary": "LAD leads 2-1"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Final",
|
||||
"is_live": false,
|
||||
"is_final": true,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "LAD",
|
||||
"home_id": "19",
|
||||
"home_score": "5",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "SF",
|
||||
"away_id": "26",
|
||||
"away_score": "3",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"status": "STATUS_FINAL",
|
||||
"status_state": "post",
|
||||
"inning": 9,
|
||||
"inning_half": "top",
|
||||
"balls": 0,
|
||||
"strikes": 0,
|
||||
"outs": 3,
|
||||
"bases_occupied": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"start_time": "2026-07-16T23:05:00Z",
|
||||
"series_summary": "Series tied 2-2"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "7:05 PM",
|
||||
"is_live": false,
|
||||
"is_final": false,
|
||||
"is_upcoming": true,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "LAD",
|
||||
"home_id": "19",
|
||||
"home_score": "0",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "SF",
|
||||
"away_id": "26",
|
||||
"away_score": "0",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"status": "STATUS_SCHEDULED",
|
||||
"status_state": "pre",
|
||||
"inning": 0,
|
||||
"inning_half": "top",
|
||||
"balls": 0,
|
||||
"strikes": 0,
|
||||
"outs": 0,
|
||||
"bases_occupied": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"start_time": "2026-07-16T23:05:00Z",
|
||||
"series_summary": ""
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Q4 2:34",
|
||||
"is_live": true,
|
||||
"is_final": false,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "OKC",
|
||||
"home_id": "19",
|
||||
"home_score": "5",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "",
|
||||
"away_abbr": "MIN",
|
||||
"away_id": "26",
|
||||
"away_score": "3",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "",
|
||||
"is_within_window": true,
|
||||
"period": 4,
|
||||
"period_text": "Q4",
|
||||
"clock": "2:34"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Final",
|
||||
"is_live": false,
|
||||
"is_final": true,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "OKC",
|
||||
"home_id": "19",
|
||||
"home_score": "5",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "",
|
||||
"away_abbr": "MIN",
|
||||
"away_id": "26",
|
||||
"away_score": "3",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "",
|
||||
"is_within_window": true,
|
||||
"period": 4,
|
||||
"period_text": "Final",
|
||||
"clock": "0:00"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "7:05 PM",
|
||||
"is_live": false,
|
||||
"is_final": false,
|
||||
"is_upcoming": true,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "OKC",
|
||||
"home_id": "19",
|
||||
"home_score": "0",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "",
|
||||
"away_abbr": "MIN",
|
||||
"away_id": "26",
|
||||
"away_score": "0",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "",
|
||||
"is_within_window": true,
|
||||
"period": 0,
|
||||
"period_text": "",
|
||||
"clock": "0:00"
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Q3 8:12",
|
||||
"is_live": true,
|
||||
"is_final": false,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "KC",
|
||||
"home_id": "19",
|
||||
"home_score": "21",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "BUF",
|
||||
"away_id": "26",
|
||||
"away_score": "17",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 3,
|
||||
"period_text": "Q3",
|
||||
"clock": "8:12",
|
||||
"home_timeouts": 2,
|
||||
"away_timeouts": 3,
|
||||
"down_distance_text": "3rd & 4",
|
||||
"down_distance_text_long": "3rd & 4 at KC 22",
|
||||
"is_redzone": true,
|
||||
"possession": "12",
|
||||
"possession_indicator": "away",
|
||||
"scoring_event": null
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Final",
|
||||
"is_live": false,
|
||||
"is_final": true,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "KC",
|
||||
"home_id": "19",
|
||||
"home_score": "21",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "BUF",
|
||||
"away_id": "26",
|
||||
"away_score": "17",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 4,
|
||||
"period_text": "Final",
|
||||
"clock": "0:00",
|
||||
"home_timeouts": 0,
|
||||
"away_timeouts": 0,
|
||||
"down_distance_text": "",
|
||||
"down_distance_text_long": "",
|
||||
"is_redzone": false,
|
||||
"possession": null,
|
||||
"possession_indicator": null,
|
||||
"scoring_event": null
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "7:05 PM",
|
||||
"is_live": false,
|
||||
"is_final": false,
|
||||
"is_upcoming": true,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "KC",
|
||||
"home_id": "19",
|
||||
"home_score": "0",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "BUF",
|
||||
"away_id": "26",
|
||||
"away_score": "0",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 0,
|
||||
"period_text": "",
|
||||
"clock": "0:00",
|
||||
"home_timeouts": 3,
|
||||
"away_timeouts": 3,
|
||||
"down_distance_text": "",
|
||||
"down_distance_text_long": "",
|
||||
"is_redzone": false,
|
||||
"possession": null,
|
||||
"possession_indicator": null,
|
||||
"scoring_event": null
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "P3 14:55",
|
||||
"is_live": true,
|
||||
"is_final": false,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "COL",
|
||||
"home_id": "19",
|
||||
"home_score": "2",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "VGK",
|
||||
"away_id": "26",
|
||||
"away_score": "2",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 3,
|
||||
"period_text": "P3",
|
||||
"clock": "14:55",
|
||||
"power_play": true,
|
||||
"penalties": [],
|
||||
"home_shots": 27,
|
||||
"away_shots": 31
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "Final/OT",
|
||||
"is_live": false,
|
||||
"is_final": true,
|
||||
"is_upcoming": false,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "COL",
|
||||
"home_id": "19",
|
||||
"home_score": "3",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "VGK",
|
||||
"away_id": "26",
|
||||
"away_score": "2",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 5,
|
||||
"period_text": "Final/OT",
|
||||
"clock": "0:00",
|
||||
"power_play": false,
|
||||
"penalties": [],
|
||||
"home_shots": 35,
|
||||
"away_shots": 33
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "401570001",
|
||||
"game_time": "7:05PM",
|
||||
"game_date": "Jul 16th",
|
||||
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||
"status_text": "7:05 PM",
|
||||
"is_live": false,
|
||||
"is_final": false,
|
||||
"is_upcoming": true,
|
||||
"is_halftime": false,
|
||||
"is_period_break": false,
|
||||
"home_abbr": "COL",
|
||||
"home_id": "19",
|
||||
"home_score": "0",
|
||||
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||
"home_logo_url": null,
|
||||
"home_record": "58-33",
|
||||
"away_abbr": "VGK",
|
||||
"away_id": "26",
|
||||
"away_score": "0",
|
||||
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||
"away_logo_url": null,
|
||||
"away_record": "49-42",
|
||||
"is_within_window": true,
|
||||
"period": 0,
|
||||
"period_text": "",
|
||||
"clock": "0:00",
|
||||
"power_play": false,
|
||||
"penalties": [],
|
||||
"home_shots": 0,
|
||||
"away_shots": 0
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 444 B |
Binary file not shown.
|
After Width: | Height: | Size: 446 B |
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Skin API: the classes a skin author works with.
|
||||
|
||||
A skin is a directory under skins/<skin-id>/ containing a skin.json
|
||||
manifest and a Python module exposing a ScoreboardSkin subclass. The
|
||||
host (a sports scoreboard's base classes) builds a SkinContext per
|
||||
render and calls render_live / render_recent / render_upcoming with the
|
||||
game view model. The skin draws onto ctx.canvas and returns True; the
|
||||
host composites the canvas onto the display. A skin never talks to the
|
||||
display, the network, or the plugin directly.
|
||||
|
||||
Skin API Version: 1.0.0
|
||||
View Model Version: 1.0
|
||||
"""
|
||||
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, Optional, Tuple, Union
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
try:
|
||||
import freetype
|
||||
except ImportError: # pragma: no cover - freetype ships with the project deps
|
||||
freetype = None
|
||||
|
||||
from src.adaptive_layout import FitResult, LayoutContext, Region
|
||||
|
||||
# Major must match a skin manifest's skin_api_version major or the skin
|
||||
# is refused at load time (renames/removals bump major; additions minor).
|
||||
SKIN_API_VERSION = "1.0.0"
|
||||
|
||||
# Version of the guaranteed `game` dict keys (see docs/CREATING_SKINS.md).
|
||||
VIEW_MODEL_VERSION = "1.0"
|
||||
|
||||
|
||||
def _draw_bdf_text_on(draw: ImageDraw.ImageDraw, text: str, x: int, y: int,
|
||||
color: Tuple[int, int, int], face: Any,
|
||||
clip_w: int, clip_h: int) -> None:
|
||||
"""Render a freetype BDF face glyph-by-glyph onto an arbitrary canvas.
|
||||
|
||||
DisplayManager._draw_bdf_text only draws onto the panel image; skins
|
||||
draw onto their own canvas, so the fitted-font path (fit_text can
|
||||
return freetype faces) needs this standalone equivalent.
|
||||
"""
|
||||
try:
|
||||
ascender_px = face.size.ascender >> 6
|
||||
except Exception:
|
||||
ascender_px = 0
|
||||
baseline_y = y + ascender_px
|
||||
for char in text:
|
||||
face.load_char(char)
|
||||
bitmap = face.glyph.bitmap
|
||||
glyph_left = face.glyph.bitmap_left
|
||||
glyph_top = face.glyph.bitmap_top
|
||||
for i in range(bitmap.rows):
|
||||
for j in range(bitmap.width):
|
||||
byte_index = i * bitmap.pitch + (j // 8)
|
||||
if byte_index < len(bitmap.buffer) and \
|
||||
bitmap.buffer[byte_index] & (1 << (7 - (j % 8))):
|
||||
px = x + glyph_left + j
|
||||
py = baseline_y - glyph_top + i
|
||||
if 0 <= px < clip_w and 0 <= py < clip_h:
|
||||
draw.point((px, py), fill=color)
|
||||
x += face.glyph.advance.x >> 6
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkinContext:
|
||||
"""Everything a skin may touch during one render call.
|
||||
|
||||
The canvas is a fresh RGB image sized to the current display (or
|
||||
vegas card). Draw onto it via the helpers below or raw ``draw``;
|
||||
never call display/update methods — the host composites the canvas.
|
||||
"""
|
||||
|
||||
canvas: Image.Image
|
||||
draw: ImageDraw.ImageDraw
|
||||
layout: LayoutContext
|
||||
width: int
|
||||
height: int
|
||||
fonts: Dict[str, Any]
|
||||
options: Dict[str, Any]
|
||||
logger: Any
|
||||
sport: Optional[str] = None
|
||||
view_model_version: str = VIEW_MODEL_VERSION
|
||||
# load_logo("home") / load_logo("away") -> RGBA PIL image or None.
|
||||
# Bound to the current game; hits the host's logo cache (never loads
|
||||
# from disk twice), downloads missing logos like the built-in layout.
|
||||
load_logo: Callable[[str], Optional[Image.Image]] = field(default=lambda side: None)
|
||||
# draw_text_outlined(text, (x, y), font, fill=..., outline_color=...)
|
||||
# — the classic scorebug outlined text, drawn onto this canvas.
|
||||
# TTF fonts only (ctx.fonts values are TTF); for ladder-fitted fonts
|
||||
# use draw_fit / draw_text, which handle BDF faces too.
|
||||
draw_text_outlined: Callable[..., None] = field(default=lambda *a, **k: None)
|
||||
|
||||
def draw_text(self, text: str, x: int, y: int,
|
||||
color: Tuple[int, int, int] = (255, 255, 255),
|
||||
font: Any = None) -> None:
|
||||
"""Draw text at a top-left position, handling both PIL fonts and
|
||||
the freetype BDF faces that layout.fit_text can return."""
|
||||
if font is None:
|
||||
font = self.fonts.get('time')
|
||||
if freetype is not None and isinstance(font, freetype.Face):
|
||||
_draw_bdf_text_on(self.draw, text, int(x), int(y), color, font,
|
||||
self.width, self.height)
|
||||
else:
|
||||
self.draw.text((int(x), int(y)), text, font=font, fill=color)
|
||||
|
||||
def draw_fit(self, fit: FitResult, box: Union[Region, Tuple[int, int]],
|
||||
color: Tuple[int, int, int] = (255, 255, 255),
|
||||
align: str = "center", valign: str = "center") -> None:
|
||||
"""Draw a layout.fit_text() result aligned within a Region — the
|
||||
canvas-local equivalent of adaptive_layout.draw_fitted_text."""
|
||||
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
||||
x, y = region.align_xy(fit.width, fit.height, align, valign)
|
||||
self.draw_text(fit.text, x, y - fit.y_offset, color=color, font=fit.font)
|
||||
|
||||
def draw_image(self, img: Optional[Image.Image],
|
||||
box: Union[Region, Tuple[int, int]], *,
|
||||
mode: str = "contain", align: str = "center",
|
||||
valign: str = "center", cache_key: Any = None) -> None:
|
||||
"""Fit an image (a logo, art) into a Region and paste it, honoring
|
||||
alpha. Silently no-ops on None so `ctx.draw_image(ctx.load_logo(
|
||||
'home'), ...)` stays safe when a logo is missing."""
|
||||
if img is None:
|
||||
return
|
||||
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
||||
fitted = self.layout.fit_image(img, region, mode=mode,
|
||||
cache_key=cache_key)
|
||||
result = fitted.image # fit_image returns an ImageFitResult (always RGBA)
|
||||
if result is None:
|
||||
return
|
||||
x, y = region.align_xy(result.width, result.height, align, valign)
|
||||
self.canvas.paste(result, (int(x), int(y)), result)
|
||||
|
||||
|
||||
class ScoreboardSkin(ABC):
|
||||
"""Base class for scoreboard skins.
|
||||
|
||||
Override only the modes you want to restyle; any mode you leave
|
||||
unimplemented (or return False from) falls back to the plugin's
|
||||
built-in renderer, so a live-only skin still gets recent/upcoming
|
||||
screens for free.
|
||||
|
||||
Skins should be stateless: three host instances (live, recent,
|
||||
upcoming) each hold their own skin instance, and a render must be
|
||||
derivable from (ctx, game) alone.
|
||||
"""
|
||||
|
||||
SKIN_API_VERSION = SKIN_API_VERSION
|
||||
|
||||
def __init__(self, manifest: Dict[str, Any], options: Dict[str, Any]):
|
||||
self.manifest = manifest
|
||||
self.options = options or {}
|
||||
|
||||
def render_live(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
def render_recent(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
def render_upcoming(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
def render_vegas_card(self, ctx: SkinContext,
|
||||
game: Dict[str, Any]) -> Optional[Image.Image]:
|
||||
"""Render one vegas scroll card at ctx.width x ctx.height. Return
|
||||
the finished image, or None to let the host use its default vegas
|
||||
rendering (which captures the regular display output)."""
|
||||
return None
|
||||
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
Skin runtime: discovery, validation, loading, and context building.
|
||||
|
||||
Deliberately generic — this module knows nothing about sports beyond
|
||||
passing a `sport` label through; the sports flavor lives in skin_base
|
||||
(ScoreboardSkin) and in the hosts that call build_context.
|
||||
|
||||
Every failure path here logs and returns None: a broken or missing skin
|
||||
must never take down the plugin that references it — the host falls
|
||||
back to its built-in renderer.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from src.adaptive_layout import LayoutContext
|
||||
from src.logging_config import get_logger
|
||||
from src.skin_system.skin_base import (
|
||||
SKIN_API_VERSION,
|
||||
ScoreboardSkin,
|
||||
SkinContext,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_REQUIRED_MANIFEST_FIELDS = ("id", "name", "version", "skin_api_version", "class_name")
|
||||
_DEFAULT_ENTRY_POINT = "skin.py"
|
||||
|
||||
_lock = threading.RLock()
|
||||
# skins_dir -> (fingerprint, {skin_id: manifest+path})
|
||||
_discovery_cache: Dict[str, Tuple[Tuple, Dict[str, Dict[str, Any]]]] = {}
|
||||
|
||||
_shared_layout_font_manager: Optional[Any] = None
|
||||
|
||||
|
||||
def _get_font_manager() -> Any:
|
||||
"""Shared FontManager for skin LayoutContexts. SportsCore hosts don't
|
||||
carry a plugin_manager, so skins share one module-level FontManager —
|
||||
the same shape as base_plugin._fallback_font_manager, constructed
|
||||
directly so rendering never has to import the whole plugin system."""
|
||||
global _shared_layout_font_manager
|
||||
if _shared_layout_font_manager is None:
|
||||
from src.font_manager import FontManager
|
||||
_shared_layout_font_manager = FontManager({})
|
||||
return _shared_layout_font_manager
|
||||
|
||||
|
||||
def get_skins_directory() -> Path:
|
||||
"""Central skins directory: <project_root>/skins. Lives outside the
|
||||
plugin directories on purpose — plugin reinstall/update deletes the
|
||||
whole plugin directory, and a skin must survive that."""
|
||||
return Path(__file__).resolve().parents[2] / "skins"
|
||||
|
||||
|
||||
def _major(version: str) -> Optional[int]:
|
||||
try:
|
||||
return int(str(version).split(".")[0])
|
||||
except (ValueError, AttributeError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _read_manifest(skin_dir: Path) -> Optional[Dict[str, Any]]:
|
||||
manifest_path = skin_dir / "skin.json"
|
||||
if not manifest_path.is_file():
|
||||
return None
|
||||
try:
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.error("Skin manifest %s is unreadable: %s", manifest_path, e)
|
||||
return None
|
||||
missing = [k for k in _REQUIRED_MANIFEST_FIELDS if not manifest.get(k)]
|
||||
if missing:
|
||||
logger.error("Skin manifest %s missing required fields: %s",
|
||||
manifest_path, ", ".join(missing))
|
||||
return None
|
||||
if manifest["id"] != skin_dir.name:
|
||||
logger.warning("Skin manifest id %r does not match directory name %r",
|
||||
manifest["id"], skin_dir.name)
|
||||
manifest["_skin_dir"] = str(skin_dir)
|
||||
return manifest
|
||||
|
||||
|
||||
def _discovery_fingerprint(skins_dir: Path) -> Optional[Tuple]:
|
||||
"""Cache key for a skins directory: its mtime plus every skin.json's
|
||||
(path, mtime). The directory mtime alone misses in-place manifest edits
|
||||
(a skin updated without adding/removing entries)."""
|
||||
try:
|
||||
parts = [skins_dir.stat().st_mtime]
|
||||
for manifest_path in sorted(skins_dir.glob("*/skin.json")):
|
||||
parts.append((str(manifest_path), manifest_path.stat().st_mtime))
|
||||
return tuple(parts)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def discover_skins(skins_dir: Optional[Path] = None,
|
||||
force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
|
||||
"""Return {skin_id: manifest} for every valid skin package installed.
|
||||
|
||||
Cached per directory and invalidated when the directory or any
|
||||
skin.json changes; pass force_refresh to bypass.
|
||||
"""
|
||||
skins_dir = Path(skins_dir) if skins_dir else get_skins_directory()
|
||||
cache_key = str(skins_dir)
|
||||
fingerprint = _discovery_fingerprint(skins_dir)
|
||||
if fingerprint is None:
|
||||
return {}
|
||||
|
||||
with _lock:
|
||||
cached = _discovery_cache.get(cache_key)
|
||||
if cached and not force_refresh and cached[0] == fingerprint:
|
||||
return dict(cached[1])
|
||||
|
||||
skins: Dict[str, Dict[str, Any]] = {}
|
||||
for entry in sorted(skins_dir.iterdir()):
|
||||
if not entry.is_dir() or entry.name.startswith((".", "_")):
|
||||
continue
|
||||
manifest = _read_manifest(entry)
|
||||
if manifest:
|
||||
skins[manifest["id"]] = manifest
|
||||
_discovery_cache[cache_key] = (fingerprint, skins)
|
||||
return dict(skins)
|
||||
|
||||
|
||||
def skin_targets(manifest: Dict[str, Any]) -> Tuple[list, list]:
|
||||
"""(sports, sport_keys) a skin declares it supports."""
|
||||
targets = manifest.get("targets") or {}
|
||||
return (list(targets.get("sports") or []),
|
||||
list(targets.get("sport_keys") or []))
|
||||
|
||||
|
||||
def skin_matches_target(manifest: Dict[str, Any], sport: Optional[str],
|
||||
sport_key: Optional[str]) -> bool:
|
||||
"""True when the skin declares support for this sport family or exact
|
||||
sport key. A skin with no targets at all matches everything."""
|
||||
sports, sport_keys = skin_targets(manifest)
|
||||
if not sports and not sport_keys:
|
||||
return True
|
||||
if sport and sport in sports:
|
||||
return True
|
||||
if sport_key and sport_key in sport_keys:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def skins_for_plugin(plugin_id: str,
|
||||
skins: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Dict[str, Any]]:
|
||||
"""Installed skins that plausibly apply to a plugin, for UI dropdowns.
|
||||
|
||||
A skin matches when the plugin id is listed in targets.plugins, or any
|
||||
declared sport / sport_key appears as a token of the plugin id (so a
|
||||
skin targeting sports=["baseball"] matches "baseball-scoreboard", and
|
||||
sport_keys=["milb"] matches "milb-scoreboard")."""
|
||||
if skins is None:
|
||||
skins = discover_skins()
|
||||
tokens = set(str(plugin_id).lower().replace("-", "_").split("_"))
|
||||
matched = {}
|
||||
for skin_id, manifest in skins.items():
|
||||
targets = manifest.get("targets") or {}
|
||||
if plugin_id in (targets.get("plugins") or []):
|
||||
matched[skin_id] = manifest
|
||||
continue
|
||||
sports, sport_keys = skin_targets(manifest)
|
||||
if any(str(t).lower() in tokens for t in sports + sport_keys):
|
||||
matched[skin_id] = manifest
|
||||
return matched
|
||||
|
||||
|
||||
def _load_skin_module(skin_id: str, skin_dir: Path, entry_point: str) -> Optional[Any]:
|
||||
"""Import the skin's entry module under a namespaced sys.modules key,
|
||||
namespacing its sibling .py files the same way — the collision-
|
||||
avoidance scheme plugins use (plugin_loader._namespace_plugin_modules),
|
||||
so two skins can both ship a helpers.py.
|
||||
|
||||
The entry module is cached: the live/recent/upcoming hosts all load
|
||||
the same skin, and only the first load executes any code. (A skin
|
||||
whose *code* changed on disk needs a service restart to take effect —
|
||||
Python modules can't be safely hot-swapped.)
|
||||
"""
|
||||
entry_path = skin_dir / entry_point
|
||||
if not entry_path.is_file():
|
||||
logger.error("Skin '%s' entry point not found: %s", skin_id, entry_path)
|
||||
return None
|
||||
|
||||
module_name = f"_skin_{skin_id}_{Path(entry_point).stem}"
|
||||
with _lock:
|
||||
cached_entry = sys.modules.get(module_name)
|
||||
if cached_entry is not None:
|
||||
return cached_entry
|
||||
|
||||
# Import siblings under their namespaced alias, and *bind* the bare
|
||||
# name (cached or fresh) so `import helpers` inside the entry module
|
||||
# resolves to this skin's copy. The bare bindings are transient —
|
||||
# restored below so another skin's identically-named sibling can't
|
||||
# be shadowed by ours.
|
||||
replaced_bare: Dict[str, Any] = {}
|
||||
try:
|
||||
for sibling in skin_dir.glob("*.py"):
|
||||
if sibling.name == entry_point:
|
||||
continue
|
||||
alias = f"_skin_{skin_id}_{sibling.stem}"
|
||||
module = sys.modules.get(alias)
|
||||
if module is None:
|
||||
spec = importlib.util.spec_from_file_location(alias, sibling)
|
||||
if not spec or not spec.loader:
|
||||
continue
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[alias] = module
|
||||
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
|
||||
sys.modules[sibling.stem] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as e:
|
||||
logger.error("Skin '%s' sibling module %s failed to import: %s",
|
||||
skin_id, sibling.name, e, exc_info=True)
|
||||
sys.modules.pop(alias, None)
|
||||
return None
|
||||
else:
|
||||
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
|
||||
sys.modules[sibling.stem] = module
|
||||
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, entry_path)
|
||||
if not spec or not spec.loader:
|
||||
logger.error("Skin '%s': could not create import spec for %s",
|
||||
skin_id, entry_path)
|
||||
return None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
except Exception as e:
|
||||
sys.modules.pop(module_name, None)
|
||||
logger.error("Skin '%s' failed to import: %s", skin_id, e, exc_info=True)
|
||||
return None
|
||||
finally:
|
||||
for bare_name, previous in replaced_bare.items():
|
||||
if previous is None:
|
||||
sys.modules.pop(bare_name, None)
|
||||
else:
|
||||
sys.modules[bare_name] = previous
|
||||
|
||||
|
||||
def load_skin(skin_id: str, sport: Optional[str] = None,
|
||||
sport_key: Optional[str] = None,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
skins_dir: Optional[Path] = None) -> Optional[ScoreboardSkin]:
|
||||
"""Load and instantiate a skin. Returns None (after logging why) on
|
||||
any failure — callers treat None as 'use the built-in renderer'."""
|
||||
skins = discover_skins(skins_dir)
|
||||
manifest = skins.get(skin_id)
|
||||
if manifest is None:
|
||||
logger.warning("Skin '%s' is configured but not installed under %s; "
|
||||
"using built-in renderer",
|
||||
skin_id, skins_dir or get_skins_directory())
|
||||
return None
|
||||
|
||||
manifest_major = _major(manifest.get("skin_api_version"))
|
||||
api_major = _major(SKIN_API_VERSION)
|
||||
if manifest_major != api_major:
|
||||
logger.error("Skin '%s' targets skin API %s but this LEDMatrix "
|
||||
"provides %s — the skin needs an update; using "
|
||||
"built-in renderer",
|
||||
skin_id, manifest.get("skin_api_version"), SKIN_API_VERSION)
|
||||
return None
|
||||
|
||||
if not skin_matches_target(manifest, sport, sport_key):
|
||||
# Soft: the user explicitly configured it, so warn but load anyway
|
||||
# (a baseball skin may render an acceptable generic scoreboard).
|
||||
logger.warning("Skin '%s' does not declare support for sport=%r / "
|
||||
"sport_key=%r; loading anyway", skin_id, sport, sport_key)
|
||||
|
||||
skin_dir = Path(manifest["_skin_dir"])
|
||||
module = _load_skin_module(skin_id, skin_dir,
|
||||
manifest.get("entry_point", _DEFAULT_ENTRY_POINT))
|
||||
if module is None:
|
||||
return None
|
||||
|
||||
class_name = manifest["class_name"]
|
||||
skin_class = getattr(module, class_name, None)
|
||||
if skin_class is None or not isinstance(skin_class, type) or \
|
||||
not issubclass(skin_class, ScoreboardSkin):
|
||||
logger.error("Skin '%s': %s is missing or not a ScoreboardSkin subclass",
|
||||
skin_id, class_name)
|
||||
return None
|
||||
|
||||
try:
|
||||
return skin_class(manifest, options or {})
|
||||
except Exception as e:
|
||||
logger.error("Skin '%s' failed to instantiate: %s", skin_id, e, exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def build_context(host: Any, game: Dict[str, Any],
|
||||
size: Optional[Tuple[int, int]] = None) -> SkinContext:
|
||||
"""Build a SkinContext for one render call.
|
||||
|
||||
`host` is a SportsCore-style object: display_manager, fonts, logger,
|
||||
sport, skin_options, _load_and_resize_logo, _draw_text_with_outline.
|
||||
`size` overrides the canvas size (vegas cards); default is the
|
||||
current display size read live from the display manager.
|
||||
"""
|
||||
if size is not None:
|
||||
width, height = int(size[0]), int(size[1])
|
||||
else:
|
||||
dm = host.display_manager
|
||||
width = getattr(dm, "width", None) or dm.matrix.width
|
||||
height = getattr(dm, "height", None) or dm.matrix.height
|
||||
|
||||
canvas = Image.new("RGB", (width, height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
layout = LayoutContext(width, height, _get_font_manager())
|
||||
|
||||
def load_logo(side: str) -> Optional[Image.Image]:
|
||||
if side not in ("home", "away"):
|
||||
return None
|
||||
try:
|
||||
logo_path = game.get(f"{side}_logo_path")
|
||||
if logo_path is not None and not isinstance(logo_path, Path):
|
||||
logo_path = Path(logo_path)
|
||||
return host._load_and_resize_logo(
|
||||
game.get(f"{side}_id"), game.get(f"{side}_abbr"),
|
||||
logo_path, game.get(f"{side}_logo_url"))
|
||||
except Exception as e:
|
||||
host.logger.warning("Skin logo load failed for %s: %s", side, e)
|
||||
return None
|
||||
|
||||
def draw_text_outlined(text, position, font, fill=(255, 255, 255),
|
||||
outline_color=(0, 0, 0)):
|
||||
host._draw_text_with_outline(draw, text, position, font,
|
||||
fill=fill, outline_color=outline_color)
|
||||
|
||||
return SkinContext(
|
||||
canvas=canvas,
|
||||
draw=draw,
|
||||
layout=layout,
|
||||
width=width,
|
||||
height=height,
|
||||
fonts=dict(host.fonts),
|
||||
options=dict(getattr(host, "skin_options", {}) or {}),
|
||||
logger=host.logger,
|
||||
sport=getattr(host, "sport", None),
|
||||
load_logo=load_logo,
|
||||
draw_text_outlined=draw_text_outlined,
|
||||
)
|
||||
Reference in New Issue
Block a user