mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-05-14 17:43:32 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user