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>
This commit is contained in:
Chuck
2026-09-30 20:59:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 596809acc3
commit 56947298d6
25 changed files with 2578 additions and 31 deletions
+67
View File
@@ -23,6 +23,7 @@ from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
from src import display_watchdog
from src.common import render_gate
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.elements import LiveEpochs
from src.vegas_mode.plugin_adapter import PluginAdapter
from src.vegas_mode.stream_manager import StreamManager
from src.vegas_mode.render_pipeline import RenderPipeline
@@ -86,6 +87,9 @@ class VegasModeCoordinator:
# Class-level so coordinators built without __init__ (tests) have it.
_last_live_check: float = float('-inf')
#: Whether live elements are on for this run (see _apply_live_state).
live_active: bool = False
_live_reason: Optional[str] = None
# Set only while Vegas has changed the GIL switch interval; read with getattr.
_saved_switch_interval: Optional[float]
@@ -124,6 +128,12 @@ class VegasModeCoordinator:
self.stream_manager
)
# Live elements: one data epoch per plugin, shared with the adapter,
# which stamps every element it draws with it. Moved on by the plugin
# manager's update listener while Vegas runs. See _apply_live_state.
self.live_epochs = LiveEpochs()
self.plugin_adapter.live_epochs = self.live_epochs
# State management
self._is_active = False
self._is_paused = False
@@ -293,6 +303,9 @@ class VegasModeCoordinator:
self._fps_was_degraded = False
self._apply_switch_interval()
self._install_render_gate()
# Before the first background fetch below, which is the first
# that may ask a plugin for live elements.
self._apply_live_state()
# Line up the next group immediately, so the first extension is already
# warm rather than stalling the scroll to fetch it.
@@ -319,6 +332,7 @@ class VegasModeCoordinator:
self._restore_switch_interval()
self._remove_render_gate()
self._set_live(False, None)
# Cleanup components
self.render_pipeline.reset()
@@ -344,6 +358,57 @@ class VegasModeCoordinator:
sys.setswitchinterval(saved)
self._saved_switch_interval = None
# -- live elements ------------------------------------------------------
def _live_blocker(self) -> Optional[str]:
"""Why live elements must stay off for this run, or None if they may run."""
cfg = self.vegas_config
if not getattr(cfg, 'live_refresh', False):
return "switched off (vegas_scroll.live_refresh)"
if getattr(self.render_pipeline, 'sync_manager', None) is not None:
# The follower mirrors whole strips only; a patch would not reach it.
return "multi-display sync is configured"
if not cfg.continuous_scroll:
return "swap mode (vegas_scroll.continuous_scroll is off)"
if not cfg.offscreen_prefetch:
return "vegas_scroll.offscreen_prefetch is off"
if not hasattr(self.display_manager, 'offscreen'):
return "the display manager has no off-screen canvas"
return None
def _apply_live_state(self) -> None:
"""Switch live elements on or off for this run, as the config allows."""
blocker = self._live_blocker()
self._set_live(blocker is None, blocker)
def _set_live(self, active: bool, reason: Optional[str]) -> None:
was, self.live_active = self.live_active, active
# getattr: tests build coordinators without every component.
adapter = getattr(self, 'plugin_adapter', None)
if adapter is not None:
adapter.live_elements_enabled = active
plugin_manager = getattr(self, 'plugin_manager', None)
add = getattr(plugin_manager, 'add_update_listener', None)
remove = getattr(plugin_manager, 'remove_update_listener', None)
if active and callable(add):
add(self._on_plugin_data_changed)
elif not active and callable(remove):
remove(self._on_plugin_data_changed)
if active != was or (reason is not None and reason != self._live_reason):
if active:
logger.info("Vegas live elements on")
elif reason is not None:
logger.info("Vegas live elements off: %s", reason)
self._live_reason = reason
def _on_plugin_data_changed(self, plugin_id: str) -> None:
"""Update listener: a plugin's data may have changed.
Runs on the update worker with the plugin's lock held, so it only
moves the plugin's epoch on; whatever redraws happen later read it.
"""
self.live_epochs.bump(plugin_id)
def _install_render_gate(self) -> None:
"""Gate the prefetch thread on the render thread's swaps; see VegasModeConfig."""
if not self.vegas_config.prefetch_gate:
@@ -774,6 +839,8 @@ class VegasModeCoordinator:
# Cached segments were trimmed under the old settings, so drop them
# or a changed trim/padding value would not visibly take effect.
self.plugin_adapter.invalidate_cache()
if self._is_active:
self._apply_live_state()
# Force refresh of stream manager to pick up plugin_order/buffer changes
self.stream_manager._last_refresh = 0