Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion config/config.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,18 @@
"plugin_order": [],
"excluded_plugins": [],
"target_fps": 125,
"buffer_ahead": 2
"buffer_ahead": 2,
"intra_plugin_gap": 8,
"auto_trim": true,
"trim_threshold": 10,
"content_padding": 8,
"min_plugin_width": 8,
"lead_in_width": 0,
"plugins_per_cycle": 6,
"max_plugin_width_ratio": 3.0,
"dynamic_duration_enabled": true,
"min_cycle_duration": 60,
"max_cycle_duration": 240
}
},
"sync": {
Expand Down
384 changes: 384 additions & 0 deletions scripts/dev/vegas_audit.py

Large diffs are not rendered by default.

26 changes: 18 additions & 8 deletions src/common/scroll_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,20 +110,30 @@ def __init__(self, display_width: int, display_height: int,
self.is_scrolling = False
self.scroll_complete = False

def create_scrolling_image(self, content_items: list,
def create_scrolling_image(self, content_items: list,
item_gap: int = 32,
element_gap: int = 16) -> Image.Image:
element_gap: int = 16,
lead_gap: Optional[int] = None) -> Image.Image:
"""
Create a wide image containing all content items for scrolling.

Args:
content_items: List of PIL Images to include in scroll
item_gap: Gap between different items
element_gap: Gap between elements within an item

lead_gap: Blank columns before the first item. Defaults to a full
display width, which makes a standalone ticker scroll in from
off-screen. Callers that loop many plugins back-to-back (Vegas
mode) pass a smaller value, since a full display width of black
reads as the panel being switched off at the start of every
cycle.

Returns:
PIL Image containing all content arranged horizontally
"""
if lead_gap is None:
lead_gap = self.display_width
lead_gap = max(0, int(lead_gap))
if not content_items:
# Create empty image if no content
# Still set total_scroll_width to 0 to indicate no scrollable content
Expand All @@ -144,13 +154,13 @@ def create_scrolling_image(self, content_items: list,
total_width += element_gap * len(content_items)

# Add initial gap before first item
total_width += self.display_width
total_width += lead_gap

# Create the full scrolling image
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))

# Position items
current_x = self.display_width # Start with initial gap
current_x = lead_gap # Start with initial gap

for i, img in enumerate(content_items):
# Paste the item image
Expand Down
19 changes: 19 additions & 0 deletions src/plugin_system/testing/visual_display_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import math
import os
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, List, Optional, Tuple

Expand Down Expand Up @@ -62,6 +63,9 @@ def __init__(self, width: int = 128, height: int = 32):
# Matrix proxy (plugins access display_manager.matrix.width/height)
self.matrix = _MatrixProxy(width, height)

# Set while inside capture_mode(); mirrors DisplayManager's flag.
self._capture_mode_active = False

# Scrolling state (interface compat, no-op)
self._scrolling_state = {
'is_scrolling': False,
Expand Down Expand Up @@ -174,6 +178,21 @@ def update_display(self):
"""No-op for hardware; marks that display was updated."""
self.update_called = True

@contextmanager
def capture_mode(self):
"""
Interface parity with DisplayManager.capture_mode().

There is no hardware to suppress here, but Vegas mode's PluginAdapter
wraps every off-screen content fetch in this context, so the harness
must provide it for that code path to be exercisable in tests.
"""
self._capture_mode_active = True
try:
yield
finally:
self._capture_mode_active = False

def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
font: Optional[Any] = None, centered: bool = False) -> None:
Expand Down
112 changes: 112 additions & 0 deletions src/vegas_mode/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,41 @@ class VegasModeConfig:
scroll_speed: float = 50.0 # Pixels per second
separator_width: int = 32 # Gap between plugins (pixels)

# Gap between rows contributed by the *same* plugin. separator_width marks
# the handoff from one plugin to the next; applying it between every image
# forced a 32px chasm between each row of a per-row ticker (the F1
# scoreboard renders its own rows 4px apart), which both looked wrong and
# silently inflated the width that plugin occupied.
intra_plugin_gap: int = 8

# Content density
#
# Plugins that render onto a full-display canvas contribute that whole
# canvas to the ticker, blank margins included. On a wide panel that is the
# dominant source of dead air: a plugin drawing 35px of text on a 512px
# canvas otherwise buys 9.5s of black at 50px/s. Trimming reclaims it.
auto_trim: bool = True
trim_threshold: int = 10 # Per-channel value a pixel must exceed to be "ink"
content_padding: int = 8 # Blank columns kept either side of trimmed content
min_plugin_width: int = 8 # Segments narrower than this after trim are dropped

# Columns of blank lead-in before the first item of a cycle. ScrollHelper
# defaults this to a full display width, which reads as the display being
# switched off at the start of every cycle.
lead_in_width: int = 0

# How many plugins are composed into one scroll cycle. Kept separate from
# buffer_ahead (which is only a prefetch low-water mark) because the two
# were previously the same number: a buffer_ahead of 2 meant just 3 plugins
# per cycle, so a 20-plugin install took seven cycles to come around.
plugins_per_cycle: int = 6

# Cap on one plugin's share of a cycle, as a multiple of display width.
# A single ticker returning 7,000px would otherwise hold the panel for over
# two minutes. Overflow is deferred to later cycles rather than discarded.
# 0 disables the cap.
max_plugin_width_ratio: float = 3.0

# Plugin management
plugin_order: List[str] = field(default_factory=list)
excluded_plugins: Set[str] = field(default_factory=set)
Expand Down Expand Up @@ -55,6 +90,15 @@ def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig':
enabled=vegas_config.get('enabled', False),
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
separator_width=int(vegas_config.get('separator_width', 32)),
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
auto_trim=vegas_config.get('auto_trim', True),
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
content_padding=int(vegas_config.get('content_padding', 8)),
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)),
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
max_plugin_width_ratio=float(
vegas_config.get('max_plugin_width_ratio', 3.0)),
plugin_order=list(vegas_config.get('plugin_order', [])),
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
target_fps=int(vegas_config.get('target_fps', 125)),
Expand All @@ -72,6 +116,14 @@ def to_dict(self) -> Dict[str, Any]:
'enabled': self.enabled,
'scroll_speed': self.scroll_speed,
'separator_width': self.separator_width,
'intra_plugin_gap': self.intra_plugin_gap,
'auto_trim': self.auto_trim,
'trim_threshold': self.trim_threshold,
'content_padding': self.content_padding,
'min_plugin_width': self.min_plugin_width,
'lead_in_width': self.lead_in_width,
'plugins_per_cycle': self.plugins_per_cycle,
'max_plugin_width_ratio': self.max_plugin_width_ratio,
'plugin_order': self.plugin_order,
'excluded_plugins': list(self.excluded_plugins),
'target_fps': self.target_fps,
Expand Down Expand Up @@ -157,6 +209,49 @@ def validate(self) -> List[str]:
if self.buffer_ahead > 5:
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")

if self.intra_plugin_gap < 0:
errors.append(
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
if self.intra_plugin_gap > 128:
errors.append(
f"intra_plugin_gap must be <= 128, got {self.intra_plugin_gap}")

if not 0 <= self.trim_threshold <= 254:
errors.append(
f"trim_threshold must be between 0 and 254, got {self.trim_threshold}")

if self.content_padding < 0:
errors.append(
f"content_padding must be >= 0, got {self.content_padding}")
if self.content_padding > 128:
errors.append(
f"content_padding must be <= 128, got {self.content_padding}")

if self.min_plugin_width < 0:
errors.append(
f"min_plugin_width must be >= 0, got {self.min_plugin_width}")
# Bounded because every segment narrower than this is dropped — an
# unbounded value would discard every plugin and leave a blank ticker.
if self.min_plugin_width > 512:
errors.append(
f"min_plugin_width must be <= 512, got {self.min_plugin_width}")

if self.lead_in_width < 0:
errors.append(
f"lead_in_width must be >= 0, got {self.lead_in_width}")

if self.plugins_per_cycle < 1:
errors.append(
f"plugins_per_cycle must be >= 1, got {self.plugins_per_cycle}")
if self.plugins_per_cycle > 50:
errors.append(
f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}")

if self.max_plugin_width_ratio < 0:
errors.append(
"max_plugin_width_ratio must be >= 0 "
f"(0 disables the cap), got {self.max_plugin_width_ratio}")

return errors

def update(self, new_config: Dict[str, Any]) -> None:
Expand All @@ -174,6 +269,23 @@ def update(self, new_config: Dict[str, Any]) -> None:
self.scroll_speed = float(vegas_config['scroll_speed'])
if 'separator_width' in vegas_config:
self.separator_width = int(vegas_config['separator_width'])
if 'intra_plugin_gap' in vegas_config:
self.intra_plugin_gap = int(vegas_config['intra_plugin_gap'])
if 'auto_trim' in vegas_config:
self.auto_trim = vegas_config['auto_trim']
if 'trim_threshold' in vegas_config:
self.trim_threshold = int(vegas_config['trim_threshold'])
if 'content_padding' in vegas_config:
self.content_padding = int(vegas_config['content_padding'])
if 'min_plugin_width' in vegas_config:
self.min_plugin_width = int(vegas_config['min_plugin_width'])
if 'lead_in_width' in vegas_config:
self.lead_in_width = int(vegas_config['lead_in_width'])
if 'plugins_per_cycle' in vegas_config:
self.plugins_per_cycle = int(vegas_config['plugins_per_cycle'])
if 'max_plugin_width_ratio' in vegas_config:
self.max_plugin_width_ratio = float(
vegas_config['max_plugin_width_ratio'])
if 'plugin_order' in vegas_config:
self.plugin_order = list(vegas_config['plugin_order'])
if 'excluded_plugins' in vegas_config:
Expand Down
6 changes: 5 additions & 1 deletion src/vegas_mode/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ def __init__(
self.plugin_manager = plugin_manager

# Initialize components
self.plugin_adapter = PluginAdapter(display_manager)
self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
self.stream_manager = StreamManager(
self.vegas_config,
plugin_manager,
Expand Down Expand Up @@ -505,6 +505,10 @@ def _apply_pending_config(self) -> None:
# Update components
self.render_pipeline.update_config(new_vegas_config)
self.stream_manager.config = new_vegas_config
self.plugin_adapter.config = new_vegas_config
# Cached segments were trimmed under the old settings, so drop them
# or a changed trim/padding value would not visibly take effect.
self.plugin_adapter.invalidate_cache()

# Force refresh of stream manager to pick up plugin_order/buffer changes
self.stream_manager._last_refresh = 0
Expand Down
Loading
Loading