From dbb53da31d14908b29655d2a1d0765ab8984b2ee Mon Sep 17 00:00:00 2001 From: sarjent <35471573+sarjent@users.noreply.github.com> Date: Wed, 13 May 2026 14:51:38 -0500 Subject: [PATCH] fix(vegas): eliminate plugin re-appearance at scroll cycle boundaries (#327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 --------- Co-authored-by: Claude Sonnet 4.6 --- src/display_manager.py | 60 +++++++++---- src/vegas_mode/plugin_adapter.py | 144 +++++++++++++----------------- src/vegas_mode/render_pipeline.py | 35 +++++++- 3 files changed, 137 insertions(+), 102 deletions(-) diff --git a/src/display_manager.py b/src/display_manager.py index ee7a1522..59e41aeb 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -3,6 +3,7 @@ if os.getenv("EMULATOR", "false") == "true": from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions else: from rgbmatrix import RGBMatrix, RGBMatrixOptions +from contextlib import contextmanager from PIL import Image, ImageDraw, ImageFont import time from typing import Dict, Any, List, Tuple @@ -28,6 +29,8 @@ 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 # Snapshot settings for web preview integration (service writes, web reads) self._snapshot_path = "/tmp/led_matrix_preview.png" self._snapshot_min_interval_sec = 0.2 # max ~5 fps @@ -255,6 +258,22 @@ class DisplayManager: except Exception as e: logger.error(f"Error drawing test pattern: {e}", exc_info=True) + @contextmanager + def capture_mode(self): + """Suppress hardware output during off-screen content capture. + + Plugins call update_display() as part of their normal display() flow. + When fetching content for Vegas mode the render loop is still running, + so any incidental hardware write causes a visible flash on the matrix. + Entering this context prevents those writes without affecting the PIL + image buffer, which the adapter reads to extract content. + """ + self._capture_mode_active = True + try: + yield + finally: + self._capture_mode_active = False + def update_display(self): """Update the display using double buffering with proper sync.""" try: @@ -264,10 +283,13 @@ class DisplayManager: # Still write a snapshot so the web UI can preview self._write_snapshot_if_due() return - - # Copy the current image to the offscreen canvas + + if self._capture_mode_active: + return # Skip hardware write — content is being captured off-screen + + # Copy the current image to the offscreen canvas self.offscreen_canvas.SetImage(self.image) - + # Swap buffers immediately self.matrix.SwapOnVSync(self.offscreen_canvas) @@ -304,21 +326,23 @@ class DisplayManager: # Create a new black image self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self.draw = ImageDraw.Draw(self.image) - - # Clear both canvases and the underlying matrix to ensure no artifacts - try: - self.offscreen_canvas.Clear() - except Exception: - pass - try: - self.current_canvas.Clear() - except Exception: - pass - try: - # Extra safety: clear the matrix front buffer as well - self.matrix.Clear() - except Exception: - pass + + if not self._capture_mode_active: + # Clear both canvases and the underlying matrix to ensure no artifacts. + # Failures are non-fatal — the image buffer is already black above, so + # the next update_display() call will push clean content regardless. + try: + self.offscreen_canvas.Clear() + except (RuntimeError, OSError) as e: + logger.error("Failed to clear offscreen canvas: %s", e) + try: + self.current_canvas.Clear() + except (RuntimeError, OSError) as e: + logger.error("Failed to clear current canvas: %s", e) + try: + self.matrix.Clear() + except (RuntimeError, OSError) as e: + logger.error("Failed to clear matrix front buffer: %s", e) # Note: We do NOT call update_display() here to avoid black flashes. # The caller should call update_display() after drawing new content. diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index 8f05a18e..ae68cd4b 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -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. diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index 71bacb07..95e2b70a 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -202,8 +202,25 @@ class RenderPipeline: # Update scroll position self.scroll_helper.update_scroll_position() - # Check if cycle is complete - if self.scroll_helper.is_scroll_complete(): + # Determine if the cycle is done. + # + # scroll_helper considers a cycle complete only after + # total_distance_scrolled >= total_scroll_width + display_width. + # That extra display_width of travel causes a "wrap-around" phase + # where scroll_position resets to ~0 and the first plugin's content + # re-enters from the right — the user sees this 2-3 s window as + # "a plugin partially displaying before the next one starts." + # + # We end the cycle as soon as total_distance_scrolled reaches + # total_scroll_width (the wrap-around point), before any second-pass + # content becomes visible. scroll_helper.is_scroll_complete() is + # kept as a fallback for edge-cases where that threshold is skipped. + at_wrap_point = ( + not self._cycle_complete and + self.scroll_helper.total_distance_scrolled >= self.scroll_helper.total_scroll_width + ) + + if at_wrap_point or self.scroll_helper.is_scroll_complete(): if not self._cycle_complete: self._cycle_complete = True self.stats['scroll_cycles'] += 1 @@ -211,6 +228,20 @@ class RenderPipeline: "Scroll cycle complete after %.1fs", time.time() - self._cycle_start_time ) + # Push blank immediately so the hardware never shows + # post-wrap content while the coordinator recomposes. + try: + from PIL import Image as _Image + blank = _Image.new('RGB', (self.display_width, self.display_height)) + self.display_manager.image = blank + self.display_manager.update_display() + except (ImportError, OSError, RuntimeError, ValueError, TypeError, MemoryError) as exc: + logger.error( + "Failed to push blank frame at cycle end " + "(display=%dx%d): %s", + self.display_width, self.display_height, exc + ) + return True # Cycle done; coordinator starts new cycle next frame # Get visible portion visible_frame = self.scroll_helper.get_visible_portion()