fix(vegas): eliminate plugin re-appearance at scroll cycle boundaries (#327)

* fix(vegas): eliminate plugin re-appearance at scroll cycle boundaries

The Vegas scroll image is wider than the display. scroll_helper marks a
cycle complete only after total_distance_scrolled >= total_scroll_width +
display_width, meaning it keeps scrolling for an extra display_width of
pixels after all content has exited left. During that extra travel the
scroll_position wraps back to ~0 and the first plugin re-enters from the
right - visible for ~2-3 seconds as a plugin partially displaying before
the next one starts.

render_pipeline.render_frame(): end the cycle the moment
total_distance_scrolled >= total_scroll_width (the natural wrap point),
before any second-pass content becomes visible. Push a blank frame
immediately on detection so hardware never shows a frozen content
snapshot while start_new_cycle() recomposes (~100 ms).

display_manager.py: add capture_mode() context manager. When active,
update_display() and the canvas clear in clear() skip the hardware
write, preventing plugins that call update_display() internally from
flashing on the matrix during off-screen content capture inside
start_new_cycle().

plugin_adapter.py: wrap all plugin.display() calls in
_capture_display_content() and _trigger_scroll_content_generation()
with capture_mode() so the fallback capture path never produces
hardware output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(vegas): tighten exception handling in clear() and blank-frame push

display_manager.clear(): replace bare except/pass on the three hardware
Clear() calls with (RuntimeError, OSError) and a logger.error() so
failures are visible in logs rather than silently swallowed.  Still
non-fatal — the PIL image buffer is already black before these calls,
so the next update_display() will push clean content regardless.

render_pipeline.render_frame(): replace broad except/pass in the
blank-frame push with (ImportError, ValueError, TypeError, MemoryError)
and a logger.error() that includes display dimensions for context.
update_display() already handles its own hardware errors internally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(vegas): catch OSError and RuntimeError in blank-frame push

Image.new() can raise OSError in some PIL environments and hardware
libraries may surface RuntimeError on I/O failures.  Add both to the
exception tuple alongside the existing ImportError/ValueError/TypeError/
MemoryError so no boundary failure escapes the local handler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sarjent
2026-05-13 14:51:38 -05:00
committed by GitHub
parent 452afacd12
commit dbb53da31d
3 changed files with 137 additions and 102 deletions

View File

@@ -329,50 +329,51 @@ class PluginAdapter:
# Save display state to restore after
original_image = self.display_manager.image.copy()
# Method 1: Try _create_scrolling_display (stocks pattern)
if hasattr(plugin, '_create_scrolling_display'):
logger.info(
"[%s] Triggering via _create_scrolling_display()",
plugin_id
)
try:
plugin._create_scrolling_display()
cached_image = getattr(scroll_helper, 'cached_image', None)
if cached_image is not None and isinstance(cached_image, Image.Image):
logger.info(
"[%s] _create_scrolling_display() SUCCESS: %dx%d",
plugin_id, cached_image.width, cached_image.height
)
return cached_image
except (AttributeError, TypeError, ValueError, OSError):
logger.exception(
"[%s] _create_scrolling_display() failed", plugin_id
)
# Method 2: Try display(force_clear=True) which typically builds scroll content
if hasattr(plugin, 'display'):
logger.info(
"[%s] Triggering via display(force_clear=True)",
plugin_id
)
try:
self.display_manager.clear()
plugin.display(force_clear=True)
cached_image = getattr(scroll_helper, 'cached_image', None)
if cached_image is not None and isinstance(cached_image, Image.Image):
logger.info(
"[%s] display(force_clear=True) SUCCESS: %dx%d",
plugin_id, cached_image.width, cached_image.height
)
return cached_image
with self.display_manager.capture_mode():
# Method 1: Try _create_scrolling_display (stocks pattern)
if hasattr(plugin, '_create_scrolling_display'):
logger.info(
"[%s] display(force_clear=True) did not populate cached_image",
"[%s] Triggering via _create_scrolling_display()",
plugin_id
)
except (AttributeError, TypeError, ValueError, OSError):
logger.exception(
"[%s] display(force_clear=True) failed", plugin_id
try:
plugin._create_scrolling_display()
cached_image = getattr(scroll_helper, 'cached_image', None)
if cached_image is not None and isinstance(cached_image, Image.Image):
logger.info(
"[%s] _create_scrolling_display() SUCCESS: %dx%d",
plugin_id, cached_image.width, cached_image.height
)
return cached_image
except (AttributeError, TypeError, ValueError, OSError):
logger.exception(
"[%s] _create_scrolling_display() failed", plugin_id
)
# Method 2: Try display(force_clear=True) which typically builds scroll content
if hasattr(plugin, 'display'):
logger.info(
"[%s] Triggering via display(force_clear=True)",
plugin_id
)
try:
self.display_manager.clear()
plugin.display(force_clear=True)
cached_image = getattr(scroll_helper, 'cached_image', None)
if cached_image is not None and isinstance(cached_image, Image.Image):
logger.info(
"[%s] display(force_clear=True) SUCCESS: %dx%d",
plugin_id, cached_image.width, cached_image.height
)
return cached_image
logger.info(
"[%s] display(force_clear=True) did not populate cached_image",
plugin_id
)
except (AttributeError, TypeError, ValueError, OSError):
logger.exception(
"[%s] display(force_clear=True) failed", plugin_id
)
logger.info(
"[%s] Could not trigger scroll content generation",
@@ -408,10 +409,7 @@ class PluginAdapter:
original_image = self.display_manager.image.copy()
logger.info("[%s] Fallback: saved original display state", plugin_id)
# Lightweight in-memory data refresh before capturing.
# Full update() is intentionally skipped here — the background
# update tick in the Vegas coordinator handles periodic API
# refreshes so we don't block the content-fetch thread.
# Ensure plugin has fresh data before capturing
has_update_data = hasattr(plugin, 'update_data')
logger.info("[%s] Fallback: has update_data=%s", plugin_id, has_update_data)
if has_update_data:
@@ -421,21 +419,24 @@ class PluginAdapter:
except (AttributeError, RuntimeError, OSError):
logger.exception("[%s] Fallback: update_data() failed", plugin_id)
# Clear and call plugin display
self.display_manager.clear()
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
# Clear and call plugin display — use capture_mode to suppress hardware writes
# that plugins may trigger internally via update_display().
with self.display_manager.capture_mode():
self.display_manager.clear()
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
# First try without force_clear (some plugins behave better this way)
try:
plugin.display()
logger.info("[%s] Fallback: display() called successfully", plugin_id)
except TypeError:
# Plugin may require force_clear argument
logger.info("[%s] Fallback: display() failed, trying with force_clear=True", plugin_id)
plugin.display(force_clear=True)
# First try without force_clear (some plugins behave better this way)
try:
plugin.display()
logger.info("[%s] Fallback: display() called successfully", plugin_id)
except TypeError:
# Plugin may require force_clear argument
logger.info("[%s] Fallback: display() failed, trying with force_clear=True", plugin_id)
plugin.display(force_clear=True)
# Capture the result
captured = self.display_manager.image.copy()
# Capture the result
captured = self.display_manager.image.copy()
logger.info(
"[%s] Fallback: captured frame %dx%d, mode=%s",
plugin_id, captured.width, captured.height, captured.mode
@@ -454,9 +455,10 @@ class PluginAdapter:
plugin_id
)
# Try once more with force_clear=True
self.display_manager.clear()
plugin.display(force_clear=True)
captured = self.display_manager.image.copy()
with self.display_manager.capture_mode():
self.display_manager.clear()
plugin.display(force_clear=True)
captured = self.display_manager.image.copy()
is_blank, bright_ratio = self._is_blank_image(captured, return_ratio=True)
logger.info(
@@ -585,28 +587,6 @@ class PluginAdapter:
else:
self._content_cache.clear()
def invalidate_plugin_scroll_cache(self, plugin: 'BasePlugin', plugin_id: str) -> None:
"""
Clear a plugin's scroll_helper cache so Vegas re-fetches fresh visuals.
Uses scroll_helper.clear_cache() to reset all cached state (cached_image,
cached_array, total_scroll_width, scroll_position, etc.) — not just the
image. Without this, plugins that use scroll_helper (stocks, news,
odds-ticker, etc.) would keep serving stale scroll images even after
their data refreshes.
Args:
plugin: Plugin instance
plugin_id: Plugin identifier
"""
scroll_helper = getattr(plugin, 'scroll_helper', None)
if scroll_helper is None:
return
if getattr(scroll_helper, 'cached_image', None) is not None:
scroll_helper.clear_cache()
logger.debug("[%s] Cleared scroll_helper cache", plugin_id)
def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
"""
Get the type of content a plugin provides.