Files
LEDMatrix/src/vegas_mode/render_pipeline.py
Chuck efe6b1fe23 fix: reduce CPU usage, fix Vegas refresh, throttle high-FPS ticks (#304)
* fix: reduce CPU usage, fix Vegas mid-cycle refresh, and throttle high-FPS plugin ticks

Web UI Info plugin was causing 90%+ CPU on RPi4 due to frequent subprocess
calls and re-rendering. Fixed by: trying socket-based IP detection first
(zero subprocess overhead), caching AP mode checks with 60s TTL, reducing
IP refresh from 30s to 5m, caching rendered display images, and loading
fonts once at init.

Vegas mode was not updating the display mid-cycle because hot_swap_content()
reset the scroll position to 0 on every recomposition. Now saves and
restores scroll position for mid-cycle updates.

High-FPS display loop was calling _tick_plugin_updates() 125x/sec with no
benefit. Added throttled wrapper that limits to 1 call/sec.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — respect plugin update_interval, narrow exception handlers

Make _tick_plugin_updates_throttled default to no-throttle (min_interval=0)
so plugin-configured update_interval values are never silently capped.
The high-FPS call site passes an explicit 1.0s interval.

Narrow _load_font exception handler from bare Exception to
FileNotFoundError | OSError so unexpected errors surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(vegas): scale scroll position proportionally on mid-cycle hot-swap

When content width changes during a mid-cycle recomposition (e.g., a
plugin gains or loses items), blindly restoring the old scroll_position
and total_distance_scrolled could overshoot the new total_scroll_width
and trigger immediate false completion. Scale both values proportionally
to the new width and clamp scroll_position to stay in bounds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 08:46:52 -04:00

422 lines
14 KiB
Python

"""
Render Pipeline for Vegas Mode
Handles high-FPS (125 FPS) rendering with double-buffering for smooth scrolling.
Uses the existing ScrollHelper for numpy-optimized scroll operations.
"""
import logging
import time
import threading
from collections import deque
from typing import Optional, List, Any, Dict, Deque, TYPE_CHECKING
from PIL import Image
import numpy as np
from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.stream_manager import StreamManager, ContentSegment
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
class RenderPipeline:
"""
High-performance render pipeline for Vegas scroll mode.
Key responsibilities:
- Compose content segments into scrollable image
- Manage scroll position and velocity
- Handle 125 FPS rendering loop
- Double-buffer for hot-swap during updates
- Track scroll cycle completion
"""
def __init__(
self,
config: VegasModeConfig,
display_manager: Any,
stream_manager: StreamManager
):
"""
Initialize the render pipeline.
Args:
config: Vegas mode configuration
display_manager: DisplayManager for rendering
stream_manager: StreamManager for content
"""
self.config = config
self.display_manager = display_manager
self.stream_manager = stream_manager
# Display dimensions (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
)
# ScrollHelper for optimized scrolling
self.scroll_helper = ScrollHelper(
self.display_width,
self.display_height,
logger
)
# Configure scroll helper
self._configure_scroll_helper()
# Double-buffer for composed images
self._active_scroll_image: Optional[Image.Image] = None
self._staging_scroll_image: Optional[Image.Image] = None
self._buffer_lock = threading.Lock()
# Render state
self._is_rendering = False
self._cycle_complete = False
self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll
# Timing
self._last_frame_time = 0.0
self._frame_interval = config.get_frame_interval()
self._cycle_start_time = 0.0
# Statistics
self.stats = {
'frames_rendered': 0,
'scroll_cycles': 0,
'composition_count': 0,
'hot_swaps': 0,
'avg_frame_time_ms': 0.0,
}
self._frame_times: Deque[float] = deque(maxlen=100) # Efficient fixed-size buffer
logger.info(
"RenderPipeline initialized: %dx%d @ %d FPS",
self.display_width, self.display_height, config.target_fps
)
def _configure_scroll_helper(self) -> None:
"""Configure ScrollHelper with current settings."""
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
self.scroll_helper.set_scroll_delay(self.config.scroll_delay)
# Config scroll_speed is always pixels per second, but ScrollHelper
# interprets it differently based on frame_based_scrolling mode:
# - Frame-based: pixels per frame step
# - Time-based: pixels per second
if self.config.frame_based_scrolling:
# Convert pixels/second to pixels/frame
# pixels_per_frame = pixels_per_second * seconds_per_frame
pixels_per_frame = self.config.scroll_speed * self.config.scroll_delay
self.scroll_helper.set_scroll_speed(pixels_per_frame)
else:
self.scroll_helper.set_scroll_speed(self.config.scroll_speed)
self.scroll_helper.set_dynamic_duration_settings(
enabled=self.config.dynamic_duration_enabled,
min_duration=self.config.min_cycle_duration,
max_duration=self.config.max_cycle_duration,
buffer=0.1 # 10% buffer
)
def compose_scroll_content(self) -> bool:
"""
Compose content from stream manager into scrollable image.
Returns:
True if composition successful
"""
try:
# Get all buffered content
images = self.stream_manager.get_all_content_for_composition()
if not images:
logger.warning("No content available for composition")
return False
# Add separator gaps between images
content_with_gaps = []
for i, img in enumerate(images):
content_with_gaps.append(img)
# Create scrolling image via ScrollHelper
self.scroll_helper.create_scrolling_image(
content_items=content_with_gaps,
item_gap=self.config.separator_width,
element_gap=0
)
# Verify scroll image was created successfully
if not self.scroll_helper.cached_image:
logger.error("ScrollHelper failed to create cached image")
return False
# Store reference to composed image
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
# Track which plugins are in this scroll (get safely via buffer status)
self._segments_in_scroll = self.stream_manager.get_active_plugin_ids()
self.stats['composition_count'] += 1
self._cycle_start_time = time.time()
self._cycle_complete = False
logger.info(
"Composed scroll image: %dx%d, %d plugins, %d items",
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
self.display_height,
len(self._segments_in_scroll),
len(images)
)
return True
except (ValueError, TypeError, OSError, RuntimeError):
# Expected errors from image operations, scroll helper, or bad data
logger.exception("Error composing scroll content")
return False
def render_frame(self) -> bool:
"""
Render a single frame to the display.
Should be called at ~125 FPS (8ms intervals).
Returns:
True if frame was rendered, False if no content
"""
frame_start = time.time()
try:
if not self.scroll_helper.cached_image:
return False
# Update scroll position
self.scroll_helper.update_scroll_position()
# Check if cycle is complete
if self.scroll_helper.is_scroll_complete():
if not self._cycle_complete:
self._cycle_complete = True
self.stats['scroll_cycles'] += 1
logger.info(
"Scroll cycle complete after %.1fs",
time.time() - self._cycle_start_time
)
# Get visible portion
visible_frame = self.scroll_helper.get_visible_portion()
if not visible_frame:
return False
# Render to display
self.display_manager.image = visible_frame
self.display_manager.update_display()
# Update scrolling state
self.display_manager.set_scrolling_state(True)
# Track statistics
self.stats['frames_rendered'] += 1
frame_time = time.time() - frame_start
self._track_frame_time(frame_time)
return True
except (ValueError, TypeError, OSError, RuntimeError):
# Expected errors from scroll helper or display manager operations
logger.exception("Error rendering frame")
return False
def _track_frame_time(self, frame_time: float) -> None:
"""Track frame timing for statistics."""
self._frame_times.append(frame_time) # deque with maxlen auto-removes old entries
if self._frame_times:
self.stats['avg_frame_time_ms'] = (
sum(self._frame_times) / len(self._frame_times) * 1000
)
def is_cycle_complete(self) -> bool:
"""Check if current scroll cycle is complete."""
return self._cycle_complete
def should_recompose(self) -> bool:
"""
Check if scroll content should be recomposed.
Returns True when:
- Cycle is complete and we should start fresh
- Staging buffer has new content
"""
if self._cycle_complete:
return True
# Check if we need more content in the buffer
buffer_status = self.stream_manager.get_buffer_status()
if buffer_status['staging_count'] > 0:
return True
# Trigger recompose when pending updates affect visible segments
if self.stream_manager.has_pending_updates_for_visible_segments():
return True
return False
def hot_swap_content(self) -> bool:
"""
Hot-swap to new composed content.
Called when staging buffer has updated content or pending updates exist.
Preserves scroll position for mid-cycle updates to prevent visual jumps.
Returns:
True if swap occurred
"""
try:
# Save scroll position for mid-cycle updates
saved_position = self.scroll_helper.scroll_position
saved_total_distance = self.scroll_helper.total_distance_scrolled
saved_total_width = max(1, self.scroll_helper.total_scroll_width)
was_mid_cycle = not self._cycle_complete
# Process any pending updates
self.stream_manager.process_updates()
self.stream_manager.swap_buffers()
# Recompose with updated content
if self.compose_scroll_content():
self.stats['hot_swaps'] += 1
# Restore scroll position for mid-cycle updates so the
# scroll continues from where it was instead of jumping to 0
if was_mid_cycle:
new_total_width = max(1, self.scroll_helper.total_scroll_width)
progress_ratio = min(saved_total_distance / saved_total_width, 0.999)
self.scroll_helper.total_distance_scrolled = progress_ratio * new_total_width
self.scroll_helper.scroll_position = min(
saved_position,
float(new_total_width - 1)
)
self.scroll_helper.scroll_complete = False
self._cycle_complete = False
logger.debug("Hot-swap completed (mid_cycle_restore=%s)", was_mid_cycle)
return True
return False
except (ValueError, TypeError, OSError, RuntimeError):
# Expected errors from stream manager or composition operations
logger.exception("Error during hot-swap")
return False
def start_new_cycle(self) -> bool:
"""
Start a new scroll cycle.
Fetches fresh content and recomposes.
Returns:
True if new cycle started successfully
"""
# Reset scroll position
self.scroll_helper.reset_scroll()
self._cycle_complete = False
# Clear buffer from previous cycle so new content is fetched
self.stream_manager.advance_cycle()
# Refresh stream content (picks up plugin list changes)
self.stream_manager.refresh()
# Reinitialize stream (fills buffer with fresh content)
if not self.stream_manager.initialize():
logger.warning("Failed to reinitialize stream for new cycle")
return False
# Compose new scroll content
return self.compose_scroll_content()
def get_current_scroll_info(self) -> Dict[str, Any]:
"""Get current scroll state information."""
scroll_info = self.scroll_helper.get_scroll_info()
return {
**scroll_info,
'cycle_complete': self._cycle_complete,
'plugins_in_scroll': self._segments_in_scroll,
'stats': self.stats.copy(),
}
def get_scroll_position(self) -> int:
"""
Get current scroll position.
Used by coordinator to save position before static pause.
Returns:
Current scroll position in pixels
"""
return int(self.scroll_helper.scroll_position)
def set_scroll_position(self, position: int) -> None:
"""
Set scroll position.
Used by coordinator to restore position after static pause.
Args:
position: Scroll position in pixels
"""
self.scroll_helper.scroll_position = float(position)
def update_config(self, new_config: VegasModeConfig) -> None:
"""
Update render pipeline configuration.
Args:
new_config: New configuration to apply
"""
old_fps = self.config.target_fps
self.config = new_config
self._frame_interval = new_config.get_frame_interval()
# Reconfigure scroll helper
self._configure_scroll_helper()
if old_fps != new_config.target_fps:
logger.info("FPS target updated: %d -> %d", old_fps, new_config.target_fps)
def reset(self) -> None:
"""Reset the render pipeline state."""
self.scroll_helper.reset_scroll()
self.scroll_helper.clear_cache()
with self._buffer_lock:
self._active_scroll_image = None
self._staging_scroll_image = None
self._cycle_complete = False
self._segments_in_scroll = []
self._frame_times = deque(maxlen=100)
self.display_manager.set_scrolling_state(False)
logger.info("RenderPipeline reset")
def cleanup(self) -> None:
"""Clean up resources."""
self.reset()
self.display_manager.set_scrolling_state(False)
logger.debug("RenderPipeline cleanup complete")
def get_dynamic_duration(self) -> float:
"""Get the calculated dynamic duration for current content."""
return float(self.scroll_helper.get_dynamic_duration())