mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
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>
200 lines
7.0 KiB
Python
200 lines
7.0 KiB
Python
"""Where live elements land in the Vegas strip, and that the records stay true.
|
|
|
|
The pipeline records each live element's place (ElementRecord) as it builds
|
|
the strip, in absolute columns that a trim never moves: the strip column is
|
|
``abs_x - _strip_origin``. Everything a live update will do depends on these
|
|
being exact, so the check here is the strongest one available -- after any
|
|
sequence of compose, extend and trim, the strip's pixels at every record are
|
|
the element's own pixels.
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from src.vegas_mode import elements # noqa: E402
|
|
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
|
from src.vegas_mode.elements import ElementMeta # noqa: E402
|
|
from src.vegas_mode.render_pipeline import RenderPipeline # noqa: E402
|
|
|
|
W, H = 128, 32
|
|
|
|
|
|
def _live(pid, key, width, seed):
|
|
"""A tagged, pinned element as the adapter hands it over."""
|
|
rng = np.random.default_rng(seed)
|
|
pixels = rng.integers(20, 255, (H, width, 3), dtype=np.uint8)
|
|
image = Image.frombytes("RGB", (width, H), pixels.tobytes())
|
|
pinned, array = elements.pin_element(image, 8)
|
|
return elements.tag(pinned, ElementMeta(
|
|
pid, key, 1, elements.pixel_digest(array), 0.0, 0.0))
|
|
|
|
|
|
def _plain(width, colour=(200, 200, 200)):
|
|
image = Image.new("RGB", (width, H), (0, 0, 0))
|
|
ImageDraw.Draw(image).rectangle([0, 0, width - 1, H - 1], outline=colour)
|
|
return image
|
|
|
|
|
|
class _Stream:
|
|
def __init__(self, groups):
|
|
self.groups = groups
|
|
self.plugin_manager = type("PM", (), {"plugins": {}})()
|
|
self.plugin_adapter = None
|
|
self.i = 0
|
|
|
|
def get_grouped_content_for_composition(self):
|
|
return self.groups[0]
|
|
|
|
def get_active_plugin_ids(self):
|
|
return [pid for pid, _ in self.groups[0]]
|
|
|
|
def take_next_group(self, count=None, offscreen_only=False):
|
|
self.i += 1
|
|
return self.groups[self.i] if self.i < len(self.groups) else []
|
|
|
|
|
|
class _DM:
|
|
width, height = W, H
|
|
|
|
def __init__(self):
|
|
self.image = Image.new("RGB", (W, H))
|
|
|
|
def set_scrolling_state(self, *a):
|
|
pass
|
|
|
|
def update_display(self):
|
|
pass
|
|
|
|
|
|
def _pipeline(groups, **cfg):
|
|
cfg.setdefault("continuous_scroll", True)
|
|
return RenderPipeline(VegasModeConfig(**cfg), _DM(), _Stream(groups))
|
|
|
|
|
|
def _assert_records_match(p, images_by_key):
|
|
strip = p.scroll_helper.cached_array
|
|
for record in p.live_records():
|
|
x = record.abs_x - p._strip_origin
|
|
expected = np.asarray(images_by_key[record.key])
|
|
assert expected.shape[1] == record.width
|
|
lo = max(0, x)
|
|
got = strip[:, lo:x + record.width]
|
|
assert np.array_equal(got, expected[:, lo - x:]), record.key
|
|
|
|
|
|
def _group_images(groups):
|
|
found = {}
|
|
for group in groups:
|
|
for _pid, images in group:
|
|
for image in images:
|
|
meta = elements.meta_of(image)
|
|
if meta:
|
|
found[meta.key] = image
|
|
return found
|
|
|
|
|
|
def test_a_compose_records_every_live_element_exactly():
|
|
groups = [[("a", [_live("a", "a1", 40, 1), _plain(20), _live("a", "a2", 50, 2)]),
|
|
("b", [_plain(60)]),
|
|
("c", [_live("c", "c1", 70, 3)])]]
|
|
p = _pipeline(groups, lead_in_width=10)
|
|
assert p.compose_scroll_content()
|
|
keys = [r.key for r in p.live_records()]
|
|
assert keys == ["a1", "a2", "c1"]
|
|
assert all(r.width == 16 + {"a1": 40, "a2": 50, "c1": 70}[r.key]
|
|
for r in p.live_records())
|
|
_assert_records_match(p, _group_images(groups))
|
|
|
|
|
|
def test_extensions_and_trims_keep_every_record_true():
|
|
# A strip long enough that the viewport never runs off its end (trimming
|
|
# is refused while it wraps), advanced by less than each extension adds.
|
|
groups = [[("a", [_live("a", "a1", 40, 1), _plain(500)])]]
|
|
for n in range(12):
|
|
groups.append([(f"p{n}", [_live(f"p{n}", f"k{n}", 30 + n, 10 + n),
|
|
_plain(25)]),
|
|
(f"q{n}", [_plain(45)])])
|
|
p = _pipeline(groups, lead_in_width=0)
|
|
assert p.compose_scroll_content()
|
|
images = _group_images(groups)
|
|
cuts = 0
|
|
for _ in range(11):
|
|
width_before = p.scroll_helper.cached_array.shape[1]
|
|
p.scroll_helper.scroll_position += 150
|
|
assert p.scroll_helper.scroll_position + W <= width_before
|
|
position = p.scroll_helper.scroll_position
|
|
assert p.extend_scroll_content()
|
|
cut = int(position - p.scroll_helper.scroll_position)
|
|
assert 0 <= cut <= width_before
|
|
cuts += cut
|
|
assert p._strip_origin == cuts
|
|
_assert_records_match(p, images)
|
|
# Records wholly trimmed away are forgotten, and only those.
|
|
for record in p.live_records():
|
|
assert record.abs_x + record.width > p._strip_origin
|
|
assert set(p._record_by_seq) == {r.seq for r in p.live_records()}
|
|
assert cuts > 0
|
|
|
|
|
|
def test_the_first_extension_of_an_empty_strip_starts_at_zero():
|
|
groups = [[], [("a", [_live("a", "a1", 40, 1)]), ("b", [_live("b", "b1", 20, 2)])]]
|
|
p = _pipeline(groups)
|
|
assert p.extend_scroll_content()
|
|
first = p.live_records()[0]
|
|
assert first.abs_x == 0 and p._strip_origin == 0
|
|
_assert_records_match(p, _group_images(groups))
|
|
|
|
|
|
def test_plain_content_is_not_recorded():
|
|
p = _pipeline([[("a", [_plain(80)])], [("b", [_plain(90)])]])
|
|
assert p.compose_scroll_content()
|
|
assert p.extend_scroll_content()
|
|
assert p.live_records() == ()
|
|
|
|
|
|
def test_a_new_strip_starts_a_new_generation_with_no_records():
|
|
groups = [[("a", [_live("a", "a1", 40, 1)])]]
|
|
p = _pipeline(groups)
|
|
assert p.compose_scroll_content()
|
|
gen = p._strip_gen
|
|
assert p.live_records()
|
|
assert p.compose_scroll_content()
|
|
assert p._strip_gen == gen + 1
|
|
assert [r.key for r in p.live_records()] == ["a1"]
|
|
p.reset()
|
|
assert p._strip_gen == gen + 2
|
|
assert p.live_records() == () and p._strip_origin == 0
|
|
|
|
|
|
def test_record_numbers_are_never_reused():
|
|
groups = [[("a", [_live("a", "a1", 40, 1)])]]
|
|
p = _pipeline(groups)
|
|
seen = set()
|
|
for _ in range(3):
|
|
assert p.compose_scroll_content()
|
|
for record in p.live_records():
|
|
assert record.seq not in seen
|
|
seen.add(record.seq)
|
|
|
|
|
|
def test_static_markers_still_land_where_they_did():
|
|
# The marker arithmetic now shares _block_starts with the records.
|
|
class Stream(_Stream):
|
|
def is_static_plugin(self, pid):
|
|
return pid == "pause"
|
|
|
|
groups = [[("a", [_plain(100)])],
|
|
[("b", [_plain(60)]), ("pause", []), ("c", [_plain(70)])]]
|
|
p = RenderPipeline(VegasModeConfig(continuous_scroll=True, lead_in_width=0,
|
|
separator_width=32),
|
|
_DM(), Stream(groups))
|
|
assert p.compose_scroll_content()
|
|
strip_end = p.scroll_helper.total_scroll_width
|
|
assert p.extend_scroll_content()
|
|
# b starts after one separator and ends 60 later; the pause follows b.
|
|
assert p._static_markers == ((strip_end + 32 + 60, "pause"),)
|