Files
LEDMatrix/src/vegas_mode/stream_manager.py
T
ChuckandClaude Opus 5.5 f4bda50710 feat(vegas): live elements update in place while they scroll (#697)
* 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>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:07:31 -04:00

809 lines
32 KiB
Python

"""
Stream Manager for Vegas Mode
Manages plugin content streaming with look-ahead buffering. Maintains a queue
of plugin content that's ready to be rendered, prefetching 1-2 plugins ahead
of the current scroll position.
Each plugin takes part in one of three ways (its Vegas participation, see
BasePlugin.get_vegas_participation):
- 'scroll': its content joins the strip
- 'pause': the scroll pauses for its turn (a STATIC segment, marked for the
coordinator)
- 'exclude': left out of the rotation
"""
import logging
import threading
import time
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
from collections import deque
from dataclasses import dataclass, field
from PIL import Image
from src import display_watchdog
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.plugin_adapter import PluginAdapter
from src.plugin_system.base_plugin import VegasDisplayMode, resolve_vegas_participation
if TYPE_CHECKING:
from src.plugin_system.plugin_manager import PluginManager
logger = logging.getLogger(__name__)
@dataclass
class ContentSegment:
"""One plugin's content for a cycle.
A STATIC segment carries no images: it marks where the coordinator pauses
the scroll to show a plugin whose participation is ``'pause'``. Every
other segment is SCROLL.
"""
plugin_id: str
images: List[Image.Image]
display_mode: VegasDisplayMode = field(default=VegasDisplayMode.SCROLL)
class StreamManager:
"""
Manages streaming of plugin content for Vegas scroll mode.
Key responsibilities:
- Maintain the ordered (and priority-weighted) rotation of plugins
- Fill the active buffer with a cycle's worth of segments (swap mode), or
hand out the next group of plugins directly (continuous mode)
- Track plugins whose data changed in ``_pending_updates``, which
:meth:`process_updates` (swap mode) or
:meth:`invalidate_pending_updates` (continuous mode) consumes
"""
def __init__(
self,
config: VegasModeConfig,
plugin_manager: 'PluginManager',
plugin_adapter: PluginAdapter
):
"""
Initialize the stream manager.
Args:
config: Vegas mode configuration
plugin_manager: Plugin manager for accessing plugins
plugin_adapter: Adapter for getting plugin content
"""
self.config = config
self.plugin_manager = plugin_manager
self.plugin_adapter = plugin_adapter
# Segments composed into the current cycle (swap mode only).
self._active_buffer: Deque[ContentSegment] = deque()
# Reentrant: get_next_segment holds it while calling
# _prefetch_content, which acquires it again. _prefetch_content's
# release() around the slow fetch only frees the lock when its caller
# did not already hold it (initialize); from get_next_segment the
# count only drops to 1, so the fetch runs with the lock held.
self._buffer_lock = threading.RLock()
# Plugin rotation, and the position of the next plugin to fetch in it.
self._ordered_plugins: List[str] = []
self._prefetch_index: int = 0
# Update tracking
self._pending_updates: Dict[str, bool] = {}
self._last_refresh: float = 0.0
self._refresh_interval: float = 30.0 # Refresh plugin list every 30s
# Statistics
self.stats = {
'segments_fetched': 0,
'segments_served': 0,
'fetch_errors': 0,
}
logger.info("StreamManager initialized with buffer_ahead=%d", config.buffer_ahead)
def initialize(self) -> bool:
"""
Initialize the stream manager with current plugin list.
Returns:
True if initialized successfully with at least one plugin
"""
self._refresh_plugin_list()
if not self._ordered_plugins:
logger.warning("No plugins available for Vegas scroll")
return False
# Fill the buffer to a whole cycle's worth of plugins. This used to be
# buffer_ahead + 1, which conflated prefetch depth with cycle size and
# meant a 20-plugin install only showed 3 plugins before recomposing.
self._prefetch_content(
count=min(self.config.plugins_per_cycle, len(self._ordered_plugins)))
logger.info(
"StreamManager initialized with %d plugins, %d segments buffered",
len(self._ordered_plugins), len(self._active_buffer)
)
return len(self._active_buffer) > 0
def get_next_segment(self) -> Optional[ContentSegment]:
"""
Get the next content segment for rendering.
Returns:
ContentSegment or None if buffer is empty
"""
with self._buffer_lock:
if not self._active_buffer:
# Try to fetch more content
self._prefetch_content(count=1)
if not self._active_buffer:
return None
segment = self._active_buffer.popleft()
self.stats['segments_served'] += 1
# Trigger prefetch to maintain buffer
self._ensure_buffer_filled()
return segment
def peek_next_segment(self) -> Optional[ContentSegment]:
"""
Peek at the next segment without removing it.
Returns:
ContentSegment or None if buffer is empty
"""
with self._buffer_lock:
if self._active_buffer:
return self._active_buffer[0]
return None
def get_buffer_status(self) -> Dict[str, Any]:
"""Get current buffer status for monitoring."""
with self._buffer_lock:
return {
'active_count': len(self._active_buffer),
'total_plugins': len(self._ordered_plugins),
'prefetch_index': self._prefetch_index,
'stats': self.stats.copy(),
}
def get_active_plugin_ids(self) -> List[str]:
"""
Get list of plugin IDs currently in the active buffer.
Thread-safe accessor for render pipeline.
Returns:
List of plugin IDs in buffer order
"""
with self._buffer_lock:
return [seg.plugin_id for seg in self._active_buffer]
def mark_plugin_updated(self, plugin_id: str) -> None:
"""
Mark a plugin as having updated data.
Only records it in ``_pending_updates``. The refetch happens later,
in :meth:`process_updates` (swap mode) or by dropping the plugin's
caches in :meth:`invalidate_pending_updates` (continuous mode).
Args:
plugin_id: Plugin that was updated
"""
with self._buffer_lock:
self._pending_updates[plugin_id] = True
logger.debug("Plugin %s marked for update", plugin_id)
def invalidate_pending_updates(self) -> List[str]:
"""
Drop cached content for plugins whose data changed, without refetching.
The continuous-scroll counterpart to :meth:`process_updates`. That method
belongs to the swap path: it refetches immediately and merges into the
active buffer, which continuous mode bypasses entirely, and doing that
work on the render thread would hitch the scroll.
Here it is enough to clear the caches and let the plugin come round in
the rotation, which recomposes it from current data a moment later. Left
uncalled, ``_pending_updates`` simply accumulates and no visual ever
refreshes — a game that was live last night keeps being drawn as live.
Returns:
The plugin ids whose caches were dropped.
"""
with self._buffer_lock:
if not self._pending_updates:
return []
updated = list(self._pending_updates.keys())
self._pending_updates.clear()
plugins = getattr(self.plugin_manager, 'plugins', {})
for plugin_id in updated:
try:
self.plugin_adapter.invalidate_cache(plugin_id)
plugin = plugins.get(plugin_id)
if plugin is not None:
self.plugin_adapter.invalidate_plugin_scroll_cache(
plugin, plugin_id)
except Exception: # pylint: disable=broad-except
logger.exception(
"[%s] Could not invalidate cached content", plugin_id)
logger.info(
"Vegas: dropped cached content for %d updated plugin(s): %s",
len(updated), ', '.join(updated)
)
return updated
def has_pending_updates_for_visible_segments(self) -> bool:
"""Check if pending updates affect plugins currently in the active buffer."""
with self._buffer_lock:
if not self._pending_updates:
return False
active_ids = {
seg.plugin_id for seg in self._active_buffer if seg.images
}
return bool(active_ids & self._pending_updates.keys())
def process_updates(self) -> None:
"""
Process pending plugin updates.
Performs in-place update of segments in the active buffer,
preserving non-updated plugins and their order.
"""
with self._buffer_lock:
if not self._pending_updates:
return
updated_plugins = list(self._pending_updates.keys())
self._pending_updates.clear()
# Fetch fresh content for each updated plugin (outside lock for slow ops)
refreshed_segments = {}
for plugin_id in updated_plugins:
self.plugin_adapter.invalidate_cache(plugin_id)
# Clear the plugin's scroll_helper cache so the visual is rebuilt
# from fresh data (affects stocks, news, odds-ticker, etc.)
plugin = None
if hasattr(self.plugin_manager, 'plugins'):
plugin = self.plugin_manager.plugins.get(plugin_id)
if plugin:
self.plugin_adapter.invalidate_plugin_scroll_cache(plugin, plugin_id)
segment = self._fetch_plugin_content(plugin_id)
if segment:
refreshed_segments[plugin_id] = segment
# In-place merge: replace segments in active buffer
with self._buffer_lock:
# Build new buffer preserving order, replacing updated segments
new_buffer: Deque[ContentSegment] = deque()
seen_plugins: set = set()
for segment in self._active_buffer:
if segment.plugin_id in refreshed_segments:
# Replace with refreshed segment (only once per plugin)
if segment.plugin_id not in seen_plugins:
new_buffer.append(refreshed_segments[segment.plugin_id])
seen_plugins.add(segment.plugin_id)
# Skip duplicate entries for same plugin
else:
# Keep non-updated segment
new_buffer.append(segment)
self._active_buffer = new_buffer
logger.debug("Processed in-place updates for %d plugins", len(updated_plugins))
def refresh(self) -> None:
"""
Refresh the plugin list and content.
Called periodically to pick up new plugins or config changes.
"""
current_time = time.time()
if current_time - self._last_refresh < self._refresh_interval:
return
self._last_refresh = current_time
old_count = len(self._ordered_plugins)
self._refresh_plugin_list()
if len(self._ordered_plugins) != old_count:
logger.debug(
"Plugin list refreshed: %d -> %d plugins",
old_count, len(self._ordered_plugins)
)
def _refresh_plugin_list(self) -> None:
"""Refresh the ordered list of plugins from plugin manager.
Runs at every cycle start and every ``_refresh_interval`` seconds, so
it logs one INFO summary; the per-plugin decisions are at DEBUG.
"""
available_plugins = []
loaded = 0
if hasattr(self.plugin_manager, 'plugins'):
loaded = len(self.plugin_manager.plugins)
for plugin_id, plugin in self.plugin_manager.plugins.items():
if not getattr(plugin, 'enabled', False):
logger.debug("[%s] Vegas: skipped (not enabled)", plugin_id)
continue
# 'pause' plugins stay in the rotation: they pause the scroll
# for their turn rather than contributing to it.
participation = resolve_vegas_participation(plugin, plugin_id)
included = participation != 'exclude'
logger.debug(
"[%s] Vegas: %s (participation=%s)",
plugin_id, "included" if included else "excluded",
participation
)
if included:
available_plugins.append(plugin_id)
else:
logger.warning(
"plugin_manager does not have plugins attribute: %s",
type(self.plugin_manager).__name__
)
# Apply ordering from config (outside lock for potentially slow operation)
ordered_plugins = self.config.get_ordered_plugins(available_plugins)
ordered_plugins = self._apply_priority_weights(ordered_plugins)
# Atomically update shared state under lock to avoid races with prefetchers
with self._buffer_lock:
self._ordered_plugins = ordered_plugins
if self._prefetch_index >= len(self._ordered_plugins):
self._prefetch_index = 0
slots = (f", {len(ordered_plugins)} slots"
if len(ordered_plugins) != len(set(ordered_plugins)) else "")
logger.info(
"Vegas rotation: %d of %d loaded plugin(s)%s: %s",
len(set(ordered_plugins)), loaded, slots, ', '.join(ordered_plugins)
)
def _plugin_weight(self, plugin_id: str) -> int:
"""Slots per cycle for one plugin.
A plugin may answer for itself via get_vegas_priority_weight() -- the
only way favorite-team awareness can reach here, since the core can see
that a game is live but not whose. When it declines (returns None, the
default), live content earns ``live_weight`` and everything else 1.
"""
plugin = None
try:
plugin = self.plugin_manager.plugins.get(plugin_id)
except (AttributeError, TypeError):
return 1
if plugin is None:
return 1
try:
if hasattr(plugin, 'get_vegas_priority_weight'):
declared = plugin.get_vegas_priority_weight()
if declared is not None:
return max(1, min(10, int(declared)))
except Exception:
# Deliberately falls through to the core's own live check rather
# than demoting to 1. The plugin's weight calculation is broken,
# but has_live_priority() and has_live_content() are separate
# methods guarded separately below -- a plugin that genuinely has
# a live game should still get live_weight for it.
logger.exception("[%s] get_vegas_priority_weight() failed", plugin_id)
try:
if (hasattr(plugin, 'has_live_priority')
and hasattr(plugin, 'has_live_content')
and plugin.has_live_priority()
and plugin.has_live_content()):
return self.config.live_weight
except Exception:
logger.exception("[%s] live-content check failed", plugin_id)
return 1
def _apply_priority_weights(self, ordered: List[str]) -> List[str]:
"""Expand the rotation so weighted plugins take several turns per cycle.
Smooth Weighted Round-Robin, the same scheduler the sports plugins use
to rotate their own games: a plugin of weight N appears N times per
cycle, and the repeats are spaced through the cycle rather than
clumped, so a live score is never three-in-a-row followed by a long
silence.
Returns the input unchanged when nothing is weighted, which is both the
common case and the pre-existing behaviour.
"""
if not ordered or not self.config.live_in_ticker:
return ordered
weights = {pid: self._plugin_weight(pid) for pid in ordered}
total = sum(weights.values())
if total <= len(ordered):
return ordered # nothing boosted; plain round robin
current = {pid: 0 for pid in ordered}
schedule: List[str] = []
for _ in range(total):
for pid in ordered:
current[pid] += weights[pid]
picked = max(current, key=lambda p: current[p])
current[picked] -= total
schedule.append(picked)
schedule = self._unclump_seam(schedule)
boosted = {p: w for p, w in weights.items() if w > 1}
logger.debug(
"Vegas rotation weighted: %d slots for %d plugins (boosted: %s)",
len(schedule), len(ordered), boosted)
return schedule
@staticmethod
def _unclump_seam(schedule: List[str]) -> List[str]:
"""Stop the heaviest plugin sitting on both ends of the cycle.
Smooth Weighted Round-Robin spaces repeats well *within* a pass, but
it schedules the heaviest item first and often last too. The strip
loops, so those two are neighbours: the one place the marquee shows
the same plugin twice running is the seam between cycles.
Rotating the list cannot fix this. Rotation preserves the cyclic order
exactly, so it only moves where the seam is drawn, not the adjacency
itself. The trailing entry has to be swapped with one from the middle
whose neighbours differ from it, which breaks the pair without
creating another.
Left alone when no such position exists -- a rotation short enough or
lopsided enough to have none is one where the plugin is unavoidably
adjacent to itself anyway.
"""
if len(schedule) < 3 or schedule[0] != schedule[-1]:
return schedule
repeated = schedule[-1]
size = len(schedule)
def cyclic_doubles(seq) -> int:
return sum(1 for i in range(size) if seq[i] == seq[(i + 1) % size])
def clearance(seq, value) -> int:
"""Smallest cyclic gap between appearances of `value`."""
at = [i for i, v in enumerate(seq) if v == value]
if len(at) < 2:
return size
return min(min((b - a) % size, (a - b) % size)
for i, a in enumerate(at) for b in at[i + 1:])
# Try each swap and judge the result, rather than reasoning about which
# neighbours the two moved elements will end up with. That reasoning is
# where the first version went wrong: it guarded the slot `repeated`
# moves into but not the one the displaced element lands in, so
# ['a','b','c','d','x','y','x','a'] came back ending ['x','x'] -- the
# seam duplicate traded for a fresh one.
best = None
best_clearance = -1
for j in range(1, size - 1):
candidate = list(schedule)
candidate[j], candidate[-1] = candidate[-1], candidate[j]
if cyclic_doubles(candidate):
continue
# Among the repairs that work, prefer the one that leaves the
# boosted plugin most evenly spread; taking the first that merely
# fits moved a repeat from a gap of 7 into a gap of 2.
spread = clearance(candidate, repeated)
if spread > best_clearance:
best, best_clearance = candidate, spread
# None exists when the value is unavoidably adjacent to itself -- a
# plugin holding most of the slots has to be. Schedule it as it is
# rather than refuse.
return best if best is not None else schedule
def _prefetch_content(self, count: int = 1) -> None:
"""
Prefetch content for upcoming plugins.
Args:
count: Number of plugins to prefetch
"""
with self._buffer_lock:
if not self._ordered_plugins:
return
for _ in range(count):
if len(self._active_buffer) >= self.config.plugins_per_cycle:
break
# Ensure index is valid (guard against empty list)
num_plugins = len(self._ordered_plugins)
if num_plugins == 0:
break
plugin_id = self._ordered_plugins[self._prefetch_index]
# Release for the potentially slow content fetch. This frees
# the lock only when the caller did not hold it already
# (initialize); under get_next_segment's hold the RLock count
# just drops to 1 and other threads still wait.
self._buffer_lock.release()
try:
segment = self._fetch_plugin_content(plugin_id)
finally:
self._buffer_lock.acquire()
if segment:
self._active_buffer.append(segment)
# Revalidate num_plugins after reacquiring lock (may have changed)
num_plugins = len(self._ordered_plugins)
if num_plugins == 0:
break
# Advance prefetch index (thread-safe within lock)
self._prefetch_index = (self._prefetch_index + 1) % num_plugins
def _fetch_plugin_content(self, plugin_id: str) -> Optional[ContentSegment]:
"""
Fetch content from a specific plugin.
Args:
plugin_id: Plugin to fetch from
Returns:
ContentSegment or None if fetch failed
"""
# Composing a cycle fetches plugin after plugin on the render thread
# (on the prefetch thread this is ignored), so check in between.
display_watchdog.beat()
try:
if not hasattr(self.plugin_manager, 'plugins'):
logger.warning("[%s] plugin_manager has no plugins attribute", plugin_id)
return None
plugin = self.plugin_manager.plugins.get(plugin_id)
if not plugin:
logger.warning("[%s] Plugin not found in plugin_manager.plugins", plugin_id)
return None
# A 'pause' plugin gets a placeholder segment; the coordinator
# draws it with display() when the scroll reaches its turn.
if resolve_vegas_participation(plugin, plugin_id) == 'pause':
segment = ContentSegment(
plugin_id=plugin_id,
images=[], # No images needed for static pause
display_mode=VegasDisplayMode.STATIC
)
self.stats['segments_fetched'] += 1
logger.debug(
"[%s] Created STATIC placeholder (pause trigger)",
plugin_id
)
return segment
images = self.plugin_adapter.get_content(plugin, plugin_id)
if not images:
# The adapter already warns when every content path failed;
# an empty result is otherwise routine (nothing scheduled).
logger.debug("[%s] No Vegas content this cycle", plugin_id)
return None
# Calculate total width
total_width = sum(img.width for img in images)
segment = ContentSegment(
plugin_id=plugin_id,
images=images,
display_mode=VegasDisplayMode.SCROLL
)
self.stats['segments_fetched'] += 1
logger.debug(
"[%s] Segment: %d image(s), %dpx",
plugin_id, len(images), total_width
)
return segment
except Exception:
logger.exception("[%s] ERROR fetching content", plugin_id)
self.stats['fetch_errors'] += 1
return None
def _ensure_buffer_filled(self) -> None:
"""
Top the buffer back up after segments have been served.
buffer_ahead is the low-water mark only; plugins_per_cycle is the
ceiling and is enforced inside _prefetch_content.
"""
low_water = min(self.config.buffer_ahead, self.config.plugins_per_cycle)
if len(self._active_buffer) < low_water:
self._prefetch_content(count=low_water - len(self._active_buffer))
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
"""
Get buffered content grouped by the plugin that produced it.
The grouping matters: separator_width is meant to mark the handoff from
one plugin to the next, not to sit between every row a single plugin
contributes. A per-row ticker like the F1 scoreboard returns over a
hundred images that it renders 4px apart internally, so flattening them
into one list and applying a uniform gap forced 32px between each of
its rows — both inconsistent with how the plugin looks standalone, and
a large hidden addition to the width it occupies.
Skips STATIC segments, which trigger a pause rather than contributing
scroll content, and segments left with no images.
Returns:
List of (plugin_id, images) in buffer order
"""
grouped: List[Tuple[str, List[Image.Image]]] = []
with self._buffer_lock:
for segment in self._active_buffer:
if segment.display_mode == VegasDisplayMode.STATIC:
continue
if not segment.images:
continue
grouped.append((segment.plugin_id, list(segment.images)))
return grouped
def get_static_layout(self) -> List[Tuple[str, bool]]:
"""
The buffer's composition order, with STATIC segments kept in place.
get_grouped_content_for_composition() drops STATIC segments because
they contribute no columns; this says where they sat. Each entry is
(plugin_id, is_static), and only segments that composition keeps or
that are STATIC are listed, so the non-static entries line up one to
one with get_grouped_content_for_composition()'s groups.
"""
layout: List[Tuple[str, bool]] = []
with self._buffer_lock:
for segment in self._active_buffer:
if segment.display_mode == VegasDisplayMode.STATIC:
layout.append((segment.plugin_id, True))
elif segment.images:
layout.append((segment.plugin_id, False))
return layout
def is_static_plugin(self, plugin_id: str) -> bool:
"""Whether a loaded plugin asks Vegas to pause for it (participation 'pause').
Only 'pause' is acted on here. A plugin whose participation has turned
to 'exclude' since the rotation was built is still fetched this cycle,
as it always was; the next refresh drops it.
"""
plugin = getattr(self.plugin_manager, 'plugins', {}).get(plugin_id)
if plugin is None:
return False
return resolve_vegas_participation(plugin, plugin_id) == 'pause'
def take_next_group(
self, count: Optional[int] = None, offscreen_only: bool = False
) -> List[Tuple[str, Optional[List[Image.Image]]]]:
"""
Fetch and hand over the next slice of the rotation.
For continuous scrolling, where the strip is extended rather than
replaced. Advances the rotation index so plugins come round in order
across an unbroken strip, and bypasses the active buffer entirely — that
buffer exists to stage a *replacement* cycle, which continuous mode has
no use for.
Args:
count: Number of plugins to gather, defaulting to plugins_per_cycle
offscreen_only: Only use content paths that avoid the shared display
canvas, for use off the render thread
Returns:
Ordered list of (plugin_id, images). ``images`` is None when the
plugin could not be served under ``offscreen_only``, so the caller
can fetch just those on the render thread while keeping the order.
That only happens with ``offscreen_prefetch`` switched off: every
content path now draws on a canvas of its own, so a background
fetch that comes back empty had nothing to show, and ``images``
is an empty list rather than a request for the render thread.
A STATIC plugin is also returned with an empty list, unfetched: it
pauses the scroll instead of adding to it (see is_static_plugin).
"""
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
for plugin_id in self.plan_next_group(count):
member = self.fetch_group_member(plugin_id, offscreen_only=offscreen_only)
if member is not None:
group.append(member)
return group
def plan_next_group(self, count: Optional[int] = None) -> List[str]:
"""Which plugins the next group holds, advancing the rotation past them.
The first half of take_next_group(). The live-element worker fetches
a group one plugin at a time (fetch_group_member) so it can fit more
urgent redraws between them.
"""
if count is None:
count = self.config.plugins_per_cycle
self.refresh()
with self._buffer_lock:
if not self._ordered_plugins:
return []
total = len(self._ordered_plugins)
ids = []
for _ in range(min(max(1, count), total)):
ids.append(self._ordered_plugins[self._prefetch_index])
self._prefetch_index = (self._prefetch_index + 1) % total
return ids
def fetch_group_member(
self, plugin_id: str, offscreen_only: bool = False
) -> Optional[Tuple[str, Optional[List[Image.Image]]]]:
"""One plugin's entry in a group, as take_next_group() describes it.
None when the plugin is gone or its fetch raised: it is left out of
the group.
"""
plugin = getattr(self.plugin_manager, 'plugins', {}).get(plugin_id)
if not plugin:
return None
if self.is_static_plugin(plugin_id):
# A STATIC plugin pauses the scroll rather than scrolling by, so it
# contributes no columns. It keeps its place in the group (empty)
# so the pipeline can mark where its turn falls.
return (plugin_id, [])
try:
images = self.plugin_adapter.get_content(
plugin, plugin_id, offscreen_only=offscreen_only)
except Exception:
logger.exception("[%s] ERROR fetching content", plugin_id)
self.stats['fetch_errors'] += 1
return None
if images:
self.stats['segments_fetched'] += 1
return (plugin_id, images)
# Only the old contract hands anything back to the render thread.
defer_empty = offscreen_only and not getattr(
self.config, 'offscreen_prefetch', True)
return (plugin_id, None if defer_empty else [])
def advance_cycle(self) -> None:
"""
Advance to next cycle by clearing the active buffer.
Called when a scroll cycle completes to allow fresh content
to be fetched for the next cycle. Does not reset indices,
so prefetching continues from the current position in the
plugin order.
"""
with self._buffer_lock:
consumed_count = len(self._active_buffer)
self._active_buffer.clear()
logger.debug("Advanced cycle, cleared %d segments", consumed_count)
def reset(self) -> None:
"""Reset the stream manager state."""
with self._buffer_lock:
self._active_buffer.clear()
self._prefetch_index = 0
self._pending_updates.clear()
self.plugin_adapter.invalidate_cache()
logger.info("StreamManager reset")
def cleanup(self) -> None:
"""Clean up resources."""
self.reset()
self.plugin_adapter.cleanup()
logger.debug("StreamManager cleanup complete")