mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
The width-budget crop snapped to the nearest blank column, and in rendered text the gap between two characters is a single column. So a cut routinely landed inside a word: the cycle showed "Wednesda" and the orphaned "y" turned up as a lone floating letter in the next cycle, positioned after whatever plugin happened to precede it. Measured on the clock-simple segment to confirm: its blank runs are [1, 1, 1, 1, 1, 8, 8] — five single-column letter gaps, every one of which find_blank_cut would happily have chosen. Cuts now only land in a run of at least min_cut_gap blank columns (default 6), which excludes letter spacing while still finding the gaps plugins put between items (the stocks ticker uses 32px, baseball 48px). Where no boundary falls inside the budget the cut waits for the next one and overruns, because splitting an item is worse than a slightly long segment. Continuous content is treated differently on purpose: an image with no internal gaps is a map or a chart, where any column is as good as another, so it is still cut to the budget exactly. The gap rule protects discrete items; letting a solid image escape the cap in its name would be wrong. blank_runs() is vectorised — 48ms for a 17,000px strip, against seconds for a per-column Python loop. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
364 lines
16 KiB
Python
364 lines
16 KiB
Python
"""
|
|
Vegas Mode Configuration
|
|
|
|
Handles configuration for Vegas-style continuous scroll mode including
|
|
plugin ordering, exclusions, scroll speed, and display settings.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Dict, Any, List, Set
|
|
from dataclasses import dataclass, field
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class VegasModeConfig:
|
|
"""Configuration for Vegas scroll mode."""
|
|
|
|
# Core settings
|
|
enabled: bool = False
|
|
scroll_speed: float = 50.0 # Pixels per second
|
|
separator_width: int = 32 # Gap between plugins (pixels)
|
|
|
|
# Fraction of the panel width a plugin is told it has while rendering for
|
|
# the ticker, as a percentage. Trimming can only remove blank margins; it
|
|
# cannot compact a layout that genuinely spans the display — a five-column
|
|
# forecast, a full-width progress bar, a centred stat block with the panel's
|
|
# whole width between its elements. Rendering at a narrower size makes the
|
|
# plugin choose a tighter layout instead. 100 disables it.
|
|
render_width_pct: int = 100
|
|
|
|
# Minimum blank columns guaranteed between adjacent content, measured from
|
|
# actual ink rather than added blindly. A flat additive gap leaves
|
|
# card-style content nearly touching when the cards are drawn flush to their
|
|
# own edges, while padding out content that already has wide margins.
|
|
min_content_separation: int = 24
|
|
|
|
# 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
|
|
|
|
# Minimum run of blank columns that counts as a boundary between items when
|
|
# an oversized segment has to be narrowed. Measured on rendered text, the
|
|
# gaps between characters are a single column while gaps between items are
|
|
# 8px and up, so anything above 1 stops a cut landing inside a word. Cutting
|
|
# mid-word orphaned the tail into the next cycle, which showed up as a lone
|
|
# letter floating between two unrelated plugins.
|
|
min_cut_gap: 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)
|
|
|
|
# Performance settings
|
|
target_fps: int = 125 # Target frame rate
|
|
buffer_ahead: int = 2 # Number of plugins to buffer ahead
|
|
|
|
# Scroll behavior
|
|
frame_based_scrolling: bool = True
|
|
scroll_delay: float = 0.02 # 50 FPS effective scroll updates
|
|
|
|
# Dynamic duration
|
|
dynamic_duration_enabled: bool = True
|
|
min_cycle_duration: int = 60 # Minimum seconds per full cycle
|
|
max_cycle_duration: int = 600 # Maximum seconds per full cycle
|
|
|
|
@classmethod
|
|
def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig':
|
|
"""
|
|
Create VegasModeConfig from main configuration dictionary.
|
|
|
|
Args:
|
|
config: Main config dict (expects config['display']['vegas_scroll'])
|
|
|
|
Returns:
|
|
VegasModeConfig instance
|
|
"""
|
|
vegas_config = config.get('display', {}).get('vegas_scroll', {})
|
|
|
|
return cls(
|
|
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)),
|
|
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
|
|
min_content_separation=int(
|
|
vegas_config.get('min_content_separation', 24)),
|
|
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
|
|
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)),
|
|
buffer_ahead=int(vegas_config.get('buffer_ahead', 2)),
|
|
frame_based_scrolling=vegas_config.get('frame_based_scrolling', True),
|
|
scroll_delay=float(vegas_config.get('scroll_delay', 0.02)),
|
|
dynamic_duration_enabled=vegas_config.get('dynamic_duration_enabled', True),
|
|
min_cycle_duration=int(vegas_config.get('min_cycle_duration', 60)),
|
|
max_cycle_duration=int(vegas_config.get('max_cycle_duration', 600)),
|
|
)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert config to dictionary for serialization."""
|
|
return {
|
|
'enabled': self.enabled,
|
|
'scroll_speed': self.scroll_speed,
|
|
'separator_width': self.separator_width,
|
|
'intra_plugin_gap': self.intra_plugin_gap,
|
|
'render_width_pct': self.render_width_pct,
|
|
'min_content_separation': self.min_content_separation,
|
|
'min_cut_gap': self.min_cut_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,
|
|
'buffer_ahead': self.buffer_ahead,
|
|
'frame_based_scrolling': self.frame_based_scrolling,
|
|
'scroll_delay': self.scroll_delay,
|
|
'dynamic_duration_enabled': self.dynamic_duration_enabled,
|
|
'min_cycle_duration': self.min_cycle_duration,
|
|
'max_cycle_duration': self.max_cycle_duration,
|
|
}
|
|
|
|
def get_frame_interval(self) -> float:
|
|
"""Get the frame interval in seconds for target FPS."""
|
|
return 1.0 / max(1, self.target_fps)
|
|
|
|
def is_plugin_included(self, plugin_id: str) -> bool:
|
|
"""
|
|
Check if a plugin should be included in Vegas scroll.
|
|
|
|
This is consistent with get_ordered_plugins - plugins not explicitly
|
|
in plugin_order are still included (appended at the end) unless excluded.
|
|
|
|
Args:
|
|
plugin_id: Plugin identifier to check
|
|
|
|
Returns:
|
|
True if plugin should be included
|
|
"""
|
|
# Plugins are included unless explicitly excluded
|
|
return plugin_id not in self.excluded_plugins
|
|
|
|
def get_ordered_plugins(self, available_plugins: List[str]) -> List[str]:
|
|
"""
|
|
Get plugins in configured order, filtering excluded ones.
|
|
|
|
Args:
|
|
available_plugins: List of all available plugin IDs
|
|
|
|
Returns:
|
|
Ordered list of plugin IDs to include in Vegas scroll
|
|
"""
|
|
if self.plugin_order:
|
|
# Use explicit order, filter to only available and non-excluded
|
|
ordered = [
|
|
p for p in self.plugin_order
|
|
if p in available_plugins and p not in self.excluded_plugins
|
|
]
|
|
# Add any available plugins not in the order list (at the end)
|
|
for p in available_plugins:
|
|
if p not in ordered and p not in self.excluded_plugins:
|
|
ordered.append(p)
|
|
return ordered
|
|
else:
|
|
# Use natural order, filter excluded
|
|
return [p for p in available_plugins if p not in self.excluded_plugins]
|
|
|
|
def validate(self) -> List[str]:
|
|
"""
|
|
Validate configuration values.
|
|
|
|
Returns:
|
|
List of validation error messages (empty if valid)
|
|
"""
|
|
errors = []
|
|
|
|
if self.scroll_speed < 1.0:
|
|
errors.append(f"scroll_speed must be >= 1.0, got {self.scroll_speed}")
|
|
if self.scroll_speed > 200.0:
|
|
errors.append(f"scroll_speed must be <= 200.0, got {self.scroll_speed}")
|
|
|
|
if self.separator_width < 0:
|
|
errors.append(f"separator_width must be >= 0, got {self.separator_width}")
|
|
if self.separator_width > 128:
|
|
errors.append(f"separator_width must be <= 128, got {self.separator_width}")
|
|
|
|
if self.target_fps < 30:
|
|
errors.append(f"target_fps must be >= 30, got {self.target_fps}")
|
|
if self.target_fps > 200:
|
|
errors.append(f"target_fps must be <= 200, got {self.target_fps}")
|
|
|
|
if self.buffer_ahead < 1:
|
|
errors.append(f"buffer_ahead must be >= 1, got {self.buffer_ahead}")
|
|
if self.buffer_ahead > 5:
|
|
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
|
|
|
|
if not 10 <= self.render_width_pct <= 100:
|
|
errors.append(
|
|
"render_width_pct must be between 10 and 100, "
|
|
f"got {self.render_width_pct}")
|
|
|
|
if not 0 <= self.min_content_separation <= 256:
|
|
errors.append(
|
|
"min_content_separation must be between 0 and 256, "
|
|
f"got {self.min_content_separation}")
|
|
|
|
if not 1 <= self.min_cut_gap <= 128:
|
|
errors.append(
|
|
"min_cut_gap must be between 1 and 128, "
|
|
f"got {self.min_cut_gap}")
|
|
|
|
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:
|
|
"""
|
|
Update configuration from new values.
|
|
|
|
Args:
|
|
new_config: New configuration values to apply
|
|
"""
|
|
vegas_config = new_config.get('display', {}).get('vegas_scroll', {})
|
|
|
|
if 'enabled' in vegas_config:
|
|
self.enabled = vegas_config['enabled']
|
|
if 'scroll_speed' in vegas_config:
|
|
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 'render_width_pct' in vegas_config:
|
|
self.render_width_pct = int(vegas_config['render_width_pct'])
|
|
if 'min_content_separation' in vegas_config:
|
|
self.min_content_separation = int(
|
|
vegas_config['min_content_separation'])
|
|
if 'min_cut_gap' in vegas_config:
|
|
self.min_cut_gap = int(vegas_config['min_cut_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:
|
|
self.excluded_plugins = set(vegas_config['excluded_plugins'])
|
|
if 'target_fps' in vegas_config:
|
|
self.target_fps = int(vegas_config['target_fps'])
|
|
if 'buffer_ahead' in vegas_config:
|
|
self.buffer_ahead = int(vegas_config['buffer_ahead'])
|
|
if 'frame_based_scrolling' in vegas_config:
|
|
self.frame_based_scrolling = vegas_config['frame_based_scrolling']
|
|
if 'scroll_delay' in vegas_config:
|
|
self.scroll_delay = float(vegas_config['scroll_delay'])
|
|
if 'dynamic_duration_enabled' in vegas_config:
|
|
self.dynamic_duration_enabled = vegas_config['dynamic_duration_enabled']
|
|
if 'min_cycle_duration' in vegas_config:
|
|
self.min_cycle_duration = int(vegas_config['min_cycle_duration'])
|
|
if 'max_cycle_duration' in vegas_config:
|
|
self.max_cycle_duration = int(vegas_config['max_cycle_duration'])
|
|
|
|
# Log config update
|
|
logger.info(
|
|
"Vegas mode config updated: enabled=%s, speed=%.1f, fps=%d, buffer=%d",
|
|
self.enabled, self.scroll_speed, self.target_fps, self.buffer_ahead
|
|
)
|