mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
* perf(timing): say which render-thread work a late frame followed The soak already says how often a moving frame reached the panel late, but not what the render thread was doing just before it. Vegas does two kinds of work there between frames -- building its strip (compose, extend) and, with live elements, patching changed pixels into it -- and deciding whether either is affordable needs their own numbers. - FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame. Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind; aggregate() still takes frames without ops. The file schema is unchanged. - Vegas tags compose and every strip extension (with the bytes it copied). - frame_soak prints an "after work" table: frames, late %, freezes and MB moved per kind, only when something tagged its work. - render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes / --patch-every / --patch-where (in-place column writes, as a live element update does) and --extend-every-screens / --extend-width (append + trim on a fixed cadence that holds the strip's width). No runtime behaviour changes: this is the measurement gate for live Vegas elements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the frame-op attribution and bench modes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(scroll): build the strip's PIL image only when something reads it Every Vegas strip extension rebuilt ScrollHelper.cached_image from cached_array in full, twice (append, then trim), on the render thread: Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a Pi 4 (measured on ledpi), about two thirds of an extension's render-thread cost. Nothing on the frame path reads the image's pixels; every frame is cut from the array. cached_image is now a property. append_content and drop_scrolled_prefix defer it; the first read builds it from the array it started with and keeps it only if the strip has not changed meanwhile, so a sync push racing an extension cannot leave a stale image cached. Assigning cached_image stores exactly what was assigned, as before. has_strip() says whether there is a strip without building its image; the helper's frame path, Vegas and the adapter's scroll-cache invalidation use it. The strip is also no longer held in memory twice. In Vegas the image is now built only by a multi-display sync push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(vegas): live elements -- a plugin API for content that changes while it scrolls Vegas bakes each plugin's pictures into one strip, so a card already on its way across the panel keeps what it showed when it was drawn. This adds the API and bookkeeping for content that can be updated in place; the worker that redraws and swaps it follows separately. No shipped plugin implements the hook yet, so nothing changes for users. Plugin API (core 3.8.0), all no-ops by default: - BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version, live, refresh_hz)]: named, fixed-width pieces of Vegas content. - BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free redraw for content that changes with time. - BasePlugin.notify_vegas_data_changed(): data that lands outside update(). - src/plugin_system/vegas_elements.py (VegasElement, re-exported from base_plugin). Core: - PluginAdapter asks a plugin that implements the hook for elements on the background fetch only (under its lock, on its own canvas); every other path keeps get_vegas_content(). Live elements are pinned (padded with content_padding, never trimmed), tagged with their key, digest and data epoch in Image.info so the existing cache and group plumbing carry them unchanged, and untagged if a width budget crops them. - RenderPipeline records where each live element lands (ElementRecord), in absolute strip columns a trim does not move; the block-start arithmetic is shared with the STATIC markers. - PluginManager update listeners (add/remove_update_listener, notify_data_changed): told the moment update() completes, not at the next ~4s Vegas poll. The coordinator uses one to move each plugin's data epoch on. - vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval, live_lead_screens; per-plugin core-owned vegas_live. Live elements are off under multi-display sync, in swap mode and with offscreen_prefetch off. - scripts/check_plugin.py checks the element contract (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub is a working example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(vegas): live elements update in place while they scroll One background worker (src/vegas_mode/live_worker.py) redraws a plugin's live elements when its data epoch moves on (update listener) or on their refresh_hz, nearest the screen first, and hands changed pixels lock-free to the render thread, which copies them into the strip between frames (RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most four patches or two screens of bytes a frame, no drawing or locks there. The worker takes over group prefetch once a live element is placed, runs inside the render gate, and is supervised. Update tick 1s while live elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md describes what was built and why SegmentStrip was not needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(sports): live Vegas cards for the scoreboards (shared layer) One live element per game, drawn only when what the card shows changes, so a score changes on a card already crossing the panel. The shared part, so each scoreboard adopts it in a few lines: - src/common/sports_vegas.py: game_key, game_fingerprint (the whole game dict, frozen: no drawn field can be missed), dedupe_games, VegasCardCache, StickyOdds (odds a live poll left out stay drawn), finished_games / with_finished_games (a game that just went final keeps its card, after its league's live games; one a heuristic only judged over keeps its live state, so a tied end of regulation never shows FINAL early). - SportsScrollDisplay.make_vegas_renderer() is the override point; build_vegas_elements() and SportsScrollDisplayManager .get_vegas_elements_for() do the rest. A card's version includes its teams' ranks, which the renderer draws from the rankings cache. - SportsLiveSharedMixin._record_finished_game() / finished_games_snapshot(): held for FINISHED_GAME_TTL after it leaves the live list. A sport that does not implement make_vegas_renderer keeps its ordinary Vegas content, so no scoreboard changes until it opts in. scripts/render_plugin.py --vegas renders a plugin's Vegas block as the ticker lays it out, and --timeline stacks it at successive moments as the ticker would update it in place; the join is now render_pipeline.join_plugin_rows(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(sports): a default _determine_game_type on SportsScrollDisplay render_vegas_card looked the method up with getattr and a None default, which static analysis (Codacy) reports as calling something that may not be callable. The base class now has the default -- the card type from the game's state -- and the plugins that define their own override it as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: review follow-ups on the shared live-card layer - The reused Vegas renderer always gets the current rankings, empty included, so ranks cleared since are not kept drawn. - render_plugin.py: --timeline refuses --no-live (a timeline shows live elements changing), --timeline/--no-live need --vegas, and the Vegas paths create the output's directory like the display path does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
10 KiB
Python
251 lines
10 KiB
Python
"""Live Vegas cards for the sports scoreboards.
|
|
|
|
A scoreboard hands the Vegas ticker one card per game. As live elements
|
|
(src/plugin_system/vegas_elements.py) those cards change on the panel while
|
|
they scroll: a goal redraws its game's card and the ticker swaps it in place.
|
|
This module is what every scoreboard needs for that and would otherwise write
|
|
nine times:
|
|
|
|
- :func:`game_key` -- a stable key per game, so the ticker can tell which card
|
|
a redraw belongs to however the slate is re-sorted.
|
|
- :class:`VegasCardCache` -- draws a card only when what it shows changed
|
|
(its fingerprint), so an unchanged slate costs a dictionary lookup per game
|
|
and a changed one only the cards that changed.
|
|
- :class:`StickyOdds` -- live odds are fetched only for games near the front
|
|
of the rotation, so a card's odds come and go between polls; this keeps the
|
|
last odds for a while instead of redrawing the card without them.
|
|
- :func:`dedupe_games` -- a game present in two managers' lists (live and
|
|
recent, around the final whistle) appears once, its liveliest copy.
|
|
- :func:`finished_games` / :func:`with_finished_games` -- a game that has just
|
|
gone final keeps its card, now showing FINAL, where its live card was,
|
|
until the recent list (refreshed about hourly) takes it over.
|
|
- :func:`game_fingerprint` -- what a card is redrawn on by default: the whole
|
|
game dict, frozen hashable.
|
|
|
|
SportsScrollDisplay.build_vegas_elements (src/common/sports_scroll.py) puts
|
|
them together; a plugin adopts it by implementing make_vegas_renderer().
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from collections import OrderedDict
|
|
from typing import Any, Callable, Dict, Hashable, Iterable, List, Optional, Tuple
|
|
|
|
from PIL import Image
|
|
|
|
#: Which copy of a duplicated game wins: the liveliest.
|
|
_STATE_PRIORITY = {'in': 3, 'post': 2, 'pre': 1}
|
|
|
|
|
|
def _state(game: Dict[str, Any]) -> str:
|
|
status = game.get('status')
|
|
state = status.get('state') if isinstance(status, dict) else status
|
|
if isinstance(state, str):
|
|
return state
|
|
if game.get('is_live'):
|
|
return 'in'
|
|
if game.get('is_final'):
|
|
return 'post'
|
|
return 'pre'
|
|
|
|
|
|
def _freeze(value: Any) -> Any:
|
|
"""A hashable, order-stable copy of feed data."""
|
|
if isinstance(value, dict):
|
|
return tuple(sorted((str(k), _freeze(v)) for k, v in value.items()))
|
|
if isinstance(value, (list, tuple)):
|
|
return tuple(_freeze(v) for v in value)
|
|
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
return value
|
|
return repr(value)
|
|
|
|
|
|
def game_fingerprint(game: Dict[str, Any]) -> Hashable:
|
|
"""Everything in a game dict, hashable: a card drawn from it changes only if this does.
|
|
|
|
The default card version. Nothing a card could draw is left out, so no
|
|
field is ever frozen on the panel; the cost is a redraw when a field the
|
|
card does not draw changes too, which feed data rarely does between polls.
|
|
"""
|
|
frozen: Hashable = _freeze(game)
|
|
return frozen
|
|
|
|
|
|
def game_key(game: Dict[str, Any]) -> str:
|
|
"""A key that names this game and nothing else, across polls.
|
|
|
|
``game:<league>:<id>`` from the feed's own id. A game without one falls
|
|
back to its teams and start time, which is stable for the life of a game.
|
|
"""
|
|
league = game.get('league') or 'game'
|
|
game_id = game.get('id') or game.get('game_id')
|
|
if game_id not in (None, ''):
|
|
return f"game:{league}:{game_id}"
|
|
away = game.get('away_abbr') or game.get('away_team') or '?'
|
|
home = game.get('home_abbr') or game.get('home_team') or '?'
|
|
start = game.get('start_time_utc') or game.get('start_time') or ''
|
|
return f"game:{league}:{away}@{home}:{start}"
|
|
|
|
|
|
def dedupe_games(games: Iterable[Dict[str, Any]],
|
|
key_fn: Callable[[Dict[str, Any]], str] = game_key) -> List[Dict[str, Any]]:
|
|
"""Each game once, in first-seen order, keeping its liveliest copy.
|
|
|
|
Around a final whistle a game can be in the live list (last poll) and the
|
|
recent list (next poll) at once; two cards with one key would be refused
|
|
by the ticker, and showing the game twice is wrong anyway.
|
|
"""
|
|
chosen: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
|
for game in games:
|
|
key = key_fn(game)
|
|
current = chosen.get(key)
|
|
if current is None or _STATE_PRIORITY.get(_state(game), 0) > \
|
|
_STATE_PRIORITY.get(_state(current), 0):
|
|
chosen[key] = game
|
|
return list(chosen.values())
|
|
|
|
|
|
def finished_games(
|
|
live_managers: Iterable[Tuple[str, Any]]) -> List[Dict[str, Any]]:
|
|
"""Games that just left these live managers' lists, final ones as recent games.
|
|
|
|
``live_managers`` pairs each league with its live manager (None is
|
|
skipped). Each manager reports what SportsLiveSharedMixin recorded
|
|
(finished_games_snapshot, copies), with its league. A final game is
|
|
drawn as a recent card. One a poll only judged over -- a tied end of
|
|
regulation looks like that too -- keeps its last live state, so its card
|
|
never says FINAL early; if play resumes the live list has it again, and
|
|
dedupe_games keeps that copy.
|
|
"""
|
|
finished: List[Dict[str, Any]] = []
|
|
for league, manager in live_managers:
|
|
snapshot = getattr(manager, 'finished_games_snapshot', None)
|
|
if not callable(snapshot):
|
|
continue
|
|
for game in snapshot():
|
|
game['league'] = league
|
|
if game.get('is_final'):
|
|
status = game.get('status')
|
|
status = dict(status) if isinstance(status, dict) else {}
|
|
status['state'] = 'post'
|
|
game.update(status=status, is_live=False)
|
|
finished.append(game)
|
|
return finished
|
|
|
|
|
|
def with_finished_games(
|
|
games: List[Dict[str, Any]], leagues: List[str],
|
|
finished: List[Dict[str, Any]],
|
|
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
|
"""The slate with games that just went final where their live cards were.
|
|
|
|
A slate lists each league's games together, live ones first. Each
|
|
finished game goes after its league's live games, ahead of the rest; a
|
|
league with no games left in the slate is added at the end. A finished
|
|
game the slate also has (the recent list caught up) is left for
|
|
dedupe_games, which keeps one copy.
|
|
"""
|
|
if not finished:
|
|
return list(games), list(leagues)
|
|
pending: "OrderedDict[Any, List[Dict[str, Any]]]" = OrderedDict()
|
|
for game in finished:
|
|
pending.setdefault(game.get('league'), []).append(game)
|
|
merged: List[Dict[str, Any]] = []
|
|
for index, game in enumerate(games):
|
|
league = game.get('league')
|
|
if league in pending and _state(game) != 'in':
|
|
merged.extend(pending.pop(league))
|
|
merged.append(game)
|
|
following = games[index + 1] if index + 1 < len(games) else None
|
|
if league in pending and (following is None or following.get('league') != league):
|
|
merged.extend(pending.pop(league)) # the league's games were all live
|
|
leagues = list(leagues)
|
|
for league, rest in pending.items():
|
|
merged.extend(rest)
|
|
if league not in leagues:
|
|
leagues.append(league)
|
|
return merged, leagues
|
|
|
|
|
|
class VegasCardCache:
|
|
"""Cards drawn once per fingerprint, kept for as long as their game is.
|
|
|
|
``element(key, fingerprint, render)`` returns a VegasElement whose image is
|
|
``render()``'s -- called only when the fingerprint differs from the one the
|
|
cached card was drawn for. The fingerprint is also the element's version,
|
|
so the ticker skips unchanged cards without comparing pixels.
|
|
|
|
Bounded: keys not passed to :meth:`retain` after a slate are dropped, and
|
|
at most ``max_entries`` are ever held (oldest first).
|
|
"""
|
|
|
|
def __init__(self, max_entries: int = 96) -> None:
|
|
self.max_entries = max(1, int(max_entries))
|
|
self._cards: "OrderedDict[str, Tuple[Hashable, Image.Image]]" = OrderedDict()
|
|
self.renders = 0
|
|
|
|
def element(self, key: str, fingerprint: Hashable,
|
|
render: Callable[[], Image.Image], live: bool = True) -> Any:
|
|
from src.plugin_system.vegas_elements import VegasElement
|
|
|
|
cached = self._cards.get(key)
|
|
if cached is not None and cached[0] == fingerprint:
|
|
self._cards.move_to_end(key)
|
|
image = cached[1]
|
|
else:
|
|
image = render()
|
|
self.renders += 1
|
|
self._cards[key] = (fingerprint, image)
|
|
self._cards.move_to_end(key)
|
|
while len(self._cards) > self.max_entries:
|
|
self._cards.popitem(last=False)
|
|
return VegasElement(key=key, image=image, version=fingerprint, live=live)
|
|
|
|
def retain(self, keys: Iterable[str]) -> None:
|
|
"""Forget every card whose key is not in ``keys``."""
|
|
keep = set(keys)
|
|
for key in [k for k in self._cards if k not in keep]:
|
|
self._cards.pop(key, None)
|
|
|
|
def clear(self) -> None:
|
|
self._cards.clear()
|
|
|
|
def __len__(self) -> int:
|
|
return len(self._cards)
|
|
|
|
|
|
class StickyOdds:
|
|
"""Keep a game's last odds on its card while a live poll leaves them out.
|
|
|
|
Live odds are fetched only for games near the front of the rotation
|
|
(src/common/sports_fetch.py), so the same game's dict has odds on one poll
|
|
and none on the next. Drawn as-is that redraws the card every poll with
|
|
the odds flickering in and out. ``apply`` returns the game with its last
|
|
non-empty odds put back, for up to ``ttl_s`` seconds after they were seen.
|
|
"""
|
|
|
|
def __init__(self, ttl_s: float = 600.0) -> None:
|
|
self.ttl_s = float(ttl_s)
|
|
self._seen: Dict[str, Tuple[float, Any]] = {}
|
|
|
|
def apply(self, key: str, game: Dict[str, Any],
|
|
now: Optional[float] = None) -> Dict[str, Any]:
|
|
now = time.monotonic() if now is None else now
|
|
odds = game.get('odds')
|
|
if odds:
|
|
self._seen[key] = (now, odds)
|
|
return game
|
|
seen = self._seen.get(key)
|
|
if seen is None or now - seen[0] > self.ttl_s:
|
|
self._seen.pop(key, None)
|
|
return game
|
|
refilled = dict(game)
|
|
refilled['odds'] = seen[1]
|
|
return refilled
|
|
|
|
def retain(self, keys: Iterable[str]) -> None:
|
|
keep = set(keys)
|
|
for key in [k for k in self._seen if k not in keep]:
|
|
self._seen.pop(key, None)
|