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

@@ -3,6 +3,7 @@ if os.getenv("EMULATOR", "false") == "true":
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
else: else:
from rgbmatrix import RGBMatrix, RGBMatrixOptions from rgbmatrix import RGBMatrix, RGBMatrixOptions
from contextlib import contextmanager
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import time import time
from typing import Dict, Any, List, Tuple from typing import Dict, Any, List, Tuple
@@ -28,6 +29,8 @@ class DisplayManager:
self.config = config or {} self.config = config or {}
self._force_fallback = force_fallback self._force_fallback = force_fallback
self._suppress_test_pattern = suppress_test_pattern 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) # Snapshot settings for web preview integration (service writes, web reads)
self._snapshot_path = "/tmp/led_matrix_preview.png" self._snapshot_path = "/tmp/led_matrix_preview.png"
self._snapshot_min_interval_sec = 0.2 # max ~5 fps self._snapshot_min_interval_sec = 0.2 # max ~5 fps
@@ -255,6 +258,22 @@ class DisplayManager:
except Exception as e: except Exception as e:
logger.error(f"Error drawing test pattern: {e}", exc_info=True) 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): def update_display(self):
"""Update the display using double buffering with proper sync.""" """Update the display using double buffering with proper sync."""
try: try:
@@ -265,6 +284,9 @@ class DisplayManager:
self._write_snapshot_if_due() self._write_snapshot_if_due()
return return
if self._capture_mode_active:
return # Skip hardware write — content is being captured off-screen
# Copy the current image to the offscreen canvas # Copy the current image to the offscreen canvas
self.offscreen_canvas.SetImage(self.image) self.offscreen_canvas.SetImage(self.image)
@@ -305,20 +327,22 @@ class DisplayManager:
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height)) self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
self.draw = ImageDraw.Draw(self.image) self.draw = ImageDraw.Draw(self.image)
# Clear both canvases and the underlying matrix to ensure no artifacts 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: try:
self.offscreen_canvas.Clear() self.offscreen_canvas.Clear()
except Exception: except (RuntimeError, OSError) as e:
pass logger.error("Failed to clear offscreen canvas: %s", e)
try: try:
self.current_canvas.Clear() self.current_canvas.Clear()
except Exception: except (RuntimeError, OSError) as e:
pass logger.error("Failed to clear current canvas: %s", e)
try: try:
# Extra safety: clear the matrix front buffer as well
self.matrix.Clear() self.matrix.Clear()
except Exception: except (RuntimeError, OSError) as e:
pass logger.error("Failed to clear matrix front buffer: %s", e)
# Note: We do NOT call update_display() here to avoid black flashes. # Note: We do NOT call update_display() here to avoid black flashes.
# The caller should call update_display() after drawing new content. # The caller should call update_display() after drawing new content.

View File

@@ -329,6 +329,7 @@ class PluginAdapter:
# Save display state to restore after # Save display state to restore after
original_image = self.display_manager.image.copy() original_image = self.display_manager.image.copy()
with self.display_manager.capture_mode():
# Method 1: Try _create_scrolling_display (stocks pattern) # Method 1: Try _create_scrolling_display (stocks pattern)
if hasattr(plugin, '_create_scrolling_display'): if hasattr(plugin, '_create_scrolling_display'):
logger.info( logger.info(
@@ -408,10 +409,7 @@ class PluginAdapter:
original_image = self.display_manager.image.copy() original_image = self.display_manager.image.copy()
logger.info("[%s] Fallback: saved original display state", plugin_id) logger.info("[%s] Fallback: saved original display state", plugin_id)
# Lightweight in-memory data refresh before capturing. # Ensure plugin has fresh data 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.
has_update_data = hasattr(plugin, 'update_data') has_update_data = hasattr(plugin, 'update_data')
logger.info("[%s] Fallback: has update_data=%s", plugin_id, has_update_data) logger.info("[%s] Fallback: has update_data=%s", plugin_id, has_update_data)
if has_update_data: if has_update_data:
@@ -421,7 +419,9 @@ class PluginAdapter:
except (AttributeError, RuntimeError, OSError): except (AttributeError, RuntimeError, OSError):
logger.exception("[%s] Fallback: update_data() failed", plugin_id) logger.exception("[%s] Fallback: update_data() failed", plugin_id)
# Clear and call plugin display # 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() self.display_manager.clear()
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id) logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
@@ -436,6 +436,7 @@ class PluginAdapter:
# Capture the result # Capture the result
captured = self.display_manager.image.copy() captured = self.display_manager.image.copy()
logger.info( logger.info(
"[%s] Fallback: captured frame %dx%d, mode=%s", "[%s] Fallback: captured frame %dx%d, mode=%s",
plugin_id, captured.width, captured.height, captured.mode plugin_id, captured.width, captured.height, captured.mode
@@ -454,6 +455,7 @@ class PluginAdapter:
plugin_id plugin_id
) )
# Try once more with force_clear=True # Try once more with force_clear=True
with self.display_manager.capture_mode():
self.display_manager.clear() self.display_manager.clear()
plugin.display(force_clear=True) plugin.display(force_clear=True)
captured = self.display_manager.image.copy() captured = self.display_manager.image.copy()
@@ -585,28 +587,6 @@ class PluginAdapter:
else: else:
self._content_cache.clear() 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: def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
""" """
Get the type of content a plugin provides. Get the type of content a plugin provides.

View File

@@ -202,8 +202,25 @@ class RenderPipeline:
# Update scroll position # Update scroll position
self.scroll_helper.update_scroll_position() self.scroll_helper.update_scroll_position()
# Check if cycle is complete # Determine if the cycle is done.
if self.scroll_helper.is_scroll_complete(): #
# 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: if not self._cycle_complete:
self._cycle_complete = True self._cycle_complete = True
self.stats['scroll_cycles'] += 1 self.stats['scroll_cycles'] += 1
@@ -211,6 +228,20 @@ class RenderPipeline:
"Scroll cycle complete after %.1fs", "Scroll cycle complete after %.1fs",
time.time() - self._cycle_start_time 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 # Get visible portion
visible_frame = self.scroll_helper.get_visible_portion() visible_frame = self.scroll_helper.get_visible_portion()