diff --git a/config/config.template.json b/config/config.template.json index b3f63589..ece166c2 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -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": { diff --git a/scripts/dev/vegas_audit.py b/scripts/dev/vegas_audit.py new file mode 100644 index 00000000..06fc250e --- /dev/null +++ b/scripts/dev/vegas_audit.py @@ -0,0 +1,379 @@ +#!/usr/bin/env python3 +""" +Vegas Mode Density Audit + +Reports how much of the Vegas ticker is actually showing something. Loads the +real enabled plugins, pulls each one's content through the real +``PluginAdapter``, composes the strip through the real ``ScrollHelper``, then +measures the result. + +The headline number is the **dead-frame ratio**: the fraction of viewport +positions across a full cycle that are effectively blank. Because the panel +only ever shows ``display_width`` columns at a time, a blank stretch wider than +the viewport is a stretch where the display looks switched off — so this ratio +tracks perceived dead time rather than just counting unlit pixels. + +Runs entirely off-hardware, so it is safe to run alongside a live display. + +Usage: + # Audit every enabled plugin at the display size from config.json + python scripts/dev/vegas_audit.py + + # Specific plugins, dump each segment as a PNG for eyeballing + python scripts/dev/vegas_audit.py -p of-the-day,youtube-stats --dump-dir /tmp/vg + + # Machine-readable, for before/after comparison + python scripts/dev/vegas_audit.py --json > after.json +""" + +import argparse +import json +import logging +import os +import sys +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +# Must precede any src import that may reach for hardware. +os.environ.setdefault('EMULATOR', 'true') + +from PIL import Image # noqa: E402 + +from src.common.scroll_helper import ScrollHelper # noqa: E402 +from src.plugin_system.testing.loading import ( # noqa: E402 + build_full_config, + find_plugin_dir, + load_manifest, +) +from src.vegas_mode.config import VegasModeConfig # noqa: E402 +from src.vegas_mode.geometry import ( # noqa: E402 + DEFAULT_INK_THRESHOLD, + column_has_ink, + content_bounds, + dead_window_stats, + window_coverage_stats, +) +from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402 + +# Sampling stride for the dead-window scan. A full cycle can be 30,000px wide; +# 4px granularity keeps the scan instant while staying well under the ~10px a +# single scroll step ever covers, so no dead stretch is missed. +DEAD_SCAN_STEP = 4 + + +def load_main_config(path: Path) -> Dict[str, Any]: + with open(path, 'r') as fh: + return json.load(fh) + + +def display_size_from_config(config: Dict[str, Any]) -> tuple: + """Derive the logical ticker size the way DisplayManager does.""" + hw = config.get('display', {}).get('hardware', {}) + cols = int(hw.get('cols', 64)) + chain = int(hw.get('chain_length', 1)) + rows = int(hw.get('rows', 32)) + parallel = int(hw.get('parallel', 1)) + return cols * chain, rows * parallel + + +def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]: + """Plugin IDs that are enabled in config, excluding non-plugin sections.""" + ids = [] + for key, value in config.items(): + if isinstance(value, dict) and value.get('enabled') is True: + ids.append(key) + return ids + + +def instantiate(plugin_id: str, display_manager, cache_manager, plugin_manager): + """Load one plugin offline. Returns the instance or None.""" + from src.plugin_system.plugin_loader import PluginLoader + + search_dirs = [ + str(PROJECT_ROOT / 'plugin-repos'), + str(PROJECT_ROOT / 'plugins'), + ] + plugin_dir = find_plugin_dir(plugin_id, search_dirs) + if not plugin_dir: + return None + + try: + manifest = load_manifest(Path(plugin_dir)) + cfg = build_full_config(Path(plugin_dir)) + instance, _ = PluginLoader().load_plugin( + plugin_id=plugin_id, + manifest=manifest, + plugin_dir=Path(plugin_dir), + config=cfg, + display_manager=display_manager, + cache_manager=cache_manager, + plugin_manager=plugin_manager, + install_deps=False, + ) + return instance + except Exception as exc: # noqa: BLE001 - audit tool must survive any plugin + print(f" ! {plugin_id}: load failed ({type(exc).__name__}: {exc})", + file=sys.stderr) + return None + + +def join_rows(images: List[Image.Image], gap: int) -> Image.Image: + """Concatenate one plugin's rows, matching RenderPipeline._join_plugin_rows.""" + if len(images) == 1: + return images[0] + gap = max(0, gap) + width = sum(img.width for img in images) + gap * (len(images) - 1) + height = max(img.height for img in images) + block = Image.new('RGB', (width, height), (0, 0, 0)) + x = 0 + for img in images: + block.paste(img, (x, 0)) + x += img.width + gap + return block + + +def measure_segment(images: List[Image.Image], display_width: int, + scroll_speed: float, threshold: int) -> Dict[str, Any]: + """Geometry of one plugin's contribution to the ticker.""" + total_width = sum(img.width for img in images) + combined = Image.new('RGB', (max(1, total_width), images[0].height)) + x = 0 + for img in images: + combined.paste(img, (x, 0)) + x += img.width + + ink = column_has_ink(combined, threshold) + bounds = content_bounds(combined, threshold) + ink_cols = int(ink.sum()) + + return { + 'images': len(images), + 'width_px': total_width, + 'ink_cols': ink_cols, + 'ink_pct': round(100.0 * ink_cols / total_width, 1) if total_width else 0.0, + 'lead_black_px': bounds[0] if bounds else total_width, + 'trail_black_px': (total_width - 1 - bounds[1]) if bounds else 0, + 'seconds_on_screen': round(total_width / scroll_speed, 1) if scroll_speed else 0.0, + 'widths': [img.width for img in images], + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description='Audit Vegas mode content density') + parser.add_argument('--config', default=str(PROJECT_ROOT / 'config' / 'config.json'), + help='Path to main config.json') + parser.add_argument('-p', '--plugins', default=None, + help='Comma-separated plugin IDs (default: all enabled)') + parser.add_argument('--width', type=int, default=None, + help='Override display width (default: from config hardware)') + parser.add_argument('--height', type=int, default=None, + help='Override display height (default: from config hardware)') + parser.add_argument('--dump-dir', default=None, + help='Write each segment and the composed strip as PNGs here') + parser.add_argument('--threshold', type=int, default=DEFAULT_INK_THRESHOLD, + help=f'Ink threshold (default: {DEFAULT_INK_THRESHOLD})') + parser.add_argument('--per-cycle', type=int, default=None, + help='Plugins composed per cycle ' + '(default: buffer_ahead + 1, matching production)') + parser.add_argument('--json', action='store_true', + help='Emit JSON instead of a text report') + args = parser.parse_args() + + config = load_main_config(Path(args.config)) + vegas = VegasModeConfig.from_config(config) + + cfg_w, cfg_h = display_size_from_config(config) + width = args.width or cfg_w + height = args.height or cfg_h + speed = vegas.scroll_speed + + if args.plugins: + plugin_ids = [p.strip() for p in args.plugins.split(',') if p.strip()] + else: + plugin_ids = vegas.get_ordered_plugins(enabled_plugin_ids(config)) + + dump_dir = Path(args.dump_dir) if args.dump_dir else None + if dump_dir: + dump_dir.mkdir(parents=True, exist_ok=True) + + from src.plugin_system.testing import ( + MockCacheManager, MockPluginManager, VisualTestDisplayManager, + ) + + display_manager = VisualTestDisplayManager(width=width, height=height) + cache_manager = MockCacheManager() + plugin_manager = MockPluginManager() + adapter = PluginAdapter(display_manager) + + if not args.json: + print(f"Vegas audit — display {width}x{height}, scroll {speed:g}px/s, " + f"separator {vegas.separator_width}px") + print(f"One display width = {width / speed:.1f}s of screen time\n") + + results: List[Dict[str, Any]] = [] + segments: List[Image.Image] = [] + + for plugin_id in plugin_ids: + started = time.time() + instance = instantiate(plugin_id, display_manager, cache_manager, plugin_manager) + if instance is None: + results.append({'plugin': plugin_id, 'status': 'load_failed'}) + continue + + plugin_manager.plugins[plugin_id] = instance + adapter.invalidate_cache(plugin_id) + + try: + images = adapter.get_content(instance, plugin_id) + except Exception as exc: # noqa: BLE001 + results.append({'plugin': plugin_id, 'status': 'fetch_error', + 'error': f'{type(exc).__name__}: {exc}'}) + continue + + fetch_ms = round((time.time() - started) * 1000) + + if not images: + results.append({'plugin': plugin_id, 'status': 'no_content', + 'fetch_ms': fetch_ms}) + if not args.json: + print(f" {plugin_id:28s} NO CONTENT ({fetch_ms}ms)") + continue + + entry = {'plugin': plugin_id, 'status': 'ok', 'fetch_ms': fetch_ms} + entry.update(measure_segment(images, width, speed, args.threshold)) + results.append(entry) + segments.extend(images) + + if dump_dir: + for idx, img in enumerate(images): + img.save(dump_dir / f"{plugin_id}__{idx:02d}.png") + + if not args.json: + print(f" {plugin_id:28s} {entry['width_px']:>6d}px " + f"{entry['images']:>2d} img ink {entry['ink_pct']:>5.1f}% " + f"lead {entry['lead_black_px']:>4d} tail {entry['trail_black_px']:>4d} " + f"{entry['seconds_on_screen']:>6.1f}s ({fetch_ms}ms)") + + summary: Dict[str, Any] = { + 'display_width': width, + 'display_height': height, + 'scroll_speed': speed, + 'separator_width': vegas.separator_width, + 'plugins_audited': len(plugin_ids), + 'plugins_with_content': sum(1 for r in results if r.get('status') == 'ok'), + } + + # Production composes only the plugins sitting in the active buffer, so + # measuring one giant strip of every plugin would hide the per-cycle costs + # (most importantly the leading gap, which is charged once per cycle). + # Group the segments the way the running service does. + per_cycle = max(1, args.per_cycle or vegas.plugins_per_cycle) + + cycles: List[Dict[str, Any]] = [] + with_content = [r for r in results if r.get('status') == 'ok'] + + if segments: + logger = logging.getLogger('vegas_audit') + seg_index = 0 + for start in range(0, len(with_content), per_cycle): + group = with_content[start:start + per_cycle] + + # Mirror RenderPipeline: each plugin's rows are joined by + # intra_plugin_gap into one block, and separator_width is applied + # only between blocks. Measuring a flat list here would report gaps + # the service does not emit. + blocks: List[Image.Image] = [] + for entry in group: + count = entry['images'] + rows = segments[seg_index:seg_index + count] + seg_index += count + if rows: + blocks.append(join_rows(rows, vegas.intra_plugin_gap)) + if not blocks: + continue + + # ScrollHelper logs unconditionally, so it needs a real logger. + helper = ScrollHelper(width, height, logger) + helper.create_scrolling_image( + content_items=blocks, + item_gap=vegas.separator_width, + element_gap=0, + # Must match RenderPipeline. Omitting this made the audit + # measure a full-display-width leading gap the service no + # longer emits, overstating dead space by 512px per cycle. + lead_gap=vegas.lead_in_width, + ) + composed = helper.cached_image + if composed is None: + continue + + dead = dead_window_stats(composed, width, args.threshold, step=DEAD_SCAN_STEP) + cover = window_coverage_stats( + composed, width, args.threshold, step=DEAD_SCAN_STEP) + + if dump_dir: + composed.save(dump_dir / f"_cycle{len(cycles):02d}.png") + + cycles.append({ + 'plugins': [e['plugin'] for e in group], + 'width_px': composed.width, + 'seconds': round(composed.width / speed, 1) if speed else 0.0, + 'dead_pct': round(100 * dead.dead_ratio, 1), + 'longest_dead_seconds': round( + dead.longest_dead_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0, + 'mean_ink_pct': round(100 * cover.mean_ink_ratio, 1), + 'sparse_pct': round(100 * cover.sparse_ratio, 1), + 'longest_sparse_seconds': round( + cover.longest_sparse_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0, + }) + + if cycles: + total_px = sum(c['width_px'] for c in cycles) + # Weight each cycle by its width so a long cycle counts proportionally. + summary.update({ + 'cycles': len(cycles), + 'total_px': total_px, + 'full_rotation_seconds': round(total_px / speed, 1) if speed else 0.0, + 'dead_pct': round( + sum(c['dead_pct'] * c['width_px'] for c in cycles) / total_px, 1), + 'mean_ink_pct': round( + sum(c['mean_ink_pct'] * c['width_px'] for c in cycles) / total_px, 1), + 'sparse_pct': round( + sum(c['sparse_pct'] * c['width_px'] for c in cycles) / total_px, 1), + 'worst_dead_seconds': max(c['longest_dead_seconds'] for c in cycles), + 'worst_sparse_seconds': max(c['longest_sparse_seconds'] for c in cycles), + }) + + if args.json: + print(json.dumps({'summary': summary, 'cycles': cycles, 'plugins': results}, + indent=2)) + else: + print(f"\n Cycles ({per_cycle} plugins each, as production composes them):") + for idx, cyc in enumerate(cycles): + print(f" [{idx}] {cyc['width_px']:>6d}px {cyc['seconds']:>6.1f}s " + f"ink {cyc['mean_ink_pct']:>5.1f}% blank {cyc['dead_pct']:>5.1f}% " + f"worst blank {cyc['longest_dead_seconds']:>5.1f}s " + f"| {', '.join(cyc['plugins'])}") + + print(f"\n {'-' * 66}") + print(f" full rotation {summary.get('full_rotation_seconds', 0):>7.1f}s " + f"over {summary.get('cycles', 0)} cycles") + print(f" mean ink coverage {summary.get('mean_ink_pct', 0):>7.1f}% " + f"(higher is better; target >25%)") + print(f" fully blank {summary.get('dead_pct', 0):>7.1f}% (target <2%)") + print(f" reads as empty {summary.get('sparse_pct', 0):>7.1f}% (target <15%)") + print(f" worst blank stretch {summary.get('worst_dead_seconds', 0):>7.1f}s " + f"(target <1.5s)") + print(f" plugins w/ content {summary.get('plugins_with_content', 0):>7d}" + f" of {summary['plugins_audited']}") + + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/src/common/scroll_helper.py b/src/common/scroll_helper.py index fd4c5552..ee7eed1c 100644 --- a/src/common/scroll_helper.py +++ b/src/common/scroll_helper.py @@ -110,20 +110,30 @@ class ScrollHelper: 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 @@ -144,13 +154,13 @@ class ScrollHelper: 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 diff --git a/src/plugin_system/testing/visual_display_manager.py b/src/plugin_system/testing/visual_display_manager.py index 7f84a94e..54f14f81 100644 --- a/src/plugin_system/testing/visual_display_manager.py +++ b/src/plugin_system/testing/visual_display_manager.py @@ -15,6 +15,7 @@ PIL Image canvas and draws text using the actual project fonts. import math import os import time +from contextlib import contextmanager from pathlib import Path from typing import Any, List, Optional, Tuple @@ -62,6 +63,9 @@ class VisualTestDisplayManager: # 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, @@ -174,6 +178,21 @@ class VisualTestDisplayManager: """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: diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index 9c930c1b..e02b111c 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -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) @@ -55,6 +90,15 @@ class 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)), @@ -72,6 +116,14 @@ class VegasModeConfig: '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, @@ -157,6 +209,44 @@ class VegasModeConfig: 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}") + + 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: @@ -174,6 +264,23 @@ class VegasModeConfig: 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: diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index 42a3fd15..4ec9d8df 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -64,7 +64,7 @@ class VegasModeCoordinator: 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, @@ -505,6 +505,10 @@ class VegasModeCoordinator: # 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 diff --git a/src/vegas_mode/geometry.py b/src/vegas_mode/geometry.py new file mode 100644 index 00000000..03d040da --- /dev/null +++ b/src/vegas_mode/geometry.py @@ -0,0 +1,341 @@ +""" +Geometry primitives for Vegas Mode. + +Pure, side-effect-free measurements over PIL images. Two consumers: + +- ``PluginAdapter`` trims the blank margins plugins bake into their content + before it enters the ticker (see ``trim_to_content``). +- ``scripts/dev/vegas_audit.py`` reports how much of the composed ticker is + dead space (see ``dead_window_stats``). + +Keeping both on the same primitives means the number the audit reports is the +number the trimmer acted on. + +All column scans go through numpy: a Python-level per-column loop over a +17,000px-wide ticker image takes seconds, which is far too slow for the render +path. +""" + +from typing import NamedTuple, Optional, Tuple + +import numpy as np +from PIL import Image + +# A pixel counts as "ink" when any channel exceeds this. Chosen to ignore the +# 1-2/255 noise that JPEG-sourced logos and alpha compositing leave behind in +# nominally black areas, while still treating any deliberately drawn dark grey +# as real content. +DEFAULT_INK_THRESHOLD = 10 + +# A window counts as "dead" when this fraction of its columns carry no ink. +DEFAULT_DEAD_WINDOW_RATIO = 0.95 + + +def column_has_ink(img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD) -> np.ndarray: + """ + Return a boolean array, one entry per image column, True where the column + contains at least one pixel brighter than ``threshold`` in any channel. + + Args: + img: Image to scan (converted to RGB internally) + threshold: Per-channel value a pixel must exceed to count as ink + + Returns: + Bool array of shape (width,) + """ + arr = np.asarray(img if img.mode == 'RGB' else img.convert('RGB')) + if arr.ndim != 3: + # Degenerate/empty image — treat every column as blank. + return np.zeros(img.width, dtype=bool) + # Collapse rows and channels: a column is ink if any pixel in it is bright. + return arr.max(axis=(0, 2)) > threshold + + +def content_bounds( + img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD +) -> Optional[Tuple[int, int]]: + """ + Find the first and last columns containing ink. + + Args: + img: Image to measure + threshold: Ink threshold + + Returns: + (first_col, last_col) inclusive, or None if the image is entirely blank + """ + ink = column_has_ink(img, threshold) + if not ink.any(): + return None + first = int(ink.argmax()) + last = len(ink) - 1 - int(ink[::-1].argmax()) + return first, last + + +class TrimResult(NamedTuple): + """Outcome of a ``trim_to_content`` call.""" + + image: Optional[Image.Image] # None when the source was entirely blank + original_width: int + trimmed_left: int + trimmed_right: int + + @property + def is_blank(self) -> bool: + """True when the source image carried no ink at all.""" + return self.image is None + + @property + def width(self) -> int: + """Width after trimming (0 for a blank source).""" + return 0 if self.image is None else self.image.width + + @property + def removed(self) -> int: + """Total columns removed.""" + return self.trimmed_left + self.trimmed_right + + +def trim_to_content( + img: Image.Image, + threshold: int = DEFAULT_INK_THRESHOLD, + padding: int = 0, +) -> TrimResult: + """ + Crop blank columns off the left and right edges of an image. + + Only the outer edges are considered. Blank columns *between* two pieces of + content are deliberately preserved — those are the plugin's own layout + (e.g. a logo on the left and a score on the right), and closing them up + would corrupt the design rather than reclaim dead space. + + A plugin drawing on a non-black background is unaffected: every column of a + filled background carries ink, so there is nothing to trim. + + Args: + img: Image to trim + threshold: Ink threshold + padding: Columns of the original blank margin to keep on each side, as + breathing room. Capped at what the margin actually contains, so + this never widens the image beyond its original bounds. + + Returns: + TrimResult. When the image is entirely blank, ``image`` is None and the + caller decides whether to skip the plugin. + """ + bounds = content_bounds(img, threshold) + if bounds is None: + return TrimResult(None, img.width, 0, 0) + + first, last = bounds + pad = max(0, padding) + left = max(0, first - pad) + right = min(img.width, last + 1 + pad) + + if left == 0 and right == img.width: + return TrimResult(img, img.width, 0, 0) + + cropped = img.crop((left, 0, right, img.height)) + return TrimResult(cropped, img.width, left, img.width - right) + + +def find_blank_cut( + img: Image.Image, + target: int, + search_radius: int, + threshold: int = DEFAULT_INK_THRESHOLD, +) -> int: + """ + Find a column near ``target`` that carries no ink, so an image can be cut + there without slicing through a glyph or logo. + + Used when a single oversized segment has to be narrowed to fit a width + budget. Cutting at an arbitrary column would leave half a character + hanging at the panel edge; snapping to the nearest gap hides the cut. + + Args: + img: Image to cut + target: Preferred cut column + search_radius: How far either side of ``target`` to look + threshold: Ink threshold + + Returns: + A blank column within the search window, or ``target`` clamped to the + image bounds when the window contains no blank column at all. + """ + width = img.width + target = max(0, min(target, width)) + if search_radius <= 0 or width == 0: + return target + + ink = column_has_ink(img, threshold) + lo = max(0, target - search_radius) + hi = min(width - 1, target + search_radius) + + # Walk outwards from target so the nearest gap wins. + for offset in range(0, search_radius + 1): + right = target + offset + if right <= hi and not ink[right]: + return right + left = target - offset + if left >= lo and not ink[left]: + return left + + return target + + +class DeadWindowStats(NamedTuple): + """How much of a composed ticker reads as blank to a viewer.""" + + total_windows: int + dead_windows: int + longest_dead_run: int # consecutive dead windows (i.e. scroll steps) + + @property + def dead_ratio(self) -> float: + """Fraction of viewport positions that are effectively blank.""" + if self.total_windows <= 0: + return 0.0 + return self.dead_windows / self.total_windows + + +def dead_window_stats( + img: Image.Image, + viewport_width: int, + threshold: int = DEFAULT_INK_THRESHOLD, + dead_ratio: float = DEFAULT_DEAD_WINDOW_RATIO, + step: int = 1, +) -> DeadWindowStats: + """ + Slide a viewport across a composed ticker image and count how many + positions are effectively blank. + + This models what the viewer actually experiences: the ticker is only ever + seen ``viewport_width`` columns at a time, so a stretch of blank wider than + the viewport becomes a period where the panel looks switched off. Measuring + per-window rather than per-column is what makes the result correspond to + perceived dead time. + + Args: + img: Composed ticker image + viewport_width: Display width in pixels + threshold: Ink threshold + dead_ratio: Fraction of blank columns for a window to count as dead + step: Column stride between sampled windows. 1 is exact; larger values + trade precision for speed on very wide images. + + Returns: + DeadWindowStats. ``longest_dead_run`` is in units of ``step`` columns, + so multiply by ``step`` for pixels. + """ + if viewport_width <= 0 or img.width <= 0: + return DeadWindowStats(0, 0, 0) + + ink = column_has_ink(img, threshold) + step = max(1, step) + + # Prefix sum of ink counts lets each window be evaluated in constant time, + # instead of re-summing viewport_width columns per position. + prefix = np.concatenate(([0], np.cumsum(ink))) + + # Only whole windows are sampled; a partial tail window would report + # artificially dead because it has fewer columns to draw ink from. + last_start = img.width - viewport_width + if last_start < 0: + # Image narrower than the viewport — evaluate it as a single window. + blank_cols = len(ink) - int(prefix[-1]) + is_dead = blank_cols >= dead_ratio * len(ink) + return DeadWindowStats(1, 1 if is_dead else 0, 1 if is_dead else 0) + + starts = np.arange(0, last_start + 1, step) + ink_counts = prefix[starts + viewport_width] - prefix[starts] + blank_counts = viewport_width - ink_counts + dead = blank_counts >= dead_ratio * viewport_width + + longest = _longest_true_run(dead) + return DeadWindowStats(len(starts), int(dead.sum()), longest) + + +class CoverageStats(NamedTuple): + """How well-filled the viewport stays as the ticker scrolls past.""" + + total_windows: int + mean_ink_ratio: float # average fraction of the viewport carrying ink + min_ink_ratio: float # worst viewport position in the cycle + sparse_windows: int # positions below the "looks empty" threshold + longest_sparse_run: int # consecutive sparse positions, in steps + + @property + def sparse_ratio(self) -> float: + """Fraction of viewport positions that read as near-empty.""" + if self.total_windows <= 0: + return 0.0 + return self.sparse_windows / self.total_windows + + +def window_coverage_stats( + img: Image.Image, + viewport_width: int, + threshold: int = DEFAULT_INK_THRESHOLD, + sparse_ink_ratio: float = 0.10, + step: int = 1, +) -> CoverageStats: + """ + Measure how full the viewport stays across a whole scroll cycle. + + ``dead_window_stats`` only catches viewport positions that are *entirely* + blank. That misses the more common complaint: a position holding one narrow + sliver of content at the very edge, with the other 90% black. Such a + position is not "dead" by that definition but still looks switched off. + This function grades every position by how much ink it carries, so + "there is always something to see" becomes measurable. + + Args: + img: Composed ticker image + viewport_width: Display width in pixels + threshold: Ink threshold + sparse_ink_ratio: A position with less than this fraction of inked + columns counts as reading near-empty + step: Column stride between sampled positions + + Returns: + CoverageStats + """ + if viewport_width <= 0 or img.width <= 0: + return CoverageStats(0, 0.0, 0.0, 0, 0) + + ink = column_has_ink(img, threshold) + step = max(1, step) + prefix = np.concatenate(([0], np.cumsum(ink))) + + last_start = img.width - viewport_width + if last_start < 0: + ratio = float(prefix[-1]) / viewport_width + sparse = ratio < sparse_ink_ratio + return CoverageStats(1, ratio, ratio, 1 if sparse else 0, 1 if sparse else 0) + + starts = np.arange(0, last_start + 1, step) + ratios = (prefix[starts + viewport_width] - prefix[starts]) / viewport_width + sparse_flags = ratios < sparse_ink_ratio + + return CoverageStats( + total_windows=len(starts), + mean_ink_ratio=float(ratios.mean()), + min_ink_ratio=float(ratios.min()), + sparse_windows=int(sparse_flags.sum()), + longest_sparse_run=_longest_true_run(sparse_flags), + ) + + +def _longest_true_run(flags: np.ndarray) -> int: + """Length of the longest consecutive run of True in a boolean array.""" + if flags.size == 0 or not flags.any(): + return 0 + # Reset a running counter at every False by subtracting the cumulative max + # of the counter's value at the preceding False positions. + idx = np.arange(len(flags)) + not_flag = ~flags + # For each position, the index of the most recent False at or before it. + last_false = np.maximum.accumulate(np.where(not_flag, idx, -1)) + run_lengths = idx - last_false + return int(run_lengths[flags].max()) diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index b50d0946..f790c984 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -11,6 +11,8 @@ import time from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING from PIL import Image +from src.vegas_mode.geometry import find_blank_cut, trim_to_content + if TYPE_CHECKING: from src.plugin_system.base_plugin import BasePlugin @@ -26,14 +28,21 @@ class PluginAdapter: 2. Fallback: Capture display_manager.image after calling plugin.display() """ - def __init__(self, display_manager: Any): + def __init__(self, display_manager: Any, config: Optional[Any] = None): """ Initialize the plugin adapter. Args: display_manager: DisplayManager instance for fallback capture + config: VegasModeConfig controlling trim behaviour. When omitted, + trimming runs with the dataclass defaults, so existing callers + and tests keep working unchanged. """ self.display_manager = display_manager + if config is None: + from src.vegas_mode.config import VegasModeConfig + config = VegasModeConfig() + self.config = config # Handle both property and method access patterns self.display_width = ( display_manager.width() if callable(display_manager.width) @@ -49,6 +58,11 @@ class PluginAdapter: self._cache_lock = threading.Lock() self._cache_ttl = 5.0 # Cache for 5 seconds + # Per-plugin rotation offset, so a plugin whose content exceeds its + # width budget shows a different slice on each cycle rather than + # always the same opening items. + self._item_offsets: dict = {} + logger.info( "PluginAdapter initialized: display=%dx%d", self.display_width, self.display_height @@ -93,8 +107,7 @@ class PluginAdapter: "[%s] Native content SUCCESS: %d images, %dpx total", plugin_id, len(content), total_width ) - self._cache_content(plugin_id, content) - return content + return self._finalize(content, plugin_id, 'native') logger.info("[%s] Native content returned None", plugin_id) # Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds) @@ -107,8 +120,7 @@ class PluginAdapter: "[%s] ScrollHelper content SUCCESS: %d images, %dpx total", plugin_id, len(content), total_width ) - self._cache_content(plugin_id, content) - return content + return self._finalize(content, plugin_id, 'scroll_helper') if has_scroll_helper: logger.info("[%s] ScrollHelper content returned None", plugin_id) @@ -121,8 +133,7 @@ class PluginAdapter: "[%s] Fallback capture SUCCESS: %d images, %dpx total", plugin_id, len(content), total_width ) - self._cache_content(plugin_id, content) - return content + return self._finalize(content, plugin_id, 'fallback') logger.warning( "[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)", @@ -130,6 +141,181 @@ class PluginAdapter: ) return None + def _finalize( + self, images: List[Image.Image], plugin_id: str, source: str + ) -> Optional[List[Image.Image]]: + """ + Trim dead space off a segment, then cache it. + + Every content path funnels through here so trimming is applied + uniformly. Previously only the scroll_helper path had its margins + stripped, which left plugins that render onto a full-display canvas + contributing their entire blank canvas to the ticker. + + Each image is trimmed independently because compose_scroll_content() + treats every image as its own item and inserts separator_width between + them — so a per-image trim is what makes that separator the real gap. + + Args: + images: Raw content from one of the fetch paths + plugin_id: Plugin identifier for logging + source: Which path produced the content, for logging + + Returns: + Trimmed image list, or None if nothing worth showing remains + """ + if not self.config.auto_trim: + self._cache_content(plugin_id, images) + return images + + original_width = sum(img.width for img in images) + kept: List[Image.Image] = [] + dropped_blank = 0 + + for img in images: + result = trim_to_content( + img, + threshold=self.config.trim_threshold, + padding=self.config.content_padding, + ) + if result.is_blank: + dropped_blank += 1 + continue + kept.append(result.image) + + if not kept: + logger.info( + "[%s] All %d image(s) from %s were blank — contributing nothing", + plugin_id, len(images), source + ) + return None + + trimmed_width = sum(img.width for img in kept) + + if trimmed_width < self.config.min_plugin_width: + logger.info( + "[%s] Trimmed content %dpx is below min_plugin_width %dpx — skipping", + plugin_id, trimmed_width, self.config.min_plugin_width + ) + return None + + if trimmed_width != original_width or dropped_blank: + logger.info( + "[%s] Trimmed %s content: %dpx -> %dpx (%.0f%% reclaimed), " + "%d image(s) kept, %d blank dropped", + plugin_id, source, original_width, trimmed_width, + 100.0 * (original_width - trimmed_width) / original_width + if original_width else 0.0, + len(kept), dropped_blank + ) + + kept = self._apply_width_budget(kept, plugin_id) + + self._cache_content(plugin_id, kept) + return kept + + def _width_budget(self) -> int: + """Maximum columns one plugin may occupy in a cycle. 0 means unlimited.""" + ratio = self.config.max_plugin_width_ratio + if ratio <= 0: + return 0 + return int(self.display_width * ratio) + + def _apply_width_budget( + self, images: List[Image.Image], plugin_id: str + ) -> List[Image.Image]: + """ + Hold one plugin to its share of a cycle. + + A ticker returning 7,000px would otherwise own the panel for over two + minutes, which defeats the point of a rotation. Overflow is deferred + rather than discarded: the starting offset advances each time this + plugin is fetched, so later items appear on subsequent cycles instead + of never being seen. + + Args: + images: Trimmed images for this plugin + plugin_id: Plugin identifier, used to track its rotation offset + + Returns: + Images that fit the budget, starting from the plugin's current + rotation offset. + """ + budget = self._width_budget() + + # Count the gaps the compositor will insert between these rows, not + # just the pixels of the rows themselves — otherwise a plugin with many + # rows quietly occupies far more of the panel than its budget allows. + gap = max(0, self.config.intra_plugin_gap) + total = sum(img.width for img in images) + gap * (len(images) - 1) + + if not budget or total <= budget: + # Fits, so reset rotation — the whole segment is being shown. + self._item_offsets.pop(plugin_id, None) + return images + + if len(images) == 1: + return [self._crop_to_budget(images[0], budget, plugin_id)] + + start = self._item_offsets.get(plugin_id, 0) % len(images) + selected: List[Image.Image] = [] + used = 0 + consumed = 0 + + # Walk forward from the rotation offset, taking whole items only, so a + # cut never lands in the middle of one. + for step in range(len(images)): + img = images[(start + step) % len(images)] + cost = img.width + (gap if selected else 0) + if selected and used + cost > budget: + break + selected.append(img) + used += cost + consumed += 1 + + self._item_offsets[plugin_id] = (start + consumed) % len(images) + + logger.info( + "[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) " + "from offset %d; remainder deferred to a later cycle", + plugin_id, budget, len(selected), len(images), used, start + ) + return selected + + def _crop_to_budget( + self, img: Image.Image, budget: int, plugin_id: str + ) -> Image.Image: + """ + Narrow a single oversized image to the budget, advancing a window + through it across cycles. + + The cut is snapped to the nearest blank column so it does not slice + through a glyph or logo and leave half a character at the panel edge. + """ + offset = self._item_offsets.get(plugin_id, 0) + if offset >= img.width: + offset = 0 + + # Snap both edges to blank columns. The search radius is generous + # enough to clear a wide glyph but small enough not to distort the + # requested budget much. + snap = max(8, self.display_width // 16) + start = find_blank_cut(img, offset, snap, self.config.trim_threshold) + end = find_blank_cut( + img, min(start + budget, img.width), snap, self.config.trim_threshold) + if end <= start: + end = min(start + budget, img.width) + + # Next cycle resumes where this one stopped; wrap when the strip ends. + self._item_offsets[plugin_id] = 0 if end >= img.width else end + + logger.info( + "[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] " + "(%dpx), window advances next cycle", + plugin_id, budget, img.width, start, end, end - start + ) + return img.crop((start, 0, end, img.height)) + def _get_native_content( self, plugin: 'BasePlugin', plugin_id: str ) -> Optional[List[Image.Image]]: diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index 7c9b7c90..aa624ebf 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -66,10 +66,6 @@ class RenderPipeline: else display_manager.height ) - # Reusable blank frame for cycle-end pushes (allocated lazily, - # re-blacked before each reuse) - self._blank_frame = None - # ScrollHelper for optimized scrolling self.scroll_helper = ScrollHelper( self.display_width, @@ -141,23 +137,37 @@ class RenderPipeline: True if composition successful """ try: - # Get all buffered content - images = self.stream_manager.get_all_content_for_composition() + # Content grouped by plugin, so a separator can be placed at the + # plugin boundaries only. + grouped = self.stream_manager.get_grouped_content_for_composition() - if not images: + if not grouped: 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) + # Collapse each plugin's rows into a single block, joined by + # intra_plugin_gap. ScrollHelper applies one uniform gap between the + # items it is given, so handing it one item per plugin is what makes + # separator_width mean "between plugins" instead of "between every + # row". Without this, a per-row ticker such as the F1 scoreboard got + # the full separator between each of its ~116 rows. + blocks = [] + total_rows = 0 + for plugin_id, images in grouped: + total_rows += len(images) + blocks.append(self._join_plugin_rows(images)) - # Create scrolling image via ScrollHelper + # Create scrolling image via ScrollHelper. + # + # lead_gap is explicit because ScrollHelper otherwise prepends a + # full display width of black — appropriate for a standalone ticker + # scrolling in from off-screen, but in Vegas mode it is charged + # once per cycle and reads as the panel switching off. self.scroll_helper.create_scrolling_image( - content_items=content_with_gaps, + content_items=blocks, item_gap=self.config.separator_width, - element_gap=0 + element_gap=0, + lead_gap=self.config.lead_in_width ) # Verify scroll image was created successfully @@ -177,11 +187,14 @@ class RenderPipeline: self._cycle_complete = False logger.info( - "Composed scroll image: %dx%d, %d plugins, %d items", + "Composed scroll image: %dx%d, %d plugin block(s), %d rows, " + "separator=%dpx between plugins / %dpx within", self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0, self.display_height, - len(self._segments_in_scroll), - len(images) + len(blocks), + total_rows, + self.config.separator_width, + self.config.intra_plugin_gap, ) return True @@ -191,6 +204,32 @@ class RenderPipeline: logger.exception("Error composing scroll content") return False + def _join_plugin_rows(self, images: List[Image.Image]) -> Image.Image: + """ + Concatenate one plugin's images into a single block. + + Args: + images: That plugin's content, in order + + Returns: + A single image with the rows laid out left to right, separated by + ``intra_plugin_gap``. Returned unchanged when there is only one row, + which is the common case and avoids a pointless copy. + """ + if len(images) == 1: + return images[0] + + gap = max(0, self.config.intra_plugin_gap) + width = sum(img.width for img in images) + gap * (len(images) - 1) + height = max(img.height for img in images) + + block = Image.new('RGB', (width, height), (0, 0, 0)) + x = 0 + for img in images: + block.paste(img, (x, 0)) + x += img.width + gap + return block + def render_frame(self) -> bool: """ Render a single frame to the display. @@ -236,24 +275,17 @@ class RenderPipeline: "Scroll cycle complete after %.1fs", time.time() - self._cycle_start_time ) - # Push blank immediately so the hardware never shows any - # post-wrap content while the coordinator recomposes the - # next cycle (~100 ms). The blank is allocated once and - # reused across cycle wraps (fresh paste each time in case - # a consumer drew on the previous one). - try: - if self._blank_frame is None or self._blank_frame.size != ( - self.display_width, self.display_height): - self._blank_frame = Image.new( - 'RGB', (self.display_width, self.display_height)) - else: - self._blank_frame.paste( - (0, 0, 0), - (0, 0, self.display_width, self.display_height)) - self.display_manager.image = self._blank_frame - self.display_manager.update_display() - except Exception: - logger.exception("Failed to write blank frame to display at cycle end") + # Deliberately leave the last rendered frame on the panel. + # + # This used to push a blank frame so no post-wrap content + # could be seen while the next cycle was composed. But + # recomposing is synchronous and fetches plugin content: + # measured 84ms at best and 4.8s at worst on a 512px panel, + # and every millisecond of it was black. Holding the last + # frame instead turns that into a brief freeze, which reads + # as far less broken than the display switching off. The + # frame is already past the end of the content, so there is + # no second-pass content to leak. return True # Cycle done; coordinator starts new cycle next frame # Get visible portion @@ -415,11 +447,12 @@ class RenderPipeline: result = self.compose_scroll_content() if result and self.sync_manager: - # When sync is active, start the leader at display_width instead of 0. - # This skips the initial black gap so the leader immediately shows content. - # The follower starts at position 0 (the gap) which looks like a clean - # blank transition rather than near-end content wrapping around. - self.scroll_helper.scroll_position = float(self.display_width) + # When sync is active, start the leader past the lead-in gap so it + # immediately shows content, leaving the follower on the blank gap + # for a clean transition rather than near-end content wrapping + # around. This tracks lead_in_width rather than assuming a full + # display width of gap, which is no longer the default. + self.scroll_helper.scroll_position = float(self.config.lead_in_width) if result and self.sync_manager: # Signal follower that a new cycle started (triggers its own rebuild) diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index 85d5abdd..bd15277d 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -14,7 +14,7 @@ Supports three display modes: import logging import threading import time -from typing import Optional, List, Dict, Any, Deque, TYPE_CHECKING +from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING from collections import deque from dataclasses import dataclass, field from PIL import Image @@ -116,8 +116,11 @@ class StreamManager: logger.warning("No plugins available for Vegas scroll") return False - # Prefetch initial content - self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins))) + # Fill the buffer to a whole cycle's worth of plugins. This used to be + # buffer_ahead + 1, which conflated prefetch depth with cycle size and + # meant a 20-plugin install only showed 3 plugins before recomposing. + self._prefetch_content( + count=min(self.config.plugins_per_cycle, len(self._ordered_plugins))) logger.info( "StreamManager initialized with %d plugins, %d segments buffered", @@ -385,7 +388,7 @@ class StreamManager: return for _ in range(count): - if len(self._active_buffer) >= self.config.buffer_ahead + 1: + if len(self._active_buffer) >= self.config.plugins_per_cycle: break # Ensure index is valid (guard against empty list) @@ -521,28 +524,61 @@ class StreamManager: logger.debug("Refreshed content for %s in staging buffer", plugin_id) def _ensure_buffer_filled(self) -> None: - """Ensure buffer has enough content prefetched.""" - if len(self._active_buffer) < self.config.buffer_ahead: - needed = self.config.buffer_ahead - len(self._active_buffer) - self._prefetch_content(count=needed) + """ + Top the buffer back up after segments have been served. + + buffer_ahead is the low-water mark only; plugins_per_cycle is the + ceiling and is enforced inside _prefetch_content. + """ + low_water = min(self.config.buffer_ahead, self.config.plugins_per_cycle) + if len(self._active_buffer) < low_water: + self._prefetch_content(count=low_water - len(self._active_buffer)) def get_all_content_for_composition(self) -> List[Image.Image]: """ Get all buffered content as a flat list of images. - Used when composing the full scroll image. Skips STATIC segments as they don't have images to compose. + Prefer get_grouped_content_for_composition(): flattening loses the + plugin boundaries, which is what tells the compositor where a + separator belongs and where it does not. + Returns: List of all images in buffer order """ all_images = [] + for _plugin_id, images in self.get_grouped_content_for_composition(): + all_images.extend(images) + return all_images + + def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]: + """ + Get buffered content grouped by the plugin that produced it. + + The grouping matters: separator_width is meant to mark the handoff from + one plugin to the next, not to sit between every row a single plugin + contributes. A per-row ticker like the F1 scoreboard returns over a + hundred images that it renders 4px apart internally, so flattening them + into one list and applying a uniform gap forced 32px between each of + its rows — both inconsistent with how the plugin looks standalone, and + a large hidden addition to the width it occupies. + + Skips STATIC segments, which trigger a pause rather than contributing + scroll content, and segments left with no images. + + Returns: + List of (plugin_id, images) in buffer order + """ + grouped: List[Tuple[str, List[Image.Image]]] = [] with self._buffer_lock: for segment in self._active_buffer: - # Skip STATIC segments - they trigger pauses, not scroll content - if segment.display_mode != VegasDisplayMode.STATIC: - all_images.extend(segment.images) - return all_images + if segment.display_mode == VegasDisplayMode.STATIC: + continue + if not segment.images: + continue + grouped.append((segment.plugin_id, list(segment.images))) + return grouped def advance_cycle(self) -> None: """ diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py new file mode 100644 index 00000000..b4109c36 --- /dev/null +++ b/test/test_vegas_density.py @@ -0,0 +1,527 @@ +""" +Tests for the Vegas mode density work: dead-space trimming in PluginAdapter +and the configurable lead-in gap in ScrollHelper. +""" + +import pytest +from PIL import Image + +from src.common.scroll_helper import ScrollHelper +from src.vegas_mode.config import VegasModeConfig +from src.vegas_mode.geometry import column_has_ink +from src.vegas_mode.plugin_adapter import PluginAdapter + +DISPLAY_W = 512 +DISPLAY_H = 64 + + +class FakeDisplayManager: + """Minimal stand-in; the native content path never touches the canvas.""" + + width = DISPLAY_W + height = DISPLAY_H + + def __init__(self): + self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H)) + + +class NativePlugin: + """Plugin that returns pre-rendered Vegas content.""" + + def __init__(self, images): + self._images = images + + def get_vegas_content(self): + return self._images + + +def canvas(content_spans, width=DISPLAY_W, height=DISPLAY_H): + """Full-display canvas with white content in the given [x0, x1) spans.""" + img = Image.new('RGB', (width, height), (0, 0, 0)) + for x0, x1 in content_spans: + img.paste(Image.new('RGB', (x1 - x0, height), (255, 255, 255)), (x0, 0)) + return img + + +def adapter_with(**overrides): + cfg = VegasModeConfig(**overrides) + return PluginAdapter(FakeDisplayManager(), cfg) + + +class TestAdapterTrimming: + def test_of_the_day_case_is_reclaimed(self): + # Measured on devpi: "No Data" occupying 35px of a 512px canvas bought + # 9.5s of black at 50px/s. + adapter = adapter_with(content_padding=0) + plugin = NativePlugin([canvas([(4, 39)])]) + images = adapter.get_content(plugin, 'of-the-day') + assert len(images) == 1 + assert images[0].width == 35 + + def test_youtube_stats_case_is_reclaimed(self): + # Centred 142px of content on a 512px canvas: 185px black each side. + adapter = adapter_with(content_padding=0) + plugin = NativePlugin([canvas([(185, 327)])]) + images = adapter.get_content(plugin, 'youtube-stats') + assert images[0].width == 142 + + def test_padding_is_applied_within_available_margin(self): + adapter = adapter_with(content_padding=8) + plugin = NativePlugin([canvas([(185, 327)])]) + images = adapter.get_content(plugin, 'youtube-stats') + assert images[0].width == 142 + 16 + + def test_interior_layout_gap_survives(self): + # A logo far left and a score far right is deliberate layout; closing + # the gap would corrupt the design rather than reclaim dead space. + adapter = adapter_with(content_padding=0) + plugin = NativePlugin([canvas([(10, 40), (400, 460)])]) + images = adapter.get_content(plugin, 'scoreboard') + assert images[0].width == 450 # 10..459 + assert int(column_has_ink(images[0]).sum()) == 90 + + def test_wide_legitimate_content_is_left_alone(self): + # geochron uses 444 of 512 columns; only the real tail should go. + adapter = adapter_with(content_padding=0) + plugin = NativePlugin([canvas([(0, 444)])]) + images = adapter.get_content(plugin, 'geochron') + assert images[0].width == 444 + + def test_non_black_background_is_untouched(self): + adapter = adapter_with(content_padding=0) + bg = Image.new('RGB', (DISPLAY_W, DISPLAY_H), (0, 0, 40)) + plugin = NativePlugin([bg]) + images = adapter.get_content(plugin, 'weather') + assert images[0].width == DISPLAY_W + + def test_each_image_of_a_multi_item_segment_is_trimmed(self): + # compose_scroll_content treats every image as its own item, so a + # per-image trim is what makes separator_width the real gap. + adapter = adapter_with(content_padding=0) + plugin = NativePlugin([ + canvas([(168, 344)]), + canvas([(200, 300)]), + canvas([(0, 512)]), + ]) + images = adapter.get_content(plugin, 'ledmatrix-flights') + assert [img.width for img in images] == [176, 100, 512] + + def test_fully_blank_segment_contributes_nothing(self): + adapter = adapter_with() + plugin = NativePlugin([Image.new('RGB', (DISPLAY_W, DISPLAY_H))]) + assert adapter.get_content(plugin, 'empty') is None + + def test_blank_images_are_dropped_but_others_kept(self): + adapter = adapter_with(content_padding=0) + plugin = NativePlugin([ + canvas([(100, 150)]), + Image.new('RGB', (DISPLAY_W, DISPLAY_H)), + canvas([(200, 260)]), + ]) + images = adapter.get_content(plugin, 'mixed') + assert [img.width for img in images] == [50, 60] + + def test_min_plugin_width_rejects_noise(self): + adapter = adapter_with(content_padding=0, min_plugin_width=32) + plugin = NativePlugin([canvas([(10, 14)])]) + assert adapter.get_content(plugin, 'sliver') is None + + def test_min_plugin_width_of_zero_keeps_everything(self): + adapter = adapter_with(content_padding=0, min_plugin_width=0) + plugin = NativePlugin([canvas([(10, 14)])]) + images = adapter.get_content(plugin, 'sliver') + assert images[0].width == 4 + + def test_auto_trim_off_preserves_original_behaviour(self): + adapter = adapter_with(auto_trim=False) + plugin = NativePlugin([canvas([(4, 39)])]) + images = adapter.get_content(plugin, 'of-the-day') + assert images[0].width == DISPLAY_W + + def test_trim_threshold_ignores_near_black_noise(self): + # A very dark band should not be mistaken for content. + img = Image.new('RGB', (DISPLAY_W, DISPLAY_H), (0, 0, 0)) + img.paste(Image.new('RGB', (100, DISPLAY_H), (3, 3, 3)), (0, 0)) + img.paste(Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)), (200, 0)) + adapter = adapter_with(content_padding=0, trim_threshold=10) + images = adapter.get_content(NativePlugin([img]), 'noisy') + assert images[0].width == 50 + + def test_height_is_preserved_through_trim(self): + adapter = adapter_with(content_padding=0) + plugin = NativePlugin([canvas([(100, 200)])]) + images = adapter.get_content(plugin, 'x') + assert images[0].height == DISPLAY_H + + def test_trimmed_result_is_cached(self): + adapter = adapter_with(content_padding=0) + plugin = NativePlugin([canvas([(100, 200)])]) + first = adapter.get_content(plugin, 'cached') + # Swap the plugin's content; the cache should still serve the old size. + plugin._images = [canvas([(0, 512)])] + second = adapter.get_content(plugin, 'cached') + assert first[0].width == second[0].width == 100 + + def test_default_adapter_construction_still_works(self): + # Existing callers pass only the display manager. + adapter = PluginAdapter(FakeDisplayManager()) + assert adapter.config.auto_trim is True + + +class TestWidthBudget: + def test_segment_within_budget_is_untouched(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0) + plugin = NativePlugin([canvas([(0, 400)])]) + assert adapter.get_content(plugin, 'small')[0].width == 400 + + def test_oversized_multi_item_segment_is_capped(self): + # 10 items of 400px = 4000px against a 1536px budget (3 x 512). + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0) + items = [canvas([(0, 400)], width=400) for _ in range(10)] + images = adapter.get_content(NativePlugin(items), 'stock-news') + assert sum(i.width for i in images) <= 3 * DISPLAY_W + assert len(images) == 3 # 1200px; a 4th would exceed 1536 + + def test_deferred_items_appear_on_the_next_cycle(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0) + # Distinguish items by width so the rotation is observable. + items = [canvas([(0, w)], width=w) for w in (200, 210, 220, 230, 240)] + plugin = NativePlugin(items) + + first = adapter.get_content(plugin, 'ticker') + adapter.invalidate_cache('ticker') + second = adapter.get_content(plugin, 'ticker') + assert [i.width for i in first] != [i.width for i in second] + + def test_rotation_eventually_covers_every_item(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0) + widths = (200, 210, 220, 230, 240) + items = [canvas([(0, w)], width=w) for w in widths] + plugin = NativePlugin(items) + + seen = set() + for _ in range(10): + adapter.invalidate_cache('ticker') + for img in adapter.get_content(plugin, 'ticker'): + seen.add(img.width) + assert seen == set(widths) + + def test_single_oversized_image_is_cropped_to_budget(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0) + # A 6898px leaderboard strip, as measured on devpi. Solid ink means + # there is no blank column to snap to, so the cut lands on the budget. + plugin = NativePlugin([canvas([(0, 6898)], width=6898)]) + images = adapter.get_content(plugin, 'ledmatrix-leaderboard') + assert len(images) == 1 + assert images[0].width == DISPLAY_W + + def test_single_image_crop_snaps_to_a_blank_column(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0) + # Content blocks with gaps; the cut should land in a gap, not mid-block. + spans = [(x, x + 90) for x in range(0, 2000, 100)] + plugin = NativePlugin([canvas(spans, width=2000)]) + images = adapter.get_content(plugin, 'gapped') + assert images[0].width != DISPLAY_W + assert abs(images[0].width - DISPLAY_W) <= DISPLAY_W // 16 + 1 + + def test_single_image_window_advances_across_cycles(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0) + plugin = NativePlugin([canvas([(0, 3000)], width=3000)]) + adapter.get_content(plugin, 'strip') + assert adapter._item_offsets['strip'] == DISPLAY_W + + def test_budget_of_zero_disables_the_cap(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0) + plugin = NativePlugin([canvas([(0, 6898)], width=6898)]) + assert adapter.get_content(plugin, 'huge')[0].width == 6898 + + def test_rotation_resets_when_content_shrinks_to_fit(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0) + big = [canvas([(0, 300)], width=300) for _ in range(5)] + adapter.get_content(NativePlugin(big), 'shrink') + assert 'shrink' in adapter._item_offsets + + adapter.invalidate_cache('shrink') + adapter.get_content(NativePlugin([canvas([(0, 100)], width=100)]), 'shrink') + assert 'shrink' not in adapter._item_offsets + + def test_one_item_wider_than_budget_is_still_shown(self): + # Never return nothing just because the first whole item overflows. + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0) + items = [canvas([(0, 900)], width=900), canvas([(0, 100)], width=100)] + images = adapter.get_content(NativePlugin(items), 'wide-first') + assert len(images) >= 1 + assert images[0].width == 900 + + +class TestPluginBoundaryGaps: + """separator_width belongs between plugins; intra_plugin_gap within one.""" + + def _pipeline(self, grouped, **cfg): + from src.vegas_mode.render_pipeline import RenderPipeline + + class FakeStream: + def get_grouped_content_for_composition(self): + return grouped + + def get_active_plugin_ids(self): + return [pid for pid, _ in grouped] + + class DM: + width = DISPLAY_W + height = DISPLAY_H + + def set_scrolling_state(self, *a): + pass + + return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream()) + + def test_separator_only_at_plugin_boundaries(self): + # Two plugins, two rows each. Expect: row row [sep] row row, with the + # small intra gap inside each pair. + rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)] + pipeline = self._pipeline( + [('a', rows[:2]), ('b', rows[2:])], + separator_width=32, intra_plugin_gap=8, lead_in_width=0, + ) + assert pipeline.compose_scroll_content() + + ink = column_has_ink(pipeline.scroll_helper.cached_image) + assert ink[:100].all() + assert not ink[100:108].any() # intra gap inside plugin a + assert ink[108:208].all() + assert not ink[208:240].any() # separator between a and b + assert ink[240:340].all() + assert not ink[340:348].any() # intra gap inside plugin b + assert ink[348:448].all() + + def test_total_width_uses_both_gap_sizes(self): + rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)] + pipeline = self._pipeline( + [('a', rows[:2]), ('b', rows[2:])], + separator_width=32, intra_plugin_gap=8, lead_in_width=0, + ) + pipeline.compose_scroll_content() + # 4 rows + 2 intra gaps + 1 separator + assert pipeline.scroll_helper.cached_image.width == 400 + 16 + 32 + + def test_f1_shaped_case_reclaims_the_chasms(self): + # 12 rows from one plugin: previously 11 separators at 32px = 352px of + # gap; now 11 intra gaps at 8px = 88px. + rows = [Image.new('RGB', (128, DISPLAY_H), (255, 255, 255)) for _ in range(12)] + pipeline = self._pipeline( + [('f1-scoreboard', rows)], + separator_width=32, intra_plugin_gap=8, lead_in_width=0, + ) + pipeline.compose_scroll_content() + assert pipeline.scroll_helper.cached_image.width == 12 * 128 + 11 * 8 + + def test_single_row_plugin_image_is_not_copied(self): + row = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) + pipeline = self._pipeline([('solo', [row])], lead_in_width=0) + assert pipeline._join_plugin_rows([row]) is row + + def test_zero_intra_gap_butts_rows_together(self): + rows = [Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)) for _ in range(3)] + pipeline = self._pipeline( + [('a', rows)], separator_width=32, intra_plugin_gap=0, lead_in_width=0) + pipeline.compose_scroll_content() + assert pipeline.scroll_helper.cached_image.width == 150 + assert column_has_ink(pipeline.scroll_helper.cached_image).all() + + def test_empty_grouping_fails_composition(self): + pipeline = self._pipeline([]) + assert pipeline.compose_scroll_content() is False + + +class TestWidthBudgetCountsGaps: + def test_budget_accounts_for_intra_plugin_gaps(self): + # 8 rows of 200px = 1600px of pixels, but with 8px gaps the real + # occupancy is 1600 + 56 = 1656px. Against a 512px budget the row count + # must be chosen using the gap-inclusive cost. + adapter = adapter_with( + content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=8) + items = [canvas([(0, 200)], width=200) for _ in range(8)] + images = adapter.get_content(NativePlugin(items), 'rows') + n = len(images) + assert 200 * n + 8 * (n - 1) <= DISPLAY_W + + def test_gap_free_config_fits_more_rows(self): + items = [canvas([(0, 200)], width=200) for _ in range(8)] + with_gap = adapter_with( + content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=64) + without = adapter_with( + content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=0) + assert len(without.get_content(NativePlugin(items), 'r')) >= \ + len(with_gap.get_content(NativePlugin(items), 'r')) + + +class TestStreamGrouping: + def _stream(self, segments): + from src.vegas_mode.stream_manager import StreamManager + from collections import deque + + sm = StreamManager.__new__(StreamManager) + import threading + sm._buffer_lock = threading.RLock() + sm._active_buffer = deque(segments) + return sm + + def _seg(self, plugin_id, count, mode=None): + from src.vegas_mode.stream_manager import ContentSegment + from src.plugin_system.base_plugin import VegasDisplayMode + imgs = [Image.new('RGB', (10, 8)) for _ in range(count)] + return ContentSegment( + plugin_id=plugin_id, images=imgs, total_width=10 * count, + display_mode=mode or VegasDisplayMode.SCROLL) + + def test_grouping_preserves_plugin_boundaries(self): + sm = self._stream([self._seg('a', 3), self._seg('b', 1)]) + grouped = sm.get_grouped_content_for_composition() + assert [(pid, len(imgs)) for pid, imgs in grouped] == [('a', 3), ('b', 1)] + + def test_static_segments_are_skipped(self): + from src.plugin_system.base_plugin import VegasDisplayMode + sm = self._stream([ + self._seg('a', 2), + self._seg('paused', 1, VegasDisplayMode.STATIC), + self._seg('b', 1), + ]) + assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['a', 'b'] + + def test_imageless_segments_are_skipped(self): + sm = self._stream([self._seg('a', 0), self._seg('b', 2)]) + assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b'] + + def test_flat_accessor_still_matches_grouped_total(self): + sm = self._stream([self._seg('a', 3), self._seg('b', 2)]) + assert len(sm.get_all_content_for_composition()) == 5 + + +class TestCycleSizing: + def test_plugins_per_cycle_defaults_above_buffer_ahead(self): + cfg = VegasModeConfig() + assert cfg.plugins_per_cycle == 6 + assert cfg.plugins_per_cycle > cfg.buffer_ahead + 1 + + def test_plugins_per_cycle_parses(self): + cfg = VegasModeConfig.from_config( + {'display': {'vegas_scroll': {'plugins_per_cycle': 10}}}) + assert cfg.plugins_per_cycle == 10 + + def test_max_plugin_width_ratio_parses(self): + cfg = VegasModeConfig.from_config( + {'display': {'vegas_scroll': {'max_plugin_width_ratio': 1.5}}}) + assert cfg.max_plugin_width_ratio == 1.5 + + @pytest.mark.parametrize('overrides,bad_key', [ + ({'plugins_per_cycle': 0}, 'plugins_per_cycle'), + ({'plugins_per_cycle': 99}, 'plugins_per_cycle'), + ({'max_plugin_width_ratio': -1.0}, 'max_plugin_width_ratio'), + ]) + def test_validate_rejects_out_of_range(self, overrides, bad_key): + errors = VegasModeConfig(**overrides).validate() + assert any(bad_key in e for e in errors), errors + + +class TestScrollHelperLeadGap: + def test_default_lead_gap_is_display_width(self): + # Standalone tickers rely on scrolling in from off-screen; that + # behaviour must not change for the many non-Vegas callers. + sh = ScrollHelper(128, 32) + sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))], + item_gap=0, element_gap=0) + assert sh.cached_image.width == 128 + 100 + assert not column_has_ink(sh.cached_image)[:128].any() + + def test_zero_lead_gap_starts_on_content(self): + sh = ScrollHelper(128, 32) + sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))], + item_gap=0, element_gap=0, lead_gap=0) + assert sh.cached_image.width == 100 + assert column_has_ink(sh.cached_image)[0] + + def test_explicit_lead_gap_is_honoured(self): + sh = ScrollHelper(128, 32) + sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))], + item_gap=0, element_gap=0, lead_gap=16) + assert sh.cached_image.width == 116 + ink = column_has_ink(sh.cached_image) + assert not ink[:16].any() + assert ink[16:].all() + + def test_negative_lead_gap_is_clamped(self): + sh = ScrollHelper(128, 32) + sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))], + item_gap=0, element_gap=0, lead_gap=-50) + assert sh.cached_image.width == 100 + + def test_total_scroll_width_matches_image(self): + # The cycle-complete check compares against total_scroll_width, so a + # mismatch here would cut cycles short or overrun them. + sh = ScrollHelper(128, 32) + items = [Image.new('RGB', (60, 32), (255, 0, 0)) for _ in range(3)] + sh.create_scrolling_image(items, item_gap=32, element_gap=0, lead_gap=0) + assert sh.total_scroll_width == sh.cached_image.width + assert sh.cached_image.width == 60 * 3 + 32 * 2 + + def test_item_gaps_are_unaffected_by_lead_gap(self): + sh = ScrollHelper(128, 32) + items = [Image.new('RGB', (10, 32), (255, 0, 0)) for _ in range(2)] + sh.create_scrolling_image(items, item_gap=20, element_gap=0, lead_gap=0) + ink = column_has_ink(sh.cached_image) + assert ink[:10].all() + assert not ink[10:30].any() + assert ink[30:40].all() + + +class TestConfigSurface: + def test_new_keys_parse_from_config(self): + cfg = VegasModeConfig.from_config({'display': {'vegas_scroll': { + 'auto_trim': False, + 'trim_threshold': 25, + 'content_padding': 4, + 'min_plugin_width': 64, + 'lead_in_width': 32, + }}}) + assert cfg.auto_trim is False + assert cfg.trim_threshold == 25 + assert cfg.content_padding == 4 + assert cfg.min_plugin_width == 64 + assert cfg.lead_in_width == 32 + + def test_defaults_favour_trimming(self): + cfg = VegasModeConfig.from_config({}) + assert cfg.auto_trim is True + assert cfg.lead_in_width == 0 + assert cfg.content_padding == 8 + + def test_round_trips_through_to_dict(self): + cfg = VegasModeConfig(trim_threshold=20, lead_in_width=64) + restored = VegasModeConfig.from_config( + {'display': {'vegas_scroll': cfg.to_dict()}}) + assert restored.trim_threshold == 20 + assert restored.lead_in_width == 64 + + def test_update_applies_new_keys(self): + cfg = VegasModeConfig() + cfg.update({'display': {'vegas_scroll': {'content_padding': 16}}}) + assert cfg.content_padding == 16 + + @pytest.mark.parametrize('overrides,bad_key', [ + ({'trim_threshold': 300}, 'trim_threshold'), + ({'trim_threshold': -1}, 'trim_threshold'), + ({'content_padding': -5}, 'content_padding'), + ({'content_padding': 500}, 'content_padding'), + ({'min_plugin_width': -1}, 'min_plugin_width'), + ({'lead_in_width': -1}, 'lead_in_width'), + ]) + def test_validate_rejects_out_of_range(self, overrides, bad_key): + errors = VegasModeConfig(**overrides).validate() + assert any(bad_key in e for e in errors), errors + + def test_valid_config_has_no_errors(self): + assert VegasModeConfig( + trim_threshold=10, content_padding=8, + min_plugin_width=8, lead_in_width=0, + ).validate() == [] diff --git a/test/test_vegas_geometry.py b/test/test_vegas_geometry.py new file mode 100644 index 00000000..b2353857 --- /dev/null +++ b/test/test_vegas_geometry.py @@ -0,0 +1,273 @@ +"""Tests for Vegas mode geometry primitives.""" + +import numpy as np +import pytest +from PIL import Image + +from src.vegas_mode.geometry import ( + DEFAULT_INK_THRESHOLD, + column_has_ink, + content_bounds, + dead_window_stats, + trim_to_content, + window_coverage_stats, +) + + +def make_img(width, height=8, fill=(0, 0, 0)): + return Image.new('RGB', (width, height), fill) + + +def paint(img, x0, x1, color=(255, 255, 255)): + """Fill columns [x0, x1) with a colour.""" + block = Image.new('RGB', (x1 - x0, img.height), color) + img.paste(block, (x0, 0)) + return img + + +class TestColumnHasInk: + def test_all_black_has_no_ink(self): + assert not column_has_ink(make_img(16)).any() + + def test_marks_only_painted_columns(self): + img = paint(make_img(16), 4, 8) + ink = column_has_ink(img) + assert ink.tolist() == [False] * 4 + [True] * 4 + [False] * 8 + + def test_threshold_is_exclusive(self): + # A pixel exactly at the threshold is not ink; one above it is. + at = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD,) * 3) + above = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD + 1,) * 3) + assert not column_has_ink(at).any() + assert column_has_ink(above).all() + + def test_single_bright_channel_counts(self): + img = paint(make_img(4), 1, 2, (0, 0, 200)) + assert column_has_ink(img).tolist() == [False, True, False, False] + + def test_one_lit_pixel_lights_the_column(self): + img = make_img(4, height=8) + img.putpixel((2, 5), (255, 255, 255)) + assert column_has_ink(img).tolist() == [False, False, True, False] + + +class TestContentBounds: + def test_blank_returns_none(self): + assert content_bounds(make_img(16)) is None + + def test_finds_inclusive_bounds(self): + assert content_bounds(paint(make_img(20), 5, 12)) == (5, 11) + + def test_full_width_content(self): + assert content_bounds(paint(make_img(10), 0, 10)) == (0, 9) + + def test_spans_interior_gap(self): + img = paint(make_img(30), 2, 5) + paint(img, 20, 25) + assert content_bounds(img) == (2, 24) + + +class TestTrimToContent: + def test_blank_image_reports_blank(self): + result = trim_to_content(make_img(512)) + assert result.is_blank + assert result.image is None + assert result.width == 0 + assert result.original_width == 512 + + def test_trims_both_edges(self): + result = trim_to_content(paint(make_img(512), 100, 150)) + assert not result.is_blank + assert result.width == 50 + assert result.trimmed_left == 100 + assert result.trimmed_right == 362 + assert result.removed == 462 + + def test_preserves_interior_gap(self): + # Two content blocks with a wide blank between them: the gap is the + # plugin's layout and must survive trimming. + img = paint(make_img(400), 50, 80) + paint(img, 300, 330) + result = trim_to_content(img) + assert result.width == 280 # 50..329 inclusive + assert column_has_ink(result.image).sum() == 60 + + def test_full_width_content_is_returned_unchanged(self): + img = paint(make_img(128), 0, 128) + result = trim_to_content(img) + assert result.image is img + assert result.removed == 0 + + def test_non_black_background_is_never_trimmed(self): + # A plugin drawing on a dark-but-not-black background fills every + # column with ink, so there is nothing to reclaim. + result = trim_to_content(make_img(256, fill=(0, 0, 40))) + assert result.removed == 0 + assert result.width == 256 + + def test_padding_keeps_margin_up_to_what_exists(self): + result = trim_to_content(paint(make_img(512), 100, 150), padding=8) + assert result.trimmed_left == 92 + assert result.width == 66 # 50 content + 8 each side + + def test_padding_cannot_widen_beyond_original(self): + # Content starts 2px in; padding of 8 can only reclaim the 2 available. + result = trim_to_content(paint(make_img(64), 2, 60), padding=8) + assert result.trimmed_left == 0 + assert result.trimmed_right == 0 + assert result.width == 64 + + def test_height_is_preserved(self): + result = trim_to_content(paint(make_img(200, height=64), 10, 20)) + assert result.image.height == 64 + + def test_real_world_of_the_day_case(self): + # Measured on devpi: "No Data" occupying 35px of a 512px canvas. + result = trim_to_content(paint(make_img(512, height=64), 4, 39)) + assert result.width == 35 + assert result.removed == 477 + + +class TestDeadWindowStats: + def test_fully_inked_ticker_has_no_dead_windows(self): + stats = dead_window_stats(paint(make_img(400), 0, 400), viewport_width=100) + assert stats.dead_windows == 0 + assert stats.dead_ratio == 0.0 + assert stats.longest_dead_run == 0 + + def test_fully_blank_ticker_is_all_dead(self): + stats = dead_window_stats(make_img(400), viewport_width=100) + assert stats.total_windows == 301 + assert stats.dead_windows == 301 + assert stats.dead_ratio == 1.0 + assert stats.longest_dead_run == 301 + + def test_leading_blank_run_is_measured(self): + # 512px of black then solid content: windows fully inside the black + # stretch are dead. With a 100px viewport, starts 0..412 exist and a + # window is dead while it holds >=95 blank columns. + img = paint(make_img(1024), 512, 1024) + stats = dead_window_stats(img, viewport_width=100) + assert stats.dead_windows == 418 # starts 0..417 keep >=95 blank cols + assert stats.longest_dead_run == 418 + + def test_narrow_content_island_still_leaves_dead_windows(self): + # 35px of content in a 512px field, viewed 100px at a time: no window + # can be 95% blank once it overlaps 35 lit columns, but the windows + # clear of it are dead. + img = paint(make_img(512), 100, 135) + stats = dead_window_stats(img, viewport_width=100) + assert stats.dead_windows > 0 + assert stats.dead_ratio == pytest.approx( + stats.dead_windows / stats.total_windows + ) + + def test_step_reduces_sampling(self): + img = paint(make_img(1000), 500, 1000) + exact = dead_window_stats(img, viewport_width=100, step=1) + strided = dead_window_stats(img, viewport_width=100, step=10) + assert strided.total_windows < exact.total_windows + # Same underlying shape, so the ratios should stay close. + assert strided.dead_ratio == pytest.approx(exact.dead_ratio, abs=0.02) + + def test_image_narrower_than_viewport_is_one_window(self): + stats = dead_window_stats(make_img(50), viewport_width=100) + assert stats.total_windows == 1 + assert stats.dead_windows == 1 + + def test_zero_viewport_is_handled(self): + stats = dead_window_stats(make_img(50), viewport_width=0) + assert stats.total_windows == 0 + assert stats.dead_ratio == 0.0 + + def test_longest_run_picks_the_larger_of_two_gaps(self): + # Short blank gap, content, then a long blank gap. + img = make_img(1000) + paint(img, 150, 400) + paint(img, 500, 520) + stats = dead_window_stats(img, viewport_width=100) + # The 400..500 gap is only 100 wide; the tail from 520 is 480 wide. + assert stats.longest_dead_run >= 380 + + +class TestWindowCoverageStats: + def test_solid_content_is_fully_covered(self): + stats = window_coverage_stats(paint(make_img(600), 0, 600), viewport_width=100) + assert stats.mean_ink_ratio == 1.0 + assert stats.min_ink_ratio == 1.0 + assert stats.sparse_windows == 0 + + def test_blank_strip_is_entirely_sparse(self): + stats = window_coverage_stats(make_img(600), viewport_width=100) + assert stats.mean_ink_ratio == 0.0 + assert stats.sparse_ratio == 1.0 + + def test_catches_sliver_windows_that_dead_ratio_misses(self): + # Narrow content islands separated by more than the viewport. A window + # holding one whole 40px island carries 472 blank columns — under the + # 486 needed to count as "dead" — yet only 7.8% ink, so it still reads + # as an empty panel. Coverage must flag strictly more positions than + # the dead-window scan does. + img = paint(make_img(2000), 0, 40) + paint(img, 1000, 1040) + dead = dead_window_stats(img, viewport_width=512) + cover = window_coverage_stats(img, viewport_width=512, sparse_ink_ratio=0.10) + assert cover.sparse_windows > dead.dead_windows + assert cover.min_ink_ratio == 0.0 + + def test_adjacent_full_width_segments_stay_partially_covered(self): + # Documents why the dead-window scan alone understated the problem: + # two 512px segments with mid-canvas content never fully blank the + # viewport, they just hold it at a thin ~28%. + img = paint(make_img(1024), 185, 330) + paint(img, 697, 842) + dead = dead_window_stats(img, viewport_width=512) + cover = window_coverage_stats(img, viewport_width=512) + assert dead.dead_windows == 0 + assert cover.mean_ink_ratio == pytest.approx(0.283, abs=0.01) + + def test_min_ink_ratio_finds_the_worst_position(self): + # A wide blank tail guarantees at least one totally empty viewport. + img = paint(make_img(1200), 0, 200) + stats = window_coverage_stats(img, viewport_width=200) + assert stats.min_ink_ratio == 0.0 + assert stats.mean_ink_ratio > 0.0 + + def test_sparse_threshold_is_respected(self): + # 40 inked columns in a 200px viewport = 20% coverage everywhere the + # island is fully inside the window. + img = paint(make_img(400), 100, 140) + lenient = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.05) + strict = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.50) + assert strict.sparse_windows > lenient.sparse_windows + + def test_step_approximates_exact_scan(self): + img = paint(make_img(2000), 300, 500) + paint(img, 1200, 1400) + exact = window_coverage_stats(img, viewport_width=512, step=1) + strided = window_coverage_stats(img, viewport_width=512, step=4) + assert strided.mean_ink_ratio == pytest.approx(exact.mean_ink_ratio, abs=0.01) + + def test_zero_viewport_is_handled(self): + stats = window_coverage_stats(make_img(50), viewport_width=0) + assert stats.total_windows == 0 + assert stats.sparse_ratio == 0.0 + + def test_image_narrower_than_viewport(self): + stats = window_coverage_stats(paint(make_img(50), 0, 50), viewport_width=100) + assert stats.total_windows == 1 + assert stats.mean_ink_ratio == pytest.approx(0.5) + + +class TestLongestRunHelper: + @pytest.mark.parametrize("flags,expected", [ + ([], 0), + ([False, False], 0), + ([True], 1), + ([True, True, False, True], 2), + ([False, True, True, True, False, True], 3), + ([True, True, True], 3), + ]) + def test_run_lengths(self, flags, expected): + from src.vegas_mode.geometry import _longest_true_run + assert _longest_true_run(np.array(flags, dtype=bool)) == expected diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index f92c7852..c6ea9286 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -918,7 +918,12 @@ def save_main_config(): # Handle Vegas scroll mode settings vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width', - 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins'] + 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins', + 'vegas_auto_trim', 'vegas_trim_threshold', 'vegas_content_padding', + 'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle', + 'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled', + 'vegas_min_cycle_duration', 'vegas_max_cycle_duration', + 'vegas_intra_plugin_gap'] if any(k in data for k in vegas_fields): if 'display' not in current_config: @@ -933,13 +938,43 @@ def save_main_config(): # was submitted (any vegas field present) but enabled key is missing, # the checkbox was unchecked and we should set enabled=False vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled')) + vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim')) + vegas_config['dynamic_duration_enabled'] = _coerce_to_bool( + data.get('vegas_dynamic_duration_enabled')) + + # max_plugin_width_ratio is the one fractional setting, so it is + # handled outside the integer loop below. + if data.get('vegas_max_plugin_width_ratio') not in ('', None): + try: + ratio = float(data['vegas_max_plugin_width_ratio']) + except (ValueError, TypeError): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for vegas_max_plugin_width_ratio: " + "must be a number" + }), 400 + if not (0 <= ratio <= 20): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for vegas_max_plugin_width_ratio: " + "must be between 0 and 20 (0 disables the cap)" + }), 400 + vegas_config['max_plugin_width_ratio'] = ratio # Handle numeric settings with validation numeric_fields = { 'vegas_scroll_speed': ('scroll_speed', 1, 100), 'vegas_separator_width': ('separator_width', 0, 500), + 'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128), 'vegas_target_fps': ('target_fps', 1, 200), 'vegas_buffer_ahead': ('buffer_ahead', 1, 20), + 'vegas_trim_threshold': ('trim_threshold', 0, 254), + 'vegas_content_padding': ('content_padding', 0, 128), + 'vegas_min_plugin_width': ('min_plugin_width', 0, 512), + 'vegas_lead_in_width': ('lead_in_width', 0, 2048), + 'vegas_plugins_per_cycle': ('plugins_per_cycle', 1, 50), + 'vegas_min_cycle_duration': ('min_cycle_duration', 5, 3600), + 'vegas_max_cycle_duration': ('max_cycle_duration', 10, 3600), } for field_name, (config_key, min_val, max_val) in numeric_fields.items(): if field_name in data: diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index 80cc0709..92663f9d 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -425,7 +425,7 @@
How long one pass through the ticker lasts, and how many plugins it covers.
+ +Plugins that draw onto a full-screen canvas contribute all the empty space around their content. Trimming reclaims it so the ticker stays full.
+ +