diff --git a/config/config.template.json b/config/config.template.json index b3f63589..6f84e048 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -130,7 +130,25 @@ "plugin_order": [], "excluded_plugins": [], "target_fps": 125, - "buffer_ahead": 2 + "buffer_ahead": 2, + "intra_plugin_gap": 8, + "render_width_pct": 100, + "min_content_separation": 24, + "min_cut_gap": 6, + "continuous_scroll": true, + "smooth_scroll": true, + "extend_threshold_screens": 2.0, + "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, + "overflow_mode": "rotate", + "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..e3fdffa7 --- /dev/null +++ b/scripts/dev/vegas_audit.py @@ -0,0 +1,384 @@ +#!/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 + +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() + # Pass the loaded config, exactly as VegasModeCoordinator does. Omitting it + # makes PluginAdapter fall back to VegasModeConfig() defaults, so the audit + # would silently report trimming and width-budget behaviour that differs + # from the user's config.json — the same drift the lead_gap and grouping + # arguments below exist to avoid. + adapter = PluginAdapter(display_manager, vegas) + + 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..4f2e215e 100644 --- a/src/common/scroll_helper.py +++ b/src/common/scroll_helper.py @@ -110,20 +110,30 @@ def __init__(self, display_width: int, display_height: int, self.is_scrolling = False self.scroll_complete = False - def create_scrolling_image(self, content_items: list, + def create_scrolling_image(self, content_items: list, item_gap: int = 32, - element_gap: int = 16) -> Image.Image: + element_gap: int = 16, + lead_gap: Optional[int] = None) -> Image.Image: """ Create a wide image containing all content items for scrolling. - + Args: content_items: List of PIL Images to include in scroll item_gap: Gap between different items element_gap: Gap between elements within an item - + lead_gap: Blank columns before the first item. Defaults to a full + display width, which makes a standalone ticker scroll in from + off-screen. Callers that loop many plugins back-to-back (Vegas + mode) pass a smaller value, since a full display width of black + reads as the panel being switched off at the start of every + cycle. + Returns: PIL Image containing all content arranged horizontally """ + if lead_gap is None: + lead_gap = self.display_width + lead_gap = max(0, int(lead_gap)) if not content_items: # Create empty image if no content # Still set total_scroll_width to 0 to indicate no scrollable content @@ -144,13 +154,13 @@ def create_scrolling_image(self, content_items: list, total_width += element_gap * len(content_items) # Add initial gap before first item - total_width += self.display_width - + total_width += lead_gap + # Create the full scrolling image full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0)) - + # Position items - current_x = self.display_width # Start with initial gap + current_x = lead_gap # Start with initial gap for i, img in enumerate(content_items): # Paste the item image @@ -338,13 +348,72 @@ def get_visible_portion(self) -> Optional[Image.Image]: """ if not self.cached_image or self.cached_array is None: return None - - # Use integer pixel positioning for high FPS scrolling (like stock ticker) + start_x_int = int(self.scroll_position) end_x_int = start_x_int + self.display_width - - # Fast integer pixel path (no interpolation - high frame rate provides smoothness) + + # Integer positioning quantises motion to whole pixels, so the number of + # distinct frames per second equals the scroll speed in px/s, no matter + # how fast the loop renders. At 50px/s and 78fps that made 36% of frames + # identical: the extra frames cost work and bought nothing. Blending + # between the two neighbouring positions gives motion at the frame rate + # instead of the step rate. + if self.sub_pixel_scrolling: + fractional = self.scroll_position - start_x_int + if fractional > 0.0: + return self._blend_visible_portion(start_x_int, fractional) + return self._get_visible_portion_integer(start_x_int, end_x_int) + + def _blend_visible_portion(self, start_x: int, fractional: float) -> Image.Image: + """ + Linear blend between the frames at ``start_x`` and ``start_x + 1``. + + Implemented with numpy rather than scipy.ndimage.shift: scipy is not + installed on the target devices (HAS_SCIPY is False there), which is why + the pre-existing sub-pixel path was dead code — get_visible_portion never + consulted the flag, and the scipy fallback would not have interpolated + anyway. + + Args: + start_x: Left column of the earlier of the two frames + fractional: How far between the two, in [0, 1) + + Returns: + The blended frame + """ + width = self.display_width + strip_width = self.cached_array.shape[1] + + if start_x + width + 1 <= strip_width: + # Slice the backing array directly. Going via + # _get_visible_portion_integer would build two PIL images only for + # them to be converted straight back to arrays, which measured 15x + # the cost of the integer path. + near = self.cached_array[:, start_x:start_x + width] + far = self.cached_array[:, start_x + 1:start_x + 1 + width] + else: + # Close enough to the end that one of the slices wraps; let the + # integer path handle that and pay the conversion. Continuous mode + # extends the strip before reaching here, so this is the rare case. + near = np.asarray( + self._get_visible_portion_integer(start_x, start_x + width)) + far = np.asarray( + self._get_visible_portion_integer(start_x + 1, start_x + 1 + width)) + + # Fixed-point rather than float32: integer multiply-add on uint16 is + # markedly faster than float maths on the Pi's ARM cores, and 8 bits of + # weight is finer than the panel can show. + weight = int(fractional * 256.0) + blended = ( + (near.astype(np.uint16) * (256 - weight) + + far.astype(np.uint16) * weight) >> 8 + ).astype(np.uint8) + + return Image.frombytes( + 'RGB', (width, self.display_height), + np.ascontiguousarray(blended).tobytes() + ) def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image: """Fast integer pixel extraction (no interpolation). @@ -638,6 +707,128 @@ def is_scroll_complete(self) -> bool: """ return self.scroll_complete + def append_content(self, content_items: list, + item_gap: int = 32, + element_gap: int = 0) -> bool: + """ + Append items to the right of the existing strip, preserving scroll state. + + Lets a caller keep one continuous strip instead of replacing it. Vegas + mode uses this so the next group of plugins scrolls in from the right + rather than the strip being swapped out underneath the viewer — a swap + shows as a flash and a hard cut to already-full-screen content. + + ``scroll_position`` and ``total_distance_scrolled`` are untouched, so + motion continues uninterrupted; only the strip gets longer. Because + completion is measured against ``total_scroll_width``, extending the + strip also defers completion, which is the intent. + + Args: + content_items: Images to append, in order + item_gap: Gap between appended items, and between the existing + content and the first appended item + element_gap: Extra gap after each item, mirroring + create_scrolling_image + + Returns: + True if content was appended + """ + if not content_items: + return False + + if self.cached_image is None or self.cached_array is None: + # Nothing to extend yet — this is just the first build. + self.create_scrolling_image( + content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0) + return True + + gap = max(0, item_gap) + addition_width = ( + sum(img.width for img in content_items) + + gap * len(content_items) # one leading gap per item + + element_gap * len(content_items) + ) + + addition = Image.new('RGB', (addition_width, self.display_height), (0, 0, 0)) + x = 0 + for img in content_items: + x += gap # separate from whatever precedes + addition.paste(img, (x, 0)) + x += img.width + element_gap + + # numpy concatenate then one conversion back, rather than allocating a + # full-width PIL image and pasting twice: the strip can be tens of + # thousands of columns wide and this runs on the render path. + self.cached_array = np.concatenate( + (self.cached_array, np.array(addition)), axis=1) + self.cached_image = Image.fromarray(self.cached_array) + self.total_scroll_width = self.cached_image.width + self.scroll_complete = False + + self.logger.info( + "Appended %d item(s) (%dpx) to scroll strip: now %dpx, position %.0f", + len(content_items), addition_width, self.total_scroll_width, + self.scroll_position + ) + return True + + def drop_scrolled_prefix(self, keep_before: int = 0) -> int: + """ + Discard columns that have already scrolled past, to bound memory. + + A continuously extended strip would otherwise grow without limit. All + the positional state is shifted by the amount removed so the visible + frame and the completion arithmetic are unchanged: + ``total_distance_scrolled`` and ``total_scroll_width`` both shrink by the + same amount, preserving their difference. + + Args: + keep_before: Columns to retain behind the current position, as a + safety margin against a caller reading slightly behind it + + Returns: + Number of columns actually removed + """ + if self.cached_image is None or self.cached_array is None: + return 0 + + # While the viewport wraps, get_visible_portion fills its right-hand side + # from the *head* of the strip, so trimming the head would change what + # is on screen. Continuous mode extends before ever reaching that state; + # refusing here keeps "trimming is invisible" true unconditionally. + if self.scroll_position + self.display_width > self.cached_image.width: + return 0 + + cut = int(self.scroll_position) - max(0, keep_before) + if cut <= 0: + return 0 + # Never trim so far that the remaining strip is narrower than the + # viewport, or get_visible_portion has nothing to slice. + cut = min(cut, max(0, self.cached_image.width - self.display_width)) + if cut <= 0: + return 0 + + # .copy() so the original buffer is released rather than kept alive by + # a numpy view. + self.cached_array = self.cached_array[:, cut:].copy() + self.cached_image = Image.fromarray(self.cached_array) + self.total_scroll_width = self.cached_image.width + self.scroll_position -= cut + self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut) + + self.logger.debug( + "Dropped %dpx of scrolled strip: now %dpx, position %.0f", + cut, self.total_scroll_width, self.scroll_position + ) + return cut + + def remaining_unscrolled(self) -> int: + """Columns of strip still to the right of the viewport.""" + if self.cached_image is None: + return 0 + return max(0, self.total_scroll_width - int(self.scroll_position) + - self.display_width) + def reset_scroll(self) -> None: """ Reset scroll position to beginning. diff --git a/src/display_manager.py b/src/display_manager.py index 9de558a1..d96fef35 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -186,8 +186,14 @@ def __init__(self, config: Dict[str, Any] = None, force_fallback: bool = False, self.config = config or {} self._force_fallback = force_fallback self._suppress_test_pattern = suppress_test_pattern - # When True, update_display() and clear() skip hardware writes (used during off-screen content capture) - self._capture_mode_active = False + # Per-thread capture state. update_display() and clear() skip hardware + # writes while the *calling* thread is capturing content off-screen. + # + # Thread-local rather than a plain flag because Vegas mode prepares + # upcoming content on a background thread: a shared flag set there would + # suppress the render loop's own frame pushes for the duration, freezing + # the panel exactly when the point was to avoid a freeze. + self._capture_state = threading.local() # Double-sided mode state (resolved in _setup_matrix). When disabled, # the logical image is blitted to the matrix unchanged. self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None @@ -520,6 +526,15 @@ def _draw_test_pattern(self): except Exception as e: logger.error(f"Error drawing test pattern: {e}", exc_info=True) + @property + def _capture_mode_active(self) -> bool: + """True while the calling thread is capturing content off-screen.""" + return getattr(self._capture_state, 'active', False) + + @_capture_mode_active.setter + def _capture_mode_active(self, value: bool) -> None: + self._capture_state.active = bool(value) + @contextmanager def capture_mode(self): """Suppress hardware output during off-screen content capture. @@ -536,6 +551,59 @@ def capture_mode(self): finally: self._capture_mode_active = False + @contextmanager + def render_size(self, width: int, height: Optional[int] = None): + """Temporarily present a smaller logical canvas to plugins. + + Plugins lay out against ``display_manager.matrix.width`` (and the + ``width``/``height`` properties, which defer to it), so the only way to + get a *narrower layout* rather than a cropped one is to tell the plugin + the screen is narrower while it renders. Trimming after the fact cannot + fix a forecast spread across five columns or a progress bar drawn at + 100% width — those need the plugin to make different layout decisions. + + Vegas mode uses this so a plugin can occupy a fraction of a wide panel + and still look deliberately composed. Reuses the same _LogicalMatrix + indirection that double-sided mode relies on, so plugins see a + consistent size from every accessor. + + Only meaningful inside :meth:`capture_mode` — this swaps the shared + image buffer, so the render loop must not be writing to it concurrently. + + Args: + width: Logical width to report, clamped to at least 1 and to the + real panel width (a larger canvas would overflow the hardware). + height: Logical height, defaulting to the current height. + """ + real_matrix = self.matrix + prev_image = getattr(self, 'image', None) + prev_draw = getattr(self, 'draw', None) + + current_w = self.width + current_h = self.height + target_w = max(1, min(int(width), current_w)) + target_h = max(1, min(int(height) if height else current_h, current_h)) + + if target_w == current_w and target_h == current_h: + # Nothing to do; avoid pointless wrapping and buffer churn. + yield + return + + try: + if real_matrix is not None: + self.matrix = _LogicalMatrix(real_matrix, target_w, target_h) + # With no hardware, the width/height properties fall through to + # self.image, so swapping the buffer below is enough on its own. + self.image = Image.new('RGB', (target_w, target_h)) + self.draw = ImageDraw.Draw(self.image) + yield + finally: + self.matrix = real_matrix + if prev_image is not None: + self.image = prev_image + if prev_draw is not None: + self.draw = prev_draw + def _composite_double_sided(self): """Tile the logical screen across the full physical chain. diff --git a/src/plugin_system/base_plugin.py b/src/plugin_system/base_plugin.py index 54759855..87056b0f 100644 --- a/src/plugin_system/base_plugin.py +++ b/src/plugin_system/base_plugin.py @@ -505,6 +505,40 @@ def get_live_modes(self): # ------------------------------------------------------------------------- # Vegas scroll mode support # ------------------------------------------------------------------------- + def get_vegas_render_width(self) -> int: + """ + Width the Vegas ticker wants this plugin's content to occupy. + + On a wide panel a layout built to fill the screen reads as sparse in a + ticker — a forecast spread over five columns, a progress bar drawn at + 100% width, a stat block with the panel's whole width between its + elements. Vegas asks for a narrower render so the plugin can choose a + tighter arrangement instead of being cropped afterwards. + + Vegas also narrows ``display_manager`` for the duration of the call, so + a plugin that already sizes itself from ``matrix.width`` needs no + changes. Read this only when you size content some other way. + + Controlled by the plugin's own ``vegas_width_pct`` config value, else + the global ``display.vegas_scroll.render_width_pct``. + + Returns: + Target width in pixels. Outside a Vegas content request, the full + display width. + """ + requested = getattr(self, '_vegas_render_width', None) + if isinstance(requested, int) and requested > 0: + return requested + + display_manager = getattr(self, 'display_manager', None) + matrix = getattr(display_manager, 'matrix', None) + if matrix is not None and getattr(matrix, 'width', None): + return int(matrix.width) + width = getattr(display_manager, 'width', None) + if callable(width): + width = width() + return int(width) if width else 128 + def get_vegas_content(self) -> Optional[Any]: """ Get content for Vegas-style continuous scroll mode. diff --git a/src/plugin_system/testing/visual_display_manager.py b/src/plugin_system/testing/visual_display_manager.py index 7f84a94e..e3dc2ec6 100644 --- a/src/plugin_system/testing/visual_display_manager.py +++ b/src/plugin_system/testing/visual_display_manager.py @@ -15,6 +15,7 @@ import math import os import time +from contextlib import contextmanager from pathlib import Path from typing import Any, List, Optional, Tuple @@ -62,6 +63,9 @@ def __init__(self, width: int = 128, height: int = 32): # Matrix proxy (plugins access display_manager.matrix.width/height) self.matrix = _MatrixProxy(width, height) + # Set while inside capture_mode(); mirrors DisplayManager's flag. + self._capture_mode_active = False + # Scrolling state (interface compat, no-op) self._scrolling_state = { 'is_scrolling': False, @@ -174,6 +178,50 @@ def update_display(self): """No-op for hardware; marks that display was updated.""" self.update_called = True + @contextmanager + def render_size(self, width: int, height: Optional[int] = None): + """ + Interface parity with DisplayManager.render_size(). + + Vegas mode narrows the canvas so plugins lay out compactly instead of + being cropped. The harness must offer the same context or that path + cannot be exercised offline — and because the adapter catches broadly, + a missing method shows up as "no content" rather than an error. + """ + prev_image = self.image + prev_draw = self.draw + prev_w, prev_h = self._width, self._height + + target_w = max(1, min(int(width), prev_w)) + target_h = max(1, min(int(height) if height else prev_h, prev_h)) + + try: + self._width, self._height = target_w, target_h + self.matrix = _MatrixProxy(target_w, target_h) + self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0)) + self.draw = ImageDraw.Draw(self.image) + yield + finally: + self._width, self._height = prev_w, prev_h + self.matrix = _MatrixProxy(prev_w, prev_h) + self.image = prev_image + self.draw = prev_draw + + @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..14786481 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -21,6 +21,94 @@ class VegasModeConfig: 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 + + # Blend between neighbouring pixel positions so motion happens at the frame + # rate rather than the scroll speed. With integer positioning the number of + # distinct frames per second equals scroll_speed, so at 50px/s the motion is + # 50 discrete 1px steps however fast the loop runs. The trade is a slight + # horizontal softening of text, since each frame is a blend of two positions. + smooth_scroll: bool = True + + # Keep one continuous strip, extending it with the next group of plugins as + # the scroll approaches the end, instead of composing a fresh strip and + # swapping it in. A swap stops the motion, substitutes every pixel at once + # and restarts with the viewport already full — read as a freeze, a flash + # and a jump. Extending means the next group simply scrolls in from the + # right. Set false to restore the swap behaviour. + continuous_scroll: bool = True + + # Extend once the unscrolled remainder falls below this many screen widths. + # Needs to be more than one so the join is prepared before it is on screen. + extend_threshold_screens: float = 2.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 + + # What to do when a plugin's content exceeds its width budget. + # + # "rotate" — advance a window each cycle so everything is seen eventually. + # Right for interchangeable items: news headlines, odds, stocks. + # "truncate" — always show the start. Right for ordered content, where a + # window into the middle is meaningless: a league table that + # shows ranks 1-6 then resumes at 7 two rotations later reads + # as out of order and out of context. + # + # Override per plugin with vegas_overflow. + overflow_mode: str = "rotate" + + # 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 +143,24 @@ def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig': enabled=vegas_config.get('enabled', False), scroll_speed=float(vegas_config.get('scroll_speed', 50.0)), separator_width=int(vegas_config.get('separator_width', 32)), + intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)), + 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)), + smooth_scroll=vegas_config.get('smooth_scroll', True), + continuous_scroll=vegas_config.get('continuous_scroll', True), + extend_threshold_screens=float( + vegas_config.get('extend_threshold_screens', 2.0)), + 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)), + overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')), 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 +178,21 @@ def to_dict(self) -> Dict[str, Any]: 'enabled': self.enabled, 'scroll_speed': self.scroll_speed, 'separator_width': self.separator_width, + 'intra_plugin_gap': self.intra_plugin_gap, + 'render_width_pct': self.render_width_pct, + 'min_content_separation': self.min_content_separation, + 'min_cut_gap': self.min_cut_gap, + 'smooth_scroll': self.smooth_scroll, + 'continuous_scroll': self.continuous_scroll, + 'extend_threshold_screens': self.extend_threshold_screens, + '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, + 'overflow_mode': self.overflow_mode, 'plugin_order': self.plugin_order, 'excluded_plugins': list(self.excluded_plugins), 'target_fps': self.target_fps, @@ -157,6 +278,74 @@ def validate(self) -> List[str]: 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.0 <= self.extend_threshold_screens <= 10.0: + errors.append( + "extend_threshold_screens must be between 1.0 and 10.0, " + f"got {self.extend_threshold_screens}") + + 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.overflow_mode not in ('rotate', 'truncate'): + errors.append( + "overflow_mode must be 'rotate' or 'truncate', " + f"got {self.overflow_mode!r}") + + 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 +363,39 @@ def update(self, new_config: Dict[str, Any]) -> None: self.scroll_speed = float(vegas_config['scroll_speed']) if 'separator_width' in vegas_config: self.separator_width = int(vegas_config['separator_width']) + if 'intra_plugin_gap' in vegas_config: + self.intra_plugin_gap = int(vegas_config['intra_plugin_gap']) + if '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 'smooth_scroll' in vegas_config: + self.smooth_scroll = vegas_config['smooth_scroll'] + if 'continuous_scroll' in vegas_config: + self.continuous_scroll = vegas_config['continuous_scroll'] + if 'extend_threshold_screens' in vegas_config: + self.extend_threshold_screens = float( + vegas_config['extend_threshold_screens']) + 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 'overflow_mode' in vegas_config: + self.overflow_mode = str(vegas_config['overflow_mode']) 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..6276f3b0 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -64,7 +64,7 @@ def __init__( self.plugin_manager = plugin_manager # Initialize components - self.plugin_adapter = PluginAdapter(display_manager) + self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config) self.stream_manager = StreamManager( self.vegas_config, plugin_manager, @@ -233,6 +233,11 @@ def start(self) -> bool: self._should_stop = False self._start_time = time.time() + # Line up the next group immediately, so the first extension is already + # warm rather than stalling the scroll to fetch it. + if self.vegas_config.continuous_scroll: + self.render_pipeline.start_prefetch() + logger.info("Vegas mode started") return True @@ -301,16 +306,33 @@ def run_frame(self) -> bool: if has_pending_update: self._apply_pending_config() - # Check if we need to start a new cycle - if self.render_pipeline.is_cycle_complete(): - if not self.render_pipeline.start_new_cycle(): - logger.warning("Failed to start new Vegas cycle") - return False - self.stats['cycles_completed'] += 1 + if self.vegas_config.continuous_scroll: + # Extend the strip before the scroll can reach its end, so the next + # group arrives from the right and motion never stops. No cycle + # boundary, so no freeze, no substitution and no restart with the + # viewport already full. + # Trickle in the plugins that can only be fetched here, one per + # frame, before considering a further extension. + if self.render_pipeline.has_deferred(): + self.render_pipeline.drain_deferred() + elif self.render_pipeline.needs_extension(): + if self.render_pipeline.extend_scroll_content(): + self.stats['cycles_completed'] += 1 + elif self.render_pipeline.is_cycle_complete(): + # Extension failed and the strip has run out: fall back to + # the swap rather than sitting on a dead frame. + self.render_pipeline.start_new_cycle() + else: + # Check if we need to start a new cycle + if self.render_pipeline.is_cycle_complete(): + if not self.render_pipeline.start_new_cycle(): + logger.warning("Failed to start new Vegas cycle") + return False + self.stats['cycles_completed'] += 1 - # Check for hot-swap opportunities - if self.render_pipeline.should_recompose(): - self.render_pipeline.hot_swap_content() + # Check for hot-swap opportunities + if self.render_pipeline.should_recompose(): + self.render_pipeline.hot_swap_content() # Render frame return self.render_pipeline.render_frame() @@ -337,7 +359,14 @@ def run_iteration(self) -> bool: self._update_static_mode_plugins() frame_interval = self.vegas_config.get_frame_interval() - duration = self.render_pipeline.get_dynamic_duration() + if self.vegas_config.continuous_scroll: + # The strip is continuously extended and trimmed, so its width says + # nothing about how long to run. This is only how often control + # returns to the display controller; interrupts are still checked + # every few frames, so it costs nothing to make it a fixed period. + duration = float(self.vegas_config.max_cycle_duration) + else: + duration = self.render_pipeline.get_dynamic_duration() start_time = time.time() frame_count = 0 fps_log_interval = 5.0 # Log FPS every 5 seconds @@ -347,6 +376,8 @@ def run_iteration(self) -> bool: logger.info("Starting Vegas iteration for %.1fs", duration) while True: + frame_started = time.time() + # Check for STATIC mode plugin that should pause scroll static_plugin = self._check_static_plugin_trigger() if static_plugin: @@ -367,8 +398,14 @@ def run_iteration(self) -> bool: # Paused for live priority - let caller handle return False - # Sleep for frame interval - time.sleep(frame_interval) + # Sleep only the remainder of the frame budget. This used to sleep + # the whole interval on top of however long the frame took, so at a + # measured 31.6ms per frame a fixed 8ms of that was pure idle — a + # quarter of the budget spent not rendering. Subtracting the work + # already done keeps the pacing target while reclaiming that time, + # and yields the GIL either way so other threads still run. + frame_elapsed = time.time() - frame_started + time.sleep(max(0.0, frame_interval - frame_elapsed)) # Increment frame count and check for interrupt periodically frame_count += 1 @@ -505,6 +542,10 @@ def _apply_pending_config(self) -> None: # Update components self.render_pipeline.update_config(new_vegas_config) self.stream_manager.config = new_vegas_config + self.plugin_adapter.config = new_vegas_config + # Cached segments were trimmed under the old settings, so drop them + # or a changed trim/padding value would not visibly take effect. + self.plugin_adapter.invalidate_cache() # Force refresh of stream manager to pick up plugin_order/buffer changes self.stream_manager._last_refresh = 0 diff --git a/src/vegas_mode/geometry.py b/src/vegas_mode/geometry.py new file mode 100644 index 00000000..2d6e3b8e --- /dev/null +++ b/src/vegas_mode/geometry.py @@ -0,0 +1,474 @@ +""" +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 List, 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 edge_blank( + img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD +) -> Tuple[int, int]: + """ + Blank column counts at the left and right edges of an image. + + Used to space items by *measured* separation rather than a flat added gap. + A fixed gap gets this wrong in both directions at once: card-style content + drawn flush to its own edges ends up nearly touching its neighbour, while + content that already carries wide margins gets pushed even further apart. + + Args: + img: Image to measure + threshold: Ink threshold + + Returns: + (left_blank, right_blank). For an entirely blank image both are the + full width, since there is no ink to be close to. + """ + bounds = content_bounds(img, threshold) + if bounds is None: + return img.width, img.width + first, last = bounds + return first, img.width - 1 - last + + +def separation_gap( + left_img: Image.Image, + right_img: Image.Image, + target: int, + minimum: int = 0, + threshold: int = DEFAULT_INK_THRESHOLD, +) -> int: + """ + Columns to insert between two images so their ink is ``target`` apart. + + Only the shortfall is added: if the two images already carry enough blank + at the facing edges, nothing (beyond ``minimum``) is inserted. + + Args: + left_img: Image on the left + right_img: Image on the right + target: Desired blank columns between the two pieces of ink + minimum: Floor applied regardless of what the images already have + threshold: Ink threshold + + Returns: + Number of columns to insert, never negative + """ + existing = edge_blank(left_img, threshold)[1] + edge_blank(right_img, threshold)[0] + return max(minimum, target - existing, 0) + + +def blank_runs( + img: Image.Image, + min_run: int, + threshold: int = DEFAULT_INK_THRESHOLD, +) -> List[Tuple[int, int]]: + """ + Find maximal runs of blank columns at least ``min_run`` wide. + + Distinguishes item boundaries from letter spacing. Measured on real + rendered text, the gaps *between characters* are a single column, while the + gaps a plugin puts *between items* are 8px and up (the stocks ticker uses + 32px, baseball 48px). Treating any blank column as a cut point therefore + slices words in half; requiring a run excludes letter spacing. + + Args: + img: Image to scan + min_run: Minimum consecutive blank columns to qualify + threshold: Ink threshold + + Returns: + List of (start, end) half-open column ranges, in left-to-right order + """ + blank = ~column_has_ink(img, threshold) + if not blank.any(): + return [] + + # Vectorised run detection: pad with False so runs touching either edge get + # a boundary, then read starts and ends off the first difference. A Python + # loop here would be far too slow on a 17,000px ticker strip. + padded = np.concatenate(([False], blank, [False])) + diff = np.diff(padded.astype(np.int8)) + starts = np.flatnonzero(diff == 1) + ends = np.flatnonzero(diff == -1) + + long_enough = (ends - starts) >= max(1, min_run) + return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist())) + + +def find_item_boundary( + img: Image.Image, + target: int, + min_run: int, + threshold: int = DEFAULT_INK_THRESHOLD, +) -> Optional[int]: + """ + Find the column nearest ``target`` that sits inside a gap between items. + + Used to narrow an oversized segment without cutting through a word. Only + runs of at least ``min_run`` blank columns are considered, so the + single-column gaps between characters are never chosen — cutting there + orphaned the tail of a word into the following cycle, which is how a lone + "y" from "Wednesday" ended up floating between two unrelated plugins. + + Args: + img: Image to cut + target: Preferred cut column + min_run: Minimum blank-run width that counts as an item boundary + threshold: Ink threshold + + Returns: + A column inside a qualifying gap, or None when the image has no such + gap at all — in which case the caller must not cut it. + """ + runs = blank_runs(img, min_run, threshold) + if not runs: + return None + + # Nearest point of the nearest run. For a run left of target that is its + # end (content resumes just after), for a run right of target its start + # (content stopped just before) — the right choice in both directions. + def clamp_to_run(run: Tuple[int, int]) -> int: + start, end = run + return max(start, min(target, end - 1)) + + return min((clamp_to_run(r) for r in runs), key=lambda c: abs(c - target)) + + +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) + + # target may legitimately equal width (a cut after the last column), but + # there is no column to inspect there, so both bounds stop at width - 1. + lo = max(0, min(target - search_radius, width - 1)) + hi = max(0, min(target + search_radius, width - 1)) + + # Walk outwards from target so the nearest gap wins. + for offset in range(0, search_radius + 1): + right = target + offset + if lo <= right <= hi and not ink[right]: + return right + left = target - offset + if lo <= left <= hi 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..c722edf0 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -8,9 +8,16 @@ import logging import threading import time +from contextlib import nullcontext from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING from PIL import Image +from src.vegas_mode.geometry import ( + blank_runs, + separation_gap, + trim_to_content, +) + if TYPE_CHECKING: from src.plugin_system.base_plugin import BasePlugin @@ -26,14 +33,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,12 +63,18 @@ def __init__(self, display_manager: Any): 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 ) - def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Image.Image]]: + def get_content(self, plugin: 'BasePlugin', plugin_id: str, + offscreen_only: bool = False) -> Optional[List[Image.Image]]: """ Get scrollable content from a plugin. @@ -63,6 +83,13 @@ def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Ima Args: plugin: Plugin instance to get content from plugin_id: Plugin identifier for logging + offscreen_only: Skip every path that touches the shared display + canvas, for callers running off the render thread. The canvas + and the matrix proxy are process-wide mutable state, so + narrowing or capturing through them from another thread would + corrupt the frame the render loop is pushing. Returns None when + the plugin can only be served that way, leaving the caller to + fetch it on the render thread. Returns: List of PIL Images representing plugin content, or None if no content @@ -86,32 +113,38 @@ def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Ima has_native = hasattr(plugin, 'get_vegas_content') logger.info("[%s] Has get_vegas_content: %s", plugin_id, has_native) if has_native: - content = self._get_native_content(plugin, plugin_id) + content = self._get_native_content(plugin, plugin_id, offscreen_only) if content: total_width = sum(img.width for img in content) logger.info( "[%s] Native content SUCCESS: %d images, %dpx total", plugin_id, len(content), total_width ) - self._cache_content(plugin_id, content) - return content + return self._finalize(content, plugin_id, 'native', plugin) logger.info("[%s] Native content returned None", plugin_id) # Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds) has_scroll_helper = hasattr(plugin, 'scroll_helper') logger.info("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper) - content = self._get_scroll_helper_content(plugin, plugin_id) + content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only) if content: total_width = sum(img.width for img in content) logger.info( "[%s] ScrollHelper content SUCCESS: %d images, %dpx total", plugin_id, len(content), total_width ) - self._cache_content(plugin_id, content) - return content + return self._finalize(content, plugin_id, 'scroll_helper', plugin) if has_scroll_helper: logger.info("[%s] ScrollHelper content returned None", plugin_id) + if offscreen_only: + # Display capture needs the shared canvas; leave it to the caller. + logger.info( + "[%s] Needs display capture, deferring to the render thread", + plugin_id + ) + return None + # Fall back to display capture logger.info("[%s] Trying fallback display capture...", plugin_id) content = self._capture_display_content(plugin, plugin_id) @@ -121,8 +154,7 @@ def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Ima "[%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', plugin) logger.warning( "[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)", @@ -130,8 +162,397 @@ def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Ima ) return None + def _finalize( + self, images: List[Image.Image], plugin_id: str, source: str, + plugin: Optional['BasePlugin'] = None + ) -> 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: + # Trimming is off, but the width budget is a separate concern — + # turning off margin cropping should not let one plugin hold the + # panel for minutes. Skipping it here previously let a 14,848px + # segment through untouched. + kept = self._apply_width_budget(list(images), plugin_id, plugin) + self._cache_content(plugin_id, kept) + return kept + + 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, plugin) + + self._cache_content(plugin_id, kept) + return kept + + def _capture(self): + """ + Context manager suppressing hardware writes while plugin render code runs. + + Degrades to a no-op when the display manager predates capture_mode. As + with _render_at, losing the suppression risks a visible flash, whereas + raising would be swallowed by the broad handlers upstream and drop the + plugin's content entirely — much worse. + """ + capture_mode = getattr(self.display_manager, 'capture_mode', None) + if capture_mode is None: + logger.debug( + "display_manager has no capture_mode(); plugin writes during " + "content capture may reach the panel" + ) + return nullcontext() + return capture_mode() + + def _render_at(self, width: int): + """ + Context manager narrowing the plugin-facing canvas to ``width``. + + Degrades to a no-op when the display manager predates render_size (a + third-party or older test harness). Losing the narrowing is a cosmetic + regression; raising here would be caught by the broad handlers upstream + and silently drop the plugin's content entirely. + """ + render_size = getattr(self.display_manager, 'render_size', None) + if render_size is None: + logger.debug( + "display_manager has no render_size(); Vegas width requests " + "will be ignored" + ) + return nullcontext() + return render_size(width) + + def resolve_render_width(self, plugin: 'BasePlugin', plugin_id: str) -> int: + """ + Width to tell a plugin it has while it renders for the ticker. + + Resolution order, most specific first: + 1. the plugin's own ``vegas_width_pct`` config value + 2. the global ``vegas_scroll.render_width_pct`` + 3. the full panel width + + A percentage rather than an absolute width so one setting travels + across panel sizes. + + Args: + plugin: Plugin instance, consulted for a per-plugin override + plugin_id: Plugin identifier for logging + + Returns: + Target width in pixels, never wider than the panel + """ + pct = self.config.render_width_pct + + plugin_cfg = getattr(plugin, 'config', None) + if isinstance(plugin_cfg, dict): + raw = plugin_cfg.get('vegas_width_pct') + if raw not in (None, ''): + try: + candidate = int(raw) + except (TypeError, ValueError): + logger.warning( + "[%s] Invalid vegas_width_pct %r, ignoring", plugin_id, raw) + else: + if 10 <= candidate <= 100: + pct = candidate + else: + logger.warning( + "[%s] vegas_width_pct %d out of range 10-100, ignoring", + plugin_id, candidate) + + if pct >= 100: + return self.display_width + return max(1, int(self.display_width * pct / 100)) + + def _row_gap(self, left: Image.Image, right: Image.Image) -> int: + """ + Gap the compositor will insert between two of a plugin's rows. + + Mirrors RenderPipeline._join_plugin_rows so the width budget measures + what will actually be rendered. + """ + return separation_gap( + left, right, + target=max(0, self.config.min_content_separation), + minimum=max(0, self.config.intra_plugin_gap), + threshold=self.config.trim_threshold, + ) + + def _plugin_setting(self, plugin: 'BasePlugin', key: str): + """Read a per-plugin config override, or None if absent.""" + plugin_cfg = getattr(plugin, 'config', None) + if not isinstance(plugin_cfg, dict): + return None + value = plugin_cfg.get(key) + return None if value in (None, '') else value + + def resolve_overflow_mode(self, plugin: 'BasePlugin', plugin_id: str) -> str: + """ + How to handle content that exceeds this plugin's width budget. + + 'rotate' advances a window each cycle so everything is seen eventually, + which suits interchangeable items. 'truncate' always shows the start, + which suits ordered content — a league table that shows ranks 1-6 and + then resumes at 7 two rotations later reads as out of order, and nobody + needs rank 23 in a ticker anyway. + + Per-plugin ``vegas_overflow`` wins over the global ``overflow_mode``. + """ + raw = self._plugin_setting(plugin, 'vegas_overflow') + if raw is not None: + candidate = str(raw).strip().lower() + if candidate in ('rotate', 'truncate'): + return candidate + logger.warning( + "[%s] Invalid vegas_overflow %r, expected 'rotate' or 'truncate'", + plugin_id, raw + ) + return self.config.overflow_mode + + def _width_budget(self, plugin: Optional['BasePlugin'] = None, + plugin_id: str = '') -> int: + """ + Maximum columns one plugin may occupy in a cycle. 0 means unlimited. + + A per-plugin ``vegas_max_width_screens`` overrides the global ratio, so + content that has to stay whole can be given room (or uncapped with 0) + without lifting the cap on every ticker. + """ + ratio = self.config.max_plugin_width_ratio + + if plugin is not None: + raw = self._plugin_setting(plugin, 'vegas_max_width_screens') + if raw is not None: + try: + candidate = float(raw) + except (TypeError, ValueError): + logger.warning( + "[%s] Invalid vegas_max_width_screens %r, ignoring", + plugin_id, raw + ) + else: + if candidate >= 0: + ratio = candidate + else: + logger.warning( + "[%s] vegas_max_width_screens must be >= 0, got %s", + plugin_id, candidate + ) + + if ratio <= 0: + return 0 + return int(self.display_width * ratio) + + def _apply_width_budget( + self, images: List[Image.Image], plugin_id: str, + plugin: Optional['BasePlugin'] = None + ) -> 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(plugin, plugin_id) + mode = (self.resolve_overflow_mode(plugin, plugin_id) + if plugin is not None else self.config.overflow_mode) + + # Count the gaps the compositor will actually insert, not just the + # pixels of the rows — otherwise a plugin with many rows quietly + # occupies far more of the panel than its budget allows. These must use + # the same measured rule as RenderPipeline._join_plugin_rows; assuming + # the flat intra_plugin_gap here under-counted by up to + # (min_content_separation - intra_plugin_gap) per row. + total = sum(img.width for img in images) + sum( + self._row_gap(images[i], images[i + 1]) for i in range(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, mode)] + + if mode == 'truncate': + # Ordered content: always show from the top. Deliberately does not + # advance the offset, so the same opening items appear every time + # rather than the viewer being shown the middle of a ranked list. + start = 0 + else: + 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 + if selected: + cost += self._row_gap(selected[-1], img) + if selected and used + cost > budget: + break + selected.append(img) + used += cost + consumed += 1 + + if mode == 'truncate': + logger.info( + "[%s] Width budget %dpx: showing the first %d of %d row(s) " + "(%dpx incl. gaps); the rest are not shown (overflow=truncate)", + plugin_id, budget, len(selected), len(images), used + ) + else: + 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, + mode: str = 'rotate' + ) -> 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. + """ + if mode == 'truncate': + # Always the start of the strip, so a ranked table is never entered + # from the middle. + offset = 0 + else: + offset = self._item_offsets.get(plugin_id, 0) + if offset >= img.width: + offset = 0 + + # Cut only where the plugin left a real gap between items. Snapping to + # any blank column used to pick the single-column gaps between + # characters, splitting a word and orphaning its tail into the next + # cycle — a lone "y" from "Wednesday" floating between two unrelated + # plugins. Overshooting the budget is the lesser evil. + min_run = max(2, self.config.min_cut_gap) + gaps = blank_runs(img, min_run, self.config.trim_threshold) + + if not gaps: + # No internal gaps means continuous content — a map, a chart, a + # photo — where any column is as good as any other, so cut to the + # budget exactly. The gap rule exists to protect discrete items + # (words, ticker entries); it would be wrong to let a solid image + # escape the cap in its name. + end = min(offset + budget, img.width) + if mode != 'truncate': + self._item_offsets[plugin_id] = 0 if end >= img.width else end + logger.info( + "[%s] Width budget %dpx: cropped continuous %dpx image to " + "[%d:%d] (no item gaps of %dpx+ to align to)%s", + plugin_id, budget, img.width, offset, end, min_run, + "" if mode != 'truncate' else "; showing the start only" + ) + return img.crop((offset, 0, end, img.height)) + + # Cut mid-gap so the content either side keeps some breathing room. + cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps}) + + start = max((c for c in cuts if c <= offset), default=0) + later = [c for c in cuts if c > start] + if not later: + end = img.width + else: + within = [c for c in later if c <= start + budget] + # No boundary inside the budget: take the next one and overrun, + # because the alternative is cutting through an item. + end = max(within) if within else min(later) + + if mode != 'truncate': + # 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) at item boundaries, %s", + plugin_id, budget, img.width, start, end, end - start, + "showing the start only (overflow=truncate)" + if mode == 'truncate' else "window advances next cycle" + ) + return img.crop((start, 0, end, img.height)) + def _get_native_content( - self, plugin: 'BasePlugin', plugin_id: str + self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False ) -> Optional[List[Image.Image]]: """ Get content via plugin's native get_vegas_content() method. @@ -145,7 +566,40 @@ def _get_native_content( """ try: logger.info("[%s] Native: calling get_vegas_content()", plugin_id) - result = plugin.get_vegas_content() + + # Tell the plugin how much width the ticker wants it to use, and + # narrow the canvas for the duration of the call. A plugin that + # sizes its own images from display_manager.matrix.width picks up + # the narrower value with no changes of its own; one that wants to + # be explicit can read get_vegas_render_width(). + render_width = self.resolve_render_width(plugin, plugin_id) + if render_width != self.display_width: + logger.info( + "[%s] Native: requesting %dpx instead of %dpx", + plugin_id, render_width, self.display_width + ) + + plugin._vegas_render_width = render_width + try: + # capture_mode unconditionally, even at full width. Building + # Vegas content is an off-screen operation, but a plugin is free + # to call update_display() while doing it — and outside + # capture_mode that write lands on the hardware, flashing the + # panel mid-scroll. The narrowing context is separate because it + # is a no-op at full width. + if offscreen_only: + # _render_at swaps the shared canvas, so it is unsafe here. + # _vegas_render_width is set regardless: a plugin reading + # get_vegas_render_width() still gets its narrow size, and + # one that only reads matrix.width renders full width and is + # trimmed instead. + with self._capture(): + result = plugin.get_vegas_content() + else: + with self._capture(), self._render_at(render_width): + result = plugin.get_vegas_content() + finally: + plugin._vegas_render_width = None if result is None: logger.info("[%s] Native: get_vegas_content() returned None", plugin_id) @@ -223,7 +677,7 @@ def _get_native_content( return None def _get_scroll_helper_content( - self, plugin: 'BasePlugin', plugin_id: str + self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False ) -> Optional[List[Image.Image]]: """ Get content from plugin's scroll_helper if available. @@ -257,6 +711,13 @@ def _get_scroll_helper_content( "[%s] scroll_helper.cached_image is None, triggering content generation", plugin_id ) + if offscreen_only: + # Generating it calls display(), which needs the canvas. + logger.info( + "[%s] scroll_helper cache empty; deferring generation " + "to the render thread", plugin_id + ) + return None # Try to trigger scroll content generation cached_image = self._trigger_scroll_content_generation( plugin, plugin_id, scroll_helper @@ -405,7 +866,7 @@ def _trigger_scroll_content_generation( # Save display state to restore after original_image = self.display_manager.image.copy() - with self.display_manager.capture_mode(): + with self._capture(): # Method 1: Try _create_scrolling_display (stocks pattern) if hasattr(plugin, '_create_scrolling_display'): logger.info( @@ -497,7 +958,18 @@ def _capture_display_content( # Clear and call plugin display — use capture_mode to suppress hardware writes # that plugins may trigger internally via update_display(). - with self.display_manager.capture_mode(): + # + # render_size narrows the canvas the plugin lays out against, so a + # plugin that spreads across the whole panel produces a compact + # arrangement rather than one that has to be cropped afterwards. + render_width = self.resolve_render_width(plugin, plugin_id) + if render_width != self.display_width: + logger.info( + "[%s] Fallback: rendering at %dpx instead of %dpx", + plugin_id, render_width, self.display_width + ) + + with self._capture(), self._render_at(render_width): self.display_manager.clear() logger.info("[%s] Fallback: display cleared, calling display()", plugin_id) @@ -531,7 +1003,7 @@ def _capture_display_content( plugin_id ) # Try once more with force_clear=True - with self.display_manager.capture_mode(): + with self._capture(), self._render_at(render_width): self.display_manager.clear() plugin.display(force_clear=True) captured = self.display_manager.image.copy() diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index 7c9b7c90..94b1e086 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -6,6 +6,7 @@ """ import logging +import os import time import threading from collections import deque @@ -14,6 +15,7 @@ from src.common.scroll_helper import ScrollHelper from src.vegas_mode.config import VegasModeConfig +from src.vegas_mode.geometry import separation_gap from src.vegas_mode.stream_manager import StreamManager if TYPE_CHECKING: @@ -34,6 +36,10 @@ class RenderPipeline: - Track scroll cycle completion """ + # Minimum gap between fetches of canvas-bound plugins, so their individual + # stalls land in separate moments rather than one run of hitches. + DEFERRED_DRAIN_INTERVAL = 2.0 + def __init__( self, config: VegasModeConfig, @@ -66,10 +72,6 @@ def __init__( 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, @@ -85,6 +87,14 @@ def __init__( self._staging_scroll_image: Optional[Image.Image] = None self._buffer_lock = threading.Lock() + # Group prepared off the render thread, waiting to be appended. + self._prepared_group = None + # Plugins that need the shared canvas, appended one at a time. + self._deferred_queue: List[str] = [] + self._last_drain_time = 0.0 + self._prefetch_thread: Optional[threading.Thread] = None + self._prefetch_lock = threading.Lock() + # Render state self._is_rendering = False self._cycle_complete = False @@ -114,6 +124,7 @@ def _configure_scroll_helper(self) -> None: """Configure ScrollHelper with current settings.""" self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling) self.scroll_helper.set_scroll_delay(self.config.scroll_delay) + self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll) # Config scroll_speed is always pixels per second, but ScrollHelper # interprets it differently based on frame_based_scrolling mode: @@ -141,23 +152,37 @@ def compose_scroll_content(self) -> bool: 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) - - # Create scrolling image via ScrollHelper + # 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. + # + # 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 +202,16 @@ def compose_scroll_content(self) -> bool: 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, rows spaced to %dpx of ink " + "(min added %dpx)", 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.min_content_separation, + self.config.intra_plugin_gap, ) return True @@ -191,6 +221,264 @@ def compose_scroll_content(self) -> bool: logger.exception("Error composing scroll content") return False + def needs_extension(self) -> bool: + """ + Whether the strip should be extended with the next group of plugins. + + Cheap enough to call every frame: it is arithmetic over cached state. + """ + if not self.config.continuous_scroll or not self.scroll_helper.cached_image: + return False + threshold = int(self.display_width * self.config.extend_threshold_screens) + return self.scroll_helper.remaining_unscrolled() <= threshold + + def start_prefetch(self) -> None: + """ + Begin preparing the next group in the background, if not already doing so. + + This is what makes the join seamless rather than merely continuous: + fetching a group costs 0.5-4.8s (rendering leaderboard and baseball cards + dominates), and doing it on the render thread stalls the scroll for that + long. Off the render thread there is a whole group's scroll time to work + in, so by the time the strip needs extending the content is already sat + waiting. + + Only paths that avoid the shared display canvas run here; anything + needing it is marked and picked up on the render thread, where it is + safe. Those are the cheap ones — display capture measured 12-14ms + against seconds for the native renders. + """ + if not self.config.continuous_scroll: + return + + with self._prefetch_lock: + if self._prefetch_thread is not None and self._prefetch_thread.is_alive(): + return + if self._prepared_group is not None: + return # already have one waiting + + def _work(): + # Deprioritise against the render loop. Linux applies nice + # per-thread, and the heavy lifting here is PIL and numpy work + # that releases the GIL, so the scheduler can actually act on + # it — without this the prefetch competes for the same cores and + # costs frames. + try: + os.nice(10) + except (OSError, AttributeError): + pass + try: + group = self.stream_manager.take_next_group(offscreen_only=True) + except Exception: + logger.exception("Background prefetch failed") + group = [] + with self._prefetch_lock: + self._prepared_group = group + + self._prefetch_thread = threading.Thread( + target=_work, daemon=True, name="vegas-strip-prefetch") + self._prefetch_thread.start() + + def drain_deferred(self) -> bool: + """ + Fetch one queued canvas-bound plugin and append it to the strip. + + Called once per frame. These plugins cannot be prepared off the render + thread — display capture and scroll-content generation both need the + shared canvas — so each costs roughly 290ms here. Doing one at a time + spreads that out instead of stalling for the whole group at once, and the + strip's lookahead means nothing runs dry while they arrive. + + The cost is that a deferred plugin appears slightly after the group it + came with, which is a fair trade for a smooth scroll. + + Returns: + True if a plugin was appended + """ + if not self._deferred_queue: + return False + + # Space the drains out. Each costs 40-600ms, and taking them back to + # back turns one long stall into a train of short ones — barely better. + # With a healthy lookahead there is no hurry, so wait a beat between + # them; when the strip is actually running short, fetch immediately. + threshold = int(self.display_width * self.config.extend_threshold_screens) + urgent = self.scroll_helper.remaining_unscrolled() <= threshold + if not urgent: + now = time.time() + if now - self._last_drain_time < self.DEFERRED_DRAIN_INTERVAL: + return False + self._last_drain_time = now + else: + self._last_drain_time = time.time() + + plugin_id = self._deferred_queue.pop(0) + plugins = getattr(self.stream_manager.plugin_manager, 'plugins', {}) + plugin = plugins.get(plugin_id) + if plugin is None: + return False + + try: + images = self.stream_manager.plugin_adapter.get_content(plugin, plugin_id) + except Exception: + logger.exception("[%s] Error fetching deferred content", plugin_id) + return False + + if not images: + return False + + appended = self.scroll_helper.append_content( + content_items=[self._join_plugin_rows(images)], + item_gap=self.config.separator_width, + element_gap=0, + ) + if appended: + with self._buffer_lock: + self._active_scroll_image = self.scroll_helper.cached_image + logger.info( + "[%s] Appended deferred content: strip now %dpx, %dpx ahead", + plugin_id, self.scroll_helper.total_scroll_width, + self.scroll_helper.remaining_unscrolled() + ) + return appended + + def has_deferred(self) -> bool: + """Whether any canvas-bound plugins are still queued.""" + return bool(self._deferred_queue) + + def _claim_prepared_group(self): + """Take the prefetched group, if one is ready.""" + with self._prefetch_lock: + group = self._prepared_group + self._prepared_group = None + return group + + def extend_scroll_content(self) -> bool: + """ + Append the next group of plugins to the strip, without interrupting motion. + + This is what replaces the swap. Scroll position is untouched, so the new + content simply arrives from the right; there is no substitution to see + and no restart with the viewport already full. + + Consumed columns behind the viewport are then released, keeping the strip + bounded however long Vegas runs. + + Returns: + True if the strip was extended + """ + try: + grouped = self._claim_prepared_group() + if grouped is None: + # Nothing prepared (first extension, or prefetch still running). + # Fetch inline; the scroll hitches, but content keeps flowing. + logger.info("No prepared group ready; fetching inline") + grouped = self.stream_manager.take_next_group() + + if not grouped: + logger.warning("No content available to extend the scroll strip") + return False + + # Plugins the background thread had to defer need the shared canvas, + # so they can only be fetched here. Queue them rather than doing all + # of them now: measured, six in one go held the render thread for + # 1.75s. They are trickled in one per frame by drain_deferred(), + # which the strip's lookahead comfortably absorbs. + deferred = [pid for pid, images in grouped if images is None] + if deferred: + self._deferred_queue.extend(deferred) + logger.info( + "Queued %d plugin(s) needing the render thread: %s", + len(deferred), ', '.join(deferred) + ) + + grouped = [(pid, imgs) for pid, imgs in grouped if imgs] + + if not grouped: + # Everything in this group is queued; the queue will extend the + # strip as it drains, so this is not a failure. + logger.info("Whole group deferred; strip will extend as it drains") + self.start_prefetch() + return bool(deferred) + + blocks = [] + total_rows = 0 + for _plugin_id, images in grouped: + total_rows += len(images) + blocks.append(self._join_plugin_rows(images)) + + appended = self.scroll_helper.append_content( + content_items=blocks, + item_gap=self.config.separator_width, + element_gap=0, + ) + if not appended: + return False + + # Keep a screen's worth behind the viewport as a safety margin. + self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width) + + with self._buffer_lock: + self._active_scroll_image = self.scroll_helper.cached_image + + self._segments_in_scroll = [pid for pid, _ in grouped] + self.stats['composition_count'] += 1 + self.stats['extensions'] = self.stats.get('extensions', 0) + 1 + + logger.info( + "Extended scroll strip with %d plugin block(s), %d rows: " + "strip now %dpx, %dpx still ahead of the viewport", + len(blocks), total_rows, self.scroll_helper.total_scroll_width, + self.scroll_helper.remaining_unscrolled() + ) + + # Line up the group after this one straight away, so it is ready + # well before the strip runs short again. + self.start_prefetch() + return True + + except (ValueError, TypeError, OSError, RuntimeError): + logger.exception("Error extending 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] + + floor = max(0, self.config.intra_plugin_gap) + target = max(0, self.config.min_content_separation) + threshold = self.config.trim_threshold + + # Space by measured separation, not a flat gap. Rows drawn flush to + # their own edges (sports score cards) would otherwise end up nearly + # touching, while rows that already carry wide margins would be pushed + # needlessly further apart. + gaps = [ + separation_gap(images[i], images[i + 1], target, floor, threshold) + for i in range(len(images) - 1) + ] + + width = sum(img.width for img in images) + sum(gaps) + height = max(img.height for img in images) + + block = Image.new('RGB', (width, height), (0, 0, 0)) + x = 0 + for i, img in enumerate(images): + block.paste(img, (x, 0)) + x += img.width + (gaps[i] if i < len(gaps) else 0) + return block + def render_frame(self) -> bool: """ Render a single frame to the display. @@ -211,21 +499,33 @@ def render_frame(self) -> bool: # Determine if the cycle is done. # - # scroll_helper considers a cycle complete only after - # total_distance_scrolled >= total_scroll_width + display_width. - # That extra display_width of travel causes a "wrap-around" phase - # where scroll_position resets to ~0 and the first plugin's content - # re-enters from the right — the user sees this 2-3 s of re-entry - # as "a plugin partially displaying before the next one starts." + # get_visible_portion wraps: once scroll_position + display_width + # passes the end of the strip it fills the right-hand side of the + # frame from the *head* of the same strip. So the last + # display_width of travel shows the cycle's first plugin re-entering + # on the right while its last plugin exits on the left, and the + # recompose that follows then replaces both at once. That reads as + # the ticker "switching mid-scroll". # - # We end the cycle as soon as total_distance_scrolled reaches - # total_scroll_width (the wrap-around point), before any second-pass - # content becomes visible. The scroll_helper's own is_scroll_complete() - # check is kept as a fallback for any edge-cases where that threshold - # is never hit. + # This used to be hidden because the strip began with a full + # display_width of blank, so the wrapped-in region was black. + # lead_in_width now defaults to 0 (that blank was 10s of dead panel + # at 50px/s), which exposed the wrap — so the cycle has to end + # before it, one display width earlier. + # + # A strip no wider than the display never wraps, and subtracting + # would make the cycle complete instantly, so clamp in that case. + # In continuous mode there is no cycle to complete: the strip is + # extended before the scroll can reach its end, so the wrap is never + # entered and motion never stops. The completion path below stays for + # the swap behaviour and as a backstop if an extension fails. + wrap_point = self.scroll_helper.total_scroll_width + if wrap_point > self.display_width: + wrap_point -= self.display_width + at_wrap_point = ( not self._cycle_complete and - self.scroll_helper.total_distance_scrolled >= self.scroll_helper.total_scroll_width + self.scroll_helper.total_distance_scrolled >= wrap_point ) if at_wrap_point or self.scroll_helper.is_scroll_complete(): @@ -236,24 +536,17 @@ def render_frame(self) -> bool: "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 +708,12 @@ def start_new_cycle(self) -> bool: 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..7f43b24f 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -14,7 +14,7 @@ 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 @@ def initialize(self) -> bool: 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 @@ def _prefetch_content(self, count: int = 1) -> None: 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,117 @@ def _refresh_plugin_content(self, plugin_id: str) -> None: 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 take_next_group( + self, count: Optional[int] = None, offscreen_only: bool = False + ) -> List[Tuple[str, Optional[List[Image.Image]]]]: + """ + Fetch and hand over the next slice of the rotation. + + For continuous scrolling, where the strip is extended rather than + replaced. Advances the rotation index so plugins come round in order + across an unbroken strip, and bypasses the active buffer entirely — that + buffer exists to stage a *replacement* cycle, which continuous mode has + no use for. + + Args: + count: Number of plugins to gather, defaulting to plugins_per_cycle + offscreen_only: Only use content paths that avoid the shared display + canvas, for use off the render thread + + Returns: + Ordered list of (plugin_id, images). ``images`` is None when the + plugin could not be served under ``offscreen_only``, so the caller + can fetch just those on the render thread while keeping the order. + """ + if count is None: + count = self.config.plugins_per_cycle + + self.refresh() + + with self._buffer_lock: + if not self._ordered_plugins: + return [] + total = len(self._ordered_plugins) + ids = [] + for _ in range(min(max(1, count), total)): + ids.append(self._ordered_plugins[self._prefetch_index]) + self._prefetch_index = (self._prefetch_index + 1) % total + + plugins = getattr(self.plugin_manager, 'plugins', {}) + group: List[Tuple[str, Optional[List[Image.Image]]]] = [] + + for plugin_id in ids: + plugin = plugins.get(plugin_id) + if not plugin: + continue + try: + images = self.plugin_adapter.get_content( + plugin, plugin_id, offscreen_only=offscreen_only) + except Exception: + logger.exception("[%s] ERROR fetching content", plugin_id) + self.stats['fetch_errors'] += 1 + continue + if images: + self.stats['segments_fetched'] += 1 + group.append((plugin_id, images if images else None)) + + return group def advance_cycle(self) -> None: """ diff --git a/test/test_scroll_helper_continuous.py b/test/test_scroll_helper_continuous.py new file mode 100644 index 00000000..346450d2 --- /dev/null +++ b/test/test_scroll_helper_continuous.py @@ -0,0 +1,336 @@ +""" +Tests for ScrollHelper's continuous-strip primitives. + +append_content extends the strip to the right without disturbing motion, and +drop_scrolled_prefix reclaims what has already gone past. Together they let a +caller keep one endless strip instead of swapping a new one in, which is what +shows as a flash and a hard cut to already-full-screen content. +""" + +import numpy as np +import pytest +from PIL import Image + +from src.common.scroll_helper import ScrollHelper +from src.vegas_mode.geometry import column_has_ink + +W, H = 128, 32 + + +def helper(): + return ScrollHelper(W, H) + + +def block(width, colour=(255, 255, 255), height=H): + return Image.new('RGB', (width, height), colour) + + +class TestAppendContent: + def test_first_append_builds_the_strip(self): + sh = helper() + assert sh.append_content([block(100)], item_gap=0) + assert sh.cached_image is not None + assert sh.total_scroll_width == sh.cached_image.width + + def test_strip_grows_by_content_plus_gaps(self): + sh = helper() + sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0) + assert sh.cached_image.width == 100 + + sh.append_content([block(50)], item_gap=10, element_gap=0) + # one leading gap of 10 then the 50px block + assert sh.cached_image.width == 160 + assert sh.total_scroll_width == 160 + + def test_scroll_position_is_preserved(self): + sh = helper() + sh.create_scrolling_image([block(400)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 137.0 + sh.total_distance_scrolled = 137.0 + + sh.append_content([block(200)], item_gap=16) + assert sh.scroll_position == 137.0 + assert sh.total_distance_scrolled == 137.0 + + def test_appending_defers_completion(self): + sh = helper() + sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_complete = True + + sh.append_content([block(200)], item_gap=0) + assert not sh.scroll_complete + assert sh.total_distance_scrolled < sh.total_scroll_width + + def test_existing_pixels_are_untouched(self): + sh = helper() + original = block(80, (10, 200, 10)) + sh.create_scrolling_image([original], item_gap=0, element_gap=0, lead_gap=0) + before = sh.cached_image.crop((0, 0, 80, H)).tobytes() + + sh.append_content([block(40, (200, 10, 10))], item_gap=8) + assert sh.cached_image.crop((0, 0, 80, H)).tobytes() == before + + def test_appended_content_sits_after_the_gap(self): + sh = helper() + sh.create_scrolling_image([block(50)], item_gap=0, element_gap=0, lead_gap=0) + sh.append_content([block(30)], item_gap=12) + + ink = column_has_ink(sh.cached_image) + assert ink[:50].all() + assert not ink[50:62].any() # the 12px gap + assert ink[62:92].all() + + def test_array_and_image_stay_consistent(self): + # get_visible_portion slices cached_array but bounds-checks against + # cached_image.width, so a mismatch corrupts frames. + sh = helper() + sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0) + sh.append_content([block(100)], item_gap=8) + assert sh.cached_array.shape[1] == sh.cached_image.width + assert sh.cached_array.shape[0] == sh.cached_image.height + + def test_visible_portion_still_renders_after_append(self): + sh = helper() + sh.create_scrolling_image([block(300)], item_gap=0, element_gap=0, lead_gap=0) + sh.append_content([block(300)], item_gap=8) + sh.scroll_position = 250.0 + frame = sh.get_visible_portion() + assert frame is not None and frame.size == (W, H) + + def test_empty_append_is_a_no_op(self): + sh = helper() + sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0) + assert sh.append_content([]) is False + assert sh.cached_image.width == 100 + + def test_repeated_appends_accumulate(self): + sh = helper() + sh.append_content([block(100)], item_gap=0) + for _ in range(5): + sh.append_content([block(100)], item_gap=0) + assert sh.cached_image.width == 600 + + +class TestDropScrolledPrefix: + def test_removes_consumed_columns(self): + sh = helper() + sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 500.0 + sh.total_distance_scrolled = 500.0 + + removed = sh.drop_scrolled_prefix(keep_before=0) + assert removed == 500 + assert sh.cached_image.width == 500 + assert sh.scroll_position == 0.0 + + def test_keeps_the_requested_margin(self): + sh = helper() + sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 500.0 + sh.drop_scrolled_prefix(keep_before=100) + assert sh.scroll_position == 100.0 + assert sh.cached_image.width == 600 + + def test_completion_difference_is_preserved(self): + # total_distance_scrolled and total_scroll_width must shift together, or + # trimming would spuriously complete or un-complete the cycle. + sh = helper() + sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 600.0 + sh.total_distance_scrolled = 600.0 + before = sh.total_scroll_width - sh.total_distance_scrolled + + sh.drop_scrolled_prefix(keep_before=0) + assert sh.total_scroll_width - sh.total_distance_scrolled == before + + def test_never_trims_below_the_viewport(self): + sh = helper() + sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 190.0 + sh.drop_scrolled_prefix(keep_before=0) + assert sh.cached_image.width >= W + + def test_no_op_before_anything_has_scrolled(self): + sh = helper() + sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0) + assert sh.drop_scrolled_prefix(keep_before=0) == 0 + assert sh.cached_image.width == 500 + + def test_no_op_with_no_strip(self): + assert helper().drop_scrolled_prefix() == 0 + + def test_visible_frame_is_unchanged_by_trimming(self): + # The whole point: trimming is invisible. Same pixels on screen before + # and after. Position chosen so the viewport is well clear of the end, + # i.e. not wrapping. + sh = helper() + items = [block(200, (255, 0, 0)), block(200, (0, 255, 0)), + block(200, (0, 0, 255))] + sh.create_scrolling_image(items, item_gap=20, element_gap=0, lead_gap=0) + sh.scroll_position = 300.0 + before = sh.get_visible_portion().tobytes() + + assert sh.drop_scrolled_prefix(keep_before=0) > 0, "trim should have run" + after = sh.get_visible_portion().tobytes() + assert after == before + + def test_refuses_to_trim_while_the_viewport_wraps(self): + # Wrapping reads the head of the strip into the right of the frame, so + # trimming the head there would visibly change the picture. + sh = helper() + sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 150.0 # 150 + 128 > 200, so wrapping + before = sh.get_visible_portion().tobytes() + assert sh.drop_scrolled_prefix(keep_before=0) == 0 + assert sh.get_visible_portion().tobytes() == before + + def test_array_and_image_stay_consistent_after_trim(self): + sh = helper() + sh.create_scrolling_image([block(900)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 400.0 + sh.drop_scrolled_prefix(keep_before=0) + assert sh.cached_array.shape[1] == sh.cached_image.width + + +class TestRemainingUnscrolled: + def test_counts_content_right_of_the_viewport(self): + sh = helper() + sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0) + assert sh.remaining_unscrolled() == 500 - W + + def test_shrinks_as_the_strip_scrolls(self): + sh = helper() + sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 200.0 + assert sh.remaining_unscrolled() == 500 - 200 - W + + def test_never_negative(self): + sh = helper() + sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 500.0 + assert sh.remaining_unscrolled() == 0 + + def test_zero_with_no_strip(self): + assert helper().remaining_unscrolled() == 0 + + def test_grows_when_content_is_appended(self): + sh = helper() + sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 100.0 + before = sh.remaining_unscrolled() + assert before > 0, "fixture should leave content ahead of the viewport" + sh.append_content([block(400)], item_gap=0) + assert sh.remaining_unscrolled() == before + 400 + + +class TestContinuousScrollingEndToEnd: + def test_strip_can_be_extended_indefinitely_at_bounded_size(self): + """The invariant that makes this viable: extend + trim keeps the strip + bounded while motion never stops.""" + sh = helper() + sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0) + + widths = [] + for _ in range(20): + sh.scroll_position += 200 + sh.total_distance_scrolled += 200 + if sh.remaining_unscrolled() < 2 * W: + sh.append_content([block(600)], item_gap=16) + sh.drop_scrolled_prefix(keep_before=W) + widths.append(sh.cached_image.width) + # A frame must always be renderable. + assert sh.get_visible_portion() is not None + + assert max(widths) < 3000, f"strip grew unbounded: max {max(widths)}" + assert not sh.scroll_complete, "continuous strip should never complete" + + +class TestSubPixelBlending: + """ + Integer positioning quantises motion to whole pixels, so distinct frames per + second equals scroll speed regardless of frame rate — at 50px/s and 78fps, + 36% of frames were identical. Blending between neighbouring positions gives + motion at the frame rate instead. + """ + + def _strip(self, width=2000): + rng = np.random.default_rng(0) + arr = (rng.random((H, width, 3)) * 255).astype(np.uint8) + sh = helper() + sh.create_scrolling_image([Image.fromarray(arr)], + item_gap=0, element_gap=0, lead_gap=0) + return sh + + def _frame(self, sh, pos, subpixel): + sh.sub_pixel_scrolling = subpixel + sh.scroll_position = pos + return np.asarray(sh.get_visible_portion()).astype(int) + + def test_integer_mode_ignores_the_fraction(self): + sh = self._strip() + a = self._frame(sh, 500.0, False) + b = self._frame(sh, 500.9, False) + assert np.array_equal(a, b), "integer positioning should not move sub-pixel" + + def test_blending_moves_within_a_pixel(self): + sh = self._strip() + a = self._frame(sh, 500.0, True) + b = self._frame(sh, 500.5, True) + assert not np.array_equal(a, b) + + def test_zero_fraction_matches_the_integer_frame(self): + # No interpolation to do, so it must be pixel-identical and take the + # cheap path. + sh = self._strip() + assert np.array_equal(self._frame(sh, 700.0, True), + self._frame(sh, 700.0, False)) + + def test_blend_is_monotonic_between_neighbours(self): + # Marching the fraction from 0 to 1 should approach the next integer + # frame, not wander. + sh = self._strip() + target = self._frame(sh, 501.0, False) + dists = [] + for frac in (0.0, 0.25, 0.5, 0.75): + f = self._frame(sh, 500.0 + frac, True) + dists.append(np.abs(f - target).mean()) + assert dists == sorted(dists, reverse=True), f"not converging: {dists}" + + def test_blend_endpoints_bracket_the_two_frames(self): + sh = self._strip() + near = self._frame(sh, 500.0, False) + far = self._frame(sh, 501.0, False) + mid = self._frame(sh, 500.5, True) + # Every blended pixel must lie between its two sources. + lo = np.minimum(near, far) + hi = np.maximum(near, far) + assert (mid >= lo - 1).all() and (mid <= hi + 1).all() + + def test_output_size_and_mode_are_unchanged(self): + sh = self._strip() + sh.sub_pixel_scrolling = True + sh.scroll_position = 300.4 + frame = sh.get_visible_portion() + assert frame.size == (W, H) + assert frame.mode == 'RGB' + + def test_works_near_the_end_of_the_strip(self): + # One of the two slices wraps here; must not raise or missize. + sh = self._strip(width=600) + sh.sub_pixel_scrolling = True + sh.scroll_position = float(600 - W // 2) + 0.5 + frame = sh.get_visible_portion() + assert frame is not None and frame.size == (W, H) + + def test_works_at_the_very_last_column(self): + sh = self._strip(width=600) + sh.sub_pixel_scrolling = True + sh.scroll_position = 599.5 + assert sh.get_visible_portion().size == (W, H) + + @pytest.mark.parametrize("frac", [0.01, 0.1, 0.33, 0.5, 0.67, 0.9, 0.99]) + def test_never_raises_across_the_fraction_range(self, frac): + sh = self._strip() + sh.sub_pixel_scrolling = True + sh.scroll_position = 400.0 + frac + assert sh.get_visible_portion().size == (W, H) diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py new file mode 100644 index 00000000..1e236e04 --- /dev/null +++ b/test/test_vegas_density.py @@ -0,0 +1,1639 @@ +""" +Tests for the Vegas mode density work: dead-space trimming in PluginAdapter +and the configurable lead-in gap in ScrollHelper. +""" + +from contextlib import contextmanager + +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: + """Stand-in offering the same contexts the real DisplayManager does. + + capture_mode and render_size must both be present: the adapter degrades + gracefully when they are missing, so a fake without them would silently + exercise the degraded path instead of the real one. + """ + + width = DISPLAY_W + height = DISPLAY_H + + def __init__(self): + self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H)) + self.draw = None + self._capture_mode_active = False + + @contextmanager + def capture_mode(self): + self._capture_mode_active = True + try: + yield + finally: + self._capture_mode_active = False + + @contextmanager + def render_size(self, width, height=None): + prev = self.image + target_w = max(1, min(int(width), DISPLAY_W)) + target_h = max(1, min(int(height) if height else DISPLAY_H, DISPLAY_H)) + try: + self.image = Image.new('RGB', (target_w, target_h)) + yield + finally: + self.image = prev + + def clear(self): + self.image = Image.new('RGB', self.image.size) + + def update_display(self): + pass + + +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. These rows + # are drawn flush to their edges, so the intra-plugin gap is the full + # min_content_separation. + 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, min_content_separation=24, + 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:124].any() # measured gap inside plugin a + assert ink[124:224].all() + assert not ink[224:256].any() # separator between a and b + assert ink[256:356].all() + assert not ink[356:380].any() # measured gap inside plugin b + assert ink[380:480].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, min_content_separation=24, + lead_in_width=0, + ) + pipeline.compose_scroll_content() + # 4 rows + 2 measured intra gaps (24 each) + 1 separator + assert pipeline.scroll_helper.cached_image.width == 400 + 48 + 32 + + def test_f1_shaped_case_stays_below_the_separator_width(self): + # 12 rows from one plugin. Previously each boundary got the full 32px + # separator (352px of gap); rows are now spaced by measured separation, + # which for flush rows is min_content_separation. + 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, min_content_separation=24, + lead_in_width=0, + ) + pipeline.compose_scroll_content() + width = pipeline.scroll_helper.cached_image.width + assert width == 12 * 128 + 11 * 24 + assert width < 12 * 128 + 11 * 32 # cheaper than the old flat separator + + 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_rows_butt_together_only_when_both_gap_settings_are_zero(self): + # intra_plugin_gap alone no longer decides this: min_content_separation + # would still push flush rows apart, which is the point of it. + 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, + min_content_separation=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 TestApiBoundsMatchValidate: + """ + The web API's accepted range for each Vegas setting must agree with + VegasModeConfig.validate(), which is what actually gates Vegas starting. + + A looser API bound saves a value with a 200 and then makes + VegasModeCoordinator.start() bail out with only a log line, so the ticker + silently never runs. A tighter one rejects a legitimate value with a 400. + Both happened before this test existed. + """ + + # (config key, min, max) as validate() enforces them. + EXPECTED = { + 'scroll_speed': (1, 200), + 'separator_width': (0, 128), + 'intra_plugin_gap': (0, 128), + 'target_fps': (30, 200), + 'buffer_ahead': (1, 5), + 'trim_threshold': (0, 254), + 'content_padding': (0, 128), + 'min_plugin_width': (0, 512), + 'plugins_per_cycle': (1, 50), + } + + def _api_numeric_fields(self): + """Extract the numeric_fields map from api_v3 without importing Flask.""" + import ast + import pathlib + src = pathlib.Path('web_interface/blueprints/api_v3.py').read_text() + tree = ast.parse(src) + for node in ast.walk(tree): + if not isinstance(node, ast.Assign): + continue + targets = [t.id for t in node.targets if isinstance(t, ast.Name)] + if 'numeric_fields' not in targets: + continue + if not isinstance(node.value, ast.Dict): + continue + found = {} + for key, value in zip(node.value.keys, node.value.values): + if not isinstance(key, ast.Constant): + continue + if not str(key.value).startswith('vegas_'): + break + cfg_key, lo, hi = [ast.literal_eval(e) for e in value.elts] + found[cfg_key] = (lo, hi) + if found: + return found + raise AssertionError("could not locate the vegas numeric_fields map") + + def test_every_bound_matches_validate(self): + api = self._api_numeric_fields() + mismatched = { + key: (api[key], expected) + for key, expected in self.EXPECTED.items() + if key in api and api[key] != expected + } + assert not mismatched, f"API bounds disagree with validate(): {mismatched}" + + @pytest.mark.parametrize('key,bounds', sorted(EXPECTED.items())) + def test_validate_accepts_both_endpoints(self, key, bounds): + lo, hi = bounds + for value in (lo, hi): + cfg = VegasModeConfig(**{key: value}) + errors = [e for e in cfg.validate() if key in e] + assert not errors, f"{key}={value} should be valid, got {errors}" + + @pytest.mark.parametrize('key,bounds', sorted(EXPECTED.items())) + def test_validate_rejects_just_outside(self, key, bounds): + lo, hi = bounds + for value in (lo - 1, hi + 1): + cfg = VegasModeConfig(**{key: value}) + errors = [e for e in cfg.validate() if key in e] + assert errors, f"{key}={value} should be rejected" + + +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() == [] + + +class TestRenderWidthResolution: + """Vegas asks plugins to render narrower so layouts compact, not crop.""" + + class CfgPlugin: + def __init__(self, cfg=None): + self.config = cfg or {} + + def get_vegas_content(self): + return None + + def test_defaults_to_full_width(self): + adapter = adapter_with() + assert adapter.resolve_render_width(self.CfgPlugin(), 'p') == DISPLAY_W + + def test_global_percentage_applies(self): + adapter = adapter_with(render_width_pct=50) + assert adapter.resolve_render_width(self.CfgPlugin(), 'p') == DISPLAY_W // 2 + + def test_per_plugin_override_beats_global(self): + adapter = adapter_with(render_width_pct=50) + plugin = self.CfgPlugin({'vegas_width_pct': 30}) + assert adapter.resolve_render_width(plugin, 'p') == int(DISPLAY_W * 0.3) + + def test_per_plugin_can_opt_back_to_full_width(self): + adapter = adapter_with(render_width_pct=30) + plugin = self.CfgPlugin({'vegas_width_pct': 100}) + assert adapter.resolve_render_width(plugin, 'p') == DISPLAY_W + + @pytest.mark.parametrize('bad', [0, 5, 150, -10, 'wide', None, '']) + def test_invalid_override_falls_back_to_global(self, bad): + adapter = adapter_with(render_width_pct=50) + plugin = self.CfgPlugin({'vegas_width_pct': bad}) + assert adapter.resolve_render_width(plugin, 'p') == DISPLAY_W // 2 + + def test_plugin_without_config_is_safe(self): + adapter = adapter_with(render_width_pct=50) + + class NoCfg: + pass + + assert adapter.resolve_render_width(NoCfg(), 'p') == DISPLAY_W // 2 + + def test_never_exceeds_panel_width(self): + adapter = adapter_with(render_width_pct=100) + assert adapter.resolve_render_width(self.CfgPlugin(), 'p') <= DISPLAY_W + + +class TestMeasuredSeparation: + """Rows are spaced by measured blank, not a flat additive gap.""" + + 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_flush_rows_are_pushed_to_the_target(self): + # The reported problem: score cards drawn edge to edge sat 8px apart. + rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(3)] + p = self._pipeline([('scores', rows)], + intra_plugin_gap=8, min_content_separation=24, + lead_in_width=0) + block = p._join_plugin_rows(rows) + assert block.width == 300 + 24 * 2 + ink = column_has_ink(block) + assert not ink[100:124].any() + assert ink[124:224].all() + + def test_rows_with_margins_are_not_pushed_further(self): + # Each row already carries 12px blank per side = 24px facing total, + # which meets the target, so only the floor is added. + rows = [canvas([(12, 88)], width=100) for _ in range(3)] + p = self._pipeline([('padded', rows)], + intra_plugin_gap=0, min_content_separation=24, + lead_in_width=0) + block = p._join_plugin_rows(rows) + assert block.width == 300 + + def test_floor_still_applies_when_target_is_met(self): + rows = [canvas([(12, 88)], width=100) for _ in range(2)] + p = self._pipeline([('padded', rows)], + intra_plugin_gap=6, min_content_separation=24, + lead_in_width=0) + assert p._join_plugin_rows(rows).width == 200 + 6 + + def test_gaps_are_per_pair_not_uniform(self): + # Flush row then a padded row: the two gaps must differ. + flush = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) + padded = canvas([(20, 80)], width=100) + p = self._pipeline([('mixed', [flush, padded, flush])], + intra_plugin_gap=0, min_content_separation=24, + lead_in_width=0) + block = p._join_plugin_rows([flush, padded, flush]) + # gap1: flush right(0) + padded left(20) = 20 -> add 4 + # gap2: padded right(20) + flush left(0) = 20 -> add 4 + assert block.width == 300 + 4 + 4 + + def test_zero_target_falls_back_to_the_floor(self): + rows = [Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)) for _ in range(2)] + p = self._pipeline([('a', rows)], + intra_plugin_gap=5, min_content_separation=0, + lead_in_width=0) + assert p._join_plugin_rows(rows).width == 100 + 5 + + +class TestNewConfigKeys: + def test_render_width_pct_parses(self): + cfg = VegasModeConfig.from_config( + {'display': {'vegas_scroll': {'render_width_pct': 40}}}) + assert cfg.render_width_pct == 40 + + def test_min_content_separation_parses(self): + cfg = VegasModeConfig.from_config( + {'display': {'vegas_scroll': {'min_content_separation': 16}}}) + assert cfg.min_content_separation == 16 + + def test_defaults(self): + cfg = VegasModeConfig() + assert cfg.render_width_pct == 100 + assert cfg.min_content_separation == 24 + + @pytest.mark.parametrize('overrides,bad_key', [ + ({'render_width_pct': 5}, 'render_width_pct'), + ({'render_width_pct': 101}, 'render_width_pct'), + ({'min_content_separation': -1}, 'min_content_separation'), + ({'min_content_separation': 300}, 'min_content_separation'), + ]) + 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 TestCycleEndsBeforeWrap: + """ + get_visible_portion wraps the head of the strip into the right side of the + frame once scroll_position + display_width passes the end. With a leading + blank that was invisible; with lead_in_width=0 it showed the cycle's first + plugin re-entering while the last one exited, then a recompose replaced + both — the reported "switched mid-scroll". + """ + + def _pipeline(self, strip_width, **cfg): + from src.vegas_mode.render_pipeline import RenderPipeline + + class FakeStream: + def get_grouped_content_for_composition(self): + return [('a', [Image.new('RGB', (strip_width, DISPLAY_H), (255, 255, 255))])] + + def get_active_plugin_ids(self): + return ['a'] + + class DM: + width = DISPLAY_W + height = DISPLAY_H + + def __init__(self): + self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H)) + + def set_scrolling_state(self, *a): + pass + + def update_display(self): + pass + + p = RenderPipeline(VegasModeConfig(lead_in_width=0, **cfg), DM(), FakeStream()) + assert p.compose_scroll_content() + return p + + def _advance_to(self, pipeline, distance): + pipeline.scroll_helper.total_distance_scrolled = distance + pipeline.scroll_helper.scroll_position = float(distance) + + def test_cycle_is_not_complete_before_the_wrap_point(self): + p = self._pipeline(2000) + self._advance_to(p, 2000 - DISPLAY_W - 1) + p.render_frame() + assert not p.is_cycle_complete() + + def test_cycle_completes_exactly_at_the_wrap_point(self): + p = self._pipeline(2000) + self._advance_to(p, 2000 - DISPLAY_W) + p.render_frame() + assert p.is_cycle_complete() + + def test_completes_a_full_display_width_earlier_than_the_strip_end(self): + # The whole point: it must not run to total_scroll_width, which is + # where the wrapped content has already been on screen for 10s at + # 50px/s on a 512px panel. + p = self._pipeline(3000) + self._advance_to(p, 3000 - DISPLAY_W - 1) + p.render_frame() + assert not p.is_cycle_complete() + self._advance_to(p, 3000 - DISPLAY_W) + p.render_frame() + assert p.is_cycle_complete() + + def test_strip_narrower_than_the_display_does_not_complete_instantly(self): + # Subtracting the display width would go negative and end the cycle on + # the very first frame, spinning the recompose loop. + p = self._pipeline(200) + self._advance_to(p, 0) + p.render_frame() + assert not p.is_cycle_complete() + + def test_strip_narrower_than_the_display_still_completes(self): + p = self._pipeline(200) + self._advance_to(p, 200) + p.render_frame() + assert p.is_cycle_complete() + + def test_strip_exactly_the_display_width(self): + p = self._pipeline(DISPLAY_W) + self._advance_to(p, 0) + p.render_frame() + assert not p.is_cycle_complete() + self._advance_to(p, DISPLAY_W) + p.render_frame() + assert p.is_cycle_complete() + + +class TestBudgetIndependentOfTrim: + """ + Turning off margin trimming must not disable the per-plugin width cap — + they are unrelated concerns. Found in the field: with auto_trim off, the F1 + scoreboard contributed 116 images / 14,848px untouched, producing a 33,821px + cycle. + """ + + def test_budget_still_applies_with_trim_off(self): + adapter = adapter_with(auto_trim=False, max_plugin_width_ratio=1.0, + intra_plugin_gap=0, min_content_separation=0) + items = [canvas([(0, 400)], width=400) for _ in range(10)] + images = adapter.get_content(NativePlugin(items), 'f1-scoreboard') + assert sum(i.width for i in images) <= DISPLAY_W + + def test_trim_off_still_leaves_content_untrimmed(self): + # The margins must survive; only the cap should act. + adapter = adapter_with(auto_trim=False, max_plugin_width_ratio=0) + images = adapter.get_content(NativePlugin([canvas([(4, 39)])]), 'x') + assert images[0].width == DISPLAY_W + + def test_single_oversized_image_capped_with_trim_off(self): + adapter = adapter_with(auto_trim=False, max_plugin_width_ratio=1.0) + images = adapter.get_content( + NativePlugin([canvas([(0, 6898)], width=6898)]), 'leaderboard') + assert images[0].width <= DISPLAY_W + DISPLAY_W // 16 + + +class TestBudgetUsesMeasuredGaps: + """ + The budget must count the gaps the compositor actually inserts. Assuming the + flat intra_plugin_gap under-counted by up to + (min_content_separation - intra_plugin_gap) per row, so a many-row plugin + overran its cap. + """ + + def test_flush_rows_are_budgeted_with_the_measured_gap(self): + # 6 flush rows of 100px against a 512px budget. With 24px measured gaps + # only 4 fit (400 + 3*24 = 472; a 5th would be 596). + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=8, min_content_separation=24) + rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(6)] + images = adapter.get_content(NativePlugin(rows), 'rows') + n = len(images) + assert 100 * n + 24 * (n - 1) <= DISPLAY_W + assert n == 4 + + def test_larger_separation_fits_fewer_rows(self): + rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(8)] + tight = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=0, min_content_separation=0) + loose = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=0, min_content_separation=48) + assert len(loose.get_content(NativePlugin(rows), 'r')) < \ + len(tight.get_content(NativePlugin(rows), 'r')) + + def test_composed_block_respects_the_budget_end_to_end(self): + # The real invariant: what the compositor produces must fit the cap. + from src.vegas_mode.render_pipeline import RenderPipeline + + cfg = dict(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=8, min_content_separation=24) + adapter = adapter_with(**cfg) + rows = [Image.new('RGB', (90, DISPLAY_H), (255, 255, 255)) for _ in range(9)] + selected = adapter.get_content(NativePlugin(rows), 'rows') + + class FakeStream: + def get_grouped_content_for_composition(self): + return [('rows', selected)] + + def get_active_plugin_ids(self): + return ['rows'] + + class DM: + width = DISPLAY_W + height = DISPLAY_H + + def set_scrolling_state(self, *a): + pass + + p = RenderPipeline(VegasModeConfig(lead_in_width=0, **cfg), DM(), FakeStream()) + assert p._join_plugin_rows(selected).width <= DISPLAY_W + + +class TestRotationAcrossMultipleCycles: + """ + The single-image crop advances a window across cycles. The second and later + passes are where start + budget can land exactly on the image width, which + crashed find_blank_cut in the field and lost that plugin's content for the + cycle. First-pass-only tests never reach it. + """ + + def test_window_advances_over_many_cycles_without_error(self): + # Mirrors the field case: 1840px stocks strip, 1536px budget, so the + # second pass starts at 1536 and start + budget == 3072 -> clamped to + # the 1840 width, i.e. target == img.width. + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0) + strip = canvas([(0, 1840)], width=1840) + widths = [] + for _ in range(8): + adapter.invalidate_cache('ledmatrix-stocks') + images = adapter.get_content(NativePlugin([strip]), 'ledmatrix-stocks') + assert images, "content must never be lost mid-rotation" + widths.append(images[0].width) + assert all(w > 0 for w in widths) + + def test_offset_wraps_back_to_zero_at_the_end(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0) + strip = canvas([(0, 1840)], width=1840) + seen_reset = False + for _ in range(6): + adapter.invalidate_cache('s') + adapter.get_content(NativePlugin([strip]), 's') + if adapter._item_offsets.get('s', 0) == 0: + seen_reset = True + assert seen_reset, "window should wrap round rather than stall at the end" + + def test_multi_row_rotation_never_returns_empty(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0) + rows = [canvas([(0, 200)], width=200) for _ in range(7)] + for _ in range(10): + adapter.invalidate_cache('rows') + assert adapter.get_content(NativePlugin(rows), 'rows') + + +def word_strip(words, letter_w=7, letter_gap=1, item_gap=32, height=DISPLAY_H): + """ + Build a ticker-like strip: 'words' of solid blocks separated by 1px, with + a wide gap between words. Mirrors real rendered text, where the measured + gap between characters is a single column and the gap between items is 8px+. + + Returns (image, list of (word_start, word_end) column ranges). + """ + spans = [] + x = 0 + for w_i, letters in enumerate(words): + start = x + for l_i in range(letters): + x += letter_w + if l_i < letters - 1: + x += letter_gap + spans.append((start, x)) + if w_i < len(words) - 1: + x += item_gap + img = Image.new('RGB', (x, height), (0, 0, 0)) + for w_i, letters in enumerate(words): + sx = spans[w_i][0] + for l_i in range(letters): + lx = sx + l_i * (letter_w + letter_gap) + img.paste(Image.new('RGB', (letter_w, height), (255, 255, 255)), (lx, 0)) + return img, spans + + +class TestCutsNeverSplitWords: + """ + A cut placed in a 1px inter-letter gap orphans the tail of a word into the + next cycle. That is what produced a lone "y" from "Wednesday" floating + between two unrelated plugins. + """ + + def test_cut_lands_in_an_item_gap_not_between_letters(self): + from src.vegas_mode.geometry import blank_runs + img, spans = word_strip([9, 9, 9, 9, 9]) # five 9-letter words + + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2, + min_cut_gap=6) + # Budget deliberately lands mid-word if cut naively. + images = adapter.get_content(NativePlugin([img]), 'ticker') + cut_width = images[0].width + + # Every qualifying gap midpoint is a legal cut; assert we used one. + legal = {0, img.width} | {(a + b) // 2 for a, b in blank_runs(img, 6)} + assert cut_width in {c for c in legal}, \ + f"cut at {cut_width} is not an item boundary; legal: {sorted(legal)}" + + def test_no_partial_letter_at_either_edge(self): + # A split letter shows as a lit column touching the crop edge with the + # rest of its glyph missing. Requiring the edges to be blank is the + # simplest way to assert we cut inside a gap. + from src.vegas_mode.geometry import column_has_ink + img, _ = word_strip([9, 9, 9, 9, 9, 9]) + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.25, + min_cut_gap=6) + out = adapter.get_content(NativePlugin([img]), 'ticker')[0] + ink = column_has_ink(out) + assert not ink[0] or not ink[-1] or out.width == img.width, \ + "crop edges land on ink, so a glyph was cut through" + + def test_rotation_never_orphans_a_fragment(self): + # Walk the window across the whole strip and assert no slice is a + # narrow sliver, which is what an orphaned letter looks like. + img, _ = word_strip([9] * 8) + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2, + min_cut_gap=6) + widths = [] + for _ in range(12): + adapter.invalidate_cache('ticker') + out = adapter.get_content(NativePlugin([img]), 'ticker') + assert out + widths.append(out[0].width) + # A single 7px letter is the fragment signature; nothing that narrow. + assert min(widths) > 10, f"orphaned fragment in {widths}" + + def test_continuous_image_is_still_cut_to_budget(self): + # A map or chart has no item gaps; the gap rule must not let it escape + # the cap, because any column there is as good as another. + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + min_cut_gap=6) + solid = canvas([(0, 4000)], width=4000) + out = adapter.get_content(NativePlugin([solid]), 'geochron')[0] + assert out.width == DISPLAY_W + + def test_overruns_budget_rather_than_splitting(self): + # One very long item with no internal gap: the cut must wait for the + # next real boundary even though that exceeds the budget. + img, spans = word_strip([80, 9], letter_gap=1, item_gap=32) + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.1, + min_cut_gap=6) + out = adapter.get_content(NativePlugin([img]), 'longitem')[0] + assert out.width > int(DISPLAY_W * 0.1), \ + "should overrun to the next boundary instead of cutting the item" + + def test_min_cut_gap_of_two_still_excludes_letter_spacing(self): + from src.vegas_mode.geometry import blank_runs + img, _ = word_strip([9, 9, 9]) + # 1px letter gaps must never qualify, whatever the setting. + assert all(end - start >= 2 for start, end in blank_runs(img, 2)) + + + def test_permissive_gap_rule_would_have_split_a_word(self): + """ + Pins the actual regression. With min_cut_gap=1 the 1px gaps between + letters qualify as cut points, so the crop lands inside a word; with the + default it can only land in the wide gaps between items. + """ + from src.vegas_mode.geometry import blank_runs + img, _ = word_strip([9, 9, 9, 9, 9]) + + letter_gaps = {(a + b) // 2 for a, b in blank_runs(img, 1)} + item_gaps = {(a + b) // 2 for a, b in blank_runs(img, 6)} + + # The permissive rule offers many more cut points, and the extra ones + # are exactly the mid-word positions. + assert len(letter_gaps) > len(item_gaps) + mid_word = letter_gaps - item_gaps + assert mid_word, "expected inter-letter gaps to exist in the fixture" + + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2, + min_cut_gap=6) + out = adapter.get_content(NativePlugin([img]), 'ticker')[0] + assert out.width not in mid_word, \ + f"cut at {out.width} is a mid-word position" + + +class TestCaptureModeAlwaysHeld: + """ + Building Vegas content is off-screen work, but plugins are free to call + update_display() while doing it. Outside capture_mode that write reaches the + hardware and flashes the panel mid-scroll, so every path that runs plugin + render code must hold capture_mode — including at full width, where the + narrowing context is a no-op. + """ + + class RecordingDM: + width = DISPLAY_W + height = DISPLAY_H + + def __init__(self): + self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H)) + self.draw = None + self.capture_depth = 0 + self.hardware_writes_while_uncaptured = 0 + + from contextlib import contextmanager + + @contextmanager + def capture_mode(self): + self.capture_depth += 1 + try: + yield + finally: + self.capture_depth -= 1 + + @contextmanager + def render_size(self, width, height=None): + yield + + def clear(self): + self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H)) + + def update_display(self): + if self.capture_depth == 0: + self.hardware_writes_while_uncaptured += 1 + + class PushyPlugin: + """A plugin that pushes to the display while building Vegas content.""" + + def __init__(self, dm, images): + self.display_manager = dm + self.config = {} + self._images = images + + def get_vegas_content(self): + self.display_manager.update_display() + return self._images + + def _run(self, **cfg): + dm = self.RecordingDM() + adapter = PluginAdapter(dm, VegasModeConfig(**cfg)) + plugin = self.PushyPlugin(dm, [canvas([(100, 300)])]) + adapter.get_content(plugin, 'pushy') + return dm + + def test_no_hardware_write_escapes_at_full_width(self): + dm = self._run(render_width_pct=100) + assert dm.hardware_writes_while_uncaptured == 0 + + def test_no_hardware_write_escapes_when_narrowing(self): + dm = self._run(render_width_pct=50) + assert dm.hardware_writes_while_uncaptured == 0 + + def test_capture_mode_is_released_afterwards(self): + dm = self._run(render_width_pct=100) + assert dm.capture_depth == 0 + + def test_capture_mode_released_even_when_the_plugin_raises(self): + dm = self.RecordingDM() + adapter = PluginAdapter(dm, VegasModeConfig()) + + class Boom: + def __init__(self, d): + self.display_manager = d + self.config = {} + + def get_vegas_content(self): + raise ValueError("boom") + + adapter.get_content(Boom(dm), 'boom') + assert dm.capture_depth == 0 + + +class TestContinuousExtension: + """ + Continuous mode extends one strip instead of swapping in a new one, so the + next group scrolls in from the right: no freeze, no substitution, and no + restart with the viewport already full. + """ + + def _pipeline(self, groups, **cfg): + """groups: list of lists of (plugin_id, [images]) handed out in turn.""" + from src.vegas_mode.render_pipeline import RenderPipeline + + class FakeStream: + def __init__(self): + self.calls = [] + self.plugin_manager = type('PM', (), {'plugins': {}})() + self.plugin_adapter = None + self._i = 0 + + def get_grouped_content_for_composition(self): + return groups[0] if groups else [] + + def get_active_plugin_ids(self): + return [pid for pid, _ in (groups[0] if groups else [])] + + def take_next_group(self, count=None, offscreen_only=False): + self.calls.append(offscreen_only) + if self._i >= len(groups): + return [] + g = groups[self._i] + self._i += 1 + return g + + class DM: + width = DISPLAY_W + height = DISPLAY_H + + def __init__(self): + self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H)) + self.pushes = 0 + + def set_scrolling_state(self, *a): + pass + + def update_display(self): + self.pushes += 1 + + cfg.setdefault('lead_in_width', 0) + stream = FakeStream() + return RenderPipeline(VegasModeConfig(**cfg), DM(), stream), stream + + def _block(self, w): + return Image.new('RGB', (w, DISPLAY_H), (255, 255, 255)) + + def test_needs_extension_only_near_the_end(self): + p, _ = self._pipeline([[('a', [self._block(400)])]], + continuous_scroll=True, extend_threshold_screens=2.0) + p.compose_scroll_content() + # 400px strip on a 512px display: already inside the threshold. + assert p.needs_extension() + + def test_no_extension_when_plenty_remains(self): + p, _ = self._pipeline([[('a', [self._block(4000)])]], + continuous_scroll=True, extend_threshold_screens=2.0) + p.compose_scroll_content() + assert not p.needs_extension() + + def test_disabled_never_extends(self): + p, _ = self._pipeline([[('a', [self._block(100)])]], + continuous_scroll=False) + p.compose_scroll_content() + assert not p.needs_extension() + + def test_extension_grows_the_strip_and_keeps_position(self): + groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]] + p, _ = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + before_width = p.scroll_helper.total_scroll_width + p.scroll_helper.scroll_position = 120.0 + + assert p.extend_scroll_content() + assert p.scroll_helper.total_scroll_width > before_width + assert p.scroll_helper.scroll_position == 120.0 + + def test_extension_never_completes_the_cycle(self): + groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]] + p, _ = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + p.extend_scroll_content() + assert not p.scroll_helper.scroll_complete + + def test_deferred_plugins_are_dropped_when_unresolvable(self): + # A None entry means "needs the render thread"; with no plugin instance + # available it must be skipped rather than crashing or inserting a gap. + groups = [[('a', [self._block(300)])], + [('needs-canvas', None), ('b', [self._block(300)])]] + p, _ = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + assert p.extend_scroll_content() + assert p.scroll_helper.total_scroll_width > 300 + + def test_empty_next_group_fails_cleanly(self): + # compose_scroll_content does not consume a group, so the first extend + # takes groups[0]; the second finds nothing left. + groups = [[('a', [self._block(300)])], []] + p, _ = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + assert p.extend_scroll_content() is True + assert p.extend_scroll_content() is False + + def test_prefetch_requests_offscreen_only(self): + # The background thread must never take a canvas-touching path. + groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]] + p, stream = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + p.start_prefetch() + if p._prefetch_thread: + p._prefetch_thread.join(timeout=5) + assert stream.calls == [True] + + def test_prepared_group_is_used_without_refetching(self): + groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]] + p, stream = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + p.start_prefetch() + if p._prefetch_thread: + p._prefetch_thread.join(timeout=5) + + assert p.extend_scroll_content() + # One offscreen prefetch, then one more kicked off for the group after. + assert stream.calls[0] is True + assert p._prepared_group is None or isinstance(p._prepared_group, list) + + def test_strip_stays_bounded_over_many_extensions(self): + groups = [[('g%d' % i, [self._block(600)])] for i in range(30)] + p, _ = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + + widths = [] + for _ in range(25): + p.scroll_helper.scroll_position += 300 + p.scroll_helper.total_distance_scrolled += 300 + if p.needs_extension(): + p.extend_scroll_content() + widths.append(p.scroll_helper.total_scroll_width) + assert max(widths) < 6000, f"strip grew unbounded: {max(widths)}" + + +class TestDeferredDraining: + """ + Canvas-bound plugins cannot be prepared off the render thread, so they are + queued and appended one per frame. Doing all of them at once held the render + thread for 1.75s on hardware. + """ + + def _pipeline(self, group, plugin_images, **cfg): + from src.vegas_mode.render_pipeline import RenderPipeline + + class FakeAdapter: + def __init__(self, mapping): + self.mapping = mapping + self.calls = [] + + def get_content(self, plugin, plugin_id, offscreen_only=False): + self.calls.append((plugin_id, offscreen_only)) + return self.mapping.get(plugin_id) + + class FakeStream: + def __init__(self): + self.plugin_manager = type( + 'PM', (), {'plugins': {pid: object() for pid in plugin_images}})() + self.plugin_adapter = FakeAdapter(plugin_images) + self._served = False + + def get_grouped_content_for_composition(self): + return [('seed', [Image.new('RGB', (600, DISPLAY_H), (255, 255, 255))])] + + def get_active_plugin_ids(self): + return ['seed'] + + def take_next_group(self, count=None, offscreen_only=False): + if self._served: + return [] + self._served = True + return group + + class DM: + width = DISPLAY_W + height = DISPLAY_H + + def __init__(self): + self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H)) + + def set_scrolling_state(self, *a): + pass + + def update_display(self): + pass + + cfg.setdefault('lead_in_width', 0) + cfg.setdefault('continuous_scroll', True) + stream = FakeStream() + p = RenderPipeline(VegasModeConfig(**cfg), DM(), stream) + p.compose_scroll_content() + return p, stream + + def _img(self, w): + return [Image.new('RGB', (w, DISPLAY_H), (255, 255, 255))] + + def test_deferred_plugins_are_queued_not_fetched_inline(self): + group = [('ready', self._img(200)), ('needs-canvas', None)] + p, stream = self._pipeline(group, {'needs-canvas': self._img(150)}) + p.extend_scroll_content() + # Only queued at this point — no fetch for it yet. + assert p.has_deferred() + assert all(pid != 'needs-canvas' for pid, _ in stream.plugin_adapter.calls) + + def test_draining_appends_one_at_a_time(self): + group = [('a', None), ('b', None), ('c', None)] + images = {k: self._img(120) for k in ('a', 'b', 'c')} + p, _ = self._pipeline(group, images) + p.extend_scroll_content() + + widths = [p.scroll_helper.total_scroll_width] + drained = 0 + while p.has_deferred(): + assert p.drain_deferred() + drained += 1 + widths.append(p.scroll_helper.total_scroll_width) + assert drained == 3 + assert widths == sorted(widths), "each drain should extend the strip" + + def test_drain_uses_the_full_path_not_offscreen(self): + group = [('needs-canvas', None)] + p, stream = self._pipeline(group, {'needs-canvas': self._img(150)}) + p.extend_scroll_content() + p.drain_deferred() + assert ('needs-canvas', False) in stream.plugin_adapter.calls + + def test_drain_is_a_no_op_with_an_empty_queue(self): + p, _ = self._pipeline([('a', self._img(200))], {}) + p.extend_scroll_content() + assert not p.has_deferred() + assert p.drain_deferred() is False + + def test_scroll_position_survives_draining(self): + group = [('a', None), ('b', None)] + p, _ = self._pipeline(group, {k: self._img(120) for k in ('a', 'b')}) + p.extend_scroll_content() + p.scroll_helper.scroll_position = 200.0 + while p.has_deferred(): + p.drain_deferred() + assert p.scroll_helper.scroll_position == 200.0 + + def test_a_plugin_yielding_nothing_is_dropped_from_the_queue(self): + group = [('empty', None)] + p, _ = self._pipeline(group, {'empty': None}) + p.extend_scroll_content() + assert p.drain_deferred() is False + assert not p.has_deferred(), "must not retry forever" + + def test_all_deferred_group_still_reports_progress(self): + # Nothing appendable right now, but the queue will extend the strip. + group = [('a', None), ('b', None)] + p, _ = self._pipeline(group, {k: self._img(100) for k in ('a', 'b')}) + assert p.extend_scroll_content() is True + assert p.has_deferred() + + def test_drains_are_spaced_when_lookahead_is_healthy(self): + # With plenty of strip ahead there is no hurry, so consecutive drains + # must be throttled rather than firing back to back. + group = [('a', None), ('b', None)] + p, _ = self._pipeline(group, {k: self._img(4000) for k in ('a', 'b')}) + p.extend_scroll_content() + + assert p.drain_deferred() is True # first one goes through + # Strip is now long, so the next is deferred by the interval. + assert p.scroll_helper.remaining_unscrolled() > ( + DISPLAY_W * p.config.extend_threshold_screens) + assert p.drain_deferred() is False + assert p.has_deferred(), "queue must be kept, not dropped" + + def test_urgent_drain_ignores_the_throttle(self): + # When the strip is nearly exhausted, content matters more than smoothness. + group = [('a', None), ('b', None)] + p, _ = self._pipeline(group, {k: self._img(80) for k in ('a', 'b')}) + p.extend_scroll_content() + assert p.drain_deferred() is True + assert p.drain_deferred() is True + + +class TestOverflowMode: + """ + Rotating a window through content only makes sense when the items are + interchangeable. For ordered content it shows the middle of a ranked list — + ranks 1-6, then 7 onwards two rotations later, which reads as out of order. + """ + + class Cfg: + def __init__(self, cfg=None): + self.config = cfg or {} + + def get_vegas_content(self): + return None + + def test_default_is_rotate(self): + adapter = adapter_with() + assert adapter.resolve_overflow_mode(self.Cfg(), 'p') == 'rotate' + + def test_global_mode_applies(self): + adapter = adapter_with(overflow_mode='truncate') + assert adapter.resolve_overflow_mode(self.Cfg(), 'p') == 'truncate' + + def test_per_plugin_override_wins(self): + adapter = adapter_with(overflow_mode='rotate') + plugin = self.Cfg({'vegas_overflow': 'truncate'}) + assert adapter.resolve_overflow_mode(plugin, 'p') == 'truncate' + + @pytest.mark.parametrize('bad', ['sideways', '', None, 5]) + def test_invalid_override_falls_back(self, bad): + adapter = adapter_with(overflow_mode='rotate') + plugin = self.Cfg({'vegas_overflow': bad}) + assert adapter.resolve_overflow_mode(plugin, 'p') == 'rotate' + + def test_truncate_always_shows_the_same_opening_rows(self): + # The reported problem: a ranked list should not resume from the middle. + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=0, min_content_separation=0, + overflow_mode='truncate') + rows = [canvas([(0, 200)], width=200) for _ in range(8)] + plugin = NativePlugin(rows) + + first = adapter.get_content(plugin, 'ranks') + adapter.invalidate_cache('ranks') + second = adapter.get_content(plugin, 'ranks') + assert len(first) == len(second) + assert 'ranks' not in adapter._item_offsets, "must not advance a window" + + def test_rotate_still_advances(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=0, min_content_separation=0, + overflow_mode='rotate') + rows = [canvas([(0, 200)], width=200) for _ in range(8)] + adapter.get_content(NativePlugin(rows), 'ticker') + assert adapter._item_offsets.get('ticker', 0) > 0 + + def test_truncate_on_a_single_wide_image_starts_at_zero(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + overflow_mode='truncate') + strip = canvas([(0, 5000)], width=5000) + plugin = NativePlugin([strip]) + first = adapter.get_content(plugin, 'table')[0] + adapter.invalidate_cache('table') + second = adapter.get_content(plugin, 'table')[0] + assert first.tobytes() == second.tobytes(), "window must not advance" + + +class TestPerPluginWidthBudget: + class Cfg: + def __init__(self, cfg=None): + self.config = cfg or {} + + def get_vegas_content(self): + return None + + def test_global_ratio_by_default(self): + adapter = adapter_with(max_plugin_width_ratio=3.0) + assert adapter._width_budget(self.Cfg(), 'p') == DISPLAY_W * 3 + + def test_per_plugin_override_widens(self): + adapter = adapter_with(max_plugin_width_ratio=3.0) + plugin = self.Cfg({'vegas_max_width_screens': 8}) + assert adapter._width_budget(plugin, 'p') == DISPLAY_W * 8 + + def test_per_plugin_zero_means_uncapped(self): + adapter = adapter_with(max_plugin_width_ratio=3.0) + plugin = self.Cfg({'vegas_max_width_screens': 0}) + assert adapter._width_budget(plugin, 'p') == 0 + + def test_uncapped_plugin_keeps_all_its_content(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0) + + class Wide(NativePlugin): + def __init__(self, images): + super().__init__(images) + self.config = {'vegas_max_width_screens': 0} + + strip = canvas([(0, 6000)], width=6000) + assert adapter.get_content(Wide([strip]), 'whole')[0].width == 6000 + + @pytest.mark.parametrize('bad', ['wide', -1, None, '']) + def test_invalid_override_falls_back_to_global(self, bad): + adapter = adapter_with(max_plugin_width_ratio=2.0) + plugin = self.Cfg({'vegas_max_width_screens': bad}) + assert adapter._width_budget(plugin, 'p') == DISPLAY_W * 2 + + def test_no_plugin_uses_the_global(self): + adapter = adapter_with(max_plugin_width_ratio=2.0) + assert adapter._width_budget() == DISPLAY_W * 2 + + def test_truncate_single_image_never_records_an_offset(self): + # The behavioural guarantee behind the log message: no window state is + # kept, so every pass starts at the top. + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + overflow_mode='truncate') + strip = canvas([(0, 5000)], width=5000) + for _ in range(4): + adapter.invalidate_cache('table') + adapter.get_content(NativePlugin([strip]), 'table') + assert 'table' not in adapter._item_offsets + + def test_rotate_single_image_does_record_an_offset(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + overflow_mode='rotate') + strip = canvas([(0, 5000)], width=5000) + adapter.get_content(NativePlugin([strip]), 'ticker') + assert adapter._item_offsets.get('ticker', 0) > 0 diff --git a/test/test_vegas_geometry.py b/test/test_vegas_geometry.py new file mode 100644 index 00000000..5a336fc0 --- /dev/null +++ b/test/test_vegas_geometry.py @@ -0,0 +1,370 @@ +"""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, + edge_blank, + find_blank_cut, + separation_gap, + 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 + + +class TestEdgeBlank: + def test_measures_both_edges(self): + assert edge_blank(paint(make_img(100), 20, 60)) == (20, 40) + + def test_flush_content_has_no_blank(self): + assert edge_blank(paint(make_img(50), 0, 50)) == (0, 0) + + def test_blank_image_reports_full_width_both_sides(self): + # No ink means nothing to be close to. + assert edge_blank(make_img(64)) == (64, 64) + + +class TestSeparationGap: + def test_flush_edges_get_the_full_target(self): + a = paint(make_img(50), 0, 50) + b = paint(make_img(50), 0, 50) + assert separation_gap(a, b, target=24) == 24 + + def test_existing_margins_reduce_the_added_gap(self): + # 8px blank on each facing edge already covers 16 of the 24 target. + a = paint(make_img(50), 0, 42) + b = paint(make_img(50), 8, 50) + assert separation_gap(a, b, target=24) == 8 + + def test_ample_existing_margin_adds_nothing(self): + a = paint(make_img(100), 0, 60) + b = paint(make_img(100), 40, 100) + assert separation_gap(a, b, target=24) == 0 + + def test_minimum_is_a_floor(self): + a = paint(make_img(100), 0, 60) + b = paint(make_img(100), 40, 100) + assert separation_gap(a, b, target=24, minimum=4) == 4 + + def test_never_negative(self): + a = paint(make_img(200), 0, 10) + b = paint(make_img(200), 190, 200) + assert separation_gap(a, b, target=8) == 0 + + def test_sports_card_case_gets_real_separation(self): + # The reported problem: cards drawn edge to edge sat 8px apart under a + # flat gap; measured separation lifts them to the 24px target. + card = paint(make_img(150), 0, 150) + assert separation_gap(card, card, target=24, minimum=8) == 24 + + +class TestFindBlankCut: + def test_snaps_to_the_nearest_gap(self): + img = paint(make_img(200), 0, 90) + paint(img, 110, 200) + # 100 is inside the 90..110 gap already. + assert find_blank_cut(img, 100, 20) == 100 + + def test_walks_outwards_to_find_a_gap(self): + img = paint(make_img(200), 0, 95) + paint(img, 105, 200) + cut = find_blank_cut(img, 90, 20) + assert 95 <= cut < 105 + + def test_solid_ink_returns_the_target(self): + assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100 + + def test_target_at_image_width_does_not_index_past_the_end(self): + # A cut after the last column is legal. Indexing ink[width] raised + # IndexError in the field, losing that plugin's content for the cycle. + # Reached once the rotation offset advances so start + budget lands + # exactly on the image width. + img = paint(make_img(1840), 0, 1840) + assert find_blank_cut(img, 1840, 32) == 1840 + + def test_target_past_image_width_is_clamped(self): + img = paint(make_img(100), 0, 100) + assert find_blank_cut(img, 500, 32) == 100 + + def test_target_at_width_with_a_trailing_gap_snaps_back(self): + # Content 0..179, blank 180..199. The nearest blank column to 200 is + # 199, not the start of the gap — nearest is what keeps the cut as + # close as possible to the requested budget. + img = paint(make_img(200), 0, 180) + assert find_blank_cut(img, 200, 32) == 199 + + def test_zero_radius_returns_the_target(self): + assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50 + + def test_negative_target_is_clamped_to_zero(self): + assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0 + + @pytest.mark.parametrize("target", [0, 1, 50, 99, 100]) + def test_never_raises_across_the_range(self, target): + img = paint(make_img(100), 0, 100) + cut = find_blank_cut(img, target, 16) + assert 0 <= cut <= 100 diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index f92c7852..fef28649 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -918,7 +918,15 @@ def _copies_fits_hardware(copies: int) -> Optional[str]: # 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', 'vegas_render_width_pct', + 'vegas_min_content_separation', 'vegas_min_cut_gap', + 'vegas_continuous_scroll', 'vegas_extend_threshold_screens', + 'vegas_smooth_scroll', 'vegas_overflow_mode'] if any(k in data for k in vegas_fields): if 'display' not in current_config: @@ -933,13 +941,85 @@ def _copies_fits_hardware(copies: int) -> Optional[str]: # 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')) + vegas_config['continuous_scroll'] = _coerce_to_bool( + data.get('vegas_continuous_scroll')) + vegas_config['smooth_scroll'] = _coerce_to_bool( + data.get('vegas_smooth_scroll')) + + # max_plugin_width_ratio is the one fractional setting, so it is + # handled outside the integer loop below. + if data.get('vegas_overflow_mode') not in ('', None): + mode = str(data['vegas_overflow_mode']).strip().lower() + if mode not in ('rotate', 'truncate'): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for vegas_overflow_mode: " + "must be 'rotate' or 'truncate'" + }), 400 + vegas_config['overflow_mode'] = mode + + if data.get('vegas_extend_threshold_screens') not in ('', None): + try: + screens = float(data['vegas_extend_threshold_screens']) + except (ValueError, TypeError): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for vegas_extend_threshold_screens: " + "must be a number" + }), 400 + if not (1.0 <= screens <= 10.0): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for vegas_extend_threshold_screens: " + "must be between 1.0 and 10.0" + }), 400 + vegas_config['extend_threshold_screens'] = screens - # Handle numeric settings with validation + 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. + # + # These bounds must match VegasModeConfig.validate(), which is what + # actually gates Vegas starting. Where they were looser, a value + # saved with a 200 and then made VegasModeCoordinator.start() bail + # out with only a log line, so the ticker silently never ran. + # Where they were tighter (scroll_speed capped at 100 against a + # slider that goes to 200), a legitimate value was rejected with a + # 400. See test_vegas_api_bounds_match_validate. numeric_fields = { - 'vegas_scroll_speed': ('scroll_speed', 1, 100), - 'vegas_separator_width': ('separator_width', 0, 500), - 'vegas_target_fps': ('target_fps', 1, 200), - 'vegas_buffer_ahead': ('buffer_ahead', 1, 20), + 'vegas_scroll_speed': ('scroll_speed', 1, 200), + 'vegas_separator_width': ('separator_width', 0, 128), + 'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128), + 'vegas_render_width_pct': ('render_width_pct', 10, 100), + 'vegas_min_content_separation': ('min_content_separation', 0, 256), + 'vegas_min_cut_gap': ('min_cut_gap', 1, 128), + 'vegas_target_fps': ('target_fps', 30, 200), + 'vegas_buffer_ahead': ('buffer_ahead', 1, 5), + '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..2fa31027 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.
+ +