mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15: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> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
6.5 KiB
Python
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
|