From 5b45f3588845701a868ef591077c0010a76822ab Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:40:38 -0400 Subject: [PATCH] Vegas mode: reclaim dead space and pace the rotation (#423) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Vegas mode: reclaim dead space and pace the rotation On a wide panel Vegas mode spent much of its time showing black. At 50px/s on a 512px display, one display width of blank is 10.2 seconds, which makes several long-standing behaviours expensive: - ScrollHelper prepended a full display width of black as an "initial gap", charged once per cycle — 10.2s of black at the start of every rotation. - Plugins without get_vegas_content() are captured off a full-display canvas, so their blank margins entered the ticker too. Measured: of-the-day drew 35px of "No Data" on a 512px canvas (92% blank), youtube-stats 142px of content with 185px of black either side. Only the scroll_helper path had any trimming. - Cycle transitions deliberately pushed a blank frame and then recomposed synchronously: 84ms at best, 4.8s at worst, every millisecond of it black. - buffer_ahead doubled as the cycle size, so a 21-plugin install showed 3 plugins per cycle and took ~7 cycles to come around. - separator_width was applied between every image rather than at plugin boundaries, so a per-row ticker like the F1 scoreboard (116 images, which it renders 4px apart internally) got a 32px chasm between each row — and the width budget didn't count those gaps, so the plugin quietly occupied far more of the panel than intended. Changes: - src/vegas_mode/geometry.py: numpy column-ink primitives shared by the trimmer and the audit tool, so the number reported is the number acted on. A Python per-column loop over a 17,000px strip is far too slow for the render path. - PluginAdapter trims every content path, not just scroll_helper. Only outer edges are cropped: interior blank columns are the plugin's own layout (logo left, score right) and closing them would corrupt the design. A plugin on a non-black background is inherently unaffected. - ScrollHelper.create_scrolling_image takes an explicit lead_gap, still defaulting to display_width so the many standalone-ticker callers are unchanged. Vegas passes lead_in_width (default 0). - Cycle end holds the last rendered frame instead of blanking, turning the recompose into a brief freeze rather than the panel switching off. - plugins_per_cycle (default 6) is split from buffer_ahead, which goes back to being only a prefetch low-water mark. - max_plugin_width_ratio (default 3x display width) caps one plugin's share of a cycle. Overflow is deferred, not discarded: a rotation offset advances each fetch so later rows appear on subsequent cycles. Single oversized images are cropped at a blank column so the cut misses glyphs. - Composition groups images by plugin: rows are joined by intra_plugin_gap (default 8) and separator_width applies only between plugins. The width budget now counts those gaps. - Plugin data updates no longer run on the Vegas render path. All new settings are user-configurable in Display -> Vegas Scroll, including min/max cycle duration and dynamic duration, which previously existed in code but were reachable only by hand-editing config.json. Measured with scripts/dev/vegas_audit.py on a 512x64 panel: mean ink coverage 42.7% -> 69.4% fully blank 5.9% -> 0% reads as empty 13.6% -> 0% worst blank stretch 4.8s -> 0s full rotation 414s -> 123s plugins per cycle 3 -> 6 Note the metric choice: a "fully blank" scan (>=95% black viewport) reported only 0.4% and badly understated the problem, because two full-width segments with mid-canvas content never fully blank the viewport — they hold it at ~28%. window_coverage_stats grades every viewport position by how much ink it carries, which is what tracks perceived dead time. Known remaining: cycle transitions still freeze ~3.5s while the next cycle is fetched. Fixing that needs background prefetch, which is deferred because the fallback-capture path mutates the shared display_manager.image and racing it against the render loop risks torn frames. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Drop unused Optional import from the vegas audit script Flagged by Codacy (F401). Any, Dict and List are all still used. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Align Vegas API bounds with validate(), fix audit config plumbing Both from review feedback on #423. The web API's accepted ranges disagreed with VegasModeConfig.validate(), which is what actually gates Vegas starting: scroll_speed 1-100 -> 1-200 (a slider value of 150 returned 400) separator_width 0-500 -> 0-128 target_fps 1-200 -> 30-200 buffer_ahead 1-20 -> 1-5 The three loose ones were the dangerous direction: the value saved with a 200, then VegasModeCoordinator.start() failed validation with only a log line, so the ticker silently never ran. The UI already matched validate() in all four cases, so the API was the odd one out. test_vegas_api_bounds_match_validate parses the numeric_fields map out of api_v3 and asserts every bound against validate(), plus that validate() accepts both endpoints and rejects just outside them, so these cannot drift apart again. That test immediately caught a missing upper bound on min_plugin_width, now added — unbounded it would drop every segment and leave a blank ticker. Separately, vegas_audit.py constructed PluginAdapter without the config, so it fell back to VegasModeConfig() defaults and would report trimming and width-budget behaviour that differed from the user's config.json. It now passes the loaded config exactly as the coordinator does. This is the same class of drift the explicit lead_gap and grouping arguments already guard against. Output is unchanged on a rig whose config matches the defaults. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Vegas mode: render plugins narrower, space rows by measured separation Trimming reclaims blank margins but cannot compact a layout that genuinely spans the display — a five-column forecast, a progress bar drawn at 100% width, a stat block with the panel's whole width between its elements. Those need the plugin to make different layout decisions, which means telling it the screen is narrower while it renders. DisplayManager.render_size() presents a smaller logical canvas for the duration of a Vegas content fetch, reusing the same _LogicalMatrix indirection double-sided mode already relies on so plugins see a consistent size from every accessor. Plugins that size themselves from matrix.width need no changes at all; one that wants to be explicit can read the new BasePlugin.get_vegas_render_width(). Width is a percentage so a single setting travels across panel sizes: vegas_scroll.render_width_pct globally, or vegas_width_pct in an individual plugin's config. Measured on a 512x64 panel with real data: ledmatrix-weather 1536px -> 576px (forecast becomes narrow cards) youtube-stats 353px -> 199px (2% blank left, so genuinely compact) geochron 453px -> 153px (ink density rises to 100%) ledmatrix-flights 950px -> 740px The youtube-stats figure is the clearest evidence the layout itself changed rather than being cropped: at full width the content had to be trimmed from 512px to 353px, whereas at 40% it arrives with almost no blank to reclaim. Row spacing is now measured rather than added. A flat gap gets it wrong in both directions at once — content drawn flush to its own edges ends up nearly touching (reported for recent sports scores, which sat 8px apart), while content already carrying wide margins gets pushed even further out. separation_gap() measures the blank each pair already has and adds only the shortfall, up to min_content_separation (default 24). intra_plugin_gap stays as a floor applied regardless. Two tests shipped in the previous commit encoded the old flat-gap arithmetic and are updated to the measured semantics, including one renamed to reflect that zero intra_plugin_gap alone no longer butts rows together. Also fixes a real bug found while testing: the harness display manager had no render_size(), and because the adapter catches broadly that surfaced as "no content" rather than an error, silently dropping five plugins. Added the context to VisualTestDisplayManager for parity, and _render_at() now degrades to a no-op on any display manager lacking it, so a third-party or older harness loses the narrowing rather than the content. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Vegas mode: end cycles before the wrap, keep the width budget honest Three fixes, the first a regression from lead_in_width defaulting to 0. get_visible_portion wraps: once scroll_position + display_width passes the end of the strip it fills the right of the frame from the *head* of the same strip. So the final display_width of travel showed the cycle's first plugin re-entering on the right while its last plugin exited on the left, and the recompose that followed replaced both at once. On a 512px panel at 50px/s that was 10.2s of two plugins on screen at once, ending in a hard cut — reported as the ticker "switching mid-scroll" from F1 to news. That used to be invisible because the strip began with a full display_width of blank, so the wrapped-in region was black. Removing that blank (it was 10s of dead panel per cycle) exposed the wrap. Cycles now end one display width earlier, before any wrapped content appears, clamped for strips no wider than the display so they don't complete instantly and spin the recompose loop. Verified on hardware: a 3936px strip now completes at 68.5s, exactly (3936 - 512) / 50. Second, auto_trim=False also skipped the width budget, which is an unrelated concern — turning off margin cropping should not let one plugin hold the panel for minutes. Seen in the field: the F1 scoreboard contributed 116 images and 14,848px untouched, giving a 33,821px cycle (11 minutes of content). The budget now applies regardless of trimming; with it restored that cycle is 6,362px. Third, the budget accounted for row gaps using the flat intra_plugin_gap while the compositor had moved to measured separation, so it under-counted by up to (min_content_separation - intra_plugin_gap) per row and a many-row plugin overran its cap. Both now use the same separation_gap() rule, and a test asserts the composed block fits the budget end to end rather than trusting the two paths to agree. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Fix IndexError in find_blank_cut when the cut lands on the image edge A cut position after the last column is legitimate — _crop_to_budget asks for min(start + budget, img.width), which equals the width whenever the remaining strip is shorter than the budget. find_blank_cut clamped target to width but then walked leftwards starting at target itself, so ink[width] raised IndexError. Caught on hardware: it killed the ledmatrix-stocks fetch, and because _fetch_plugin_content catches broadly that surfaced as the plugin silently contributing nothing for the cycle. Only reachable on the second or later pass of the rotating window over a single oversized image, which is why the existing tests missed it — they all exercised the first pass, where start is 0 and start + budget is comfortably inside the image. Added TestRotationAcrossMultipleCycles, which walks the window round several times and asserts content is never lost, plus direct coverage of find_blank_cut at and beyond the image edge. Both bounds now stop at width - 1 so neither direction can index past the end. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Only cut oversized segments at real gaps between items The width-budget crop snapped to the nearest blank column, and in rendered text the gap between two characters is a single column. So a cut routinely landed inside a word: the cycle showed "Wednesda" and the orphaned "y" turned up as a lone floating letter in the next cycle, positioned after whatever plugin happened to precede it. Measured on the clock-simple segment to confirm: its blank runs are [1, 1, 1, 1, 1, 8, 8] — five single-column letter gaps, every one of which find_blank_cut would happily have chosen. Cuts now only land in a run of at least min_cut_gap blank columns (default 6), which excludes letter spacing while still finding the gaps plugins put between items (the stocks ticker uses 32px, baseball 48px). Where no boundary falls inside the budget the cut waits for the next one and overruns, because splitting an item is worse than a slightly long segment. Continuous content is treated differently on purpose: an image with no internal gaps is a map or a chart, where any column is as good as another, so it is still cut to the budget exactly. The gap rule protects discrete items; letting a solid image escape the cap in its name would be wrong. blank_runs() is vectorised — 48ms for a 17,000px strip, against seconds for a per-column Python loop. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Hold capture_mode for every plugin render, not just narrowed ones The native content path only entered capture_mode when it was also narrowing the canvas, so at full width — which is every plugin without a vegas_width_pct override, i.e. most of them — a plugin calling update_display() while building its Vegas content wrote straight to the hardware. That is a visible flash mid-scroll, and it lines up with the flash reported at cycle transitions, when several plugins are fetched back to back. Suppression is now unconditional; the narrowing context stays separate because it is already a no-op at full width. Both contexts are reached through helpers that degrade to nullcontext when the display manager lacks them. That matters more than it looks: the adapter's handlers are deliberately broad, so an AttributeError from a missing context does not surface as an error — it surfaces as the plugin contributing nothing. Making the call unconditional without this turned 44 tests red for exactly that reason, all of them reporting lost content rather than the real cause. The test double now provides capture_mode and render_size too, so tests exercise the real contexts instead of silently taking the degraded path. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Vegas mode: one continuous strip instead of swapping cycles A cycle used to be a discrete strip that got replaced: motion stopped, every pixel was substituted at once, and the next group started with the viewport already full. That is the freeze, the flash and the jump. The strip is now extended rather than replaced. ScrollHelper gains append_content(), which adds items on the right without touching scroll_position or total_distance_scrolled, so motion continues and the next group simply arrives from the right. Because completion is measured against total_scroll_width, extending also defers completion — there is no longer a cycle boundary to see. drop_scrolled_prefix() reclaims what has gone past, keeping the strip bounded however long Vegas runs (observed 5,000-11,000px against an unbounded strip otherwise). It shifts total_distance_scrolled and total_scroll_width together so the completion arithmetic is unchanged, and refuses to run while the viewport is wrapping: wrapping reads the head of the strip into the right of the frame, so trimming the head there would visibly change the picture. A test caught that. Groups are prepared off the render thread. The constraint is that 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. get_content() therefore takes offscreen_only: the background thread uses only paths that avoid the canvas, and anything needing it is marked and picked up on the render thread. That puts the expensive work (native renders of leaderboard and baseball cards, seconds each) in the background and leaves the cheap work (display capture, 40-600ms) in the foreground. DisplayManager's capture flag is now thread-local. As a shared flag, a background capture would have suppressed the render loop's own frame pushes for its duration, freezing the panel precisely when the point was to avoid a freeze. Canvas-bound plugins are drained one at a time rather than as a batch: six at once held the render thread for 1.75s. Drains are also spaced by two seconds while the lookahead is healthy, since taking them back to back turns one long stall into a run of short ones. When the strip is genuinely running short the throttle is ignored, because content matters more than smoothness there. Measured on hardware: zero cycle-complete swaps, drains landing 2-4s apart, lookahead holding at 1,200-3,500px, no errors. Set continuous_scroll false to restore the swap behaviour; the old path is intact. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Pace the Vegas frame loop adaptively: 31.5 -> 78.7 fps The loop slept a fixed frame_interval on top of however long the frame took, so at a measured 31.6ms per frame a flat 8ms of that was pure idle — a quarter of the budget spent not rendering. It now sleeps only the remainder of the budget. Measured on hardware: 31.5 fps to 78.7 fps sustained, with CPU going *down* from 150% to 127%. Scroll speed is unchanged at 49.9px/s against a configured 50, because motion is derived from elapsed time rather than frame count — this buys smoothness, not speed. Worth recording what the bottleneck was not: the per-frame render path measures 0.34ms in total (0.18ms for the numpy slice, 0.17ms for the dirty-tracking digest), which is a theoretical 2900 fps. Optimising any of that would have been wasted effort. The frame was idle, not busy. Also nices the prefetch thread. Its work is PIL and numpy that releases the GIL, so the scheduler can act on the priority, and without it the prefetch competes for the same cores as the render loop. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Sub-pixel scrolling: motion at the frame rate, not the pixel rate With integer positioning the number of distinct frames per second equals the scroll speed in px/s, however fast the loop renders. Measured at 50px/s and 78.7fps, 36% of frames were byte-identical: the extra frames cost work and bought no motion, and what was left was 50 discrete 1px steps a second. Two things were wrong with the pre-existing sub-pixel support. get_visible_portion never consulted sub_pixel_scrolling — it always took the integer path, so the flag and _get_visible_portion_subpixel were dead code. And that implementation needed scipy.ndimage.shift, which is not installed on the target devices (HAS_SCIPY is False there), so it would not have interpolated even if reached. Verified both: positions 1000.0 and 1000.5 produced identical frames either way. Blending is now wired up and implemented with numpy. Two details make it affordable: slice cached_array directly instead of building two PIL images only to convert them straight back (the naive version measured 15x the integer path), and use fixed-point uint16 multiply-add rather than float32, which suits the Pi's cores and gives finer weighting than the panel can resolve. Result 0.939ms against 0.237ms — 0.70ms added per frame, a 1065fps ceiling. Measured on hardware: 81.2 fps with blending on, against 78.7 with it off, so no cost within noise — and every frame is now a distinct position rather than one in three being a repeat. The trade is a slight horizontal softening of text, since each frame blends two positions. Set smooth_scroll false for maximum crispness. Also benchmarked and cleared as non-issues: extending the strip costs 9.4ms on an 11,000px strip and trimming 2.5ms, both under one frame at this rate. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Add overflow handling: keep ordered content whole instead of rotating a window The width budget split any oversized plugin by advancing a window each cycle. That is right for interchangeable items — news headlines, odds, stock prices — but wrong for ordered content: a league table showed ranks 1-6, then resumed at 7 two rotations later, which reads as out of order and out of context. Nobody needs rank 23 in a ticker; they need the top of the table, every time. overflow_mode chooses between them: rotate — advance a window each cycle so everything is seen eventually (unchanged default) truncate — always show the start and drop the rest, keeping ordered content coherent. Records no window state, so every pass starts at the top. Per-plugin vegas_overflow overrides the global setting, since one install has both kinds of plugin. Also adds per-plugin vegas_max_width_screens, so content that must stay whole can be given more room — or uncapped with 0 — without lifting the cap on every ticker. Applied on the test rig: f1-scoreboard and ledmatrix-leaderboard set to truncate, and baseball given 4.5 screens because it was showing 8 of 9 games when the whole slate needed only a little more room. Verified: F1 now reports "the first 10 of 116 ... the rest are not shown", baseball has dropped out of the budget log entirely, and stocks, odds-ticker and stock-news still rotate. Also corrects the crop log, which claimed "window advances next cycle" unconditionally and so misreported truncated crops. A test now pins the behaviour behind the message: truncate must leave no offset recorded. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Stop Vegas mode showing last night's games as if they were live A game that was live in the evening was still being drawn as live the next morning. Two faults combined to freeze plugin visuals indefinitely. PR #291 added a call to plugin_adapter.invalidate_plugin_scroll_cache() so a plugin's own cached scroll image would be rebuilt from fresh data. That method was never implemented. hot_swap_content() wraps the call in a broad except, so every hot swap has raised AttributeError and been swallowed silently ever since — which is why the visuals it was meant to keep fresh never were. Continuous scrolling then removed the only path that reached it at all: should_recompose() and hot_swap_content() are called from the non-continuous branch of run_frame(), and continuous_scroll defaults to True. So on a default install the pending-update flags were set by the update tick, never consumed, and grew without bound. Together these froze content completely, because refetching is not enough on its own: the sports plugins' get_vegas_content() regenerates only "if the cache is empty", so take_next_group() kept receiving the same picture however often it asked. Fixed by: - Implementing invalidate_plugin_scroll_cache(). It covers both layouts — a helper directly on the plugin (stocks, news, odds-ticker) and one owned by a scroll-display manager (the sports scoreboards, which is the shape that produced this bug) — and clears cached_image and cached_array together, since the array is the image's numpy mirror. - Adding StreamManager.invalidate_pending_updates() and calling it from the continuous branch. It only drops the caches; the plugin recomposes when it next comes round in the rotation. process_updates() is wrong here: it refetches synchronously and merges into the active buffer that continuous mode bypasses, and hot_swap_content() rebuilds and repositions the whole strip, which is the freeze-and-jump this mode exists to avoid. Tests assert the fix rather than the implementation: 14 of the 17 new tests fail without it. Includes the wiring itself, since the regression was a call that was simply absent, and a check that the scroll position is untouched so this cannot regress into the swap's visible jump. All Vegas suites pass (355 tests). test_display_controller_vegas_tick.py still cannot be collected off-device for want of rgbmatrix, identically with and without this change. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ * Fix two CodeRabbit-flagged test assertions in vegas density tests test_prepared_group_is_used_without_refetching had a tautological final assertion; now checks stream.calls directly. test_no_partial_letter_at_either_edge required both crop edges to be blank, but the left edge here is always the crop's start position with no lead-in gap in word_strip, so it legitimately carries ink — only the right edge is an actual cut and needs the check. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --------- Co-authored-by: Claude --- config/config.template.json | 20 +- scripts/dev/vegas_audit.py | 384 ++++ src/common/scroll_helper.py | 215 ++- src/display_manager.py | 72 +- src/plugin_system/base_plugin.py | 34 + .../testing/visual_display_manager.py | 48 + src/vegas_mode/config.py | 222 +++ src/vegas_mode/coordinator.py | 77 +- src/vegas_mode/geometry.py | 474 +++++ src/vegas_mode/plugin_adapter.py | 551 +++++- src/vegas_mode/render_pipeline.py | 417 ++++- src/vegas_mode/stream_manager.py | 159 +- test/test_scroll_helper_continuous.py | 336 ++++ test/test_vegas_continuous_refresh.py | 227 +++ test/test_vegas_density.py | 1645 +++++++++++++++++ test/test_vegas_geometry.py | 370 ++++ web_interface/blueprints/api_v3.py | 92 +- .../templates/v3/partials/display.html | 228 ++- 18 files changed, 5455 insertions(+), 116 deletions(-) create mode 100644 scripts/dev/vegas_audit.py create mode 100644 src/vegas_mode/geometry.py create mode 100644 test/test_scroll_helper_continuous.py create mode 100644 test/test_vegas_continuous_refresh.py create mode 100644 test/test_vegas_density.py create mode 100644 test/test_vegas_geometry.py 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 @@ class ScrollHelper: self.is_scrolling = False self.scroll_complete = False - def create_scrolling_image(self, content_items: list, + def create_scrolling_image(self, content_items: list, item_gap: int = 32, - element_gap: int = 16) -> Image.Image: + element_gap: int = 16, + lead_gap: Optional[int] = None) -> Image.Image: """ Create a wide image containing all content items for scrolling. - + Args: content_items: List of PIL Images to include in scroll item_gap: Gap between different items element_gap: Gap between elements within an item - + lead_gap: Blank columns before the first item. Defaults to a full + display width, which makes a standalone ticker scroll in from + off-screen. Callers that loop many plugins back-to-back (Vegas + mode) pass a smaller value, since a full display width of black + reads as the panel being switched off at the start of every + cycle. + Returns: PIL Image containing all content arranged horizontally """ + if lead_gap is None: + lead_gap = self.display_width + lead_gap = max(0, int(lead_gap)) if not content_items: # Create empty image if no content # Still set total_scroll_width to 0 to indicate no scrollable content @@ -144,13 +154,13 @@ class ScrollHelper: total_width += element_gap * len(content_items) # Add initial gap before first item - total_width += self.display_width - + total_width += lead_gap + # Create the full scrolling image full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0)) - + # Position items - current_x = self.display_width # Start with initial gap + current_x = lead_gap # Start with initial gap for i, img in enumerate(content_items): # Paste the item image @@ -338,13 +348,72 @@ class ScrollHelper: """ 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 @@ class ScrollHelper: """ 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 @@ class DisplayManager: 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 @@ class DisplayManager: 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 @@ class DisplayManager: 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 @@ class BasePlugin(ABC): # ------------------------------------------------------------------------- # 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 @@ PIL Image canvas and draws text using the actual project fonts. import math import os import time +from contextlib import contextmanager from pathlib import Path from typing import Any, List, Optional, Tuple @@ -62,6 +63,9 @@ class VisualTestDisplayManager: # Matrix proxy (plugins access display_manager.matrix.width/height) self.matrix = _MatrixProxy(width, height) + # Set while inside capture_mode(); mirrors DisplayManager's flag. + self._capture_mode_active = False + # Scrolling state (interface compat, no-op) self._scrolling_state = { 'is_scrolling': False, @@ -174,6 +178,50 @@ class VisualTestDisplayManager: """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 @@ class VegasModeConfig: enabled=vegas_config.get('enabled', False), scroll_speed=float(vegas_config.get('scroll_speed', 50.0)), separator_width=int(vegas_config.get('separator_width', 32)), + intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)), + 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 @@ class VegasModeConfig: '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 @@ class VegasModeConfig: 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 @@ class VegasModeConfig: self.scroll_speed = float(vegas_config['scroll_speed']) if 'separator_width' in vegas_config: self.separator_width = int(vegas_config['separator_width']) + if 'intra_plugin_gap' in vegas_config: + self.intra_plugin_gap = int(vegas_config['intra_plugin_gap']) + if '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..e75e8c51 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -64,7 +64,7 @@ class VegasModeCoordinator: self.plugin_manager = plugin_manager # Initialize components - self.plugin_adapter = PluginAdapter(display_manager) + self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config) self.stream_manager = StreamManager( self.vegas_config, plugin_manager, @@ -233,6 +233,11 @@ class VegasModeCoordinator: 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,43 @@ class VegasModeCoordinator: 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: + # Drop cached content for plugins whose data just changed, so the + # next time each comes round it is composed from current data. The + # swap path's hot_swap_content() does this via process_updates(), + # but it also rebuilds and repositions the whole strip, which is + # the freeze-and-jump this mode exists to avoid. Without this the + # pending-update flags are never consumed and a segment keeps + # rendering whatever it was first built from — last night's live + # game still shown as live the next morning. + self.render_pipeline.refresh_updated_plugins() - # Check for hot-swap opportunities - if self.render_pipeline.should_recompose(): - self.render_pipeline.hot_swap_content() + # 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() # Render frame return self.render_pipeline.render_frame() @@ -337,7 +369,14 @@ class VegasModeCoordinator: 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 +386,8 @@ class VegasModeCoordinator: 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 +408,14 @@ class VegasModeCoordinator: # 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 +552,10 @@ class VegasModeCoordinator: # Update components self.render_pipeline.update_config(new_vegas_config) self.stream_manager.config = new_vegas_config + self.plugin_adapter.config = new_vegas_config + # Cached segments were trimmed under the old settings, so drop them + # or a changed trim/padding value would not visibly take effect. + self.plugin_adapter.invalidate_cache() # Force refresh of stream manager to pick up plugin_order/buffer changes self.stream_manager._last_refresh = 0 diff --git a/src/vegas_mode/geometry.py b/src/vegas_mode/geometry.py new file mode 100644 index 00000000..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..0da51ce2 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -8,9 +8,16 @@ implement get_vegas_content() and fallback capture of display() output. 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 @@ class PluginAdapter: self._cache_lock = threading.Lock() self._cache_ttl = 5.0 # Cache for 5 seconds + # Per-plugin rotation offset, so a plugin whose content exceeds its + # width budget shows a different slice on each cycle rather than + # always the same opening items. + self._item_offsets: dict = {} + logger.info( "PluginAdapter initialized: display=%dx%d", self.display_width, self.display_height ) - 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 @@ class PluginAdapter: 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 @@ class PluginAdapter: 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 @@ class PluginAdapter: "[%s] Fallback capture SUCCESS: %d images, %dpx total", plugin_id, len(content), total_width ) - self._cache_content(plugin_id, content) - return content + return self._finalize(content, plugin_id, 'fallback', plugin) logger.warning( "[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)", @@ -130,8 +162,397 @@ class PluginAdapter: ) 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 @@ class PluginAdapter: """ 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 @@ class PluginAdapter: 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 @@ class PluginAdapter: "[%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 @@ class PluginAdapter: # 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 @@ class PluginAdapter: # 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 @@ class PluginAdapter: 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() @@ -663,6 +1135,53 @@ class PluginAdapter: else: self._content_cache.clear() + def invalidate_plugin_scroll_cache( + self, plugin: 'BasePlugin', plugin_id: str + ) -> bool: + """ + Drop a plugin's own cached scroll image so its visual is rebuilt. + + Invalidating only this adapter's cache is not enough. A plugin that + composes a scroll strip hands back the *same* image every time until its + own cache is cleared — the sports plugins' ``get_vegas_content()`` + regenerates only "if the cache is empty" — so without this a segment + keeps rendering whatever data it was first built from. That is how a + game that was live last night can still be displayed as live the next + morning. + + Two layouts to cover: a helper directly on the plugin (stocks, news, + odds-ticker) and one owned by a scroll-display manager (the sports + scoreboards). ``cached_image`` and ``cached_array`` must be cleared + together, since the array is the image's numpy mirror and code paths + read whichever is convenient. + + Returns: + True if a cache was found and cleared. + """ + cleared = False + for owner in (plugin, getattr(plugin, '_scroll_manager', None), + getattr(plugin, 'scroll_manager', None)): + if owner is None: + continue + helper = getattr(owner, 'scroll_helper', None) + if helper is None: + continue + try: + if getattr(helper, 'cached_image', None) is not None: + helper.cached_image = None + cleared = True + if getattr(helper, 'cached_array', None) is not None: + helper.cached_array = None + cleared = True + except Exception: # pylint: disable=broad-except + logger.exception( + "[%s] Could not clear scroll cache on %s", + plugin_id, type(owner).__name__ + ) + if cleared: + logger.debug("[%s] Cleared plugin scroll cache", plugin_id) + return cleared + def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str: """ Get the type of content a plugin provides. diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index 7c9b7c90..8aceabe1 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -6,6 +6,7 @@ Uses the existing ScrollHelper for numpy-optimized scroll operations. """ import logging +import os import time import threading from collections import deque @@ -14,6 +15,7 @@ from PIL import Image 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 @@ class RenderPipeline: else display_manager.height ) - # Reusable blank frame for cycle-end pushes (allocated lazily, - # re-blacked before each reuse) - self._blank_frame = None - # ScrollHelper for optimized scrolling self.scroll_helper = ScrollHelper( self.display_width, @@ -85,6 +87,14 @@ class RenderPipeline: 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 @@ class RenderPipeline: """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 @@ class RenderPipeline: True if composition successful """ try: - # Get all buffered content - images = self.stream_manager.get_all_content_for_composition() + # Content grouped by plugin, so a separator can be placed at the + # plugin boundaries only. + grouped = self.stream_manager.get_grouped_content_for_composition() - if not images: + if not grouped: logger.warning("No content available for composition") return False - # Add separator gaps between images - content_with_gaps = [] - for i, img in enumerate(images): - content_with_gaps.append(img) + # Collapse each plugin's rows into a single block, joined by + # intra_plugin_gap. ScrollHelper applies one uniform gap between the + # items it is given, so handing it one item per plugin is what makes + # separator_width mean "between plugins" instead of "between every + # row". Without this, a per-row ticker such as the F1 scoreboard got + # the full separator between each of its ~116 rows. + blocks = [] + total_rows = 0 + for plugin_id, images in grouped: + total_rows += len(images) + blocks.append(self._join_plugin_rows(images)) - # Create scrolling image via ScrollHelper + # Create scrolling image via ScrollHelper. + # + # lead_gap is explicit because ScrollHelper otherwise prepends a + # full display width of black — appropriate for a standalone ticker + # scrolling in from off-screen, but in Vegas mode it is charged + # once per cycle and reads as the panel switching off. self.scroll_helper.create_scrolling_image( - content_items=content_with_gaps, + content_items=blocks, item_gap=self.config.separator_width, - element_gap=0 + element_gap=0, + lead_gap=self.config.lead_in_width ) # Verify scroll image was created successfully @@ -177,11 +202,16 @@ class RenderPipeline: self._cycle_complete = False logger.info( - "Composed scroll image: %dx%d, %d plugins, %d items", + "Composed scroll image: %dx%d, %d plugin block(s), %d rows, " + "separator=%dpx between plugins, 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 @@ class RenderPipeline: 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 @@ class RenderPipeline: # 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 @@ class RenderPipeline: "Scroll cycle complete after %.1fs", time.time() - self._cycle_start_time ) - # Push blank immediately so the hardware never shows any - # post-wrap content while the coordinator recomposes the - # next cycle (~100 ms). The blank is allocated once and - # reused across cycle wraps (fresh paste each time in case - # a consumer drew on the previous one). - try: - if self._blank_frame is None or self._blank_frame.size != ( - self.display_width, self.display_height): - self._blank_frame = Image.new( - 'RGB', (self.display_width, self.display_height)) - else: - self._blank_frame.paste( - (0, 0, 0), - (0, 0, self.display_width, self.display_height)) - self.display_manager.image = self._blank_frame - self.display_manager.update_display() - except Exception: - logger.exception("Failed to write blank frame to display at cycle end") + # Deliberately leave the last rendered frame on the panel. + # + # This used to push a blank frame so no post-wrap content + # could be seen while the next cycle was composed. But + # recomposing is synchronous and fetches plugin content: + # measured 84ms at best and 4.8s at worst on a 512px panel, + # and every millisecond of it was black. Holding the last + # frame instead turns that into a brief freeze, which reads + # as far less broken than the display switching off. The + # frame is already past the end of the content, so there is + # no second-pass content to leak. return True # Cycle done; coordinator starts new cycle next frame # Get visible portion @@ -336,6 +629,25 @@ class RenderPipeline: return False + def refresh_updated_plugins(self) -> bool: + """ + Let changed plugin data reach the strip without interrupting motion. + + Used instead of :meth:`hot_swap_content` when scrolling continuously. + The swap rebuilds the whole image and repositions the scroll, which is + visible as a freeze and a jump; the strip is extended here rather than + replaced, so it is enough to drop the stale caches and let the plugin + recompose when it next comes round. + + Returns: + True if any plugin's cached content was dropped. + """ + try: + return bool(self.stream_manager.invalidate_pending_updates()) + except Exception: # pylint: disable=broad-except + logger.exception("Failed to refresh updated plugins") + return False + def hot_swap_content(self) -> bool: """ Hot-swap to new composed content. @@ -415,11 +727,12 @@ class RenderPipeline: result = self.compose_scroll_content() if result and self.sync_manager: - # When sync is active, start the leader at display_width instead of 0. - # This skips the initial black gap so the leader immediately shows content. - # The follower starts at position 0 (the gap) which looks like a clean - # blank transition rather than near-end content wrapping around. - self.scroll_helper.scroll_position = float(self.display_width) + # When sync is active, start the leader past the lead-in gap so it + # immediately shows content, leaving the follower on the blank gap + # for a clean transition rather than near-end content wrapping + # around. This tracks lead_in_width rather than assuming a full + # display width of gap, which is no longer the default. + self.scroll_helper.scroll_position = float(self.config.lead_in_width) if result and self.sync_manager: # Signal follower that a new cycle started (triggers its own rebuild) diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index 85d5abdd..a97420c9 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -14,7 +14,7 @@ Supports three display modes: import logging import threading import time -from typing import Optional, List, Dict, Any, Deque, TYPE_CHECKING +from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING from collections import deque from dataclasses import dataclass, field from PIL import Image @@ -116,8 +116,11 @@ class StreamManager: logger.warning("No plugins available for Vegas scroll") return False - # Prefetch initial content - self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins))) + # Fill the buffer to a whole cycle's worth of plugins. This used to be + # buffer_ahead + 1, which conflated prefetch depth with cycle size and + # meant a 20-plugin install only showed 3 plugins before recomposing. + self._prefetch_content( + count=min(self.config.plugins_per_cycle, len(self._ordered_plugins))) logger.info( "StreamManager initialized with %d plugins, %d segments buffered", @@ -198,6 +201,47 @@ class StreamManager: logger.debug("Plugin %s marked for update", plugin_id) + def invalidate_pending_updates(self) -> List[str]: + """ + Drop cached content for plugins whose data changed, without refetching. + + The continuous-scroll counterpart to :meth:`process_updates`. That method + belongs to the swap path: it refetches immediately and merges into the + active buffer, which continuous mode bypasses entirely, and doing that + work on the render thread would hitch the scroll. + + Here it is enough to clear the caches and let the plugin come round in + the rotation, which recomposes it from current data a moment later. Left + uncalled, ``_pending_updates`` simply accumulates and no visual ever + refreshes — a game that was live last night keeps being drawn as live. + + Returns: + The plugin ids whose caches were dropped. + """ + with self._buffer_lock: + if not self._pending_updates: + return [] + updated = list(self._pending_updates.keys()) + self._pending_updates.clear() + + plugins = getattr(self.plugin_manager, 'plugins', {}) + for plugin_id in updated: + try: + self.plugin_adapter.invalidate_cache(plugin_id) + plugin = plugins.get(plugin_id) + if plugin is not None: + self.plugin_adapter.invalidate_plugin_scroll_cache( + plugin, plugin_id) + except Exception: # pylint: disable=broad-except + logger.exception( + "[%s] Could not invalidate cached content", plugin_id) + + logger.info( + "Vegas: dropped cached content for %d updated plugin(s): %s", + len(updated), ', '.join(updated) + ) + return updated + def has_pending_updates(self) -> bool: """Check if any plugins have pending updates awaiting processing.""" with self._buffer_lock: @@ -385,7 +429,7 @@ class StreamManager: return for _ in range(count): - if len(self._active_buffer) >= self.config.buffer_ahead + 1: + if len(self._active_buffer) >= self.config.plugins_per_cycle: break # Ensure index is valid (guard against empty list) @@ -521,28 +565,117 @@ class StreamManager: logger.debug("Refreshed content for %s in staging buffer", plugin_id) def _ensure_buffer_filled(self) -> None: - """Ensure buffer has enough content prefetched.""" - if len(self._active_buffer) < self.config.buffer_ahead: - needed = self.config.buffer_ahead - len(self._active_buffer) - self._prefetch_content(count=needed) + """ + Top the buffer back up after segments have been served. + + buffer_ahead is the low-water mark only; plugins_per_cycle is the + ceiling and is enforced inside _prefetch_content. + """ + low_water = min(self.config.buffer_ahead, self.config.plugins_per_cycle) + if len(self._active_buffer) < low_water: + self._prefetch_content(count=low_water - len(self._active_buffer)) def get_all_content_for_composition(self) -> List[Image.Image]: """ Get all buffered content as a flat list of images. - Used when composing the full scroll image. Skips STATIC segments as they don't have images to compose. + Prefer get_grouped_content_for_composition(): flattening loses the + plugin boundaries, which is what tells the compositor where a + separator belongs and where it does not. + Returns: List of all images in buffer order """ all_images = [] + for _plugin_id, images in self.get_grouped_content_for_composition(): + all_images.extend(images) + return all_images + + def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]: + """ + Get buffered content grouped by the plugin that produced it. + + The grouping matters: separator_width is meant to mark the handoff from + one plugin to the next, not to sit between every row a single plugin + contributes. A per-row ticker like the F1 scoreboard returns over a + hundred images that it renders 4px apart internally, so flattening them + into one list and applying a uniform gap forced 32px between each of + its rows — both inconsistent with how the plugin looks standalone, and + a large hidden addition to the width it occupies. + + Skips STATIC segments, which trigger a pause rather than contributing + scroll content, and segments left with no images. + + Returns: + List of (plugin_id, images) in buffer order + """ + grouped: List[Tuple[str, List[Image.Image]]] = [] with self._buffer_lock: for segment in self._active_buffer: - # Skip STATIC segments - they trigger pauses, not scroll content - if segment.display_mode != VegasDisplayMode.STATIC: - all_images.extend(segment.images) - return all_images + if segment.display_mode == VegasDisplayMode.STATIC: + continue + if not segment.images: + continue + grouped.append((segment.plugin_id, list(segment.images))) + return grouped + + def 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_continuous_refresh.py b/test/test_vegas_continuous_refresh.py new file mode 100644 index 00000000..422a4921 --- /dev/null +++ b/test/test_vegas_continuous_refresh.py @@ -0,0 +1,227 @@ +""" +Regression tests: changed plugin data must reach the strip in continuous mode. + +Two faults combined to freeze Vegas content indefinitely. + +PR #291 added a call to ``plugin_adapter.invalidate_plugin_scroll_cache()`` so a +plugin's *own* cached scroll image would be rebuilt from fresh data. The method +was never implemented, and ``hot_swap_content()`` wraps the call in a broad +except, so every hot swap raised AttributeError and was silently swallowed. + +Continuous scrolling then removed the only path that reached it at all: +``should_recompose()``/``hot_swap_content()`` are called from the non-continuous +branch, while ``continuous_scroll`` defaults to True. + +Together, a plugin composed its scroll image once and handed back the same +picture forever, because the sports plugins' ``get_vegas_content()`` regenerates +only when its cache is empty. Symptom: a game that was live last night is still +drawn as live the following morning. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import numpy as np +from PIL import Image + +from src.vegas_mode.config import VegasModeConfig +from src.vegas_mode.plugin_adapter import PluginAdapter +from src.vegas_mode.render_pipeline import RenderPipeline +from src.vegas_mode.stream_manager import StreamManager + + +class FakeDisplayManager: + width = 64 + height = 32 + + +def _helper(): + """A stand-in ScrollHelper holding both halves of its cache.""" + image = Image.new('RGB', (128, 32), (10, 20, 30)) + return SimpleNamespace(cached_image=image, cached_array=np.array(image)) + + +class TestInvalidatePluginScrollCache: + """The method PR #291 called but never defined.""" + + def test_method_exists(self): + # It was called for months without existing; the broad except in + # hot_swap_content() meant nothing ever surfaced. + assert hasattr(PluginAdapter, 'invalidate_plugin_scroll_cache') + + def test_clears_helper_attached_to_the_plugin(self): + adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig()) + helper = _helper() + plugin = SimpleNamespace(scroll_helper=helper) + + assert adapter.invalidate_plugin_scroll_cache(plugin, 'stocks') is True + assert helper.cached_image is None + assert helper.cached_array is None + + def test_clears_helper_owned_by_a_scroll_manager(self): + # The sports scoreboards keep theirs on _scroll_manager, which is the + # layout that produced the reported stale-scores bug. + adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig()) + helper = _helper() + plugin = SimpleNamespace(_scroll_manager=SimpleNamespace(scroll_helper=helper)) + + assert adapter.invalidate_plugin_scroll_cache(plugin, 'baseball') is True + assert helper.cached_image is None + assert helper.cached_array is None + + def test_clears_both_halves_together(self): + # cached_array is the image's numpy mirror; leaving one behind lets a + # reader pick up content the other no longer has. + adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig()) + helper = _helper() + adapter.invalidate_plugin_scroll_cache( + SimpleNamespace(scroll_helper=helper), 'news') + assert (helper.cached_image, helper.cached_array) == (None, None) + + def test_plugin_without_a_helper_is_not_an_error(self): + adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig()) + assert adapter.invalidate_plugin_scroll_cache(SimpleNamespace(), 'clock') is False + + +class TestInvalidatePendingUpdates: + def _manager(self, plugins): + stream = StreamManager( + VegasModeConfig(), + SimpleNamespace(plugins=plugins), + MagicMock(), + ) + stream.plugin_adapter = MagicMock() + return stream + + def test_drops_caches_for_updated_plugins(self): + helper = _helper() + plugin = SimpleNamespace(scroll_helper=helper) + stream = self._manager({'baseball': plugin}) + stream.mark_plugin_updated('baseball') + + assert stream.invalidate_pending_updates() == ['baseball'] + stream.plugin_adapter.invalidate_cache.assert_called_once_with('baseball') + stream.plugin_adapter.invalidate_plugin_scroll_cache.assert_called_once_with( + plugin, 'baseball') + + def test_pending_flags_are_consumed(self): + # Left unconsumed they accumulate forever and nothing ever refreshes. + stream = self._manager({'baseball': SimpleNamespace()}) + stream.mark_plugin_updated('baseball') + assert stream.has_pending_updates() is True + + stream.invalidate_pending_updates() + assert stream.has_pending_updates() is False + assert stream.invalidate_pending_updates() == [] + + def test_no_pending_updates_does_no_work(self): + stream = self._manager({}) + assert stream.invalidate_pending_updates() == [] + stream.plugin_adapter.invalidate_cache.assert_not_called() + + def test_a_failing_plugin_does_not_stop_the_others(self): + stream = self._manager({'a': SimpleNamespace(), 'b': SimpleNamespace()}) + stream.mark_plugin_updated('a') + stream.mark_plugin_updated('b') + stream.plugin_adapter.invalidate_cache.side_effect = [ + RuntimeError('boom'), None] + + assert sorted(stream.invalidate_pending_updates()) == ['a', 'b'] + assert stream.plugin_adapter.invalidate_cache.call_count == 2 + + +class TestContinuousModeReachesTheRefresh: + def _pipeline(self): + stream = MagicMock() + stream.get_buffer_status.return_value = {'staging_count': 0} + return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream + + def test_refresh_delegates_to_the_stream_manager(self): + pipeline, stream = self._pipeline() + stream.invalidate_pending_updates.return_value = ['baseball'] + assert pipeline.refresh_updated_plugins() is True + + def test_refresh_reports_false_when_nothing_changed(self): + pipeline, stream = self._pipeline() + stream.invalidate_pending_updates.return_value = [] + assert pipeline.refresh_updated_plugins() is False + + def test_refresh_never_raises_into_the_render_loop(self): + pipeline, stream = self._pipeline() + stream.invalidate_pending_updates.side_effect = RuntimeError('boom') + assert pipeline.refresh_updated_plugins() is False + + def test_refresh_does_not_reposition_the_scroll(self): + # The whole point of preferring this over hot_swap_content(): that path + # rebuilds and repositions, which reads as a freeze then a jump. + pipeline, stream = self._pipeline() + stream.invalidate_pending_updates.return_value = ['baseball'] + pipeline.scroll_helper.scroll_position = 1234 + + pipeline.refresh_updated_plugins() + + assert pipeline.scroll_helper.scroll_position == 1234 + stream.swap_buffers.assert_not_called() + stream.process_updates.assert_not_called() + + +class TestCoordinatorWiring: + """ + The regression itself: continuous mode has to *call* the refresh. + + should_recompose()/hot_swap_content() sit in the non-continuous branch, and + continuous_scroll defaults to True, so before this fix the refresh was + simply never reached on a default install. + """ + + def _coordinator(self, continuous): + import threading + + from src.vegas_mode.coordinator import VegasModeCoordinator + + config = VegasModeConfig() + config.continuous_scroll = continuous + # Built without __init__ so the test exercises run_frame's branching + # without standing up a display, stream and render stack. + coordinator = VegasModeCoordinator.__new__(VegasModeCoordinator) + coordinator.vegas_config = config + coordinator.render_pipeline = MagicMock() + coordinator.render_pipeline.has_deferred.return_value = False + coordinator.render_pipeline.needs_extension.return_value = False + coordinator.render_pipeline.is_cycle_complete.return_value = False + coordinator.render_pipeline.should_recompose.return_value = False + coordinator.stream_manager = MagicMock() + coordinator.stats = {'cycles_completed': 0} + coordinator._state_lock = threading.Lock() + coordinator._is_active = True + coordinator._is_paused = False + coordinator._should_stop = False + coordinator._pending_config_update = False + coordinator._live_priority_check = None + coordinator._interrupt_check = None + coordinator.sync_manager = None + return coordinator + + def test_continuous_mode_refreshes_updated_plugins_every_frame(self): + coordinator = self._coordinator(continuous=True) + coordinator.run_frame() + coordinator.render_pipeline.refresh_updated_plugins.assert_called_once() + + def test_continuous_mode_does_not_use_the_disruptive_swap(self): + coordinator = self._coordinator(continuous=True) + coordinator.run_frame() + coordinator.render_pipeline.hot_swap_content.assert_not_called() + + def test_swap_mode_still_uses_hot_swap(self): + # The non-continuous path must keep its original behaviour. + coordinator = self._coordinator(continuous=False) + coordinator.render_pipeline.should_recompose.return_value = True + coordinator.run_frame() + coordinator.render_pipeline.hot_swap_content.assert_called_once() + coordinator.render_pipeline.refresh_updated_plugins.assert_not_called() + + def test_a_frame_is_still_rendered_either_way(self): + for continuous in (True, False): + coordinator = self._coordinator(continuous=continuous) + coordinator.run_frame() + coordinator.render_pipeline.render_frame.assert_called_once() diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py new file mode 100644 index 00000000..f0c1fd72 --- /dev/null +++ b/test/test_vegas_density.py @@ -0,0 +1,1645 @@ +""" +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. The crop always starts at 0 here (fresh + # adapter, no prior rotation offset), and word_strip's first word + # begins at column 0 with no lead-in gap, so the left edge is the + # true start of the content rather than a cut and legitimately + # carries ink. Only the right edge is where the width budget actually + # cropped, so that is the one that must land in 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[-1] or out.width == img.width, \ + "crop's right edge lands 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 + # The extend must consume the prepared group rather than fetching inline, + # so no offscreen_only=False call may appear. + assert False not in stream.calls + + 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 save_main_config(): # Handle Vegas scroll mode settings vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width', - 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins'] + 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins', + 'vegas_auto_trim', 'vegas_trim_threshold', 'vegas_content_padding', + 'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle', + 'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled', + 'vegas_min_cycle_duration', 'vegas_max_cycle_duration', + 'vegas_intra_plugin_gap', '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 save_main_config(): # was submitted (any vegas field present) but enabled key is missing, # the checkbox was unchecked and we should set enabled=False vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled')) + vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim')) + vegas_config['dynamic_duration_enabled'] = _coerce_to_bool( + data.get('vegas_dynamic_duration_enabled')) + vegas_config['continuous_scroll'] = _coerce_to_bool( + data.get('vegas_continuous_scroll')) + vegas_config['smooth_scroll'] = _coerce_to_bool( + data.get('vegas_smooth_scroll')) - # Handle numeric settings with validation + # 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 + + 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 @@
- +
+
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+
+
@@ -456,6 +494,194 @@
+ +
+

Cycle Pacing

+

How long one pass through the ticker lasts, and how many plugins it covers.

+ +
+ +
+ +
+ +
+ +
+
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+
+ + +
+ +
+ + +
+
+
+ + +
+

Dead Space

+

Plugins that draw onto a full-screen canvas contribute all the empty space around their content. Trimming reclaims it so the ticker stays full.

+ +
+ +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+
+
+

Plugin Order