mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Compare commits
3
Commits
main
...
d69dfbbaee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d69dfbbaee | ||
|
|
ec96422803 | ||
|
|
8d57a748a7 |
@@ -130,7 +130,18 @@
|
||||
"plugin_order": [],
|
||||
"excluded_plugins": [],
|
||||
"target_fps": 125,
|
||||
"buffer_ahead": 2
|
||||
"buffer_ahead": 2,
|
||||
"intra_plugin_gap": 8,
|
||||
"auto_trim": true,
|
||||
"trim_threshold": 10,
|
||||
"content_padding": 8,
|
||||
"min_plugin_width": 8,
|
||||
"lead_in_width": 0,
|
||||
"plugins_per_cycle": 6,
|
||||
"max_plugin_width_ratio": 3.0,
|
||||
"dynamic_duration_enabled": true,
|
||||
"min_cycle_duration": 60,
|
||||
"max_cycle_duration": 240
|
||||
}
|
||||
},
|
||||
"sync": {
|
||||
|
||||
@@ -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())
|
||||
@@ -112,7 +112,8 @@ class ScrollHelper:
|
||||
|
||||
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.
|
||||
|
||||
@@ -120,10 +121,19 @@ class ScrollHelper:
|
||||
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
|
||||
|
||||
@@ -15,6 +15,7 @@ PIL Image canvas and draws text using the actual project fonts.
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
@@ -62,6 +63,9 @@ class VisualTestDisplayManager:
|
||||
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
||||
self.matrix = _MatrixProxy(width, height)
|
||||
|
||||
# Set while inside capture_mode(); mirrors DisplayManager's flag.
|
||||
self._capture_mode_active = False
|
||||
|
||||
# Scrolling state (interface compat, no-op)
|
||||
self._scrolling_state = {
|
||||
'is_scrolling': False,
|
||||
@@ -174,6 +178,21 @@ class VisualTestDisplayManager:
|
||||
"""No-op for hardware; marks that display was updated."""
|
||||
self.update_called = True
|
||||
|
||||
@contextmanager
|
||||
def capture_mode(self):
|
||||
"""
|
||||
Interface parity with DisplayManager.capture_mode().
|
||||
|
||||
There is no hardware to suppress here, but Vegas mode's PluginAdapter
|
||||
wraps every off-screen content fetch in this context, so the harness
|
||||
must provide it for that code path to be exercisable in tests.
|
||||
"""
|
||||
self._capture_mode_active = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._capture_mode_active = False
|
||||
|
||||
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
|
||||
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
|
||||
font: Optional[Any] = None, centered: bool = False) -> None:
|
||||
|
||||
@@ -21,6 +21,41 @@ class VegasModeConfig:
|
||||
scroll_speed: float = 50.0 # Pixels per second
|
||||
separator_width: int = 32 # Gap between plugins (pixels)
|
||||
|
||||
# Gap between rows contributed by the *same* plugin. separator_width marks
|
||||
# the handoff from one plugin to the next; applying it between every image
|
||||
# forced a 32px chasm between each row of a per-row ticker (the F1
|
||||
# scoreboard renders its own rows 4px apart), which both looked wrong and
|
||||
# silently inflated the width that plugin occupied.
|
||||
intra_plugin_gap: int = 8
|
||||
|
||||
# Content density
|
||||
#
|
||||
# Plugins that render onto a full-display canvas contribute that whole
|
||||
# canvas to the ticker, blank margins included. On a wide panel that is the
|
||||
# dominant source of dead air: a plugin drawing 35px of text on a 512px
|
||||
# canvas otherwise buys 9.5s of black at 50px/s. Trimming reclaims it.
|
||||
auto_trim: bool = True
|
||||
trim_threshold: int = 10 # Per-channel value a pixel must exceed to be "ink"
|
||||
content_padding: int = 8 # Blank columns kept either side of trimmed content
|
||||
min_plugin_width: int = 8 # Segments narrower than this after trim are dropped
|
||||
|
||||
# Columns of blank lead-in before the first item of a cycle. ScrollHelper
|
||||
# defaults this to a full display width, which reads as the display being
|
||||
# switched off at the start of every cycle.
|
||||
lead_in_width: int = 0
|
||||
|
||||
# How many plugins are composed into one scroll cycle. Kept separate from
|
||||
# buffer_ahead (which is only a prefetch low-water mark) because the two
|
||||
# were previously the same number: a buffer_ahead of 2 meant just 3 plugins
|
||||
# per cycle, so a 20-plugin install took seven cycles to come around.
|
||||
plugins_per_cycle: int = 6
|
||||
|
||||
# Cap on one plugin's share of a cycle, as a multiple of display width.
|
||||
# A single ticker returning 7,000px would otherwise hold the panel for over
|
||||
# two minutes. Overflow is deferred to later cycles rather than discarded.
|
||||
# 0 disables the cap.
|
||||
max_plugin_width_ratio: float = 3.0
|
||||
|
||||
# Plugin management
|
||||
plugin_order: List[str] = field(default_factory=list)
|
||||
excluded_plugins: Set[str] = field(default_factory=set)
|
||||
@@ -55,6 +90,15 @@ class VegasModeConfig:
|
||||
enabled=vegas_config.get('enabled', False),
|
||||
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
|
||||
separator_width=int(vegas_config.get('separator_width', 32)),
|
||||
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
|
||||
auto_trim=vegas_config.get('auto_trim', True),
|
||||
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
|
||||
content_padding=int(vegas_config.get('content_padding', 8)),
|
||||
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)),
|
||||
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
|
||||
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
|
||||
max_plugin_width_ratio=float(
|
||||
vegas_config.get('max_plugin_width_ratio', 3.0)),
|
||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
||||
target_fps=int(vegas_config.get('target_fps', 125)),
|
||||
@@ -72,6 +116,14 @@ class VegasModeConfig:
|
||||
'enabled': self.enabled,
|
||||
'scroll_speed': self.scroll_speed,
|
||||
'separator_width': self.separator_width,
|
||||
'intra_plugin_gap': self.intra_plugin_gap,
|
||||
'auto_trim': self.auto_trim,
|
||||
'trim_threshold': self.trim_threshold,
|
||||
'content_padding': self.content_padding,
|
||||
'min_plugin_width': self.min_plugin_width,
|
||||
'lead_in_width': self.lead_in_width,
|
||||
'plugins_per_cycle': self.plugins_per_cycle,
|
||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
||||
'plugin_order': self.plugin_order,
|
||||
'excluded_plugins': list(self.excluded_plugins),
|
||||
'target_fps': self.target_fps,
|
||||
@@ -157,6 +209,49 @@ class VegasModeConfig:
|
||||
if self.buffer_ahead > 5:
|
||||
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
|
||||
|
||||
if self.intra_plugin_gap < 0:
|
||||
errors.append(
|
||||
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
|
||||
if self.intra_plugin_gap > 128:
|
||||
errors.append(
|
||||
f"intra_plugin_gap must be <= 128, got {self.intra_plugin_gap}")
|
||||
|
||||
if not 0 <= self.trim_threshold <= 254:
|
||||
errors.append(
|
||||
f"trim_threshold must be between 0 and 254, got {self.trim_threshold}")
|
||||
|
||||
if self.content_padding < 0:
|
||||
errors.append(
|
||||
f"content_padding must be >= 0, got {self.content_padding}")
|
||||
if self.content_padding > 128:
|
||||
errors.append(
|
||||
f"content_padding must be <= 128, got {self.content_padding}")
|
||||
|
||||
if self.min_plugin_width < 0:
|
||||
errors.append(
|
||||
f"min_plugin_width must be >= 0, got {self.min_plugin_width}")
|
||||
# Bounded because every segment narrower than this is dropped — an
|
||||
# unbounded value would discard every plugin and leave a blank ticker.
|
||||
if self.min_plugin_width > 512:
|
||||
errors.append(
|
||||
f"min_plugin_width must be <= 512, got {self.min_plugin_width}")
|
||||
|
||||
if self.lead_in_width < 0:
|
||||
errors.append(
|
||||
f"lead_in_width must be >= 0, got {self.lead_in_width}")
|
||||
|
||||
if self.plugins_per_cycle < 1:
|
||||
errors.append(
|
||||
f"plugins_per_cycle must be >= 1, got {self.plugins_per_cycle}")
|
||||
if self.plugins_per_cycle > 50:
|
||||
errors.append(
|
||||
f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}")
|
||||
|
||||
if self.max_plugin_width_ratio < 0:
|
||||
errors.append(
|
||||
"max_plugin_width_ratio must be >= 0 "
|
||||
f"(0 disables the cap), got {self.max_plugin_width_ratio}")
|
||||
|
||||
return errors
|
||||
|
||||
def update(self, new_config: Dict[str, Any]) -> None:
|
||||
@@ -174,6 +269,23 @@ class VegasModeConfig:
|
||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||
if 'separator_width' in vegas_config:
|
||||
self.separator_width = int(vegas_config['separator_width'])
|
||||
if 'intra_plugin_gap' in vegas_config:
|
||||
self.intra_plugin_gap = int(vegas_config['intra_plugin_gap'])
|
||||
if 'auto_trim' in vegas_config:
|
||||
self.auto_trim = vegas_config['auto_trim']
|
||||
if 'trim_threshold' in vegas_config:
|
||||
self.trim_threshold = int(vegas_config['trim_threshold'])
|
||||
if 'content_padding' in vegas_config:
|
||||
self.content_padding = int(vegas_config['content_padding'])
|
||||
if 'min_plugin_width' in vegas_config:
|
||||
self.min_plugin_width = int(vegas_config['min_plugin_width'])
|
||||
if 'lead_in_width' in vegas_config:
|
||||
self.lead_in_width = int(vegas_config['lead_in_width'])
|
||||
if 'plugins_per_cycle' in vegas_config:
|
||||
self.plugins_per_cycle = int(vegas_config['plugins_per_cycle'])
|
||||
if 'max_plugin_width_ratio' in vegas_config:
|
||||
self.max_plugin_width_ratio = float(
|
||||
vegas_config['max_plugin_width_ratio'])
|
||||
if 'plugin_order' in vegas_config:
|
||||
self.plugin_order = list(vegas_config['plugin_order'])
|
||||
if 'excluded_plugins' in vegas_config:
|
||||
|
||||
@@ -64,7 +64,7 @@ class VegasModeCoordinator:
|
||||
self.plugin_manager = plugin_manager
|
||||
|
||||
# Initialize components
|
||||
self.plugin_adapter = PluginAdapter(display_manager)
|
||||
self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
|
||||
self.stream_manager = StreamManager(
|
||||
self.vegas_config,
|
||||
plugin_manager,
|
||||
@@ -505,6 +505,10 @@ class VegasModeCoordinator:
|
||||
# Update components
|
||||
self.render_pipeline.update_config(new_vegas_config)
|
||||
self.stream_manager.config = new_vegas_config
|
||||
self.plugin_adapter.config = new_vegas_config
|
||||
# Cached segments were trimmed under the old settings, so drop them
|
||||
# or a changed trim/padding value would not visibly take effect.
|
||||
self.plugin_adapter.invalidate_cache()
|
||||
|
||||
# Force refresh of stream manager to pick up plugin_order/buffer changes
|
||||
self.stream_manager._last_refresh = 0
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Geometry primitives for Vegas Mode.
|
||||
|
||||
Pure, side-effect-free measurements over PIL images. Two consumers:
|
||||
|
||||
- ``PluginAdapter`` trims the blank margins plugins bake into their content
|
||||
before it enters the ticker (see ``trim_to_content``).
|
||||
- ``scripts/dev/vegas_audit.py`` reports how much of the composed ticker is
|
||||
dead space (see ``dead_window_stats``).
|
||||
|
||||
Keeping both on the same primitives means the number the audit reports is the
|
||||
number the trimmer acted on.
|
||||
|
||||
All column scans go through numpy: a Python-level per-column loop over a
|
||||
17,000px-wide ticker image takes seconds, which is far too slow for the render
|
||||
path.
|
||||
"""
|
||||
|
||||
from typing import NamedTuple, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# A pixel counts as "ink" when any channel exceeds this. Chosen to ignore the
|
||||
# 1-2/255 noise that JPEG-sourced logos and alpha compositing leave behind in
|
||||
# nominally black areas, while still treating any deliberately drawn dark grey
|
||||
# as real content.
|
||||
DEFAULT_INK_THRESHOLD = 10
|
||||
|
||||
# A window counts as "dead" when this fraction of its columns carry no ink.
|
||||
DEFAULT_DEAD_WINDOW_RATIO = 0.95
|
||||
|
||||
|
||||
def column_has_ink(img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD) -> np.ndarray:
|
||||
"""
|
||||
Return a boolean array, one entry per image column, True where the column
|
||||
contains at least one pixel brighter than ``threshold`` in any channel.
|
||||
|
||||
Args:
|
||||
img: Image to scan (converted to RGB internally)
|
||||
threshold: Per-channel value a pixel must exceed to count as ink
|
||||
|
||||
Returns:
|
||||
Bool array of shape (width,)
|
||||
"""
|
||||
arr = np.asarray(img if img.mode == 'RGB' else img.convert('RGB'))
|
||||
if arr.ndim != 3:
|
||||
# Degenerate/empty image — treat every column as blank.
|
||||
return np.zeros(img.width, dtype=bool)
|
||||
# Collapse rows and channels: a column is ink if any pixel in it is bright.
|
||||
return arr.max(axis=(0, 2)) > threshold
|
||||
|
||||
|
||||
def content_bounds(
|
||||
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""
|
||||
Find the first and last columns containing ink.
|
||||
|
||||
Args:
|
||||
img: Image to measure
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
(first_col, last_col) inclusive, or None if the image is entirely blank
|
||||
"""
|
||||
ink = column_has_ink(img, threshold)
|
||||
if not ink.any():
|
||||
return None
|
||||
first = int(ink.argmax())
|
||||
last = len(ink) - 1 - int(ink[::-1].argmax())
|
||||
return first, last
|
||||
|
||||
|
||||
class TrimResult(NamedTuple):
|
||||
"""Outcome of a ``trim_to_content`` call."""
|
||||
|
||||
image: Optional[Image.Image] # None when the source was entirely blank
|
||||
original_width: int
|
||||
trimmed_left: int
|
||||
trimmed_right: int
|
||||
|
||||
@property
|
||||
def is_blank(self) -> bool:
|
||||
"""True when the source image carried no ink at all."""
|
||||
return self.image is None
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
"""Width after trimming (0 for a blank source)."""
|
||||
return 0 if self.image is None else self.image.width
|
||||
|
||||
@property
|
||||
def removed(self) -> int:
|
||||
"""Total columns removed."""
|
||||
return self.trimmed_left + self.trimmed_right
|
||||
|
||||
|
||||
def trim_to_content(
|
||||
img: Image.Image,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
padding: int = 0,
|
||||
) -> TrimResult:
|
||||
"""
|
||||
Crop blank columns off the left and right edges of an image.
|
||||
|
||||
Only the outer edges are considered. Blank columns *between* two pieces of
|
||||
content are deliberately preserved — those are the plugin's own layout
|
||||
(e.g. a logo on the left and a score on the right), and closing them up
|
||||
would corrupt the design rather than reclaim dead space.
|
||||
|
||||
A plugin drawing on a non-black background is unaffected: every column of a
|
||||
filled background carries ink, so there is nothing to trim.
|
||||
|
||||
Args:
|
||||
img: Image to trim
|
||||
threshold: Ink threshold
|
||||
padding: Columns of the original blank margin to keep on each side, as
|
||||
breathing room. Capped at what the margin actually contains, so
|
||||
this never widens the image beyond its original bounds.
|
||||
|
||||
Returns:
|
||||
TrimResult. When the image is entirely blank, ``image`` is None and the
|
||||
caller decides whether to skip the plugin.
|
||||
"""
|
||||
bounds = content_bounds(img, threshold)
|
||||
if bounds is None:
|
||||
return TrimResult(None, img.width, 0, 0)
|
||||
|
||||
first, last = bounds
|
||||
pad = max(0, padding)
|
||||
left = max(0, first - pad)
|
||||
right = min(img.width, last + 1 + pad)
|
||||
|
||||
if left == 0 and right == img.width:
|
||||
return TrimResult(img, img.width, 0, 0)
|
||||
|
||||
cropped = img.crop((left, 0, right, img.height))
|
||||
return TrimResult(cropped, img.width, left, img.width - right)
|
||||
|
||||
|
||||
def find_blank_cut(
|
||||
img: Image.Image,
|
||||
target: int,
|
||||
search_radius: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> int:
|
||||
"""
|
||||
Find a column near ``target`` that carries no ink, so an image can be cut
|
||||
there without slicing through a glyph or logo.
|
||||
|
||||
Used when a single oversized segment has to be narrowed to fit a width
|
||||
budget. Cutting at an arbitrary column would leave half a character
|
||||
hanging at the panel edge; snapping to the nearest gap hides the cut.
|
||||
|
||||
Args:
|
||||
img: Image to cut
|
||||
target: Preferred cut column
|
||||
search_radius: How far either side of ``target`` to look
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
A blank column within the search window, or ``target`` clamped to the
|
||||
image bounds when the window contains no blank column at all.
|
||||
"""
|
||||
width = img.width
|
||||
target = max(0, min(target, width))
|
||||
if search_radius <= 0 or width == 0:
|
||||
return target
|
||||
|
||||
ink = column_has_ink(img, threshold)
|
||||
lo = max(0, target - search_radius)
|
||||
hi = min(width - 1, target + search_radius)
|
||||
|
||||
# Walk outwards from target so the nearest gap wins.
|
||||
for offset in range(0, search_radius + 1):
|
||||
right = target + offset
|
||||
if right <= hi and not ink[right]:
|
||||
return right
|
||||
left = target - offset
|
||||
if left >= lo and not ink[left]:
|
||||
return left
|
||||
|
||||
return target
|
||||
|
||||
|
||||
class DeadWindowStats(NamedTuple):
|
||||
"""How much of a composed ticker reads as blank to a viewer."""
|
||||
|
||||
total_windows: int
|
||||
dead_windows: int
|
||||
longest_dead_run: int # consecutive dead windows (i.e. scroll steps)
|
||||
|
||||
@property
|
||||
def dead_ratio(self) -> float:
|
||||
"""Fraction of viewport positions that are effectively blank."""
|
||||
if self.total_windows <= 0:
|
||||
return 0.0
|
||||
return self.dead_windows / self.total_windows
|
||||
|
||||
|
||||
def dead_window_stats(
|
||||
img: Image.Image,
|
||||
viewport_width: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
dead_ratio: float = DEFAULT_DEAD_WINDOW_RATIO,
|
||||
step: int = 1,
|
||||
) -> DeadWindowStats:
|
||||
"""
|
||||
Slide a viewport across a composed ticker image and count how many
|
||||
positions are effectively blank.
|
||||
|
||||
This models what the viewer actually experiences: the ticker is only ever
|
||||
seen ``viewport_width`` columns at a time, so a stretch of blank wider than
|
||||
the viewport becomes a period where the panel looks switched off. Measuring
|
||||
per-window rather than per-column is what makes the result correspond to
|
||||
perceived dead time.
|
||||
|
||||
Args:
|
||||
img: Composed ticker image
|
||||
viewport_width: Display width in pixels
|
||||
threshold: Ink threshold
|
||||
dead_ratio: Fraction of blank columns for a window to count as dead
|
||||
step: Column stride between sampled windows. 1 is exact; larger values
|
||||
trade precision for speed on very wide images.
|
||||
|
||||
Returns:
|
||||
DeadWindowStats. ``longest_dead_run`` is in units of ``step`` columns,
|
||||
so multiply by ``step`` for pixels.
|
||||
"""
|
||||
if viewport_width <= 0 or img.width <= 0:
|
||||
return DeadWindowStats(0, 0, 0)
|
||||
|
||||
ink = column_has_ink(img, threshold)
|
||||
step = max(1, step)
|
||||
|
||||
# Prefix sum of ink counts lets each window be evaluated in constant time,
|
||||
# instead of re-summing viewport_width columns per position.
|
||||
prefix = np.concatenate(([0], np.cumsum(ink)))
|
||||
|
||||
# Only whole windows are sampled; a partial tail window would report
|
||||
# artificially dead because it has fewer columns to draw ink from.
|
||||
last_start = img.width - viewport_width
|
||||
if last_start < 0:
|
||||
# Image narrower than the viewport — evaluate it as a single window.
|
||||
blank_cols = len(ink) - int(prefix[-1])
|
||||
is_dead = blank_cols >= dead_ratio * len(ink)
|
||||
return DeadWindowStats(1, 1 if is_dead else 0, 1 if is_dead else 0)
|
||||
|
||||
starts = np.arange(0, last_start + 1, step)
|
||||
ink_counts = prefix[starts + viewport_width] - prefix[starts]
|
||||
blank_counts = viewport_width - ink_counts
|
||||
dead = blank_counts >= dead_ratio * viewport_width
|
||||
|
||||
longest = _longest_true_run(dead)
|
||||
return DeadWindowStats(len(starts), int(dead.sum()), longest)
|
||||
|
||||
|
||||
class CoverageStats(NamedTuple):
|
||||
"""How well-filled the viewport stays as the ticker scrolls past."""
|
||||
|
||||
total_windows: int
|
||||
mean_ink_ratio: float # average fraction of the viewport carrying ink
|
||||
min_ink_ratio: float # worst viewport position in the cycle
|
||||
sparse_windows: int # positions below the "looks empty" threshold
|
||||
longest_sparse_run: int # consecutive sparse positions, in steps
|
||||
|
||||
@property
|
||||
def sparse_ratio(self) -> float:
|
||||
"""Fraction of viewport positions that read as near-empty."""
|
||||
if self.total_windows <= 0:
|
||||
return 0.0
|
||||
return self.sparse_windows / self.total_windows
|
||||
|
||||
|
||||
def window_coverage_stats(
|
||||
img: Image.Image,
|
||||
viewport_width: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
sparse_ink_ratio: float = 0.10,
|
||||
step: int = 1,
|
||||
) -> CoverageStats:
|
||||
"""
|
||||
Measure how full the viewport stays across a whole scroll cycle.
|
||||
|
||||
``dead_window_stats`` only catches viewport positions that are *entirely*
|
||||
blank. That misses the more common complaint: a position holding one narrow
|
||||
sliver of content at the very edge, with the other 90% black. Such a
|
||||
position is not "dead" by that definition but still looks switched off.
|
||||
This function grades every position by how much ink it carries, so
|
||||
"there is always something to see" becomes measurable.
|
||||
|
||||
Args:
|
||||
img: Composed ticker image
|
||||
viewport_width: Display width in pixels
|
||||
threshold: Ink threshold
|
||||
sparse_ink_ratio: A position with less than this fraction of inked
|
||||
columns counts as reading near-empty
|
||||
step: Column stride between sampled positions
|
||||
|
||||
Returns:
|
||||
CoverageStats
|
||||
"""
|
||||
if viewport_width <= 0 or img.width <= 0:
|
||||
return CoverageStats(0, 0.0, 0.0, 0, 0)
|
||||
|
||||
ink = column_has_ink(img, threshold)
|
||||
step = max(1, step)
|
||||
prefix = np.concatenate(([0], np.cumsum(ink)))
|
||||
|
||||
last_start = img.width - viewport_width
|
||||
if last_start < 0:
|
||||
ratio = float(prefix[-1]) / viewport_width
|
||||
sparse = ratio < sparse_ink_ratio
|
||||
return CoverageStats(1, ratio, ratio, 1 if sparse else 0, 1 if sparse else 0)
|
||||
|
||||
starts = np.arange(0, last_start + 1, step)
|
||||
ratios = (prefix[starts + viewport_width] - prefix[starts]) / viewport_width
|
||||
sparse_flags = ratios < sparse_ink_ratio
|
||||
|
||||
return CoverageStats(
|
||||
total_windows=len(starts),
|
||||
mean_ink_ratio=float(ratios.mean()),
|
||||
min_ink_ratio=float(ratios.min()),
|
||||
sparse_windows=int(sparse_flags.sum()),
|
||||
longest_sparse_run=_longest_true_run(sparse_flags),
|
||||
)
|
||||
|
||||
|
||||
def _longest_true_run(flags: np.ndarray) -> int:
|
||||
"""Length of the longest consecutive run of True in a boolean array."""
|
||||
if flags.size == 0 or not flags.any():
|
||||
return 0
|
||||
# Reset a running counter at every False by subtracting the cumulative max
|
||||
# of the counter's value at the preceding False positions.
|
||||
idx = np.arange(len(flags))
|
||||
not_flag = ~flags
|
||||
# For each position, the index of the most recent False at or before it.
|
||||
last_false = np.maximum.accumulate(np.where(not_flag, idx, -1))
|
||||
run_lengths = idx - last_false
|
||||
return int(run_lengths[flags].max())
|
||||
@@ -11,6 +11,8 @@ import time
|
||||
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.geometry import find_blank_cut, trim_to_content
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
@@ -26,14 +28,21 @@ class PluginAdapter:
|
||||
2. Fallback: Capture display_manager.image after calling plugin.display()
|
||||
"""
|
||||
|
||||
def __init__(self, display_manager: Any):
|
||||
def __init__(self, display_manager: Any, config: Optional[Any] = None):
|
||||
"""
|
||||
Initialize the plugin adapter.
|
||||
|
||||
Args:
|
||||
display_manager: DisplayManager instance for fallback capture
|
||||
config: VegasModeConfig controlling trim behaviour. When omitted,
|
||||
trimming runs with the dataclass defaults, so existing callers
|
||||
and tests keep working unchanged.
|
||||
"""
|
||||
self.display_manager = display_manager
|
||||
if config is None:
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
config = VegasModeConfig()
|
||||
self.config = config
|
||||
# Handle both property and method access patterns
|
||||
self.display_width = (
|
||||
display_manager.width() if callable(display_manager.width)
|
||||
@@ -49,6 +58,11 @@ class PluginAdapter:
|
||||
self._cache_lock = threading.Lock()
|
||||
self._cache_ttl = 5.0 # Cache for 5 seconds
|
||||
|
||||
# Per-plugin rotation offset, so a plugin whose content exceeds its
|
||||
# width budget shows a different slice on each cycle rather than
|
||||
# always the same opening items.
|
||||
self._item_offsets: dict = {}
|
||||
|
||||
logger.info(
|
||||
"PluginAdapter initialized: display=%dx%d",
|
||||
self.display_width, self.display_height
|
||||
@@ -93,8 +107,7 @@ class PluginAdapter:
|
||||
"[%s] Native content SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
return self._finalize(content, plugin_id, 'native')
|
||||
logger.info("[%s] Native content returned None", plugin_id)
|
||||
|
||||
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
|
||||
@@ -107,8 +120,7 @@ class PluginAdapter:
|
||||
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
return self._finalize(content, plugin_id, 'scroll_helper')
|
||||
if has_scroll_helper:
|
||||
logger.info("[%s] ScrollHelper content returned None", plugin_id)
|
||||
|
||||
@@ -121,8 +133,7 @@ class PluginAdapter:
|
||||
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
return self._finalize(content, plugin_id, 'fallback')
|
||||
|
||||
logger.warning(
|
||||
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
|
||||
@@ -130,6 +141,181 @@ class PluginAdapter:
|
||||
)
|
||||
return None
|
||||
|
||||
def _finalize(
|
||||
self, images: List[Image.Image], plugin_id: str, source: str
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Trim dead space off a segment, then cache it.
|
||||
|
||||
Every content path funnels through here so trimming is applied
|
||||
uniformly. Previously only the scroll_helper path had its margins
|
||||
stripped, which left plugins that render onto a full-display canvas
|
||||
contributing their entire blank canvas to the ticker.
|
||||
|
||||
Each image is trimmed independently because compose_scroll_content()
|
||||
treats every image as its own item and inserts separator_width between
|
||||
them — so a per-image trim is what makes that separator the real gap.
|
||||
|
||||
Args:
|
||||
images: Raw content from one of the fetch paths
|
||||
plugin_id: Plugin identifier for logging
|
||||
source: Which path produced the content, for logging
|
||||
|
||||
Returns:
|
||||
Trimmed image list, or None if nothing worth showing remains
|
||||
"""
|
||||
if not self.config.auto_trim:
|
||||
self._cache_content(plugin_id, images)
|
||||
return images
|
||||
|
||||
original_width = sum(img.width for img in images)
|
||||
kept: List[Image.Image] = []
|
||||
dropped_blank = 0
|
||||
|
||||
for img in images:
|
||||
result = trim_to_content(
|
||||
img,
|
||||
threshold=self.config.trim_threshold,
|
||||
padding=self.config.content_padding,
|
||||
)
|
||||
if result.is_blank:
|
||||
dropped_blank += 1
|
||||
continue
|
||||
kept.append(result.image)
|
||||
|
||||
if not kept:
|
||||
logger.info(
|
||||
"[%s] All %d image(s) from %s were blank — contributing nothing",
|
||||
plugin_id, len(images), source
|
||||
)
|
||||
return None
|
||||
|
||||
trimmed_width = sum(img.width for img in kept)
|
||||
|
||||
if trimmed_width < self.config.min_plugin_width:
|
||||
logger.info(
|
||||
"[%s] Trimmed content %dpx is below min_plugin_width %dpx — skipping",
|
||||
plugin_id, trimmed_width, self.config.min_plugin_width
|
||||
)
|
||||
return None
|
||||
|
||||
if trimmed_width != original_width or dropped_blank:
|
||||
logger.info(
|
||||
"[%s] Trimmed %s content: %dpx -> %dpx (%.0f%% reclaimed), "
|
||||
"%d image(s) kept, %d blank dropped",
|
||||
plugin_id, source, original_width, trimmed_width,
|
||||
100.0 * (original_width - trimmed_width) / original_width
|
||||
if original_width else 0.0,
|
||||
len(kept), dropped_blank
|
||||
)
|
||||
|
||||
kept = self._apply_width_budget(kept, plugin_id)
|
||||
|
||||
self._cache_content(plugin_id, kept)
|
||||
return kept
|
||||
|
||||
def _width_budget(self) -> int:
|
||||
"""Maximum columns one plugin may occupy in a cycle. 0 means unlimited."""
|
||||
ratio = self.config.max_plugin_width_ratio
|
||||
if ratio <= 0:
|
||||
return 0
|
||||
return int(self.display_width * ratio)
|
||||
|
||||
def _apply_width_budget(
|
||||
self, images: List[Image.Image], plugin_id: str
|
||||
) -> List[Image.Image]:
|
||||
"""
|
||||
Hold one plugin to its share of a cycle.
|
||||
|
||||
A ticker returning 7,000px would otherwise own the panel for over two
|
||||
minutes, which defeats the point of a rotation. Overflow is deferred
|
||||
rather than discarded: the starting offset advances each time this
|
||||
plugin is fetched, so later items appear on subsequent cycles instead
|
||||
of never being seen.
|
||||
|
||||
Args:
|
||||
images: Trimmed images for this plugin
|
||||
plugin_id: Plugin identifier, used to track its rotation offset
|
||||
|
||||
Returns:
|
||||
Images that fit the budget, starting from the plugin's current
|
||||
rotation offset.
|
||||
"""
|
||||
budget = self._width_budget()
|
||||
|
||||
# Count the gaps the compositor will insert between these rows, not
|
||||
# just the pixels of the rows themselves — otherwise a plugin with many
|
||||
# rows quietly occupies far more of the panel than its budget allows.
|
||||
gap = max(0, self.config.intra_plugin_gap)
|
||||
total = sum(img.width for img in images) + gap * (len(images) - 1)
|
||||
|
||||
if not budget or total <= budget:
|
||||
# Fits, so reset rotation — the whole segment is being shown.
|
||||
self._item_offsets.pop(plugin_id, None)
|
||||
return images
|
||||
|
||||
if len(images) == 1:
|
||||
return [self._crop_to_budget(images[0], budget, plugin_id)]
|
||||
|
||||
start = self._item_offsets.get(plugin_id, 0) % len(images)
|
||||
selected: List[Image.Image] = []
|
||||
used = 0
|
||||
consumed = 0
|
||||
|
||||
# Walk forward from the rotation offset, taking whole items only, so a
|
||||
# cut never lands in the middle of one.
|
||||
for step in range(len(images)):
|
||||
img = images[(start + step) % len(images)]
|
||||
cost = img.width + (gap if selected else 0)
|
||||
if selected and used + cost > budget:
|
||||
break
|
||||
selected.append(img)
|
||||
used += cost
|
||||
consumed += 1
|
||||
|
||||
self._item_offsets[plugin_id] = (start + consumed) % len(images)
|
||||
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
||||
"from offset %d; remainder deferred to a later cycle",
|
||||
plugin_id, budget, len(selected), len(images), used, start
|
||||
)
|
||||
return selected
|
||||
|
||||
def _crop_to_budget(
|
||||
self, img: Image.Image, budget: int, plugin_id: str
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Narrow a single oversized image to the budget, advancing a window
|
||||
through it across cycles.
|
||||
|
||||
The cut is snapped to the nearest blank column so it does not slice
|
||||
through a glyph or logo and leave half a character at the panel edge.
|
||||
"""
|
||||
offset = self._item_offsets.get(plugin_id, 0)
|
||||
if offset >= img.width:
|
||||
offset = 0
|
||||
|
||||
# Snap both edges to blank columns. The search radius is generous
|
||||
# enough to clear a wide glyph but small enough not to distort the
|
||||
# requested budget much.
|
||||
snap = max(8, self.display_width // 16)
|
||||
start = find_blank_cut(img, offset, snap, self.config.trim_threshold)
|
||||
end = find_blank_cut(
|
||||
img, min(start + budget, img.width), snap, self.config.trim_threshold)
|
||||
if end <= start:
|
||||
end = min(start + budget, img.width)
|
||||
|
||||
# Next cycle resumes where this one stopped; wrap when the strip ends.
|
||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
|
||||
"(%dpx), window advances next cycle",
|
||||
plugin_id, budget, img.width, start, end, end - start
|
||||
)
|
||||
return img.crop((start, 0, end, img.height))
|
||||
|
||||
def _get_native_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
) -> Optional[List[Image.Image]]:
|
||||
|
||||
@@ -66,10 +66,6 @@ class RenderPipeline:
|
||||
else display_manager.height
|
||||
)
|
||||
|
||||
# Reusable blank frame for cycle-end pushes (allocated lazily,
|
||||
# re-blacked before each reuse)
|
||||
self._blank_frame = None
|
||||
|
||||
# ScrollHelper for optimized scrolling
|
||||
self.scroll_helper = ScrollHelper(
|
||||
self.display_width,
|
||||
@@ -141,23 +137,37 @@ class RenderPipeline:
|
||||
True if composition successful
|
||||
"""
|
||||
try:
|
||||
# Get all buffered content
|
||||
images = self.stream_manager.get_all_content_for_composition()
|
||||
# Content grouped by plugin, so a separator can be placed at the
|
||||
# plugin boundaries only.
|
||||
grouped = self.stream_manager.get_grouped_content_for_composition()
|
||||
|
||||
if not images:
|
||||
if not grouped:
|
||||
logger.warning("No content available for composition")
|
||||
return False
|
||||
|
||||
# Add separator gaps between images
|
||||
content_with_gaps = []
|
||||
for i, img in enumerate(images):
|
||||
content_with_gaps.append(img)
|
||||
# Collapse each plugin's rows into a single block, joined by
|
||||
# intra_plugin_gap. ScrollHelper applies one uniform gap between the
|
||||
# items it is given, so handing it one item per plugin is what makes
|
||||
# separator_width mean "between plugins" instead of "between every
|
||||
# row". Without this, a per-row ticker such as the F1 scoreboard got
|
||||
# the full separator between each of its ~116 rows.
|
||||
blocks = []
|
||||
total_rows = 0
|
||||
for plugin_id, images in grouped:
|
||||
total_rows += len(images)
|
||||
blocks.append(self._join_plugin_rows(images))
|
||||
|
||||
# Create scrolling image via ScrollHelper
|
||||
# Create scrolling image via ScrollHelper.
|
||||
#
|
||||
# lead_gap is explicit because ScrollHelper otherwise prepends a
|
||||
# full display width of black — appropriate for a standalone ticker
|
||||
# scrolling in from off-screen, but in Vegas mode it is charged
|
||||
# once per cycle and reads as the panel switching off.
|
||||
self.scroll_helper.create_scrolling_image(
|
||||
content_items=content_with_gaps,
|
||||
content_items=blocks,
|
||||
item_gap=self.config.separator_width,
|
||||
element_gap=0
|
||||
element_gap=0,
|
||||
lead_gap=self.config.lead_in_width
|
||||
)
|
||||
|
||||
# Verify scroll image was created successfully
|
||||
@@ -177,11 +187,14 @@ class RenderPipeline:
|
||||
self._cycle_complete = False
|
||||
|
||||
logger.info(
|
||||
"Composed scroll image: %dx%d, %d plugins, %d items",
|
||||
"Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
|
||||
"separator=%dpx between plugins / %dpx within",
|
||||
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
|
||||
self.display_height,
|
||||
len(self._segments_in_scroll),
|
||||
len(images)
|
||||
len(blocks),
|
||||
total_rows,
|
||||
self.config.separator_width,
|
||||
self.config.intra_plugin_gap,
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -191,6 +204,32 @@ class RenderPipeline:
|
||||
logger.exception("Error composing scroll content")
|
||||
return False
|
||||
|
||||
def _join_plugin_rows(self, images: List[Image.Image]) -> Image.Image:
|
||||
"""
|
||||
Concatenate one plugin's images into a single block.
|
||||
|
||||
Args:
|
||||
images: That plugin's content, in order
|
||||
|
||||
Returns:
|
||||
A single image with the rows laid out left to right, separated by
|
||||
``intra_plugin_gap``. Returned unchanged when there is only one row,
|
||||
which is the common case and avoids a pointless copy.
|
||||
"""
|
||||
if len(images) == 1:
|
||||
return images[0]
|
||||
|
||||
gap = max(0, self.config.intra_plugin_gap)
|
||||
width = sum(img.width for img in images) + gap * (len(images) - 1)
|
||||
height = max(img.height for img in images)
|
||||
|
||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
x = 0
|
||||
for img in images:
|
||||
block.paste(img, (x, 0))
|
||||
x += img.width + gap
|
||||
return block
|
||||
|
||||
def render_frame(self) -> bool:
|
||||
"""
|
||||
Render a single frame to the display.
|
||||
@@ -236,24 +275,17 @@ class RenderPipeline:
|
||||
"Scroll cycle complete after %.1fs",
|
||||
time.time() - self._cycle_start_time
|
||||
)
|
||||
# Push blank immediately so the hardware never shows any
|
||||
# post-wrap content while the coordinator recomposes the
|
||||
# next cycle (~100 ms). The blank is allocated once and
|
||||
# reused across cycle wraps (fresh paste each time in case
|
||||
# a consumer drew on the previous one).
|
||||
try:
|
||||
if self._blank_frame is None or self._blank_frame.size != (
|
||||
self.display_width, self.display_height):
|
||||
self._blank_frame = Image.new(
|
||||
'RGB', (self.display_width, self.display_height))
|
||||
else:
|
||||
self._blank_frame.paste(
|
||||
(0, 0, 0),
|
||||
(0, 0, self.display_width, self.display_height))
|
||||
self.display_manager.image = self._blank_frame
|
||||
self.display_manager.update_display()
|
||||
except Exception:
|
||||
logger.exception("Failed to write blank frame to display at cycle end")
|
||||
# Deliberately leave the last rendered frame on the panel.
|
||||
#
|
||||
# This used to push a blank frame so no post-wrap content
|
||||
# could be seen while the next cycle was composed. But
|
||||
# recomposing is synchronous and fetches plugin content:
|
||||
# measured 84ms at best and 4.8s at worst on a 512px panel,
|
||||
# and every millisecond of it was black. Holding the last
|
||||
# frame instead turns that into a brief freeze, which reads
|
||||
# as far less broken than the display switching off. The
|
||||
# frame is already past the end of the content, so there is
|
||||
# no second-pass content to leak.
|
||||
return True # Cycle done; coordinator starts new cycle next frame
|
||||
|
||||
# Get visible portion
|
||||
@@ -415,11 +447,12 @@ class RenderPipeline:
|
||||
result = self.compose_scroll_content()
|
||||
|
||||
if result and self.sync_manager:
|
||||
# When sync is active, start the leader at display_width instead of 0.
|
||||
# This skips the initial black gap so the leader immediately shows content.
|
||||
# The follower starts at position 0 (the gap) which looks like a clean
|
||||
# blank transition rather than near-end content wrapping around.
|
||||
self.scroll_helper.scroll_position = float(self.display_width)
|
||||
# When sync is active, start the leader past the lead-in gap so it
|
||||
# immediately shows content, leaving the follower on the blank gap
|
||||
# for a clean transition rather than near-end content wrapping
|
||||
# around. This tracks lead_in_width rather than assuming a full
|
||||
# display width of gap, which is no longer the default.
|
||||
self.scroll_helper.scroll_position = float(self.config.lead_in_width)
|
||||
|
||||
if result and self.sync_manager:
|
||||
# Signal follower that a new cycle started (triggers its own rebuild)
|
||||
|
||||
@@ -14,7 +14,7 @@ Supports three display modes:
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any, Deque, TYPE_CHECKING
|
||||
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from PIL import Image
|
||||
@@ -116,8 +116,11 @@ class StreamManager:
|
||||
logger.warning("No plugins available for Vegas scroll")
|
||||
return False
|
||||
|
||||
# Prefetch initial content
|
||||
self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins)))
|
||||
# Fill the buffer to a whole cycle's worth of plugins. This used to be
|
||||
# buffer_ahead + 1, which conflated prefetch depth with cycle size and
|
||||
# meant a 20-plugin install only showed 3 plugins before recomposing.
|
||||
self._prefetch_content(
|
||||
count=min(self.config.plugins_per_cycle, len(self._ordered_plugins)))
|
||||
|
||||
logger.info(
|
||||
"StreamManager initialized with %d plugins, %d segments buffered",
|
||||
@@ -385,7 +388,7 @@ class StreamManager:
|
||||
return
|
||||
|
||||
for _ in range(count):
|
||||
if len(self._active_buffer) >= self.config.buffer_ahead + 1:
|
||||
if len(self._active_buffer) >= self.config.plugins_per_cycle:
|
||||
break
|
||||
|
||||
# Ensure index is valid (guard against empty list)
|
||||
@@ -521,28 +524,61 @@ class StreamManager:
|
||||
logger.debug("Refreshed content for %s in staging buffer", plugin_id)
|
||||
|
||||
def _ensure_buffer_filled(self) -> None:
|
||||
"""Ensure buffer has enough content prefetched."""
|
||||
if len(self._active_buffer) < self.config.buffer_ahead:
|
||||
needed = self.config.buffer_ahead - len(self._active_buffer)
|
||||
self._prefetch_content(count=needed)
|
||||
"""
|
||||
Top the buffer back up after segments have been served.
|
||||
|
||||
buffer_ahead is the low-water mark only; plugins_per_cycle is the
|
||||
ceiling and is enforced inside _prefetch_content.
|
||||
"""
|
||||
low_water = min(self.config.buffer_ahead, self.config.plugins_per_cycle)
|
||||
if len(self._active_buffer) < low_water:
|
||||
self._prefetch_content(count=low_water - len(self._active_buffer))
|
||||
|
||||
def get_all_content_for_composition(self) -> List[Image.Image]:
|
||||
"""
|
||||
Get all buffered content as a flat list of images.
|
||||
|
||||
Used when composing the full scroll image.
|
||||
Skips STATIC segments as they don't have images to compose.
|
||||
|
||||
Prefer get_grouped_content_for_composition(): flattening loses the
|
||||
plugin boundaries, which is what tells the compositor where a
|
||||
separator belongs and where it does not.
|
||||
|
||||
Returns:
|
||||
List of all images in buffer order
|
||||
"""
|
||||
all_images = []
|
||||
for _plugin_id, images in self.get_grouped_content_for_composition():
|
||||
all_images.extend(images)
|
||||
return all_images
|
||||
|
||||
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
|
||||
"""
|
||||
Get buffered content grouped by the plugin that produced it.
|
||||
|
||||
The grouping matters: separator_width is meant to mark the handoff from
|
||||
one plugin to the next, not to sit between every row a single plugin
|
||||
contributes. A per-row ticker like the F1 scoreboard returns over a
|
||||
hundred images that it renders 4px apart internally, so flattening them
|
||||
into one list and applying a uniform gap forced 32px between each of
|
||||
its rows — both inconsistent with how the plugin looks standalone, and
|
||||
a large hidden addition to the width it occupies.
|
||||
|
||||
Skips STATIC segments, which trigger a pause rather than contributing
|
||||
scroll content, and segments left with no images.
|
||||
|
||||
Returns:
|
||||
List of (plugin_id, images) in buffer order
|
||||
"""
|
||||
grouped: List[Tuple[str, List[Image.Image]]] = []
|
||||
with self._buffer_lock:
|
||||
for segment in self._active_buffer:
|
||||
# Skip STATIC segments - they trigger pauses, not scroll content
|
||||
if segment.display_mode != VegasDisplayMode.STATIC:
|
||||
all_images.extend(segment.images)
|
||||
return all_images
|
||||
if segment.display_mode == VegasDisplayMode.STATIC:
|
||||
continue
|
||||
if not segment.images:
|
||||
continue
|
||||
grouped.append((segment.plugin_id, list(segment.images)))
|
||||
return grouped
|
||||
|
||||
def advance_cycle(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
"""
|
||||
Tests for the Vegas mode density work: dead-space trimming in PluginAdapter
|
||||
and the configurable lead-in gap in ScrollHelper.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.geometry import column_has_ink
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
|
||||
DISPLAY_W = 512
|
||||
DISPLAY_H = 64
|
||||
|
||||
|
||||
class FakeDisplayManager:
|
||||
"""Minimal stand-in; the native content path never touches the canvas."""
|
||||
|
||||
width = DISPLAY_W
|
||||
height = DISPLAY_H
|
||||
|
||||
def __init__(self):
|
||||
self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H))
|
||||
|
||||
|
||||
class NativePlugin:
|
||||
"""Plugin that returns pre-rendered Vegas content."""
|
||||
|
||||
def __init__(self, images):
|
||||
self._images = images
|
||||
|
||||
def get_vegas_content(self):
|
||||
return self._images
|
||||
|
||||
|
||||
def canvas(content_spans, width=DISPLAY_W, height=DISPLAY_H):
|
||||
"""Full-display canvas with white content in the given [x0, x1) spans."""
|
||||
img = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
for x0, x1 in content_spans:
|
||||
img.paste(Image.new('RGB', (x1 - x0, height), (255, 255, 255)), (x0, 0))
|
||||
return img
|
||||
|
||||
|
||||
def adapter_with(**overrides):
|
||||
cfg = VegasModeConfig(**overrides)
|
||||
return PluginAdapter(FakeDisplayManager(), cfg)
|
||||
|
||||
|
||||
class TestAdapterTrimming:
|
||||
def test_of_the_day_case_is_reclaimed(self):
|
||||
# Measured on devpi: "No Data" occupying 35px of a 512px canvas bought
|
||||
# 9.5s of black at 50px/s.
|
||||
adapter = adapter_with(content_padding=0)
|
||||
plugin = NativePlugin([canvas([(4, 39)])])
|
||||
images = adapter.get_content(plugin, 'of-the-day')
|
||||
assert len(images) == 1
|
||||
assert images[0].width == 35
|
||||
|
||||
def test_youtube_stats_case_is_reclaimed(self):
|
||||
# Centred 142px of content on a 512px canvas: 185px black each side.
|
||||
adapter = adapter_with(content_padding=0)
|
||||
plugin = NativePlugin([canvas([(185, 327)])])
|
||||
images = adapter.get_content(plugin, 'youtube-stats')
|
||||
assert images[0].width == 142
|
||||
|
||||
def test_padding_is_applied_within_available_margin(self):
|
||||
adapter = adapter_with(content_padding=8)
|
||||
plugin = NativePlugin([canvas([(185, 327)])])
|
||||
images = adapter.get_content(plugin, 'youtube-stats')
|
||||
assert images[0].width == 142 + 16
|
||||
|
||||
def test_interior_layout_gap_survives(self):
|
||||
# A logo far left and a score far right is deliberate layout; closing
|
||||
# the gap would corrupt the design rather than reclaim dead space.
|
||||
adapter = adapter_with(content_padding=0)
|
||||
plugin = NativePlugin([canvas([(10, 40), (400, 460)])])
|
||||
images = adapter.get_content(plugin, 'scoreboard')
|
||||
assert images[0].width == 450 # 10..459
|
||||
assert int(column_has_ink(images[0]).sum()) == 90
|
||||
|
||||
def test_wide_legitimate_content_is_left_alone(self):
|
||||
# geochron uses 444 of 512 columns; only the real tail should go.
|
||||
adapter = adapter_with(content_padding=0)
|
||||
plugin = NativePlugin([canvas([(0, 444)])])
|
||||
images = adapter.get_content(plugin, 'geochron')
|
||||
assert images[0].width == 444
|
||||
|
||||
def test_non_black_background_is_untouched(self):
|
||||
adapter = adapter_with(content_padding=0)
|
||||
bg = Image.new('RGB', (DISPLAY_W, DISPLAY_H), (0, 0, 40))
|
||||
plugin = NativePlugin([bg])
|
||||
images = adapter.get_content(plugin, 'weather')
|
||||
assert images[0].width == DISPLAY_W
|
||||
|
||||
def test_each_image_of_a_multi_item_segment_is_trimmed(self):
|
||||
# compose_scroll_content treats every image as its own item, so a
|
||||
# per-image trim is what makes separator_width the real gap.
|
||||
adapter = adapter_with(content_padding=0)
|
||||
plugin = NativePlugin([
|
||||
canvas([(168, 344)]),
|
||||
canvas([(200, 300)]),
|
||||
canvas([(0, 512)]),
|
||||
])
|
||||
images = adapter.get_content(plugin, 'ledmatrix-flights')
|
||||
assert [img.width for img in images] == [176, 100, 512]
|
||||
|
||||
def test_fully_blank_segment_contributes_nothing(self):
|
||||
adapter = adapter_with()
|
||||
plugin = NativePlugin([Image.new('RGB', (DISPLAY_W, DISPLAY_H))])
|
||||
assert adapter.get_content(plugin, 'empty') is None
|
||||
|
||||
def test_blank_images_are_dropped_but_others_kept(self):
|
||||
adapter = adapter_with(content_padding=0)
|
||||
plugin = NativePlugin([
|
||||
canvas([(100, 150)]),
|
||||
Image.new('RGB', (DISPLAY_W, DISPLAY_H)),
|
||||
canvas([(200, 260)]),
|
||||
])
|
||||
images = adapter.get_content(plugin, 'mixed')
|
||||
assert [img.width for img in images] == [50, 60]
|
||||
|
||||
def test_min_plugin_width_rejects_noise(self):
|
||||
adapter = adapter_with(content_padding=0, min_plugin_width=32)
|
||||
plugin = NativePlugin([canvas([(10, 14)])])
|
||||
assert adapter.get_content(plugin, 'sliver') is None
|
||||
|
||||
def test_min_plugin_width_of_zero_keeps_everything(self):
|
||||
adapter = adapter_with(content_padding=0, min_plugin_width=0)
|
||||
plugin = NativePlugin([canvas([(10, 14)])])
|
||||
images = adapter.get_content(plugin, 'sliver')
|
||||
assert images[0].width == 4
|
||||
|
||||
def test_auto_trim_off_preserves_original_behaviour(self):
|
||||
adapter = adapter_with(auto_trim=False)
|
||||
plugin = NativePlugin([canvas([(4, 39)])])
|
||||
images = adapter.get_content(plugin, 'of-the-day')
|
||||
assert images[0].width == DISPLAY_W
|
||||
|
||||
def test_trim_threshold_ignores_near_black_noise(self):
|
||||
# A very dark band should not be mistaken for content.
|
||||
img = Image.new('RGB', (DISPLAY_W, DISPLAY_H), (0, 0, 0))
|
||||
img.paste(Image.new('RGB', (100, DISPLAY_H), (3, 3, 3)), (0, 0))
|
||||
img.paste(Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)), (200, 0))
|
||||
adapter = adapter_with(content_padding=0, trim_threshold=10)
|
||||
images = adapter.get_content(NativePlugin([img]), 'noisy')
|
||||
assert images[0].width == 50
|
||||
|
||||
def test_height_is_preserved_through_trim(self):
|
||||
adapter = adapter_with(content_padding=0)
|
||||
plugin = NativePlugin([canvas([(100, 200)])])
|
||||
images = adapter.get_content(plugin, 'x')
|
||||
assert images[0].height == DISPLAY_H
|
||||
|
||||
def test_trimmed_result_is_cached(self):
|
||||
adapter = adapter_with(content_padding=0)
|
||||
plugin = NativePlugin([canvas([(100, 200)])])
|
||||
first = adapter.get_content(plugin, 'cached')
|
||||
# Swap the plugin's content; the cache should still serve the old size.
|
||||
plugin._images = [canvas([(0, 512)])]
|
||||
second = adapter.get_content(plugin, 'cached')
|
||||
assert first[0].width == second[0].width == 100
|
||||
|
||||
def test_default_adapter_construction_still_works(self):
|
||||
# Existing callers pass only the display manager.
|
||||
adapter = PluginAdapter(FakeDisplayManager())
|
||||
assert adapter.config.auto_trim is True
|
||||
|
||||
|
||||
class TestWidthBudget:
|
||||
def test_segment_within_budget_is_untouched(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
|
||||
plugin = NativePlugin([canvas([(0, 400)])])
|
||||
assert adapter.get_content(plugin, 'small')[0].width == 400
|
||||
|
||||
def test_oversized_multi_item_segment_is_capped(self):
|
||||
# 10 items of 400px = 4000px against a 1536px budget (3 x 512).
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
|
||||
items = [canvas([(0, 400)], width=400) for _ in range(10)]
|
||||
images = adapter.get_content(NativePlugin(items), 'stock-news')
|
||||
assert sum(i.width for i in images) <= 3 * DISPLAY_W
|
||||
assert len(images) == 3 # 1200px; a 4th would exceed 1536
|
||||
|
||||
def test_deferred_items_appear_on_the_next_cycle(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
# Distinguish items by width so the rotation is observable.
|
||||
items = [canvas([(0, w)], width=w) for w in (200, 210, 220, 230, 240)]
|
||||
plugin = NativePlugin(items)
|
||||
|
||||
first = adapter.get_content(plugin, 'ticker')
|
||||
adapter.invalidate_cache('ticker')
|
||||
second = adapter.get_content(plugin, 'ticker')
|
||||
assert [i.width for i in first] != [i.width for i in second]
|
||||
|
||||
def test_rotation_eventually_covers_every_item(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
widths = (200, 210, 220, 230, 240)
|
||||
items = [canvas([(0, w)], width=w) for w in widths]
|
||||
plugin = NativePlugin(items)
|
||||
|
||||
seen = set()
|
||||
for _ in range(10):
|
||||
adapter.invalidate_cache('ticker')
|
||||
for img in adapter.get_content(plugin, 'ticker'):
|
||||
seen.add(img.width)
|
||||
assert seen == set(widths)
|
||||
|
||||
def test_single_oversized_image_is_cropped_to_budget(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
# A 6898px leaderboard strip, as measured on devpi. Solid ink means
|
||||
# there is no blank column to snap to, so the cut lands on the budget.
|
||||
plugin = NativePlugin([canvas([(0, 6898)], width=6898)])
|
||||
images = adapter.get_content(plugin, 'ledmatrix-leaderboard')
|
||||
assert len(images) == 1
|
||||
assert images[0].width == DISPLAY_W
|
||||
|
||||
def test_single_image_crop_snaps_to_a_blank_column(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
# Content blocks with gaps; the cut should land in a gap, not mid-block.
|
||||
spans = [(x, x + 90) for x in range(0, 2000, 100)]
|
||||
plugin = NativePlugin([canvas(spans, width=2000)])
|
||||
images = adapter.get_content(plugin, 'gapped')
|
||||
assert images[0].width != DISPLAY_W
|
||||
assert abs(images[0].width - DISPLAY_W) <= DISPLAY_W // 16 + 1
|
||||
|
||||
def test_single_image_window_advances_across_cycles(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
plugin = NativePlugin([canvas([(0, 3000)], width=3000)])
|
||||
adapter.get_content(plugin, 'strip')
|
||||
assert adapter._item_offsets['strip'] == DISPLAY_W
|
||||
|
||||
def test_budget_of_zero_disables_the_cap(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0)
|
||||
plugin = NativePlugin([canvas([(0, 6898)], width=6898)])
|
||||
assert adapter.get_content(plugin, 'huge')[0].width == 6898
|
||||
|
||||
def test_rotation_resets_when_content_shrinks_to_fit(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
big = [canvas([(0, 300)], width=300) for _ in range(5)]
|
||||
adapter.get_content(NativePlugin(big), 'shrink')
|
||||
assert 'shrink' in adapter._item_offsets
|
||||
|
||||
adapter.invalidate_cache('shrink')
|
||||
adapter.get_content(NativePlugin([canvas([(0, 100)], width=100)]), 'shrink')
|
||||
assert 'shrink' not in adapter._item_offsets
|
||||
|
||||
def test_one_item_wider_than_budget_is_still_shown(self):
|
||||
# Never return nothing just because the first whole item overflows.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
items = [canvas([(0, 900)], width=900), canvas([(0, 100)], width=100)]
|
||||
images = adapter.get_content(NativePlugin(items), 'wide-first')
|
||||
assert len(images) >= 1
|
||||
assert images[0].width == 900
|
||||
|
||||
|
||||
class TestPluginBoundaryGaps:
|
||||
"""separator_width belongs between plugins; intra_plugin_gap within one."""
|
||||
|
||||
def _pipeline(self, grouped, **cfg):
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline
|
||||
|
||||
class FakeStream:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return grouped
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return [pid for pid, _ in grouped]
|
||||
|
||||
class DM:
|
||||
width = DISPLAY_W
|
||||
height = DISPLAY_H
|
||||
|
||||
def set_scrolling_state(self, *a):
|
||||
pass
|
||||
|
||||
return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream())
|
||||
|
||||
def test_separator_only_at_plugin_boundaries(self):
|
||||
# Two plugins, two rows each. Expect: row row [sep] row row, with the
|
||||
# small intra gap inside each pair.
|
||||
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)]
|
||||
pipeline = self._pipeline(
|
||||
[('a', rows[:2]), ('b', rows[2:])],
|
||||
separator_width=32, intra_plugin_gap=8, lead_in_width=0,
|
||||
)
|
||||
assert pipeline.compose_scroll_content()
|
||||
|
||||
ink = column_has_ink(pipeline.scroll_helper.cached_image)
|
||||
assert ink[:100].all()
|
||||
assert not ink[100:108].any() # intra gap inside plugin a
|
||||
assert ink[108:208].all()
|
||||
assert not ink[208:240].any() # separator between a and b
|
||||
assert ink[240:340].all()
|
||||
assert not ink[340:348].any() # intra gap inside plugin b
|
||||
assert ink[348:448].all()
|
||||
|
||||
def test_total_width_uses_both_gap_sizes(self):
|
||||
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)]
|
||||
pipeline = self._pipeline(
|
||||
[('a', rows[:2]), ('b', rows[2:])],
|
||||
separator_width=32, intra_plugin_gap=8, lead_in_width=0,
|
||||
)
|
||||
pipeline.compose_scroll_content()
|
||||
# 4 rows + 2 intra gaps + 1 separator
|
||||
assert pipeline.scroll_helper.cached_image.width == 400 + 16 + 32
|
||||
|
||||
def test_f1_shaped_case_reclaims_the_chasms(self):
|
||||
# 12 rows from one plugin: previously 11 separators at 32px = 352px of
|
||||
# gap; now 11 intra gaps at 8px = 88px.
|
||||
rows = [Image.new('RGB', (128, DISPLAY_H), (255, 255, 255)) for _ in range(12)]
|
||||
pipeline = self._pipeline(
|
||||
[('f1-scoreboard', rows)],
|
||||
separator_width=32, intra_plugin_gap=8, lead_in_width=0,
|
||||
)
|
||||
pipeline.compose_scroll_content()
|
||||
assert pipeline.scroll_helper.cached_image.width == 12 * 128 + 11 * 8
|
||||
|
||||
def test_single_row_plugin_image_is_not_copied(self):
|
||||
row = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255))
|
||||
pipeline = self._pipeline([('solo', [row])], lead_in_width=0)
|
||||
assert pipeline._join_plugin_rows([row]) is row
|
||||
|
||||
def test_zero_intra_gap_butts_rows_together(self):
|
||||
rows = [Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)) for _ in range(3)]
|
||||
pipeline = self._pipeline(
|
||||
[('a', rows)], separator_width=32, intra_plugin_gap=0, lead_in_width=0)
|
||||
pipeline.compose_scroll_content()
|
||||
assert pipeline.scroll_helper.cached_image.width == 150
|
||||
assert column_has_ink(pipeline.scroll_helper.cached_image).all()
|
||||
|
||||
def test_empty_grouping_fails_composition(self):
|
||||
pipeline = self._pipeline([])
|
||||
assert pipeline.compose_scroll_content() is False
|
||||
|
||||
|
||||
class TestWidthBudgetCountsGaps:
|
||||
def test_budget_accounts_for_intra_plugin_gaps(self):
|
||||
# 8 rows of 200px = 1600px of pixels, but with 8px gaps the real
|
||||
# occupancy is 1600 + 56 = 1656px. Against a 512px budget the row count
|
||||
# must be chosen using the gap-inclusive cost.
|
||||
adapter = adapter_with(
|
||||
content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=8)
|
||||
items = [canvas([(0, 200)], width=200) for _ in range(8)]
|
||||
images = adapter.get_content(NativePlugin(items), 'rows')
|
||||
n = len(images)
|
||||
assert 200 * n + 8 * (n - 1) <= DISPLAY_W
|
||||
|
||||
def test_gap_free_config_fits_more_rows(self):
|
||||
items = [canvas([(0, 200)], width=200) for _ in range(8)]
|
||||
with_gap = adapter_with(
|
||||
content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=64)
|
||||
without = adapter_with(
|
||||
content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=0)
|
||||
assert len(without.get_content(NativePlugin(items), 'r')) >= \
|
||||
len(with_gap.get_content(NativePlugin(items), 'r'))
|
||||
|
||||
|
||||
class TestStreamGrouping:
|
||||
def _stream(self, segments):
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
from collections import deque
|
||||
|
||||
sm = StreamManager.__new__(StreamManager)
|
||||
import threading
|
||||
sm._buffer_lock = threading.RLock()
|
||||
sm._active_buffer = deque(segments)
|
||||
return sm
|
||||
|
||||
def _seg(self, plugin_id, count, mode=None):
|
||||
from src.vegas_mode.stream_manager import ContentSegment
|
||||
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||
imgs = [Image.new('RGB', (10, 8)) for _ in range(count)]
|
||||
return ContentSegment(
|
||||
plugin_id=plugin_id, images=imgs, total_width=10 * count,
|
||||
display_mode=mode or VegasDisplayMode.SCROLL)
|
||||
|
||||
def test_grouping_preserves_plugin_boundaries(self):
|
||||
sm = self._stream([self._seg('a', 3), self._seg('b', 1)])
|
||||
grouped = sm.get_grouped_content_for_composition()
|
||||
assert [(pid, len(imgs)) for pid, imgs in grouped] == [('a', 3), ('b', 1)]
|
||||
|
||||
def test_static_segments_are_skipped(self):
|
||||
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||
sm = self._stream([
|
||||
self._seg('a', 2),
|
||||
self._seg('paused', 1, VegasDisplayMode.STATIC),
|
||||
self._seg('b', 1),
|
||||
])
|
||||
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['a', 'b']
|
||||
|
||||
def test_imageless_segments_are_skipped(self):
|
||||
sm = self._stream([self._seg('a', 0), self._seg('b', 2)])
|
||||
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b']
|
||||
|
||||
def test_flat_accessor_still_matches_grouped_total(self):
|
||||
sm = self._stream([self._seg('a', 3), self._seg('b', 2)])
|
||||
assert len(sm.get_all_content_for_composition()) == 5
|
||||
|
||||
|
||||
class 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() == []
|
||||
@@ -0,0 +1,273 @@
|
||||
"""Tests for Vegas mode geometry primitives."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.geometry import (
|
||||
DEFAULT_INK_THRESHOLD,
|
||||
column_has_ink,
|
||||
content_bounds,
|
||||
dead_window_stats,
|
||||
trim_to_content,
|
||||
window_coverage_stats,
|
||||
)
|
||||
|
||||
|
||||
def make_img(width, height=8, fill=(0, 0, 0)):
|
||||
return Image.new('RGB', (width, height), fill)
|
||||
|
||||
|
||||
def paint(img, x0, x1, color=(255, 255, 255)):
|
||||
"""Fill columns [x0, x1) with a colour."""
|
||||
block = Image.new('RGB', (x1 - x0, img.height), color)
|
||||
img.paste(block, (x0, 0))
|
||||
return img
|
||||
|
||||
|
||||
class TestColumnHasInk:
|
||||
def test_all_black_has_no_ink(self):
|
||||
assert not column_has_ink(make_img(16)).any()
|
||||
|
||||
def test_marks_only_painted_columns(self):
|
||||
img = paint(make_img(16), 4, 8)
|
||||
ink = column_has_ink(img)
|
||||
assert ink.tolist() == [False] * 4 + [True] * 4 + [False] * 8
|
||||
|
||||
def test_threshold_is_exclusive(self):
|
||||
# A pixel exactly at the threshold is not ink; one above it is.
|
||||
at = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD,) * 3)
|
||||
above = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD + 1,) * 3)
|
||||
assert not column_has_ink(at).any()
|
||||
assert column_has_ink(above).all()
|
||||
|
||||
def test_single_bright_channel_counts(self):
|
||||
img = paint(make_img(4), 1, 2, (0, 0, 200))
|
||||
assert column_has_ink(img).tolist() == [False, True, False, False]
|
||||
|
||||
def test_one_lit_pixel_lights_the_column(self):
|
||||
img = make_img(4, height=8)
|
||||
img.putpixel((2, 5), (255, 255, 255))
|
||||
assert column_has_ink(img).tolist() == [False, False, True, False]
|
||||
|
||||
|
||||
class TestContentBounds:
|
||||
def test_blank_returns_none(self):
|
||||
assert content_bounds(make_img(16)) is None
|
||||
|
||||
def test_finds_inclusive_bounds(self):
|
||||
assert content_bounds(paint(make_img(20), 5, 12)) == (5, 11)
|
||||
|
||||
def test_full_width_content(self):
|
||||
assert content_bounds(paint(make_img(10), 0, 10)) == (0, 9)
|
||||
|
||||
def test_spans_interior_gap(self):
|
||||
img = paint(make_img(30), 2, 5)
|
||||
paint(img, 20, 25)
|
||||
assert content_bounds(img) == (2, 24)
|
||||
|
||||
|
||||
class TestTrimToContent:
|
||||
def test_blank_image_reports_blank(self):
|
||||
result = trim_to_content(make_img(512))
|
||||
assert result.is_blank
|
||||
assert result.image is None
|
||||
assert result.width == 0
|
||||
assert result.original_width == 512
|
||||
|
||||
def test_trims_both_edges(self):
|
||||
result = trim_to_content(paint(make_img(512), 100, 150))
|
||||
assert not result.is_blank
|
||||
assert result.width == 50
|
||||
assert result.trimmed_left == 100
|
||||
assert result.trimmed_right == 362
|
||||
assert result.removed == 462
|
||||
|
||||
def test_preserves_interior_gap(self):
|
||||
# Two content blocks with a wide blank between them: the gap is the
|
||||
# plugin's layout and must survive trimming.
|
||||
img = paint(make_img(400), 50, 80)
|
||||
paint(img, 300, 330)
|
||||
result = trim_to_content(img)
|
||||
assert result.width == 280 # 50..329 inclusive
|
||||
assert column_has_ink(result.image).sum() == 60
|
||||
|
||||
def test_full_width_content_is_returned_unchanged(self):
|
||||
img = paint(make_img(128), 0, 128)
|
||||
result = trim_to_content(img)
|
||||
assert result.image is img
|
||||
assert result.removed == 0
|
||||
|
||||
def test_non_black_background_is_never_trimmed(self):
|
||||
# A plugin drawing on a dark-but-not-black background fills every
|
||||
# column with ink, so there is nothing to reclaim.
|
||||
result = trim_to_content(make_img(256, fill=(0, 0, 40)))
|
||||
assert result.removed == 0
|
||||
assert result.width == 256
|
||||
|
||||
def test_padding_keeps_margin_up_to_what_exists(self):
|
||||
result = trim_to_content(paint(make_img(512), 100, 150), padding=8)
|
||||
assert result.trimmed_left == 92
|
||||
assert result.width == 66 # 50 content + 8 each side
|
||||
|
||||
def test_padding_cannot_widen_beyond_original(self):
|
||||
# Content starts 2px in; padding of 8 can only reclaim the 2 available.
|
||||
result = trim_to_content(paint(make_img(64), 2, 60), padding=8)
|
||||
assert result.trimmed_left == 0
|
||||
assert result.trimmed_right == 0
|
||||
assert result.width == 64
|
||||
|
||||
def test_height_is_preserved(self):
|
||||
result = trim_to_content(paint(make_img(200, height=64), 10, 20))
|
||||
assert result.image.height == 64
|
||||
|
||||
def test_real_world_of_the_day_case(self):
|
||||
# Measured on devpi: "No Data" occupying 35px of a 512px canvas.
|
||||
result = trim_to_content(paint(make_img(512, height=64), 4, 39))
|
||||
assert result.width == 35
|
||||
assert result.removed == 477
|
||||
|
||||
|
||||
class TestDeadWindowStats:
|
||||
def test_fully_inked_ticker_has_no_dead_windows(self):
|
||||
stats = dead_window_stats(paint(make_img(400), 0, 400), viewport_width=100)
|
||||
assert stats.dead_windows == 0
|
||||
assert stats.dead_ratio == 0.0
|
||||
assert stats.longest_dead_run == 0
|
||||
|
||||
def test_fully_blank_ticker_is_all_dead(self):
|
||||
stats = dead_window_stats(make_img(400), viewport_width=100)
|
||||
assert stats.total_windows == 301
|
||||
assert stats.dead_windows == 301
|
||||
assert stats.dead_ratio == 1.0
|
||||
assert stats.longest_dead_run == 301
|
||||
|
||||
def test_leading_blank_run_is_measured(self):
|
||||
# 512px of black then solid content: windows fully inside the black
|
||||
# stretch are dead. With a 100px viewport, starts 0..412 exist and a
|
||||
# window is dead while it holds >=95 blank columns.
|
||||
img = paint(make_img(1024), 512, 1024)
|
||||
stats = dead_window_stats(img, viewport_width=100)
|
||||
assert stats.dead_windows == 418 # starts 0..417 keep >=95 blank cols
|
||||
assert stats.longest_dead_run == 418
|
||||
|
||||
def test_narrow_content_island_still_leaves_dead_windows(self):
|
||||
# 35px of content in a 512px field, viewed 100px at a time: no window
|
||||
# can be 95% blank once it overlaps 35 lit columns, but the windows
|
||||
# clear of it are dead.
|
||||
img = paint(make_img(512), 100, 135)
|
||||
stats = dead_window_stats(img, viewport_width=100)
|
||||
assert stats.dead_windows > 0
|
||||
assert stats.dead_ratio == pytest.approx(
|
||||
stats.dead_windows / stats.total_windows
|
||||
)
|
||||
|
||||
def test_step_reduces_sampling(self):
|
||||
img = paint(make_img(1000), 500, 1000)
|
||||
exact = dead_window_stats(img, viewport_width=100, step=1)
|
||||
strided = dead_window_stats(img, viewport_width=100, step=10)
|
||||
assert strided.total_windows < exact.total_windows
|
||||
# Same underlying shape, so the ratios should stay close.
|
||||
assert strided.dead_ratio == pytest.approx(exact.dead_ratio, abs=0.02)
|
||||
|
||||
def test_image_narrower_than_viewport_is_one_window(self):
|
||||
stats = dead_window_stats(make_img(50), viewport_width=100)
|
||||
assert stats.total_windows == 1
|
||||
assert stats.dead_windows == 1
|
||||
|
||||
def test_zero_viewport_is_handled(self):
|
||||
stats = dead_window_stats(make_img(50), viewport_width=0)
|
||||
assert stats.total_windows == 0
|
||||
assert stats.dead_ratio == 0.0
|
||||
|
||||
def test_longest_run_picks_the_larger_of_two_gaps(self):
|
||||
# Short blank gap, content, then a long blank gap.
|
||||
img = make_img(1000)
|
||||
paint(img, 150, 400)
|
||||
paint(img, 500, 520)
|
||||
stats = dead_window_stats(img, viewport_width=100)
|
||||
# The 400..500 gap is only 100 wide; the tail from 520 is 480 wide.
|
||||
assert stats.longest_dead_run >= 380
|
||||
|
||||
|
||||
class TestWindowCoverageStats:
|
||||
def test_solid_content_is_fully_covered(self):
|
||||
stats = window_coverage_stats(paint(make_img(600), 0, 600), viewport_width=100)
|
||||
assert stats.mean_ink_ratio == 1.0
|
||||
assert stats.min_ink_ratio == 1.0
|
||||
assert stats.sparse_windows == 0
|
||||
|
||||
def test_blank_strip_is_entirely_sparse(self):
|
||||
stats = window_coverage_stats(make_img(600), viewport_width=100)
|
||||
assert stats.mean_ink_ratio == 0.0
|
||||
assert stats.sparse_ratio == 1.0
|
||||
|
||||
def test_catches_sliver_windows_that_dead_ratio_misses(self):
|
||||
# Narrow content islands separated by more than the viewport. A window
|
||||
# holding one whole 40px island carries 472 blank columns — under the
|
||||
# 486 needed to count as "dead" — yet only 7.8% ink, so it still reads
|
||||
# as an empty panel. Coverage must flag strictly more positions than
|
||||
# the dead-window scan does.
|
||||
img = paint(make_img(2000), 0, 40)
|
||||
paint(img, 1000, 1040)
|
||||
dead = dead_window_stats(img, viewport_width=512)
|
||||
cover = window_coverage_stats(img, viewport_width=512, sparse_ink_ratio=0.10)
|
||||
assert cover.sparse_windows > dead.dead_windows
|
||||
assert cover.min_ink_ratio == 0.0
|
||||
|
||||
def test_adjacent_full_width_segments_stay_partially_covered(self):
|
||||
# Documents why the dead-window scan alone understated the problem:
|
||||
# two 512px segments with mid-canvas content never fully blank the
|
||||
# viewport, they just hold it at a thin ~28%.
|
||||
img = paint(make_img(1024), 185, 330)
|
||||
paint(img, 697, 842)
|
||||
dead = dead_window_stats(img, viewport_width=512)
|
||||
cover = window_coverage_stats(img, viewport_width=512)
|
||||
assert dead.dead_windows == 0
|
||||
assert cover.mean_ink_ratio == pytest.approx(0.283, abs=0.01)
|
||||
|
||||
def test_min_ink_ratio_finds_the_worst_position(self):
|
||||
# A wide blank tail guarantees at least one totally empty viewport.
|
||||
img = paint(make_img(1200), 0, 200)
|
||||
stats = window_coverage_stats(img, viewport_width=200)
|
||||
assert stats.min_ink_ratio == 0.0
|
||||
assert stats.mean_ink_ratio > 0.0
|
||||
|
||||
def test_sparse_threshold_is_respected(self):
|
||||
# 40 inked columns in a 200px viewport = 20% coverage everywhere the
|
||||
# island is fully inside the window.
|
||||
img = paint(make_img(400), 100, 140)
|
||||
lenient = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.05)
|
||||
strict = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.50)
|
||||
assert strict.sparse_windows > lenient.sparse_windows
|
||||
|
||||
def test_step_approximates_exact_scan(self):
|
||||
img = paint(make_img(2000), 300, 500)
|
||||
paint(img, 1200, 1400)
|
||||
exact = window_coverage_stats(img, viewport_width=512, step=1)
|
||||
strided = window_coverage_stats(img, viewport_width=512, step=4)
|
||||
assert strided.mean_ink_ratio == pytest.approx(exact.mean_ink_ratio, abs=0.01)
|
||||
|
||||
def test_zero_viewport_is_handled(self):
|
||||
stats = window_coverage_stats(make_img(50), viewport_width=0)
|
||||
assert stats.total_windows == 0
|
||||
assert stats.sparse_ratio == 0.0
|
||||
|
||||
def test_image_narrower_than_viewport(self):
|
||||
stats = window_coverage_stats(paint(make_img(50), 0, 50), viewport_width=100)
|
||||
assert stats.total_windows == 1
|
||||
assert stats.mean_ink_ratio == pytest.approx(0.5)
|
||||
|
||||
|
||||
class TestLongestRunHelper:
|
||||
@pytest.mark.parametrize("flags,expected", [
|
||||
([], 0),
|
||||
([False, False], 0),
|
||||
([True], 1),
|
||||
([True, True, False, True], 2),
|
||||
([False, True, True, True, False, True], 3),
|
||||
([True, True, True], 3),
|
||||
])
|
||||
def test_run_lengths(self, flags, expected):
|
||||
from src.vegas_mode.geometry import _longest_true_run
|
||||
assert _longest_true_run(np.array(flags, dtype=bool)) == expected
|
||||
@@ -918,7 +918,12 @@ def save_main_config():
|
||||
|
||||
# Handle Vegas scroll mode settings
|
||||
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins']
|
||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins',
|
||||
'vegas_auto_trim', 'vegas_trim_threshold', 'vegas_content_padding',
|
||||
'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle',
|
||||
'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled',
|
||||
'vegas_min_cycle_duration', 'vegas_max_cycle_duration',
|
||||
'vegas_intra_plugin_gap']
|
||||
|
||||
if any(k in data for k in vegas_fields):
|
||||
if 'display' not in current_config:
|
||||
@@ -933,13 +938,51 @@ 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'))
|
||||
|
||||
# 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_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_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:
|
||||
|
||||
@@ -425,7 +425,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
|
||||
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap inserted between each plugin block in the ticker (0–128 px).\nDefault: 32. Larger values make the boundary between plugins clearer.', 'Separator Width') }}</label>
|
||||
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap where one plugin hands off to the next (0–128 px).\nDefault: 32. Larger values make the boundary between plugins clearer. This does not apply between rows of the same plugin — see Row Gap for that.', 'Separator Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_separator_width"
|
||||
name="vegas_separator_width"
|
||||
@@ -436,6 +436,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_intra_plugin_gap" data-setting-key="display.vegas_scroll.intra_plugin_gap">
|
||||
<label for="vegas_intra_plugin_gap" class="block text-sm font-medium text-gray-700">Row Gap (pixels){{ ui.help_tip('Gap between rows contributed by the same plugin (0–128 px).\nDefault: 8. Multi-row plugins such as sports scoreboards, news feeds and the F1 standings return one image per row; this keeps those rows close together while Separator Width still marks the jump to the next plugin. Set 0 to butt rows directly together.', 'Row Gap') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_intra_plugin_gap"
|
||||
name="vegas_intra_plugin_gap"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('intra_plugin_gap', 8) }}"
|
||||
min="0"
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps">
|
||||
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label>
|
||||
@@ -456,6 +469,137 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cycle Pacing -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Cycle Pacing</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">How long one pass through the ticker lasts, and how many plugins it covers.</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_plugins_per_cycle" data-setting-key="display.vegas_scroll.plugins_per_cycle">
|
||||
<label for="vegas_plugins_per_cycle" class="block text-sm font-medium text-gray-700">Plugins Per Cycle{{ ui.help_tip('How many plugins are composed into one pass of the ticker (1–50).\nDefault: 6. Higher means more variety before the ticker restarts, and fewer recompose pauses. Lower means each plugin comes around sooner.', 'Plugins Per Cycle') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_plugins_per_cycle"
|
||||
name="vegas_plugins_per_cycle"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('plugins_per_cycle', 6) }}"
|
||||
min="1"
|
||||
max="50"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
|
||||
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (0–20).\nDefault: 3. A long ticker such as a news feed or leaderboard is trimmed to this and the remainder shown on later cycles, so one plugin cannot hold the display for minutes. Set 0 for no limit.', 'Max Plugin Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_max_plugin_width_ratio"
|
||||
name="vegas_max_plugin_width_ratio"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('max_plugin_width_ratio', 3.0) }}"
|
||||
min="0"
|
||||
max="20"
|
||||
step="0.5"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-4" id="setting-display-vegas_dynamic_duration_enabled" data-setting-key="display.vegas_scroll.dynamic_duration_enabled">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_dynamic_duration_enabled"
|
||||
name="vegas_dynamic_duration_enabled"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('dynamic_duration_enabled', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Size cycle time to the content{{ ui.help_tip('When on, each cycle runs just long enough to scroll all its content past, clamped to the min and max below.\nWhen off, the max is always used. Default: on.', 'Dynamic Cycle Duration') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-2">
|
||||
<div class="form-group" id="setting-display-vegas_min_cycle_duration" data-setting-key="display.vegas_scroll.min_cycle_duration">
|
||||
<label for="vegas_min_cycle_duration" class="block text-sm font-medium text-gray-700">Min Cycle Time (seconds){{ ui.help_tip('Shortest a single ticker pass may last (5–3600 s).\nDefault: 60.', 'Min Cycle Time') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_cycle_duration"
|
||||
name="vegas_min_cycle_duration"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cycle_duration', 60) }}"
|
||||
min="5"
|
||||
max="3600"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_max_cycle_duration" data-setting-key="display.vegas_scroll.max_cycle_duration">
|
||||
<label for="vegas_max_cycle_duration" class="block text-sm font-medium text-gray-700">Max Cycle Time (seconds){{ ui.help_tip('Longest a single ticker pass may last before it restarts with fresh content (10–3600 s).\nThis is the setting that caps total Vegas scroll time. Default: 240. Lower it if the ticker feels like it takes too long to come back around.', 'Max Cycle Time') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_max_cycle_duration"
|
||||
name="vegas_max_cycle_duration"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('max_cycle_duration', 240) }}"
|
||||
min="10"
|
||||
max="3600"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dead Space -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Dead Space</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">Plugins that draw onto a full-screen canvas contribute all the empty space around their content. Trimming reclaims it so the ticker stays full.</p>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-vegas_auto_trim" data-setting-key="display.vegas_scroll.auto_trim">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_auto_trim"
|
||||
name="vegas_auto_trim"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('auto_trim', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Trim empty edges from plugin content{{ ui.help_tip('Crops blank columns from the left and right of each plugin block before it enters the ticker. Space between two pieces of content inside a block is left alone, so layouts are not altered. Default: on.', 'Auto Trim') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_content_padding" data-setting-key="display.vegas_scroll.content_padding">
|
||||
<label for="vegas_content_padding" class="block text-sm font-medium text-gray-700">Content Padding (pixels){{ ui.help_tip('Blank columns kept either side of trimmed content, so it does not butt against the separator (0–128 px).\nDefault: 8.', 'Content Padding') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_content_padding"
|
||||
name="vegas_content_padding"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('content_padding', 8) }}"
|
||||
min="0"
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_lead_in_width" data-setting-key="display.vegas_scroll.lead_in_width">
|
||||
<label for="vegas_lead_in_width" class="block text-sm font-medium text-gray-700">Lead-In Gap (pixels){{ ui.help_tip('Blank space before the first plugin of each cycle (0–2048 px).\nDefault: 0. Anything approaching your screen width reads as the display switching off at the start of every cycle.', 'Lead-In Gap') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_lead_in_width"
|
||||
name="vegas_lead_in_width"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('lead_in_width', 0) }}"
|
||||
min="0"
|
||||
max="2048"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div class="form-group" id="setting-display-vegas_trim_threshold" data-setting-key="display.vegas_scroll.trim_threshold">
|
||||
<label for="vegas_trim_threshold" class="block text-sm font-medium text-gray-700">Trim Threshold{{ ui.help_tip('How bright a pixel must be to count as content rather than empty space (0–254).\nDefault: 10, which ignores the near-black noise left by image compression. Raise it if very dark artwork is being kept; lower it if dark detail is being cropped.', 'Trim Threshold') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_trim_threshold"
|
||||
name="vegas_trim_threshold"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('trim_threshold', 10) }}"
|
||||
min="0"
|
||||
max="254"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_min_plugin_width" data-setting-key="display.vegas_scroll.min_plugin_width">
|
||||
<label for="vegas_min_plugin_width" class="block text-sm font-medium text-gray-700">Min Plugin Width (pixels){{ ui.help_tip('Plugin blocks narrower than this after trimming are skipped for that cycle (0–512 px).\nDefault: 8. Raise it to hide plugins showing only a tiny placeholder such as "No Data" until they have real content.', 'Min Plugin Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_plugin_width"
|
||||
name="vegas_min_plugin_width"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_plugin_width', 8) }}"
|
||||
min="0"
|
||||
max="512"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugin Order Section -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Plugin Order</h4>
|
||||
|
||||
Reference in New Issue
Block a user