a plugin API for content that changes while it scrolls (#696)

* 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>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-30 20:59:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 596809acc3
commit 56947298d6
25 changed files with 2578 additions and 31 deletions
+84
View File
@@ -15,6 +15,8 @@ import os
import sys
from src.deprecation import deprecated, warn_deprecated
from src.logging_config import get_logger
# Re-exported: a plugin may import it from here beside VegasDisplayMode.
from src.plugin_system.vegas_elements import VegasElement # noqa: F401
_shared_fallback_font_manager: Optional[Any] = None
@@ -986,6 +988,88 @@ class BasePlugin(ABC):
"""
return None
def get_vegas_elements(self) -> Optional[List[Any]]:
"""
Vegas content as live elements: named, fixed-width pieces the ticker
can swap in place while they are on screen.
get_vegas_content() hands the ticker pictures, and a picture already
in the scrolling strip keeps what it showed when it was drawn. Return
a list of ``VegasElement`` (src/plugin_system/vegas_elements.py)
instead and the ticker records where each one is; after your update()
it calls this again, compares each element's ``version`` (or pixels)
with what the strip holds, and swaps the changed ones in between two
frames -- a score changes on a card already crossing the panel, and
nothing next to it moves.
The contract:
- Called only on the ticker's background thread, under this plugin's
lock (never while update() runs), on a canvas of its own and told
its width (get_vegas_render_width()), like get_vegas_content().
- Called often -- after every update() while any of your elements is
on or ahead of the screen -- so it must be cheap when nothing
changed (cache images by version), idempotent, and must not fetch.
- A live element's width must not depend on its data: a redraw at a
different width is not swapped in (it shows the next time the
plugin comes round).
- Keys must be unique in the list and stable for the same logical
item.
Return None (the default) to use get_vegas_content(). A core older
than 3.8.0 never calls this, so keep get_vegas_content() working and
floor ``ledmatrix_min_version`` at 3.8.0 only if you rely on it.
Example (scoreboard)::
def get_vegas_elements(self):
return [VegasElement(key=f"game:{g['id']}",
image=self._card_for(g), # cached by fingerprint
version=self._fingerprint(g))
for g in self.games]
Returns:
A list of VegasElement, or None.
"""
return None
def redraw_vegas_element(self, key: str, width: int, height: int,
at: float) -> Optional[Any]:
"""
Redraw one live element for a moment in time, without the plugin lock.
Only for elements returned with ``refresh_hz > 0``: content that
changes with time rather than with data, such as an aircraft moving
between position reports. The ticker calls it up to that often while
the element is on or near the screen.
- Called WITHOUT this plugin's lock, possibly while update() runs, so
read only state that update() replaces in a single assignment (an
immutable snapshot), never state it mutates in place.
- ``at`` is the time.monotonic() at which the pixels are expected to
reach the panel; draw the element as it should look then.
- Return an image of exactly ``width`` x ``height``, or None to skip
this tick.
Returns:
PIL Image of exactly (width, height), or None.
"""
return None
def notify_vegas_data_changed(self) -> None:
"""
Tell the Vegas ticker this plugin's data changed outside update().
The ticker redraws a plugin's live elements when its update()
completes. Data that lands some other way -- a background thread, a
push callback -- calls this so the change reaches the screen without
waiting for the next update(). Cheap and safe from any thread.
"""
notify = getattr(getattr(self, 'plugin_manager', None),
'notify_data_changed', None)
if callable(notify):
notify(self.plugin_id)
def get_vegas_participation(self) -> str:
"""
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or