Vegas mode: one continuous strip instead of swapping cycles

A cycle used to be a discrete strip that got replaced: motion stopped, every
pixel was substituted at once, and the next group started with the viewport
already full. That is the freeze, the flash and the jump.

The strip is now extended rather than replaced. ScrollHelper gains
append_content(), which adds items on the right without touching
scroll_position or total_distance_scrolled, so motion continues and the next
group simply arrives from the right. Because completion is measured against
total_scroll_width, extending also defers completion — there is no longer a
cycle boundary to see.

drop_scrolled_prefix() reclaims what has gone past, keeping the strip bounded
however long Vegas runs (observed 5,000-11,000px against an unbounded strip
otherwise). It shifts total_distance_scrolled and total_scroll_width together so
the completion arithmetic is unchanged, and refuses to run while the viewport is
wrapping: wrapping reads the head of the strip into the right of the frame, so
trimming the head there would visibly change the picture. A test caught that.

Groups are prepared off the render thread. The constraint is that the canvas and
the matrix proxy are process-wide mutable state, so narrowing or capturing
through them from another thread would corrupt the frame the render loop is
pushing. get_content() therefore takes offscreen_only: the background thread uses
only paths that avoid the canvas, and anything needing it is marked and picked up
on the render thread. That puts the expensive work (native renders of leaderboard
and baseball cards, seconds each) in the background and leaves the cheap work
(display capture, 40-600ms) in the foreground.

DisplayManager's capture flag is now thread-local. As a shared flag, a background
capture would have suppressed the render loop's own frame pushes for its
duration, freezing the panel precisely when the point was to avoid a freeze.

Canvas-bound plugins are drained one at a time rather than as a batch: six at
once held the render thread for 1.75s. Drains are also spaced by two seconds
while the lookahead is healthy, since taking them back to back turns one long
stall into a run of short ones. When the strip is genuinely running short the
throttle is ignored, because content matters more than smoothness there.

Measured on hardware: zero cycle-complete swaps, drains landing 2-4s apart,
lookahead holding at 1,200-3,500px, no errors.

Set continuous_scroll false to restore the swap behaviour; the old path is intact.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
ChuckBuilds
2026-07-29 14:31:24 -04:00
co-authored by Claude
parent 554fb858af
commit 94032e2101
12 changed files with 1110 additions and 20 deletions
+122
View File
@@ -648,6 +648,128 @@ class ScrollHelper:
"""
return self.scroll_complete
def append_content(self, content_items: list,
item_gap: int = 32,
element_gap: int = 0) -> bool:
"""
Append items to the right of the existing strip, preserving scroll state.
Lets a caller keep one continuous strip instead of replacing it. Vegas
mode uses this so the next group of plugins scrolls in from the right
rather than the strip being swapped out underneath the viewer — a swap
shows as a flash and a hard cut to already-full-screen content.
``scroll_position`` and ``total_distance_scrolled`` are untouched, so
motion continues uninterrupted; only the strip gets longer. Because
completion is measured against ``total_scroll_width``, extending the
strip also defers completion, which is the intent.
Args:
content_items: Images to append, in order
item_gap: Gap between appended items, and between the existing
content and the first appended item
element_gap: Extra gap after each item, mirroring
create_scrolling_image
Returns:
True if content was appended
"""
if not content_items:
return False
if self.cached_image is None or self.cached_array is None:
# Nothing to extend yet — this is just the first build.
self.create_scrolling_image(
content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0)
return True
gap = max(0, item_gap)
addition_width = (
sum(img.width for img in content_items)
+ gap * len(content_items) # one leading gap per item
+ element_gap * len(content_items)
)
addition = Image.new('RGB', (addition_width, self.display_height), (0, 0, 0))
x = 0
for img in content_items:
x += gap # separate from whatever precedes
addition.paste(img, (x, 0))
x += img.width + element_gap
# numpy concatenate then one conversion back, rather than allocating a
# full-width PIL image and pasting twice: the strip can be tens of
# thousands of columns wide and this runs on the render path.
self.cached_array = np.concatenate(
(self.cached_array, np.array(addition)), axis=1)
self.cached_image = Image.fromarray(self.cached_array)
self.total_scroll_width = self.cached_image.width
self.scroll_complete = False
self.logger.info(
"Appended %d item(s) (%dpx) to scroll strip: now %dpx, position %.0f",
len(content_items), addition_width, self.total_scroll_width,
self.scroll_position
)
return True
def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
"""
Discard columns that have already scrolled past, to bound memory.
A continuously extended strip would otherwise grow without limit. All
the positional state is shifted by the amount removed so the visible
frame and the completion arithmetic are unchanged:
``total_distance_scrolled`` and ``total_scroll_width`` both shrink by the
same amount, preserving their difference.
Args:
keep_before: Columns to retain behind the current position, as a
safety margin against a caller reading slightly behind it
Returns:
Number of columns actually removed
"""
if self.cached_image is None or self.cached_array is None:
return 0
# While the viewport wraps, get_visible_portion fills its right-hand side
# from the *head* of the strip, so trimming the head would change what
# is on screen. Continuous mode extends before ever reaching that state;
# refusing here keeps "trimming is invisible" true unconditionally.
if self.scroll_position + self.display_width > self.cached_image.width:
return 0
cut = int(self.scroll_position) - max(0, keep_before)
if cut <= 0:
return 0
# Never trim so far that the remaining strip is narrower than the
# viewport, or get_visible_portion has nothing to slice.
cut = min(cut, max(0, self.cached_image.width - self.display_width))
if cut <= 0:
return 0
# .copy() so the original buffer is released rather than kept alive by
# a numpy view.
self.cached_array = self.cached_array[:, cut:].copy()
self.cached_image = Image.fromarray(self.cached_array)
self.total_scroll_width = self.cached_image.width
self.scroll_position -= cut
self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut)
self.logger.debug(
"Dropped %dpx of scrolled strip: now %dpx, position %.0f",
cut, self.total_scroll_width, self.scroll_position
)
return cut
def remaining_unscrolled(self) -> int:
"""Columns of strip still to the right of the viewport."""
if self.cached_image is None:
return 0
return max(0, self.total_scroll_width - int(self.scroll_position)
- self.display_width)
def reset_scroll(self) -> None:
"""
Reset scroll position to beginning.
+17 -2
View File
@@ -186,8 +186,14 @@ class DisplayManager:
self.config = config or {}
self._force_fallback = force_fallback
self._suppress_test_pattern = suppress_test_pattern
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
self._capture_mode_active = False
# Per-thread capture state. update_display() and clear() skip hardware
# writes while the *calling* thread is capturing content off-screen.
#
# Thread-local rather than a plain flag because Vegas mode prepares
# upcoming content on a background thread: a shared flag set there would
# suppress the render loop's own frame pushes for the duration, freezing
# the panel exactly when the point was to avoid a freeze.
self._capture_state = threading.local()
# Double-sided mode state (resolved in _setup_matrix). When disabled,
# the logical image is blitted to the matrix unchanged.
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
@@ -520,6 +526,15 @@ class DisplayManager:
except Exception as e:
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
@property
def _capture_mode_active(self) -> bool:
"""True while the calling thread is capturing content off-screen."""
return getattr(self._capture_state, 'active', False)
@_capture_mode_active.setter
def _capture_mode_active(self, value: bool) -> None:
self._capture_state.active = bool(value)
@contextmanager
def capture_mode(self):
"""Suppress hardware output during off-screen content capture.
+27
View File
@@ -58,6 +58,18 @@ class VegasModeConfig:
# switched off at the start of every cycle.
lead_in_width: int = 0
# Keep one continuous strip, extending it with the next group of plugins as
# the scroll approaches the end, instead of composing a fresh strip and
# swapping it in. A swap stops the motion, substitutes every pixel at once
# and restarts with the viewport already full — read as a freeze, a flash
# and a jump. Extending means the next group simply scrolls in from the
# right. Set false to restore the swap behaviour.
continuous_scroll: bool = True
# Extend once the unscrolled remainder falls below this many screen widths.
# Needs to be more than one so the join is prepared before it is on screen.
extend_threshold_screens: float = 2.0
# How many plugins are composed into one scroll cycle. Kept separate from
# buffer_ahead (which is only a prefetch low-water mark) because the two
# were previously the same number: a buffer_ahead of 2 meant just 3 plugins
@@ -117,6 +129,9 @@ class VegasModeConfig:
min_content_separation=int(
vegas_config.get('min_content_separation', 24)),
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
continuous_scroll=vegas_config.get('continuous_scroll', True),
extend_threshold_screens=float(
vegas_config.get('extend_threshold_screens', 2.0)),
auto_trim=vegas_config.get('auto_trim', True),
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
content_padding=int(vegas_config.get('content_padding', 8)),
@@ -146,6 +161,8 @@ class VegasModeConfig:
'render_width_pct': self.render_width_pct,
'min_content_separation': self.min_content_separation,
'min_cut_gap': self.min_cut_gap,
'continuous_scroll': self.continuous_scroll,
'extend_threshold_screens': self.extend_threshold_screens,
'auto_trim': self.auto_trim,
'trim_threshold': self.trim_threshold,
'content_padding': self.content_padding,
@@ -248,6 +265,11 @@ class VegasModeConfig:
"min_content_separation must be between 0 and 256, "
f"got {self.min_content_separation}")
if not 1.0 <= self.extend_threshold_screens <= 10.0:
errors.append(
"extend_threshold_screens must be between 1.0 and 10.0, "
f"got {self.extend_threshold_screens}")
if not 1 <= self.min_cut_gap <= 128:
errors.append(
"min_cut_gap must be between 1 and 128, "
@@ -322,6 +344,11 @@ class VegasModeConfig:
vegas_config['min_content_separation'])
if 'min_cut_gap' in vegas_config:
self.min_cut_gap = int(vegas_config['min_cut_gap'])
if 'continuous_scroll' in vegas_config:
self.continuous_scroll = vegas_config['continuous_scroll']
if 'extend_threshold_screens' in vegas_config:
self.extend_threshold_screens = float(
vegas_config['extend_threshold_screens'])
if 'auto_trim' in vegas_config:
self.auto_trim = vegas_config['auto_trim']
if 'trim_threshold' in vegas_config:
+39 -10
View File
@@ -233,6 +233,11 @@ class VegasModeCoordinator:
self._should_stop = False
self._start_time = time.time()
# Line up the next group immediately, so the first extension is already
# warm rather than stalling the scroll to fetch it.
if self.vegas_config.continuous_scroll:
self.render_pipeline.start_prefetch()
logger.info("Vegas mode started")
return True
@@ -301,16 +306,33 @@ class VegasModeCoordinator:
if has_pending_update:
self._apply_pending_config()
# Check if we need to start a new cycle
if self.render_pipeline.is_cycle_complete():
if not self.render_pipeline.start_new_cycle():
logger.warning("Failed to start new Vegas cycle")
return False
self.stats['cycles_completed'] += 1
if self.vegas_config.continuous_scroll:
# Extend the strip before the scroll can reach its end, so the next
# group arrives from the right and motion never stops. No cycle
# boundary, so no freeze, no substitution and no restart with the
# viewport already full.
# Trickle in the plugins that can only be fetched here, one per
# frame, before considering a further extension.
if self.render_pipeline.has_deferred():
self.render_pipeline.drain_deferred()
elif self.render_pipeline.needs_extension():
if self.render_pipeline.extend_scroll_content():
self.stats['cycles_completed'] += 1
elif self.render_pipeline.is_cycle_complete():
# Extension failed and the strip has run out: fall back to
# the swap rather than sitting on a dead frame.
self.render_pipeline.start_new_cycle()
else:
# Check if we need to start a new cycle
if self.render_pipeline.is_cycle_complete():
if not self.render_pipeline.start_new_cycle():
logger.warning("Failed to start new Vegas cycle")
return False
self.stats['cycles_completed'] += 1
# Check for hot-swap opportunities
if self.render_pipeline.should_recompose():
self.render_pipeline.hot_swap_content()
# Check for hot-swap opportunities
if self.render_pipeline.should_recompose():
self.render_pipeline.hot_swap_content()
# Render frame
return self.render_pipeline.render_frame()
@@ -337,7 +359,14 @@ class VegasModeCoordinator:
self._update_static_mode_plugins()
frame_interval = self.vegas_config.get_frame_interval()
duration = self.render_pipeline.get_dynamic_duration()
if self.vegas_config.continuous_scroll:
# The strip is continuously extended and trimmed, so its width says
# nothing about how long to run. This is only how often control
# returns to the display controller; interrupts are still checked
# every few frames, so it costs nothing to make it a fixed period.
duration = float(self.vegas_config.max_cycle_duration)
else:
duration = self.render_pipeline.get_dynamic_duration()
start_time = time.time()
frame_count = 0
fps_log_interval = 5.0 # Log FPS every 5 seconds
+39 -7
View File
@@ -73,7 +73,8 @@ class PluginAdapter:
self.display_width, self.display_height
)
def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Image.Image]]:
def get_content(self, plugin: 'BasePlugin', plugin_id: str,
offscreen_only: bool = False) -> Optional[List[Image.Image]]:
"""
Get scrollable content from a plugin.
@@ -82,6 +83,13 @@ class PluginAdapter:
Args:
plugin: Plugin instance to get content from
plugin_id: Plugin identifier for logging
offscreen_only: Skip every path that touches the shared display
canvas, for callers running off the render thread. The canvas
and the matrix proxy are process-wide mutable state, so
narrowing or capturing through them from another thread would
corrupt the frame the render loop is pushing. Returns None when
the plugin can only be served that way, leaving the caller to
fetch it on the render thread.
Returns:
List of PIL Images representing plugin content, or None if no content
@@ -105,7 +113,7 @@ class PluginAdapter:
has_native = hasattr(plugin, 'get_vegas_content')
logger.info("[%s] Has get_vegas_content: %s", plugin_id, has_native)
if has_native:
content = self._get_native_content(plugin, plugin_id)
content = self._get_native_content(plugin, plugin_id, offscreen_only)
if content:
total_width = sum(img.width for img in content)
logger.info(
@@ -118,7 +126,7 @@ class PluginAdapter:
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
has_scroll_helper = hasattr(plugin, 'scroll_helper')
logger.info("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper)
content = self._get_scroll_helper_content(plugin, plugin_id)
content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only)
if content:
total_width = sum(img.width for img in content)
logger.info(
@@ -129,6 +137,14 @@ class PluginAdapter:
if has_scroll_helper:
logger.info("[%s] ScrollHelper content returned None", plugin_id)
if offscreen_only:
# Display capture needs the shared canvas; leave it to the caller.
logger.info(
"[%s] Needs display capture, deferring to the render thread",
plugin_id
)
return None
# Fall back to display capture
logger.info("[%s] Trying fallback display capture...", plugin_id)
content = self._capture_display_content(plugin, plugin_id)
@@ -451,7 +467,7 @@ class PluginAdapter:
return img.crop((start, 0, end, img.height))
def _get_native_content(
self, plugin: 'BasePlugin', plugin_id: str
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
) -> Optional[List[Image.Image]]:
"""
Get content via plugin's native get_vegas_content() method.
@@ -486,8 +502,17 @@ class PluginAdapter:
# capture_mode that write lands on the hardware, flashing the
# panel mid-scroll. The narrowing context is separate because it
# is a no-op at full width.
with self._capture(), self._render_at(render_width):
result = plugin.get_vegas_content()
if offscreen_only:
# _render_at swaps the shared canvas, so it is unsafe here.
# _vegas_render_width is set regardless: a plugin reading
# get_vegas_render_width() still gets its narrow size, and
# one that only reads matrix.width renders full width and is
# trimmed instead.
with self._capture():
result = plugin.get_vegas_content()
else:
with self._capture(), self._render_at(render_width):
result = plugin.get_vegas_content()
finally:
plugin._vegas_render_width = None
@@ -567,7 +592,7 @@ class PluginAdapter:
return None
def _get_scroll_helper_content(
self, plugin: 'BasePlugin', plugin_id: str
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
) -> Optional[List[Image.Image]]:
"""
Get content from plugin's scroll_helper if available.
@@ -601,6 +626,13 @@ class PluginAdapter:
"[%s] scroll_helper.cached_image is None, triggering content generation",
plugin_id
)
if offscreen_only:
# Generating it calls display(), which needs the canvas.
logger.info(
"[%s] scroll_helper cache empty; deferring generation "
"to the render thread", plugin_id
)
return None
# Try to trigger scroll content generation
cached_image = self._trigger_scroll_content_generation(
plugin, plugin_id, scroll_helper
+227
View File
@@ -35,6 +35,10 @@ class RenderPipeline:
- Track scroll cycle completion
"""
# Minimum gap between fetches of canvas-bound plugins, so their individual
# stalls land in separate moments rather than one run of hitches.
DEFERRED_DRAIN_INTERVAL = 2.0
def __init__(
self,
config: VegasModeConfig,
@@ -82,6 +86,14 @@ class RenderPipeline:
self._staging_scroll_image: Optional[Image.Image] = None
self._buffer_lock = threading.Lock()
# Group prepared off the render thread, waiting to be appended.
self._prepared_group = None
# Plugins that need the shared canvas, appended one at a time.
self._deferred_queue: List[str] = []
self._last_drain_time = 0.0
self._prefetch_thread: Optional[threading.Thread] = None
self._prefetch_lock = threading.Lock()
# Render state
self._is_rendering = False
self._cycle_complete = False
@@ -207,6 +219,217 @@ class RenderPipeline:
logger.exception("Error composing scroll content")
return False
def needs_extension(self) -> bool:
"""
Whether the strip should be extended with the next group of plugins.
Cheap enough to call every frame: it is arithmetic over cached state.
"""
if not self.config.continuous_scroll or not self.scroll_helper.cached_image:
return False
threshold = int(self.display_width * self.config.extend_threshold_screens)
return self.scroll_helper.remaining_unscrolled() <= threshold
def start_prefetch(self) -> None:
"""
Begin preparing the next group in the background, if not already doing so.
This is what makes the join seamless rather than merely continuous:
fetching a group costs 0.5-4.8s (rendering leaderboard and baseball cards
dominates), and doing it on the render thread stalls the scroll for that
long. Off the render thread there is a whole group's scroll time to work
in, so by the time the strip needs extending the content is already sat
waiting.
Only paths that avoid the shared display canvas run here; anything
needing it is marked and picked up on the render thread, where it is
safe. Those are the cheap ones display capture measured 12-14ms
against seconds for the native renders.
"""
if not self.config.continuous_scroll:
return
with self._prefetch_lock:
if self._prefetch_thread is not None and self._prefetch_thread.is_alive():
return
if self._prepared_group is not None:
return # already have one waiting
def _work():
try:
group = self.stream_manager.take_next_group(offscreen_only=True)
except Exception:
logger.exception("Background prefetch failed")
group = []
with self._prefetch_lock:
self._prepared_group = group
self._prefetch_thread = threading.Thread(
target=_work, daemon=True, name="vegas-strip-prefetch")
self._prefetch_thread.start()
def drain_deferred(self) -> bool:
"""
Fetch one queued canvas-bound plugin and append it to the strip.
Called once per frame. These plugins cannot be prepared off the render
thread display capture and scroll-content generation both need the
shared canvas so each costs roughly 290ms here. Doing one at a time
spreads that out instead of stalling for the whole group at once, and the
strip's lookahead means nothing runs dry while they arrive.
The cost is that a deferred plugin appears slightly after the group it
came with, which is a fair trade for a smooth scroll.
Returns:
True if a plugin was appended
"""
if not self._deferred_queue:
return False
# Space the drains out. Each costs 40-600ms, and taking them back to
# back turns one long stall into a train of short ones — barely better.
# With a healthy lookahead there is no hurry, so wait a beat between
# them; when the strip is actually running short, fetch immediately.
threshold = int(self.display_width * self.config.extend_threshold_screens)
urgent = self.scroll_helper.remaining_unscrolled() <= threshold
if not urgent:
now = time.time()
if now - self._last_drain_time < self.DEFERRED_DRAIN_INTERVAL:
return False
self._last_drain_time = now
else:
self._last_drain_time = time.time()
plugin_id = self._deferred_queue.pop(0)
plugins = getattr(self.stream_manager.plugin_manager, 'plugins', {})
plugin = plugins.get(plugin_id)
if plugin is None:
return False
try:
images = self.stream_manager.plugin_adapter.get_content(plugin, plugin_id)
except Exception:
logger.exception("[%s] Error fetching deferred content", plugin_id)
return False
if not images:
return False
appended = self.scroll_helper.append_content(
content_items=[self._join_plugin_rows(images)],
item_gap=self.config.separator_width,
element_gap=0,
)
if appended:
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
logger.info(
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
plugin_id, self.scroll_helper.total_scroll_width,
self.scroll_helper.remaining_unscrolled()
)
return appended
def has_deferred(self) -> bool:
"""Whether any canvas-bound plugins are still queued."""
return bool(self._deferred_queue)
def _claim_prepared_group(self):
"""Take the prefetched group, if one is ready."""
with self._prefetch_lock:
group = self._prepared_group
self._prepared_group = None
return group
def extend_scroll_content(self) -> bool:
"""
Append the next group of plugins to the strip, without interrupting motion.
This is what replaces the swap. Scroll position is untouched, so the new
content simply arrives from the right; there is no substitution to see
and no restart with the viewport already full.
Consumed columns behind the viewport are then released, keeping the strip
bounded however long Vegas runs.
Returns:
True if the strip was extended
"""
try:
grouped = self._claim_prepared_group()
if grouped is None:
# Nothing prepared (first extension, or prefetch still running).
# Fetch inline; the scroll hitches, but content keeps flowing.
logger.info("No prepared group ready; fetching inline")
grouped = self.stream_manager.take_next_group()
if not grouped:
logger.warning("No content available to extend the scroll strip")
return False
# Plugins the background thread had to defer need the shared canvas,
# so they can only be fetched here. Queue them rather than doing all
# of them now: measured, six in one go held the render thread for
# 1.75s. They are trickled in one per frame by drain_deferred(),
# which the strip's lookahead comfortably absorbs.
deferred = [pid for pid, images in grouped if images is None]
if deferred:
self._deferred_queue.extend(deferred)
logger.info(
"Queued %d plugin(s) needing the render thread: %s",
len(deferred), ', '.join(deferred)
)
grouped = [(pid, imgs) for pid, imgs in grouped if imgs]
if not grouped:
# Everything in this group is queued; the queue will extend the
# strip as it drains, so this is not a failure.
logger.info("Whole group deferred; strip will extend as it drains")
self.start_prefetch()
return bool(deferred)
blocks = []
total_rows = 0
for _plugin_id, images in grouped:
total_rows += len(images)
blocks.append(self._join_plugin_rows(images))
appended = self.scroll_helper.append_content(
content_items=blocks,
item_gap=self.config.separator_width,
element_gap=0,
)
if not appended:
return False
# Keep a screen's worth behind the viewport as a safety margin.
self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
self._segments_in_scroll = [pid for pid, _ in grouped]
self.stats['composition_count'] += 1
self.stats['extensions'] = self.stats.get('extensions', 0) + 1
logger.info(
"Extended scroll strip with %d plugin block(s), %d rows: "
"strip now %dpx, %dpx still ahead of the viewport",
len(blocks), total_rows, self.scroll_helper.total_scroll_width,
self.scroll_helper.remaining_unscrolled()
)
# Line up the group after this one straight away, so it is ready
# well before the strip runs short again.
self.start_prefetch()
return True
except (ValueError, TypeError, OSError, RuntimeError):
logger.exception("Error extending scroll content")
return False
def _join_plugin_rows(self, images: List[Image.Image]) -> Image.Image:
"""
Concatenate one plugin's images into a single block.
@@ -281,6 +504,10 @@ class RenderPipeline:
#
# A strip no wider than the display never wraps, and subtracting
# would make the cycle complete instantly, so clamp in that case.
# In continuous mode there is no cycle to complete: the strip is
# extended before the scroll can reach its end, so the wrap is never
# entered and motion never stops. The completion path below stays for
# the swap behaviour and as a backstop if an extension fails.
wrap_point = self.scroll_helper.total_scroll_width
if wrap_point > self.display_width:
wrap_point -= self.display_width
+56
View File
@@ -580,6 +580,62 @@ class StreamManager:
grouped.append((segment.plugin_id, list(segment.images)))
return grouped
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.
"""
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
plugins = getattr(self.plugin_manager, 'plugins', {})
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
for plugin_id in ids:
plugin = plugins.get(plugin_id)
if not plugin:
continue
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
continue
if images:
self.stats['segments_fetched'] += 1
group.append((plugin_id, images if images else None))
return group
def advance_cycle(self) -> None:
"""
Advance to next cycle by clearing the active buffer.