mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +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>
209 lines
7.7 KiB
Python
209 lines
7.7 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,
|
|
render_vegas_strip, render_vegas_timeline,
|
|
)
|
|
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
|
|
|
|
|
|
def test_a_timeline_moves_what_animates_and_nothing_else():
|
|
import numpy as np
|
|
plugin, dm = _stub(dot_speed=200)
|
|
_block, layout = render_vegas_strip(plugin, "vegas-live-stub", dm)
|
|
image, rows = render_vegas_timeline(plugin, "vegas-live-stub", dm, steps=3,
|
|
step_seconds=0.5)
|
|
assert rows == 3 and image.height == 3 * H + 2
|
|
pixels = np.asarray(image)
|
|
first, last = pixels[:H], pixels[2 * (H + 1):]
|
|
columns = {key: (x, width) for x, key, width in layout}
|
|
x, width = columns["map"]
|
|
assert (first[:, x:x + width] != last[:, x:x + width]).any()
|
|
x, width = columns["card:0"]
|
|
assert (first[:, x:x + width] == last[:, x:x + width]).all()
|
|
|
|
|
|
def test_a_timeline_with_updates_redraws_the_cards_in_place():
|
|
import numpy as np
|
|
plugin, dm = _stub(map_hz=0)
|
|
_block, layout = render_vegas_strip(plugin, "vegas-live-stub", dm)
|
|
image, rows = render_vegas_timeline(plugin, "vegas-live-stub", dm, steps=2,
|
|
run_update=True)
|
|
pixels = np.asarray(image)
|
|
x, width = {key: (x, width) for x, key, width in layout}["card:0"]
|
|
assert (pixels[:H, x:x + width] != pixels[H + 1:, x:x + width]).any()
|