mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-11 05:13:01 +00:00
Feature/vegas scroll mode (#215)
* feat(display): add Vegas-style continuous scroll mode Implement an opt-in Vegas ticker mode that composes all enabled plugin content into a single continuous horizontal scroll. Includes a modular package (src/vegas_mode/) with double-buffered streaming, 125 FPS render pipeline using the existing ScrollHelper, live priority interruption support, and a web UI for configuration with drag-drop plugin ordering. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(vegas): add three-mode display system (SCROLL, FIXED_SEGMENT, STATIC) Adds a flexible display mode system for Vegas scroll mode that allows plugins to control how their content appears in the continuous scroll: - SCROLL: Content scrolls continuously (multi-item plugins like sports) - FIXED_SEGMENT: Fixed block that scrolls by (clock, weather) - STATIC: Scroll pauses, plugin displays, then resumes (alerts) Changes: - Add VegasDisplayMode enum to base_plugin.py with backward-compatible mapping from legacy get_vegas_content_type() - Add static pause handling to coordinator with scroll position save/restore - Add mode-aware content composition to stream_manager - Add vegas_mode info to /api/v3/plugins/installed endpoint - Add mode indicators to Vegas settings UI - Add comprehensive plugin developer documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas,widgets): address validation, thread safety, and XSS issues Vegas mode fixes: - config.py: align validation limits with UI (scroll_speed max 200, separator_width max 128) - coordinator.py: fix race condition by properly initializing _pending_config - plugin_adapter.py: remove unused import - render_pipeline.py: preserve deque type in reset() method - stream_manager.py: fix lock handling and swap_buffers to truly swap API fixes: - api_v3.py: normalize boolean checkbox values, validate numeric fields, ensure JSON arrays Widget fixes: - day-selector.js: remove escapeHtml from JSON.stringify to prevent corruption - password-input.js: use deterministic color class mapping for Tailwind JIT - radio-group.js: replace inline onchange with addEventListener to prevent XSS - select-dropdown.js: guard global registry access - slider.js: add escapeAttr for attributes, fix null dereference in setValue Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): improve exception handling and static pause state management coordinator.py: - _check_live_priority: use logger.exception for full traceback - _end_static_pause: guard scroll resume on interruption (stop/live priority) - _update_static_mode_plugins: log errors instead of silently swallowing render_pipeline.py: - compose_scroll_content: use specific exceptions and logger.exception - render_frame: use specific exceptions and logger.exception - hot_swap_content: use specific exceptions and logger.exception Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): add interrupt mechanism and improve config/exception handling - Add interrupt checker callback to Vegas coordinator for responsive handling of on-demand requests and wifi status during Vegas mode - Fix config.py update() to include dynamic duration fields - Fix is_plugin_included() consistency with get_ordered_plugins() - Update _apply_pending_config to propagate config to StreamManager - Change _fetch_plugin_content to use logger.exception for traceback - Replace bare except in _refresh_plugin_list with specific exceptions - Add aria-label accessibility to Vegas toggle checkbox - Fix XSS vulnerability in plugin metadata rendering with escapeHtml Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): improve logging, validation, lock handling, and config updates - display_controller.py: use logger.exception for Vegas errors with traceback - base_plugin.py: validate vegas_panel_count as positive integer with warning - coordinator.py: fix _apply_pending_config to avoid losing concurrent updates by clearing _pending_config while holding lock - plugin_adapter.py: remove broad catch-all, use narrower exception types (AttributeError, TypeError, ValueError, OSError, RuntimeError) and logger.exception for traceback preservation - api_v3.py: only update vegas_config['enabled'] when key is present in data to prevent incorrect disabling when checkbox is omitted Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): improve cycle advancement, logging, and accessibility - Add advance_cycle() method to StreamManager for clearing buffer between cycles - Call advance_cycle() in RenderPipeline.start_new_cycle() for fresh content - Use logger.exception() for interrupt check and static pause errors (full tracebacks) - Add id="vegas_scroll_label" to h3 for aria-labelledby reference - Call updatePluginConfig() after rendering plugin list for proper initialization Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): add thread-safety, preserve updates, and improve logging - display_controller.py: Use logger.exception() for Vegas import errors - plugin_adapter.py: Add thread-safe cache lock, remove unused exception binding - stream_manager.py: In-place merge in process_updates() preserves non-updated plugins - api_v3.py: Change vegas_scroll_enabled default from False to True Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): add debug logging and narrow exception types - stream_manager.py: Log when get_vegas_display_mode() is unavailable - stream_manager.py: Narrow exception type from Exception to (AttributeError, TypeError) - api_v3.py: Log exceptions when reading Vegas display metadata with plugin context Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): fix method call and improve exception logging - Fix _check_vegas_interrupt() calling nonexistent _check_wifi_status(), now correctly calls _check_wifi_status_message() - Update _refresh_plugin_list() exception handler to use logger.exception() with plugin_id and class name for remote debugging Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(web): replace complex toggle with standard checkbox for Vegas mode The Tailwind pseudo-element toggle (after:content-[''], etc.) wasn't rendering because these classes weren't in the CSS bundle. Replaced with a simple checkbox that matches other form controls in the template. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * debug(vegas): add detailed logging to _refresh_plugin_list Track why plugins aren't being found for Vegas scroll: - Log count of loaded plugins - Log enabled status for each plugin - Log content_type and display_mode checks - Log when plugin_manager lacks loaded_plugins Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): use correct attribute name for plugin manager StreamManager and VegasModeCoordinator were checking for plugin_manager.loaded_plugins but PluginManager stores active plugins in plugin_manager.plugins. This caused Vegas scroll to find zero plugins despite plugins being available. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): convert scroll_speed from px/sec to px/frame correctly The config scroll_speed is in pixels per second, but ScrollHelper in frame_based_scrolling mode interprets it as pixels per frame. Previously this caused the speed to be clamped to max 5.0 regardless of the configured value. Now properly converts: pixels_per_frame = scroll_speed * scroll_delay With defaults (50 px/s, 0.02s delay), this gives 1 px/frame = 50 px/s. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(vegas): add FPS logging every 5 seconds Logs actual FPS vs target FPS to help diagnose performance issues. Shows frame count in each 5-second interval. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): improve plugin content capture reliability - Call update_data() before capture to ensure fresh plugin data - Try display() without force_clear first, fallback if TypeError - Retry capture with force_clear=True if first attempt is blank - Use histogram-based blank detection instead of point sampling (more reliable for content positioned anywhere in frame) This should help capture content from plugins that don't implement get_vegas_content() natively. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): handle callable width/height on display_manager DisplayManager.width and .height may be methods or properties depending on the implementation. Use callable() check to call them if needed, ensuring display_width and display_height are always integers. Fixes potential TypeError when width/height are methods. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): use logger.exception for display mode errors Replace logger.error with logger.exception to capture full stack trace when get_vegas_display_mode() fails on a plugin. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): protect plugin list updates with buffer lock Move assignment of _ordered_plugins and index resets under _buffer_lock to prevent race conditions with _prefetch_content() which reads these variables under the same lock. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): catch all exceptions in get_vegas_display_mode Broaden exception handling from AttributeError/TypeError to Exception so any plugin error in get_vegas_display_mode() doesn't abort the entire plugin list refresh. The loop continues with the default FIXED_SEGMENT mode. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(vegas): refresh stream manager when config updates After updating stream_manager.config, force a refresh to pick up changes to plugin_order, excluded_plugins, and buffer_ahead settings. Also use logger.exception to capture full stack traces on config update errors. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * debug(vegas): add detailed logging for blank image detection * feat(vegas): extract full scroll content from plugins using ScrollHelper Plugins like ledmatrix-stocks and odds-ticker use ScrollHelper with a cached_image that contains their full scrolling content. Instead of falling back to single-frame capture, now check for scroll_helper.cached_image first to get the complete scrolling content for Vegas mode. * debug(vegas): add comprehensive INFO-level logging for plugin content flow - Log each plugin being processed with class name - Log which content methods are tried (native, scroll_helper, fallback) - Log success/failure of each method with image dimensions - Log brightness check results for blank image detection - Add visual separators in logs for easier debugging - Log plugin list refresh with enabled/excluded status Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(vegas): trigger scroll content generation when cache is empty When a plugin has a scroll_helper but its cached_image is not yet populated, try to trigger content generation by: 1. Calling _create_scrolling_display() if available (stocks pattern) 2. Calling display(force_clear=True) as a fallback This allows plugins like stocks to provide their full scroll content even when Vegas mode starts before the plugin has run its normal display cycle. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: improve exception handling in plugin_adapter scroll content retrieval Replace broad except Exception handlers with narrow exception types (AttributeError, TypeError, ValueError, OSError) and use logger.exception instead of logger.warning/info to capture full stack traces for better diagnosability. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: narrow exception handling in coordinator and plugin_adapter - coordinator.py: Replace broad Exception catch around get_vegas_display_mode() with (AttributeError, TypeError) and use logger.exception for stack traces - plugin_adapter.py: Narrow update_data() exception handler to (AttributeError, RuntimeError, OSError) and use logger.exception Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: improve Vegas mode robustness and API validation - display_controller: Guard against None plugin_manager in Vegas init - coordinator: Restore scrolling state in resume() to match pause() - api_v3: Validate Vegas numeric fields with range checks and 400 errors Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Chuck <chuck@example.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
612
src/vegas_mode/plugin_adapter.py
Normal file
612
src/vegas_mode/plugin_adapter.py
Normal file
@@ -0,0 +1,612 @@
|
||||
"""
|
||||
Plugin Adapter for Vegas Mode
|
||||
|
||||
Converts plugin content to scrollable images. Supports both plugins that
|
||||
implement get_vegas_content() and fallback capture of display() output.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginAdapter:
|
||||
"""
|
||||
Adapter for extracting scrollable content from plugins.
|
||||
|
||||
Supports two modes:
|
||||
1. Native: Plugin implements get_vegas_content() returning PIL Image(s)
|
||||
2. Fallback: Capture display_manager.image after calling plugin.display()
|
||||
"""
|
||||
|
||||
def __init__(self, display_manager: Any):
|
||||
"""
|
||||
Initialize the plugin adapter.
|
||||
|
||||
Args:
|
||||
display_manager: DisplayManager instance for fallback capture
|
||||
"""
|
||||
self.display_manager = display_manager
|
||||
# Handle both property and method access patterns
|
||||
self.display_width = (
|
||||
display_manager.width() if callable(display_manager.width)
|
||||
else display_manager.width
|
||||
)
|
||||
self.display_height = (
|
||||
display_manager.height() if callable(display_manager.height)
|
||||
else display_manager.height
|
||||
)
|
||||
|
||||
# Cache for recently fetched content (prevents redundant fetch)
|
||||
self._content_cache: dict = {}
|
||||
self._cache_lock = threading.Lock()
|
||||
self._cache_ttl = 5.0 # Cache for 5 seconds
|
||||
|
||||
logger.info(
|
||||
"PluginAdapter initialized: display=%dx%d",
|
||||
self.display_width, self.display_height
|
||||
)
|
||||
|
||||
def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Get scrollable content from a plugin.
|
||||
|
||||
Tries get_vegas_content() first, falls back to display capture.
|
||||
|
||||
Args:
|
||||
plugin: Plugin instance to get content from
|
||||
plugin_id: Plugin identifier for logging
|
||||
|
||||
Returns:
|
||||
List of PIL Images representing plugin content, or None if no content
|
||||
"""
|
||||
logger.info(
|
||||
"[%s] Getting content (class=%s)",
|
||||
plugin_id, plugin.__class__.__name__
|
||||
)
|
||||
|
||||
# Check cache first
|
||||
cached = self._get_cached(plugin_id)
|
||||
if cached is not None:
|
||||
total_width = sum(img.width for img in cached)
|
||||
logger.info(
|
||||
"[%s] Using cached content: %d images, %dpx total",
|
||||
plugin_id, len(cached), total_width
|
||||
)
|
||||
return cached
|
||||
|
||||
# Try native Vegas content method first
|
||||
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)
|
||||
if content:
|
||||
total_width = sum(img.width for img in content)
|
||||
logger.info(
|
||||
"[%s] Native content SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
logger.info("[%s] Native content returned None", plugin_id)
|
||||
|
||||
# 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)
|
||||
if content:
|
||||
total_width = sum(img.width for img in content)
|
||||
logger.info(
|
||||
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
if has_scroll_helper:
|
||||
logger.info("[%s] ScrollHelper content returned None", plugin_id)
|
||||
|
||||
# Fall back to display capture
|
||||
logger.info("[%s] Trying fallback display capture...", plugin_id)
|
||||
content = self._capture_display_content(plugin, plugin_id)
|
||||
if content:
|
||||
total_width = sum(img.width for img in content)
|
||||
logger.info(
|
||||
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
|
||||
logger.warning(
|
||||
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
|
||||
plugin_id, has_native, has_scroll_helper
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_native_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Get content via plugin's native get_vegas_content() method.
|
||||
|
||||
Args:
|
||||
plugin: Plugin instance
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
List of images or None
|
||||
"""
|
||||
try:
|
||||
logger.info("[%s] Native: calling get_vegas_content()", plugin_id)
|
||||
result = plugin.get_vegas_content()
|
||||
|
||||
if result is None:
|
||||
logger.info("[%s] Native: get_vegas_content() returned None", plugin_id)
|
||||
return None
|
||||
|
||||
# Normalize to list
|
||||
if isinstance(result, Image.Image):
|
||||
images = [result]
|
||||
logger.info(
|
||||
"[%s] Native: got single Image %dx%d",
|
||||
plugin_id, result.width, result.height
|
||||
)
|
||||
elif isinstance(result, (list, tuple)):
|
||||
images = list(result)
|
||||
logger.info(
|
||||
"[%s] Native: got %d items in list/tuple",
|
||||
plugin_id, len(images)
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[%s] Native: unexpected return type: %s",
|
||||
plugin_id, type(result).__name__
|
||||
)
|
||||
return None
|
||||
|
||||
# Validate images
|
||||
valid_images = []
|
||||
for i, img in enumerate(images):
|
||||
if not isinstance(img, Image.Image):
|
||||
logger.warning(
|
||||
"[%s] Native: item[%d] is not an Image: %s",
|
||||
plugin_id, i, type(img).__name__
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
"[%s] Native: item[%d] is %dx%d, mode=%s",
|
||||
plugin_id, i, img.width, img.height, img.mode
|
||||
)
|
||||
|
||||
# Ensure correct height
|
||||
if img.height != self.display_height:
|
||||
logger.info(
|
||||
"[%s] Native: resizing item[%d]: %dx%d -> %dx%d",
|
||||
plugin_id, i, img.width, img.height,
|
||||
img.width, self.display_height
|
||||
)
|
||||
img = img.resize(
|
||||
(img.width, self.display_height),
|
||||
Image.Resampling.LANCZOS
|
||||
)
|
||||
|
||||
# Convert to RGB if needed
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
valid_images.append(img)
|
||||
|
||||
if valid_images:
|
||||
total_width = sum(img.width for img in valid_images)
|
||||
logger.info(
|
||||
"[%s] Native: SUCCESS - %d images, %dpx total width",
|
||||
plugin_id, len(valid_images), total_width
|
||||
)
|
||||
return valid_images
|
||||
|
||||
logger.info("[%s] Native: no valid images after validation", plugin_id)
|
||||
return None
|
||||
|
||||
except (AttributeError, TypeError, ValueError, OSError) as e:
|
||||
logger.exception(
|
||||
"[%s] Native: ERROR calling get_vegas_content(): %s",
|
||||
plugin_id, e
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_scroll_helper_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Get content from plugin's scroll_helper if available.
|
||||
|
||||
Many scrolling plugins (stocks, odds) use a ScrollHelper that caches
|
||||
their full scrolling image. This method extracts that image for Vegas
|
||||
mode instead of falling back to single-frame capture.
|
||||
|
||||
Args:
|
||||
plugin: Plugin instance
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
List with the cached scroll image, or None if not available
|
||||
"""
|
||||
try:
|
||||
# Check for scroll_helper with cached_image
|
||||
scroll_helper = getattr(plugin, 'scroll_helper', None)
|
||||
if scroll_helper is None:
|
||||
logger.debug("[%s] No scroll_helper attribute", plugin_id)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"[%s] Found scroll_helper: %s",
|
||||
plugin_id, type(scroll_helper).__name__
|
||||
)
|
||||
|
||||
cached_image = getattr(scroll_helper, 'cached_image', None)
|
||||
if cached_image is None:
|
||||
logger.info(
|
||||
"[%s] scroll_helper.cached_image is None, triggering content generation",
|
||||
plugin_id
|
||||
)
|
||||
# Try to trigger scroll content generation
|
||||
cached_image = self._trigger_scroll_content_generation(
|
||||
plugin, plugin_id, scroll_helper
|
||||
)
|
||||
if cached_image is None:
|
||||
return None
|
||||
|
||||
if not isinstance(cached_image, Image.Image):
|
||||
logger.info(
|
||||
"[%s] scroll_helper.cached_image is not an Image: %s",
|
||||
plugin_id, type(cached_image).__name__
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"[%s] scroll_helper.cached_image found: %dx%d, mode=%s",
|
||||
plugin_id, cached_image.width, cached_image.height, cached_image.mode
|
||||
)
|
||||
|
||||
# Copy the image to prevent modification
|
||||
img = cached_image.copy()
|
||||
|
||||
# Ensure correct height
|
||||
if img.height != self.display_height:
|
||||
logger.info(
|
||||
"[%s] Resizing scroll_helper content: %dx%d -> %dx%d",
|
||||
plugin_id, img.width, img.height,
|
||||
img.width, self.display_height
|
||||
)
|
||||
img = img.resize(
|
||||
(img.width, self.display_height),
|
||||
Image.Resampling.LANCZOS
|
||||
)
|
||||
|
||||
# Convert to RGB if needed
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
logger.info(
|
||||
"[%s] ScrollHelper content ready: %dx%d",
|
||||
plugin_id, img.width, img.height
|
||||
)
|
||||
|
||||
return [img]
|
||||
|
||||
except (AttributeError, TypeError, ValueError, OSError):
|
||||
logger.exception("[%s] Error getting scroll_helper content", plugin_id)
|
||||
return None
|
||||
|
||||
def _trigger_scroll_content_generation(
|
||||
self, plugin: 'BasePlugin', plugin_id: str, scroll_helper: Any
|
||||
) -> Optional[Image.Image]:
|
||||
"""
|
||||
Trigger scroll content generation for plugins that haven't built it yet.
|
||||
|
||||
Tries multiple approaches:
|
||||
1. _create_scrolling_display() - stocks plugin pattern
|
||||
2. display(force_clear=True) - general pattern that populates scroll cache
|
||||
|
||||
Args:
|
||||
plugin: Plugin instance
|
||||
plugin_id: Plugin identifier
|
||||
scroll_helper: Plugin's scroll_helper instance
|
||||
|
||||
Returns:
|
||||
The generated cached_image or None
|
||||
"""
|
||||
original_image = None
|
||||
try:
|
||||
# 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
|
||||
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",
|
||||
plugin_id
|
||||
)
|
||||
return None
|
||||
|
||||
except (AttributeError, TypeError, ValueError, OSError):
|
||||
logger.exception("[%s] Error triggering scroll content", plugin_id)
|
||||
return None
|
||||
|
||||
finally:
|
||||
# Restore original display state
|
||||
if original_image is not None:
|
||||
self.display_manager.image = original_image
|
||||
|
||||
def _capture_display_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Capture content by calling plugin.display() and grabbing the frame.
|
||||
|
||||
Args:
|
||||
plugin: Plugin instance
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
List with single captured image, or None
|
||||
"""
|
||||
original_image = None
|
||||
try:
|
||||
# Save current display state
|
||||
original_image = self.display_manager.image.copy()
|
||||
logger.info("[%s] Fallback: saved original display state", plugin_id)
|
||||
|
||||
# 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:
|
||||
try:
|
||||
plugin.update_data()
|
||||
logger.info("[%s] Fallback: update_data() called", plugin_id)
|
||||
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)
|
||||
|
||||
# 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()
|
||||
logger.info(
|
||||
"[%s] Fallback: captured frame %dx%d, mode=%s",
|
||||
plugin_id, captured.width, captured.height, captured.mode
|
||||
)
|
||||
|
||||
# Check if captured image has content (not all black)
|
||||
is_blank, bright_ratio = self._is_blank_image(captured, return_ratio=True)
|
||||
logger.info(
|
||||
"[%s] Fallback: brightness check - %.3f%% bright pixels (threshold=0.5%%)",
|
||||
plugin_id, bright_ratio * 100
|
||||
)
|
||||
|
||||
if is_blank:
|
||||
logger.info(
|
||||
"[%s] Fallback: first capture blank, retrying with force_clear",
|
||||
plugin_id
|
||||
)
|
||||
# Try once more with force_clear=True
|
||||
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(
|
||||
"[%s] Fallback: retry brightness - %.3f%% bright pixels",
|
||||
plugin_id, bright_ratio * 100
|
||||
)
|
||||
|
||||
if is_blank:
|
||||
logger.warning(
|
||||
"[%s] Fallback: BLANK IMAGE after retry (%.3f%% bright, size=%dx%d)",
|
||||
plugin_id, bright_ratio * 100,
|
||||
captured.width, captured.height
|
||||
)
|
||||
return None
|
||||
|
||||
# Convert to RGB if needed
|
||||
if captured.mode != 'RGB':
|
||||
captured = captured.convert('RGB')
|
||||
|
||||
logger.info(
|
||||
"[%s] Fallback: SUCCESS - captured %dx%d",
|
||||
plugin_id, captured.width, captured.height
|
||||
)
|
||||
|
||||
return [captured]
|
||||
|
||||
except (AttributeError, TypeError, ValueError, OSError, RuntimeError) as e:
|
||||
logger.exception(
|
||||
"[%s] Fallback: ERROR capturing display: %s",
|
||||
plugin_id, e
|
||||
)
|
||||
return None
|
||||
|
||||
finally:
|
||||
# Always restore original image to prevent display corruption
|
||||
if original_image is not None:
|
||||
self.display_manager.image = original_image
|
||||
logger.debug("[%s] Fallback: restored original display state", plugin_id)
|
||||
|
||||
def _is_blank_image(
|
||||
self, img: Image.Image, return_ratio: bool = False
|
||||
) -> Union[bool, Tuple[bool, float]]:
|
||||
"""
|
||||
Check if an image is essentially blank (all black or nearly so).
|
||||
|
||||
Uses histogram-based detection which is more reliable than
|
||||
point sampling for content that may be positioned anywhere.
|
||||
|
||||
Args:
|
||||
img: Image to check
|
||||
return_ratio: If True, return tuple of (is_blank, bright_ratio)
|
||||
|
||||
Returns:
|
||||
True if image is blank, or tuple (is_blank, bright_ratio) if return_ratio=True
|
||||
"""
|
||||
# Convert to RGB for consistent checking
|
||||
if img.mode != 'RGB':
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Use histogram to check for any non-black content
|
||||
# This is more reliable than point sampling
|
||||
histogram = img.histogram()
|
||||
|
||||
# RGB histogram: 256 values per channel
|
||||
# Check if there's any significant brightness in any channel
|
||||
total_bright_pixels = 0
|
||||
threshold = 15 # Minimum brightness to count as "content"
|
||||
|
||||
for channel_offset in [0, 256, 512]: # R, G, B
|
||||
for brightness in range(threshold, 256):
|
||||
total_bright_pixels += histogram[channel_offset + brightness]
|
||||
|
||||
# If less than 0.5% of pixels have any brightness, consider blank
|
||||
total_pixels = img.width * img.height
|
||||
bright_ratio = total_bright_pixels / (total_pixels * 3) # Normalize across channels
|
||||
|
||||
is_blank = bright_ratio < 0.005 # Less than 0.5% bright pixels
|
||||
|
||||
if return_ratio:
|
||||
return is_blank, bright_ratio
|
||||
return is_blank
|
||||
|
||||
def _get_cached(self, plugin_id: str) -> Optional[List[Image.Image]]:
|
||||
"""Get cached content if still valid."""
|
||||
with self._cache_lock:
|
||||
if plugin_id not in self._content_cache:
|
||||
return None
|
||||
|
||||
cached_time, content = self._content_cache[plugin_id]
|
||||
if time.time() - cached_time > self._cache_ttl:
|
||||
del self._content_cache[plugin_id]
|
||||
return None
|
||||
|
||||
return content
|
||||
|
||||
def _cache_content(self, plugin_id: str, content: List[Image.Image]) -> None:
|
||||
"""Cache content for a plugin."""
|
||||
# Make copies to prevent mutation (done outside lock to minimize hold time)
|
||||
cached_content = [img.copy() for img in content]
|
||||
|
||||
with self._cache_lock:
|
||||
# Periodic cleanup of expired entries to prevent memory leak
|
||||
self._cleanup_expired_cache_locked()
|
||||
self._content_cache[plugin_id] = (time.time(), cached_content)
|
||||
|
||||
def _cleanup_expired_cache_locked(self) -> None:
|
||||
"""Remove expired entries from cache. Must be called with _cache_lock held."""
|
||||
current_time = time.time()
|
||||
expired_keys = [
|
||||
key for key, (cached_time, _) in self._content_cache.items()
|
||||
if current_time - cached_time > self._cache_ttl
|
||||
]
|
||||
for key in expired_keys:
|
||||
del self._content_cache[key]
|
||||
|
||||
def invalidate_cache(self, plugin_id: Optional[str] = None) -> None:
|
||||
"""
|
||||
Invalidate cached content.
|
||||
|
||||
Args:
|
||||
plugin_id: Specific plugin to invalidate, or None for all
|
||||
"""
|
||||
with self._cache_lock:
|
||||
if plugin_id:
|
||||
self._content_cache.pop(plugin_id, None)
|
||||
else:
|
||||
self._content_cache.clear()
|
||||
|
||||
def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
|
||||
"""
|
||||
Get the type of content a plugin provides.
|
||||
|
||||
Args:
|
||||
plugin: Plugin instance
|
||||
plugin_id: Plugin identifier
|
||||
|
||||
Returns:
|
||||
'multi' for multiple items, 'static' for single frame, 'none' for excluded
|
||||
"""
|
||||
if hasattr(plugin, 'get_vegas_content_type'):
|
||||
try:
|
||||
return plugin.get_vegas_content_type()
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
logger.exception(
|
||||
"Error calling get_vegas_content_type() on %s",
|
||||
plugin_id
|
||||
)
|
||||
|
||||
# Default to static for plugins without explicit type
|
||||
return 'static'
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
with self._cache_lock:
|
||||
self._content_cache.clear()
|
||||
logger.debug("PluginAdapter cleanup complete")
|
||||
Reference in New Issue
Block a user