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>
This commit is contained in:
Chuck
2026-09-30 18:36:39 -04:00
co-authored by Claude Opus 5.5
parent 1160eb5efe
commit 701c220ad0
25 changed files with 2578 additions and 31 deletions
+53 -4
View File
@@ -16,7 +16,7 @@ import time
import threading
import types
from pathlib import Path
from typing import Dict, List, NamedTuple, Optional, Any, Tuple, Union
from typing import Callable, Dict, List, NamedTuple, Optional, Any, Tuple, Union
import logging
from src import display_watchdog
from src.exceptions import PluginError, ConfigError
@@ -197,6 +197,11 @@ class PluginManager:
# run_scheduled_updates_with_changes().
self._completed_updates: set = set()
self._completed_updates_lock = threading.Lock()
# Called with a plugin id the moment its data may have changed: its
# update() completed, or it called notify_vegas_data_changed(). See
# add_update_listener(). A tuple, replaced rather than mutated, so the
# worker can iterate it without a lock.
self._update_listeners: Tuple[Callable[[str], None], ...] = ()
# Config changes that found the plugin's lock busy, latest per plugin,
# with the instance they were meant for. See apply_config_change().
self._deferred_config_changes: Dict[str, Tuple[Any, Dict[str, Any]]] = {}
@@ -589,8 +594,8 @@ class PluginManager:
#: prefix rule would silently stop validating it.
#:
#: Read by: ``vegas_mode/plugin_adapter.py`` (``vegas_width_pct``,
#: ``vegas_overflow``) and ``base_plugin.py`` (``vegas_max_width_screens``,
#: ``vegas_participation``).
#: ``vegas_overflow``, ``vegas_live``) and ``base_plugin.py``
#: (``vegas_max_width_screens``, ``vegas_participation``).
#:
#: The list itself lives with the other core-owned per-plugin properties in
#: ``schema_manager.CORE_PLUGIN_PROPERTIES``, which the web save path also
@@ -1723,9 +1728,53 @@ class PluginManager:
return self.drain_completed_updates()
def _note_update_completed(self, plugin_id: str) -> None:
"""Record that a plugin's update() finished, for the next poll."""
"""Record that a plugin's update() finished, for the next poll.
Also tells the update listeners at once, so Vegas live elements are
redrawn the moment new data lands instead of at the next ~4s poll.
This runs while the plugin's lock is still held (see _finish), which
is what makes the listeners' contract strict.
"""
with self._completed_updates_lock:
self._completed_updates.add(plugin_id)
self._fire_update_listeners(plugin_id)
def add_update_listener(self, listener: Callable[[str], None]) -> None:
"""Call ``listener(plugin_id)`` whenever a plugin's data may have changed.
That is: its update() completed successfully, or it called
notify_vegas_data_changed(). The listener runs on the thread that
noticed -- the update worker, with the plugin's lock still held, or
the plugin's own thread -- so it must return at once and take no lock
a plugin could hold: record the id and hand off (a dict store, a
queue put). An exception from it is logged and does not reach the
plugin. Adding the same listener twice has no effect.
"""
# __dict__.get: tests build bare managers with PluginManager.__new__.
listeners = self.__dict__.get('_update_listeners', ())
if listener not in listeners:
self._update_listeners = listeners + (listener,)
def remove_update_listener(self, listener: Callable[[str], None]) -> None:
"""Stop calling a listener added with add_update_listener()."""
self._update_listeners = tuple(
fn for fn in self.__dict__.get('_update_listeners', ()) if fn != listener)
def notify_data_changed(self, plugin_id: str) -> None:
"""A plugin's data changed outside update(); tell the update listeners.
BasePlugin.notify_vegas_data_changed() lands here.
"""
self._fire_update_listeners(plugin_id)
def _fire_update_listeners(self, plugin_id: str) -> None:
for listener in self.__dict__.get('_update_listeners', ()):
try:
listener(plugin_id)
except Exception as exc: # pylint: disable=broad-except
self._warn_rate_limited(
"update-listener",
"An update listener failed for plugin %s: %r", plugin_id, exc)
def drain_completed_updates(self) -> List[str]:
"""Return and clear the plugin ids whose update() has since finished."""