diff --git a/plugins.json b/plugins.json index 8c714be9..097232cf 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-08-11", + "last_updated": "2026-08-12", "plugins": [ { "id": "cricket-scoreboard", @@ -412,7 +412,7 @@ "last_updated": "2026-08-05", "verified": true, "screenshot": "", - "latest_version": "1.3.0" + "latest_version": "1.3.2" }, { "id": "ledmatrix-flights", diff --git a/plugins/ledmatrix-leaderboard/image_renderer.py b/plugins/ledmatrix-leaderboard/image_renderer.py index 5b1a2012..55311cda 100644 --- a/plugins/ledmatrix-leaderboard/image_renderer.py +++ b/plugins/ledmatrix-leaderboard/image_renderer.py @@ -89,6 +89,14 @@ def __init__(self, display_height: int, logger: Optional[logging.Logger] = None, appearance = appearance or {} self.pixel_perfect_text = bool(appearance.get('pixel_perfect_text', True)) self.crisp_logos = bool(appearance.get('crisp_logos', True)) + # Prepared logos, keyed by file identity and target box. Both halves + # of preparing one are expensive and neither varies between rebuilds: + # Image.open defers the PNG decode to first access, so the decode lands + # inside _prepare_logo's convert(), and a LANCZOS resize follows it. A + # rebuild did that for every team, every time, for files that had not + # changed. On a live rig this ran on the render thread and was the + # largest single contributor to a 3.2s freeze of the scroll. + self._logo_cache: Dict[Any, Optional[Image.Image]] = {} self.text_outline = bool(appearance.get('text_outline', True)) self.logo_scale = self._clamp_float(appearance.get('logo_scale', 1.0), 0.5, 1.5, 1.0) self.font_size_override = self._clamp_int(appearance.get('font_size', 0), 0, 32, 0) @@ -261,8 +269,53 @@ def _prepare_logo(self, logo: Image.Image, max_width: int, self.logger.error("Error preparing logo: %s", e) return None - def _get_team_logo(self, league: str, team_id: str, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: - """Get team logo from the configured directory, downloading if missing.""" + # Logos are small; the ceiling only exists so a long-running process with + # many leagues cannot grow this without bound. Clearing wholesale on + # overflow keeps it predictable -- the next rebuild simply repopulates + # what it needs, which is a handful of entries. + _LOGO_CACHE_MAX = 512 + + def _cached_prepared_logo(self, path: Optional[str], max_width: int, + max_height: int, load) -> Optional[Image.Image]: + """A prepared logo, decoded and resized at most once per file version. + + Keyed on the file's mtime as well as its path, so a logo replaced on + disk (the downloader backfills missing ones) is picked up rather than + served stale forever. + + The result is shared, not copied: callers only ever paste from it, and + copying per team would put back a slice of the cost this removes. + """ + stamp = None + if path: + try: + stamp = os.path.getmtime(path) + except OSError: + stamp = None + + key = (path, stamp, max_width, max_height, self.crisp_logos) + if stamp is not None and key in self._logo_cache: + return self._logo_cache[key] + + prepared = self._prepare_logo(load(), max_width, max_height) + + # Only a file that exists is cacheable. Caching a miss would make a + # logo that is downloaded a moment later invisible until restart. + if stamp is not None: + if len(self._logo_cache) >= self._LOGO_CACHE_MAX: + self._logo_cache.clear() + self._logo_cache[key] = prepared + return prepared + + def _get_team_logo(self, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: + """ + Get team logo from the configured directory. + + Local files only -- this runs from layout construction on the render + thread, and a network download there would block the Vegas scroll. + Missing logos are backfilled by ``download_missing_logos()``, which + the plugin calls from ``update()`` instead. + """ if not team_abbr or not logo_dir: self.logger.debug("Cannot get team logo with missing team_abbr or logo_dir") return None @@ -272,23 +325,42 @@ def _get_team_logo(self, league: str, team_id: str, team_abbr: str, logo_dir: st logo = Image.open(logo_path) self.logger.debug(f"Successfully loaded logo for {team_abbr}") return logo - else: - self.logger.warning(f"Logo not found at path: {logo_path}") - - # Try to download the missing logo - if league: - self.logger.info(f"Attempting to download missing logo for {team_abbr} in league {league}") - success = download_missing_logo(league, team_id, team_abbr, logo_path, None) - if success and os.path.exists(logo_path): - logo = Image.open(logo_path) - self.logger.info(f"Successfully downloaded and loaded logo for {team_abbr}") - return logo - - return None + self.logger.debug(f"Logo not found at path: {logo_path}") + return None except Exception as e: self.logger.error(f"Error loading logo for {team_abbr}: {e}") return None + def download_missing_logos(self, leaderboard_data: List[Dict[str, Any]]) -> None: + """ + Backfill any missing team logo files from the network. + + Intended to be called from ``update()``, off the render thread, so a + download never blocks ``display()``. Once a file lands on disk, + ``_get_team_logo`` picks it up on the next rebuild. + """ + for league_data in leaderboard_data: + league_key = league_data.get('league') + league_config = league_data.get('league_config') or {} + logo_dir = league_config.get('logo_dir') + if not league_key or not logo_dir: + continue + for team in league_data.get('teams', []): + team_abbr = team.get('abbreviation', '') + if not team_abbr: + continue + logo_path = Path(logo_dir, f"{team_abbr}.png") + if os.path.exists(logo_path): + continue + try: + self.logger.info( + "Attempting to download missing logo for %s in league %s", + team_abbr, league_key + ) + download_missing_logo(league_key, team.get('id'), team_abbr, logo_path, None) + except Exception as e: + self.logger.error("Error downloading missing logo for %s: %s", team_abbr, e) + def _get_league_logo(self, league_logo_path: str) -> Optional[Image.Image]: """Get league logo from the configured path.""" if not league_logo_path: @@ -332,8 +404,9 @@ def _build_layout(self, leaderboard_data: List[Dict[str, Any]]) -> Tuple[List[Di if league_data.get('is_tournament') and league_key in ('ncaam_basketball', 'ncaaw_basketball'): if os.path.exists(self.MARCH_MADNESS_LOGO_PATH): league_logo_path = self.MARCH_MADNESS_LOGO_PATH - league_logo = self._prepare_logo( - self._get_league_logo(league_logo_path), self.LEAGUE_LOGO_WIDTH, height + league_logo = self._cached_prepared_logo( + league_logo_path or None, self.LEAGUE_LOGO_WIDTH, height, + lambda: self._get_league_logo(league_logo_path), ) teams = [] @@ -345,10 +418,12 @@ def _build_layout(self, leaderboard_data: List[Dict[str, Any]]) -> Tuple[List[Di team_text = team.get('abbreviation', '') text_width = self._text_advance(team_text, self.fonts['large']) - team_logo = self._prepare_logo( - self._get_team_logo(league_key, team.get('id'), team_text, - league_config.get('logo_dir')), - logo_box, logo_box + team_logo_dir = league_config.get('logo_dir') + team_logo = self._cached_prepared_logo( + str(Path(team_logo_dir, f"{team_text}.png")) + if (team_text and team_logo_dir) else None, + logo_box, logo_box, + lambda: self._get_team_logo(team_text, team_logo_dir), ) width = number_width + text_width + self.TEAM_GAP + self.LOGO_TEXT_GAP diff --git a/plugins/ledmatrix-leaderboard/manager.py b/plugins/ledmatrix-leaderboard/manager.py index cc3400f9..29121b8e 100644 --- a/plugins/ledmatrix-leaderboard/manager.py +++ b/plugins/ledmatrix-leaderboard/manager.py @@ -246,7 +246,11 @@ def update(self, force: bool = False) -> None: self.logger.warning(f"No standings data returned for {league_key}") self.last_update = current_time - + + # Backfill any missing team logos here, off the render thread -- + # display() only ever reads local files, never downloads. + self.image_renderer.download_missing_logos(self.leaderboard_data) + # Clear scroll cache when data updates self.scroll_helper.clear_cache() diff --git a/plugins/ledmatrix-leaderboard/manifest.json b/plugins/ledmatrix-leaderboard/manifest.json index ee200369..c8a93d23 100644 --- a/plugins/ledmatrix-leaderboard/manifest.json +++ b/plugins/ledmatrix-leaderboard/manifest.json @@ -1,7 +1,7 @@ { "id": "ledmatrix-leaderboard", "name": "Sports Leaderboard", - "version": "1.3.0", + "version": "1.3.2", "description": "Displays scrolling leaderboards and standings for multiple sports leagues including NFL, NBA, MLB, NCAA Football, NCAA Basketball, and more", "author": "ChuckBuilds", "entry_point": "manager.py", @@ -31,6 +31,18 @@ "requirements_file": "requirements.txt", "min_ledmatrix_version": "2.0.0", "versions": [ + { + "released": "2026-08-12", + "version": "1.3.2", + "ledmatrix_min_version": "2.0.0", + "notes": "Missing team logos are no longer downloaded from the render path. A network call inside layout construction (display()) could stall the Vegas scroll; the download now happens in update(), and the render path only ever reads local files, picking up a backfilled logo on the next rebuild." + }, + { + "released": "2026-08-12", + "version": "1.3.1", + "ledmatrix_min_version": "2.0.0", + "notes": "Decode and resize each team logo once instead of once per rebuild. Image.open defers the PNG decode to first access, so it landed inside _prepare_logo's convert(), with a LANCZOS resize after it, for every team on every rebuild of files that had not changed. Measured on real assets: 25 logos cost 276ms uncached and 0.3ms warm. This matters because leaderboard scroll content is built on the render thread -- the adapter's capture path needs the shared canvas -- so the cost lands directly on the Vegas scroll. Cached on path plus mtime, so a logo the downloader backfills is picked up rather than served stale, and a missing one is retried rather than remembered as absent." + }, { "version": "1.3.0", "released": "2026-08-05", diff --git a/plugins/ledmatrix-leaderboard/test_logo_cache.py b/plugins/ledmatrix-leaderboard/test_logo_cache.py new file mode 100644 index 00000000..038077b8 --- /dev/null +++ b/plugins/ledmatrix-leaderboard/test_logo_cache.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +"""Tests that logos are decoded and resized once, not once per rebuild. + +Preparing a logo is expensive twice over: Image.open defers the PNG decode to +first access, so the decode lands inside _prepare_logo's convert(), and a +LANCZOS resize follows it. A rebuild did that for every team, every time, for +files that had not changed. + +That mattered because leaderboard scroll content is generated on the render +thread -- the plugin adapter's capture path needs the shared canvas, so it +cannot be prepared in the background. Measured on a live rig, a rebuild froze +the Vegas scroll for 3.2 seconds; 25 logos alone accounted for 276ms of it, +and a real board carries far more. + +Run: python3 plugins/ledmatrix-leaderboard/test_logo_cache.py +""" + +import sys +import time +from pathlib import Path + +PLUGIN_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(PLUGIN_DIR)) + +try: + from PIL import Image +except ImportError: + print("SKIP: Pillow not installed") + sys.exit(2) + +import image_renderer as ir # noqa: E402 + +failures = [] + + +def check(name, cond, detail=""): + if cond: + print(" PASS %s" % name) + else: + print(" FAIL %s%s" % (name, (": " + detail) if detail else "")) + failures.append(name) + + +def _renderer(): + r = ir.ImageRenderer.__new__(ir.ImageRenderer) + r.logger = type("L", (), {m: (lambda *a, **k: None) for m in + ("debug", "info", "warning", "error", "exception")})() + r.crisp_logos = True + r._logo_cache = {} + return r + + +def _png(path, size=(32, 32), colour=(255, 0, 0, 255)): + Image.new('RGBA', size, colour).save(path) + return str(path) + + +def main(): + import tempfile + tmp = Path(tempfile.mkdtemp()) + r = _renderer() + p = _png(tmp / 'AAA.png') + + print("a logo is loaded once, however many rebuilds ask for it") + loads = {'n': 0} + + def load(): + loads['n'] += 1 + return Image.open(p) + + first = r._cached_prepared_logo(p, 20, 20, load) + for _ in range(24): + r._cached_prepared_logo(p, 20, 20, load) + check("loaded exactly once", loads['n'] == 1, "%d loads" % loads['n']) + check("and returns a real image", first is not None) + check("the same one each time", + r._cached_prepared_logo(p, 20, 20, load) is first) + + print("\ndifferent target sizes are different entries") + small = r._cached_prepared_logo(p, 10, 10, load) + check("a second size reloads", loads['n'] == 2, "%d loads" % loads['n']) + check("and is actually a different size", + small is not None and first is not None + and small.size != first.size, "%r vs %r" % (small.size, first.size)) + + print("\na logo replaced on disk is picked up, not served stale") + # The downloader backfills missing logos, so a path can gain new content. + time.sleep(0.01) + Image.new('RGBA', (48, 48), (0, 0, 255, 255)).save(p) + import os + os.utime(p, (time.time() + 5, time.time() + 5)) # ensure a distinct mtime + before = loads['n'] + r._cached_prepared_logo(p, 20, 20, load) + check("reloaded after the file changed", loads['n'] == before + 1, + "%d -> %d" % (before, loads['n'])) + + print("\na missing logo is not cached as missing") + # Otherwise a logo downloaded a moment later stays invisible until restart. + missing = str(tmp / 'ZZZ.png') + misses = {'n': 0} + + def load_missing(): + misses['n'] += 1 + return None + + check("absent logo prepares to None", + r._cached_prepared_logo(missing, 20, 20, load_missing) is None) + r._cached_prepared_logo(missing, 20, 20, load_missing) + check("and is retried rather than remembered", misses['n'] == 2, + "%d attempts" % misses['n']) + + _png(tmp / 'ZZZ.png') + check("so it appears once it exists", + r._cached_prepared_logo(missing, 20, 20, + lambda: Image.open(missing)) is not None) + + print("\nthe cache cannot grow without bound") + r2 = _renderer() + r2._LOGO_CACHE_MAX = 8 + for i in range(20): + q = _png(tmp / ('T%02d.png' % i)) + r2._cached_prepared_logo(q, 20, 20, lambda q=q: Image.open(q)) + check("stays within its ceiling", len(r2._logo_cache) <= 8, + "%d entries" % len(r2._logo_cache)) + + print("\nthe saving is real, not theoretical") + r3 = _renderer() + paths = [_png(tmp / ('B%02d.png' % i)) for i in range(25)] + for q in paths: # warm the OS page cache: be fair + r3._prepare_logo(Image.open(q), 20, 20) + t = time.perf_counter() + for q in paths: + r3._prepare_logo(Image.open(q), 20, 20) + uncached = time.perf_counter() - t + for q in paths: + r3._cached_prepared_logo(q, 20, 20, lambda q=q: Image.open(q)) + t = time.perf_counter() + for q in paths: + r3._cached_prepared_logo(q, 20, 20, lambda q=q: Image.open(q)) + warm = time.perf_counter() - t + check("a warm rebuild is far cheaper", warm * 10 < uncached, + "%.2fms cached vs %.2fms uncached" % (warm * 1e3, uncached * 1e3)) + + print("\na missing team logo is not downloaded from the render path") + # _get_team_logo runs from layout construction on the render thread; a + # network call there would block the Vegas scroll. Downloading is + # update()'s job (download_missing_logos), not the renderer's. + calls = {'n': 0} + ir.download_missing_logo = lambda *a, **k: calls.__setitem__('n', calls['n'] + 1) or True + r4 = _renderer() + result = r4._get_team_logo('NOPE', str(tmp)) # no NOPE.png on disk + check("returns None for a missing local file", result is None) + check("without ever invoking the downloader", calls['n'] == 0, "%d calls" % calls['n']) + + print("\nupdate()'s backfill downloads missing logos and a later render sees them") + downloaded = {'n': 0} + + def fake_download(league, team_id, team_abbr, logo_path, session): + downloaded['n'] += 1 + _png(logo_path) # simulate the downloader writing the file + return True + + ir.download_missing_logo = fake_download + r5 = _renderer() + leaderboard_data = [{ + 'league': 'nfl', + 'league_config': {'logo_dir': str(tmp)}, + 'teams': [{'id': '1', 'abbreviation': 'BFL'}], + }] + bfl_path = tmp / 'BFL.png' + check("logo does not exist yet", not bfl_path.exists()) + r5.download_missing_logos(leaderboard_data) + check("update()'s backfill invoked the downloader once", downloaded['n'] == 1, + "%d calls" % downloaded['n']) + check("and the file now exists locally", bfl_path.exists()) + check("so a later render finds it without downloading", + r5._get_team_logo('BFL', str(tmp)) is not None) + check("still without the render path calling the downloader", + downloaded['n'] == 1, "%d calls" % downloaded['n']) + + print("\n%s" % ("FAILED: %d" % len(failures) if failures + else "All checks passed")) + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main())