Files
LEDMatrix/test/test_harness_vegas_elements.py
T
ChuckandClaude Opus 5.5 56947298d6 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>
2026-09-30 20:59:16 -04:00

181 lines
6.5 KiB
Python

"""The offline live-element checks (src/plugin_system/testing/vegas.py).
Run against the stub fixture plugin, which honours the contract, and against
small broken plugins, each breaking one clause of it.
"""
import sys
from pathlib import Path
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.plugin_system.base_plugin import BasePlugin # noqa: E402
from src.plugin_system.testing.harness import _instantiate # noqa: E402
from src.plugin_system.testing.loading import build_full_config, load_harness_spec, load_manifest # noqa: E402
from src.plugin_system.testing.vegas import ( # noqa: E402
check_vegas_elements, implements_vegas_elements, render_vegas_elements,
)
from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager # noqa: E402
from src.plugin_system.vegas_elements import VegasElement # noqa: E402
STUB = Path(__file__).resolve().parent / "fixtures" / "plugins" / "vegas-live-stub"
W, H = 192, 48
def _stub(**config):
dm = VisualTestDisplayManager(width=W, height=H)
full = {**build_full_config(STUB, load_harness_spec(STUB), {}), **config}
plugin = _instantiate("vegas-live-stub", load_manifest(STUB), STUB, full, {}, dm)
return plugin, dm
class _Broken(BasePlugin):
"""A plugin whose get_vegas_elements returns whatever it is given."""
def __init__(self, dm, result, redraw=None):
self.plugin_id = "broken"
self.config = {}
self.display_manager = dm
self.plugin_manager = None
self._result = result
self._redraw = redraw
def update(self):
pass
def display(self, force_clear=False):
pass
def get_vegas_elements(self):
return self._result() if callable(self._result) else self._result
def redraw_vegas_element(self, key, width, height, at):
return self._redraw(width, height) if self._redraw else None
def _img(w, h=H):
return Image.new("RGB", (w, h), (255, 0, 0))
def test_the_stub_passes_every_check():
plugin, dm = _stub()
report = check_vegas_elements(plugin, dm)
assert report.implemented and report.ok, report.errors
assert report.elements == 8 and report.live == 7
assert not report.warnings, report.warnings
def test_the_stub_renders_at_the_width_it_is_given():
plugin, dm = _stub()
elements = render_vegas_elements(plugin, dm, width=96)
by_key = {e.key: e for e in elements}
assert by_key["map"].image.size == (96, H)
assert by_key["card:0"].image.width == 24
assert plugin.get_vegas_render_width() == W # restored afterwards
def test_the_stubs_cards_change_on_update_but_keep_their_width():
plugin, dm = _stub()
before = {e.key: e for e in render_vegas_elements(plugin, dm)}
plugin.update()
after = {e.key: e for e in render_vegas_elements(plugin, dm)}
for key in ("card:0", "card:3"):
assert after[key].version != before[key].version
assert after[key].image.size == before[key].image.size
assert after[key].image.tobytes() != before[key].image.tobytes()
def test_a_plugin_without_the_hook_is_not_checked():
class Plain(BasePlugin):
def update(self):
pass
def display(self, force_clear=False):
pass
plugin = Plain.__new__(Plain)
dm = VisualTestDisplayManager(width=W, height=H)
report = check_vegas_elements(plugin, dm)
assert not implements_vegas_elements(plugin)
assert not report.implemented and report.ok
def _errors(result, redraw=None):
dm = VisualTestDisplayManager(width=W, height=H)
return check_vegas_elements(_Broken(dm, result, redraw), dm)
def test_each_broken_clause_is_an_error():
assert "expected a list" in _errors("x").errors[0]
assert "not a VegasElement" in _errors([object()]).errors[0]
assert "twice" in _errors([VegasElement("k", _img(10)),
VegasElement("k", _img(10))]).errors[0]
assert "tall" in _errors([VegasElement("k", _img(10, H + 1))]).errors[0]
assert "no key" in _errors([VegasElement("", _img(10))]).errors[0]
def test_a_width_that_changes_with_nothing_new_is_an_error():
widths = iter([10, 12, 10, 10])
report = _errors(lambda: [VegasElement("k", _img(next(widths)))])
assert any("changed width" in e for e in report.errors)
def test_a_redraw_of_the_wrong_size_is_an_error():
report = _errors([VegasElement("m", _img(40), refresh_hz=2)],
redraw=lambda w, h: _img(w + 1, h))
assert any("asked for 40x48" in e for e in report.errors)
def test_animation_without_a_redraw_is_a_warning():
dm = VisualTestDisplayManager(width=W, height=H)
class NoRedraw(_Broken):
redraw_vegas_element = BasePlugin.redraw_vegas_element
report = check_vegas_elements(
NoRedraw(dm, [VegasElement("m", _img(40), refresh_hz=2)]), dm)
assert report.ok
assert any("not implemented" in w for w in report.warnings)
def test_none_means_legacy_content_and_is_only_a_warning():
report = _errors(None)
assert report.ok and "get_vegas_content" in report.warnings[0]
def test_a_refresh_rate_that_is_not_a_number_is_an_error_not_a_crash():
report = _errors([VegasElement("m", _img(40), refresh_hz="fast")])
assert any("not a number" in e for e in report.errors)
# None is what a plugin passing the dataclass default through gets.
assert _errors([VegasElement("m", _img(40), refresh_hz=None)]).ok
def test_an_empty_image_is_an_error():
assert any("empty" in e for e in _errors([VegasElement("k", _img(0))]).errors)
def test_a_second_call_that_breaks_the_contract_is_an_error_not_a_crash():
answers = iter([[VegasElement("k", _img(10))], "x", "x", "x"])
report = _errors(lambda: next(answers))
assert any("second call" in e for e in report.errors)
answers = iter([[VegasElement("k", _img(10))], [VegasElement("k", "not an image")],
[], []])
report = _errors(lambda: next(answers))
assert any("disappeared" in e for e in report.errors)
def test_check_plugin_reports_a_failing_element_check_and_carries_on(monkeypatch):
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
import check_plugin
def boom(*args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr(check_plugin, "check_plugin_vegas_elements", boom)
results = check_plugin.check_one(
"vegas-live-stub", [str(STUB.parent)], [(W, H)], {}, {}, False, None,
False, None, None)
vegas = [r for r in results if r.mode == "vegas elements"]
assert len(vegas) == 1 and "boom" in vegas[0].error