mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Vegas mode: reclaim dead space and pace the rotation
On a wide panel Vegas mode spent much of its time showing black. At 50px/s on a 512px display, one display width of blank is 10.2 seconds, which makes several long-standing behaviours expensive: - ScrollHelper prepended a full display width of black as an "initial gap", charged once per cycle — 10.2s of black at the start of every rotation. - Plugins without get_vegas_content() are captured off a full-display canvas, so their blank margins entered the ticker too. Measured: of-the-day drew 35px of "No Data" on a 512px canvas (92% blank), youtube-stats 142px of content with 185px of black either side. Only the scroll_helper path had any trimming. - Cycle transitions deliberately pushed a blank frame and then recomposed synchronously: 84ms at best, 4.8s at worst, every millisecond of it black. - buffer_ahead doubled as the cycle size, so a 21-plugin install showed 3 plugins per cycle and took ~7 cycles to come around. - separator_width was applied between every image rather than at plugin boundaries, so a per-row ticker like the F1 scoreboard (116 images, which it renders 4px apart internally) got a 32px chasm between each row — and the width budget didn't count those gaps, so the plugin quietly occupied far more of the panel than intended. Changes: - src/vegas_mode/geometry.py: numpy column-ink primitives shared by the trimmer and the audit tool, so the number reported is the number acted on. A Python per-column loop over a 17,000px strip is far too slow for the render path. - PluginAdapter trims every content path, not just scroll_helper. Only outer edges are cropped: interior blank columns are the plugin's own layout (logo left, score right) and closing them would corrupt the design. A plugin on a non-black background is inherently unaffected. - ScrollHelper.create_scrolling_image takes an explicit lead_gap, still defaulting to display_width so the many standalone-ticker callers are unchanged. Vegas passes lead_in_width (default 0). - Cycle end holds the last rendered frame instead of blanking, turning the recompose into a brief freeze rather than the panel switching off. - plugins_per_cycle (default 6) is split from buffer_ahead, which goes back to being only a prefetch low-water mark. - max_plugin_width_ratio (default 3x display width) caps one plugin's share of a cycle. Overflow is deferred, not discarded: a rotation offset advances each fetch so later rows appear on subsequent cycles. Single oversized images are cropped at a blank column so the cut misses glyphs. - Composition groups images by plugin: rows are joined by intra_plugin_gap (default 8) and separator_width applies only between plugins. The width budget now counts those gaps. - Plugin data updates no longer run on the Vegas render path. All new settings are user-configurable in Display -> Vegas Scroll, including min/max cycle duration and dynamic duration, which previously existed in code but were reachable only by hand-editing config.json. Measured with scripts/dev/vegas_audit.py on a 512x64 panel: mean ink coverage 42.7% -> 69.4% fully blank 5.9% -> 0% reads as empty 13.6% -> 0% worst blank stretch 4.8s -> 0s full rotation 414s -> 123s plugins per cycle 3 -> 6 Note the metric choice: a "fully blank" scan (>=95% black viewport) reported only 0.4% and badly understated the problem, because two full-width segments with mid-canvas content never fully blank the viewport — they hold it at ~28%. window_coverage_stats grades every viewport position by how much ink it carries, which is what tracks perceived dead time. Known remaining: cycle transitions still freeze ~3.5s while the next cycle is fetched. Fixing that needs background prefetch, which is deferred because the fallback-capture path mutates the shared display_manager.image and racing it against the render loop risks torn frames. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
@@ -130,7 +130,18 @@
|
|||||||
"plugin_order": [],
|
"plugin_order": [],
|
||||||
"excluded_plugins": [],
|
"excluded_plugins": [],
|
||||||
"target_fps": 125,
|
"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": {
|
"sync": {
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Vegas Mode Density Audit
|
||||||
|
|
||||||
|
Reports how much of the Vegas ticker is actually showing something. Loads the
|
||||||
|
real enabled plugins, pulls each one's content through the real
|
||||||
|
``PluginAdapter``, composes the strip through the real ``ScrollHelper``, then
|
||||||
|
measures the result.
|
||||||
|
|
||||||
|
The headline number is the **dead-frame ratio**: the fraction of viewport
|
||||||
|
positions across a full cycle that are effectively blank. Because the panel
|
||||||
|
only ever shows ``display_width`` columns at a time, a blank stretch wider than
|
||||||
|
the viewport is a stretch where the display looks switched off — so this ratio
|
||||||
|
tracks perceived dead time rather than just counting unlit pixels.
|
||||||
|
|
||||||
|
Runs entirely off-hardware, so it is safe to run alongside a live display.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Audit every enabled plugin at the display size from config.json
|
||||||
|
python scripts/dev/vegas_audit.py
|
||||||
|
|
||||||
|
# Specific plugins, dump each segment as a PNG for eyeballing
|
||||||
|
python scripts/dev/vegas_audit.py -p of-the-day,youtube-stats --dump-dir /tmp/vg
|
||||||
|
|
||||||
|
# Machine-readable, for before/after comparison
|
||||||
|
python scripts/dev/vegas_audit.py --json > after.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
# Must precede any src import that may reach for hardware.
|
||||||
|
os.environ.setdefault('EMULATOR', 'true')
|
||||||
|
|
||||||
|
from PIL import Image # noqa: E402
|
||||||
|
|
||||||
|
from src.common.scroll_helper import ScrollHelper # noqa: E402
|
||||||
|
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||||
|
build_full_config,
|
||||||
|
find_plugin_dir,
|
||||||
|
load_manifest,
|
||||||
|
)
|
||||||
|
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||||
|
from src.vegas_mode.geometry import ( # noqa: E402
|
||||||
|
DEFAULT_INK_THRESHOLD,
|
||||||
|
column_has_ink,
|
||||||
|
content_bounds,
|
||||||
|
dead_window_stats,
|
||||||
|
window_coverage_stats,
|
||||||
|
)
|
||||||
|
from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402
|
||||||
|
|
||||||
|
# Sampling stride for the dead-window scan. A full cycle can be 30,000px wide;
|
||||||
|
# 4px granularity keeps the scan instant while staying well under the ~10px a
|
||||||
|
# single scroll step ever covers, so no dead stretch is missed.
|
||||||
|
DEAD_SCAN_STEP = 4
|
||||||
|
|
||||||
|
|
||||||
|
def load_main_config(path: Path) -> Dict[str, Any]:
|
||||||
|
with open(path, 'r') as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
|
||||||
|
|
||||||
|
def display_size_from_config(config: Dict[str, Any]) -> tuple:
|
||||||
|
"""Derive the logical ticker size the way DisplayManager does."""
|
||||||
|
hw = config.get('display', {}).get('hardware', {})
|
||||||
|
cols = int(hw.get('cols', 64))
|
||||||
|
chain = int(hw.get('chain_length', 1))
|
||||||
|
rows = int(hw.get('rows', 32))
|
||||||
|
parallel = int(hw.get('parallel', 1))
|
||||||
|
return cols * chain, rows * parallel
|
||||||
|
|
||||||
|
|
||||||
|
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
|
||||||
|
"""Plugin IDs that are enabled in config, excluding non-plugin sections."""
|
||||||
|
ids = []
|
||||||
|
for key, value in config.items():
|
||||||
|
if isinstance(value, dict) and value.get('enabled') is True:
|
||||||
|
ids.append(key)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def instantiate(plugin_id: str, display_manager, cache_manager, plugin_manager):
|
||||||
|
"""Load one plugin offline. Returns the instance or None."""
|
||||||
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
|
|
||||||
|
search_dirs = [
|
||||||
|
str(PROJECT_ROOT / 'plugin-repos'),
|
||||||
|
str(PROJECT_ROOT / 'plugins'),
|
||||||
|
]
|
||||||
|
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
|
||||||
|
if not plugin_dir:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = load_manifest(Path(plugin_dir))
|
||||||
|
cfg = build_full_config(Path(plugin_dir))
|
||||||
|
instance, _ = PluginLoader().load_plugin(
|
||||||
|
plugin_id=plugin_id,
|
||||||
|
manifest=manifest,
|
||||||
|
plugin_dir=Path(plugin_dir),
|
||||||
|
config=cfg,
|
||||||
|
display_manager=display_manager,
|
||||||
|
cache_manager=cache_manager,
|
||||||
|
plugin_manager=plugin_manager,
|
||||||
|
install_deps=False,
|
||||||
|
)
|
||||||
|
return instance
|
||||||
|
except Exception as exc: # noqa: BLE001 - audit tool must survive any plugin
|
||||||
|
print(f" ! {plugin_id}: load failed ({type(exc).__name__}: {exc})",
|
||||||
|
file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def join_rows(images: List[Image.Image], gap: int) -> Image.Image:
|
||||||
|
"""Concatenate one plugin's rows, matching RenderPipeline._join_plugin_rows."""
|
||||||
|
if len(images) == 1:
|
||||||
|
return images[0]
|
||||||
|
gap = max(0, gap)
|
||||||
|
width = sum(img.width for img in images) + gap * (len(images) - 1)
|
||||||
|
height = max(img.height for img in images)
|
||||||
|
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||||
|
x = 0
|
||||||
|
for img in images:
|
||||||
|
block.paste(img, (x, 0))
|
||||||
|
x += img.width + gap
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
def measure_segment(images: List[Image.Image], display_width: int,
|
||||||
|
scroll_speed: float, threshold: int) -> Dict[str, Any]:
|
||||||
|
"""Geometry of one plugin's contribution to the ticker."""
|
||||||
|
total_width = sum(img.width for img in images)
|
||||||
|
combined = Image.new('RGB', (max(1, total_width), images[0].height))
|
||||||
|
x = 0
|
||||||
|
for img in images:
|
||||||
|
combined.paste(img, (x, 0))
|
||||||
|
x += img.width
|
||||||
|
|
||||||
|
ink = column_has_ink(combined, threshold)
|
||||||
|
bounds = content_bounds(combined, threshold)
|
||||||
|
ink_cols = int(ink.sum())
|
||||||
|
|
||||||
|
return {
|
||||||
|
'images': len(images),
|
||||||
|
'width_px': total_width,
|
||||||
|
'ink_cols': ink_cols,
|
||||||
|
'ink_pct': round(100.0 * ink_cols / total_width, 1) if total_width else 0.0,
|
||||||
|
'lead_black_px': bounds[0] if bounds else total_width,
|
||||||
|
'trail_black_px': (total_width - 1 - bounds[1]) if bounds else 0,
|
||||||
|
'seconds_on_screen': round(total_width / scroll_speed, 1) if scroll_speed else 0.0,
|
||||||
|
'widths': [img.width for img in images],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Audit Vegas mode content density')
|
||||||
|
parser.add_argument('--config', default=str(PROJECT_ROOT / 'config' / 'config.json'),
|
||||||
|
help='Path to main config.json')
|
||||||
|
parser.add_argument('-p', '--plugins', default=None,
|
||||||
|
help='Comma-separated plugin IDs (default: all enabled)')
|
||||||
|
parser.add_argument('--width', type=int, default=None,
|
||||||
|
help='Override display width (default: from config hardware)')
|
||||||
|
parser.add_argument('--height', type=int, default=None,
|
||||||
|
help='Override display height (default: from config hardware)')
|
||||||
|
parser.add_argument('--dump-dir', default=None,
|
||||||
|
help='Write each segment and the composed strip as PNGs here')
|
||||||
|
parser.add_argument('--threshold', type=int, default=DEFAULT_INK_THRESHOLD,
|
||||||
|
help=f'Ink threshold (default: {DEFAULT_INK_THRESHOLD})')
|
||||||
|
parser.add_argument('--per-cycle', type=int, default=None,
|
||||||
|
help='Plugins composed per cycle '
|
||||||
|
'(default: buffer_ahead + 1, matching production)')
|
||||||
|
parser.add_argument('--json', action='store_true',
|
||||||
|
help='Emit JSON instead of a text report')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config = load_main_config(Path(args.config))
|
||||||
|
vegas = VegasModeConfig.from_config(config)
|
||||||
|
|
||||||
|
cfg_w, cfg_h = display_size_from_config(config)
|
||||||
|
width = args.width or cfg_w
|
||||||
|
height = args.height or cfg_h
|
||||||
|
speed = vegas.scroll_speed
|
||||||
|
|
||||||
|
if args.plugins:
|
||||||
|
plugin_ids = [p.strip() for p in args.plugins.split(',') if p.strip()]
|
||||||
|
else:
|
||||||
|
plugin_ids = vegas.get_ordered_plugins(enabled_plugin_ids(config))
|
||||||
|
|
||||||
|
dump_dir = Path(args.dump_dir) if args.dump_dir else None
|
||||||
|
if dump_dir:
|
||||||
|
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
from src.plugin_system.testing import (
|
||||||
|
MockCacheManager, MockPluginManager, VisualTestDisplayManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||||
|
cache_manager = MockCacheManager()
|
||||||
|
plugin_manager = MockPluginManager()
|
||||||
|
adapter = PluginAdapter(display_manager)
|
||||||
|
|
||||||
|
if not args.json:
|
||||||
|
print(f"Vegas audit — display {width}x{height}, scroll {speed:g}px/s, "
|
||||||
|
f"separator {vegas.separator_width}px")
|
||||||
|
print(f"One display width = {width / speed:.1f}s of screen time\n")
|
||||||
|
|
||||||
|
results: List[Dict[str, Any]] = []
|
||||||
|
segments: List[Image.Image] = []
|
||||||
|
|
||||||
|
for plugin_id in plugin_ids:
|
||||||
|
started = time.time()
|
||||||
|
instance = instantiate(plugin_id, display_manager, cache_manager, plugin_manager)
|
||||||
|
if instance is None:
|
||||||
|
results.append({'plugin': plugin_id, 'status': 'load_failed'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
plugin_manager.plugins[plugin_id] = instance
|
||||||
|
adapter.invalidate_cache(plugin_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
images = adapter.get_content(instance, plugin_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
results.append({'plugin': plugin_id, 'status': 'fetch_error',
|
||||||
|
'error': f'{type(exc).__name__}: {exc}'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
fetch_ms = round((time.time() - started) * 1000)
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
results.append({'plugin': plugin_id, 'status': 'no_content',
|
||||||
|
'fetch_ms': fetch_ms})
|
||||||
|
if not args.json:
|
||||||
|
print(f" {plugin_id:28s} NO CONTENT ({fetch_ms}ms)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry = {'plugin': plugin_id, 'status': 'ok', 'fetch_ms': fetch_ms}
|
||||||
|
entry.update(measure_segment(images, width, speed, args.threshold))
|
||||||
|
results.append(entry)
|
||||||
|
segments.extend(images)
|
||||||
|
|
||||||
|
if dump_dir:
|
||||||
|
for idx, img in enumerate(images):
|
||||||
|
img.save(dump_dir / f"{plugin_id}__{idx:02d}.png")
|
||||||
|
|
||||||
|
if not args.json:
|
||||||
|
print(f" {plugin_id:28s} {entry['width_px']:>6d}px "
|
||||||
|
f"{entry['images']:>2d} img ink {entry['ink_pct']:>5.1f}% "
|
||||||
|
f"lead {entry['lead_black_px']:>4d} tail {entry['trail_black_px']:>4d} "
|
||||||
|
f"{entry['seconds_on_screen']:>6.1f}s ({fetch_ms}ms)")
|
||||||
|
|
||||||
|
summary: Dict[str, Any] = {
|
||||||
|
'display_width': width,
|
||||||
|
'display_height': height,
|
||||||
|
'scroll_speed': speed,
|
||||||
|
'separator_width': vegas.separator_width,
|
||||||
|
'plugins_audited': len(plugin_ids),
|
||||||
|
'plugins_with_content': sum(1 for r in results if r.get('status') == 'ok'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Production composes only the plugins sitting in the active buffer, so
|
||||||
|
# measuring one giant strip of every plugin would hide the per-cycle costs
|
||||||
|
# (most importantly the leading gap, which is charged once per cycle).
|
||||||
|
# Group the segments the way the running service does.
|
||||||
|
per_cycle = max(1, args.per_cycle or vegas.plugins_per_cycle)
|
||||||
|
|
||||||
|
cycles: List[Dict[str, Any]] = []
|
||||||
|
with_content = [r for r in results if r.get('status') == 'ok']
|
||||||
|
|
||||||
|
if segments:
|
||||||
|
logger = logging.getLogger('vegas_audit')
|
||||||
|
seg_index = 0
|
||||||
|
for start in range(0, len(with_content), per_cycle):
|
||||||
|
group = with_content[start:start + per_cycle]
|
||||||
|
|
||||||
|
# Mirror RenderPipeline: each plugin's rows are joined by
|
||||||
|
# intra_plugin_gap into one block, and separator_width is applied
|
||||||
|
# only between blocks. Measuring a flat list here would report gaps
|
||||||
|
# the service does not emit.
|
||||||
|
blocks: List[Image.Image] = []
|
||||||
|
for entry in group:
|
||||||
|
count = entry['images']
|
||||||
|
rows = segments[seg_index:seg_index + count]
|
||||||
|
seg_index += count
|
||||||
|
if rows:
|
||||||
|
blocks.append(join_rows(rows, vegas.intra_plugin_gap))
|
||||||
|
if not blocks:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ScrollHelper logs unconditionally, so it needs a real logger.
|
||||||
|
helper = ScrollHelper(width, height, logger)
|
||||||
|
helper.create_scrolling_image(
|
||||||
|
content_items=blocks,
|
||||||
|
item_gap=vegas.separator_width,
|
||||||
|
element_gap=0,
|
||||||
|
# Must match RenderPipeline. Omitting this made the audit
|
||||||
|
# measure a full-display-width leading gap the service no
|
||||||
|
# longer emits, overstating dead space by 512px per cycle.
|
||||||
|
lead_gap=vegas.lead_in_width,
|
||||||
|
)
|
||||||
|
composed = helper.cached_image
|
||||||
|
if composed is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dead = dead_window_stats(composed, width, args.threshold, step=DEAD_SCAN_STEP)
|
||||||
|
cover = window_coverage_stats(
|
||||||
|
composed, width, args.threshold, step=DEAD_SCAN_STEP)
|
||||||
|
|
||||||
|
if dump_dir:
|
||||||
|
composed.save(dump_dir / f"_cycle{len(cycles):02d}.png")
|
||||||
|
|
||||||
|
cycles.append({
|
||||||
|
'plugins': [e['plugin'] for e in group],
|
||||||
|
'width_px': composed.width,
|
||||||
|
'seconds': round(composed.width / speed, 1) if speed else 0.0,
|
||||||
|
'dead_pct': round(100 * dead.dead_ratio, 1),
|
||||||
|
'longest_dead_seconds': round(
|
||||||
|
dead.longest_dead_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
|
||||||
|
'mean_ink_pct': round(100 * cover.mean_ink_ratio, 1),
|
||||||
|
'sparse_pct': round(100 * cover.sparse_ratio, 1),
|
||||||
|
'longest_sparse_seconds': round(
|
||||||
|
cover.longest_sparse_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if cycles:
|
||||||
|
total_px = sum(c['width_px'] for c in cycles)
|
||||||
|
# Weight each cycle by its width so a long cycle counts proportionally.
|
||||||
|
summary.update({
|
||||||
|
'cycles': len(cycles),
|
||||||
|
'total_px': total_px,
|
||||||
|
'full_rotation_seconds': round(total_px / speed, 1) if speed else 0.0,
|
||||||
|
'dead_pct': round(
|
||||||
|
sum(c['dead_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
||||||
|
'mean_ink_pct': round(
|
||||||
|
sum(c['mean_ink_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
||||||
|
'sparse_pct': round(
|
||||||
|
sum(c['sparse_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
||||||
|
'worst_dead_seconds': max(c['longest_dead_seconds'] for c in cycles),
|
||||||
|
'worst_sparse_seconds': max(c['longest_sparse_seconds'] for c in cycles),
|
||||||
|
})
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({'summary': summary, 'cycles': cycles, 'plugins': results},
|
||||||
|
indent=2))
|
||||||
|
else:
|
||||||
|
print(f"\n Cycles ({per_cycle} plugins each, as production composes them):")
|
||||||
|
for idx, cyc in enumerate(cycles):
|
||||||
|
print(f" [{idx}] {cyc['width_px']:>6d}px {cyc['seconds']:>6.1f}s "
|
||||||
|
f"ink {cyc['mean_ink_pct']:>5.1f}% blank {cyc['dead_pct']:>5.1f}% "
|
||||||
|
f"worst blank {cyc['longest_dead_seconds']:>5.1f}s "
|
||||||
|
f"| {', '.join(cyc['plugins'])}")
|
||||||
|
|
||||||
|
print(f"\n {'-' * 66}")
|
||||||
|
print(f" full rotation {summary.get('full_rotation_seconds', 0):>7.1f}s "
|
||||||
|
f"over {summary.get('cycles', 0)} cycles")
|
||||||
|
print(f" mean ink coverage {summary.get('mean_ink_pct', 0):>7.1f}% "
|
||||||
|
f"(higher is better; target >25%)")
|
||||||
|
print(f" fully blank {summary.get('dead_pct', 0):>7.1f}% (target <2%)")
|
||||||
|
print(f" reads as empty {summary.get('sparse_pct', 0):>7.1f}% (target <15%)")
|
||||||
|
print(f" worst blank stretch {summary.get('worst_dead_seconds', 0):>7.1f}s "
|
||||||
|
f"(target <1.5s)")
|
||||||
|
print(f" plugins w/ content {summary.get('plugins_with_content', 0):>7d}"
|
||||||
|
f" of {summary['plugins_audited']}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -112,7 +112,8 @@ class ScrollHelper:
|
|||||||
|
|
||||||
def create_scrolling_image(self, content_items: list,
|
def create_scrolling_image(self, content_items: list,
|
||||||
item_gap: int = 32,
|
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.
|
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
|
content_items: List of PIL Images to include in scroll
|
||||||
item_gap: Gap between different items
|
item_gap: Gap between different items
|
||||||
element_gap: Gap between elements within an item
|
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:
|
Returns:
|
||||||
PIL Image containing all content arranged horizontally
|
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:
|
if not content_items:
|
||||||
# Create empty image if no content
|
# Create empty image if no content
|
||||||
# Still set total_scroll_width to 0 to indicate no scrollable 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)
|
total_width += element_gap * len(content_items)
|
||||||
|
|
||||||
# Add initial gap before first item
|
# Add initial gap before first item
|
||||||
total_width += self.display_width
|
total_width += lead_gap
|
||||||
|
|
||||||
# Create the full scrolling image
|
# Create the full scrolling image
|
||||||
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
|
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
|
||||||
|
|
||||||
# Position items
|
# 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):
|
for i, img in enumerate(content_items):
|
||||||
# Paste the item image
|
# Paste the item image
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ PIL Image canvas and draws text using the actual project fonts.
|
|||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, List, Optional, Tuple
|
from typing import Any, List, Optional, Tuple
|
||||||
|
|
||||||
@@ -62,6 +63,9 @@ class VisualTestDisplayManager:
|
|||||||
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
||||||
self.matrix = _MatrixProxy(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)
|
# Scrolling state (interface compat, no-op)
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
'is_scrolling': False,
|
'is_scrolling': False,
|
||||||
@@ -174,6 +178,21 @@ class VisualTestDisplayManager:
|
|||||||
"""No-op for hardware; marks that display was updated."""
|
"""No-op for hardware; marks that display was updated."""
|
||||||
self.update_called = True
|
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,
|
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,
|
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
|
||||||
font: Optional[Any] = None, centered: bool = False) -> None:
|
font: Optional[Any] = None, centered: bool = False) -> None:
|
||||||
|
|||||||
@@ -21,6 +21,41 @@ class VegasModeConfig:
|
|||||||
scroll_speed: float = 50.0 # Pixels per second
|
scroll_speed: float = 50.0 # Pixels per second
|
||||||
separator_width: int = 32 # Gap between plugins (pixels)
|
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 management
|
||||||
plugin_order: List[str] = field(default_factory=list)
|
plugin_order: List[str] = field(default_factory=list)
|
||||||
excluded_plugins: Set[str] = field(default_factory=set)
|
excluded_plugins: Set[str] = field(default_factory=set)
|
||||||
@@ -55,6 +90,15 @@ class VegasModeConfig:
|
|||||||
enabled=vegas_config.get('enabled', False),
|
enabled=vegas_config.get('enabled', False),
|
||||||
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
|
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
|
||||||
separator_width=int(vegas_config.get('separator_width', 32)),
|
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', [])),
|
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
||||||
target_fps=int(vegas_config.get('target_fps', 125)),
|
target_fps=int(vegas_config.get('target_fps', 125)),
|
||||||
@@ -72,6 +116,14 @@ class VegasModeConfig:
|
|||||||
'enabled': self.enabled,
|
'enabled': self.enabled,
|
||||||
'scroll_speed': self.scroll_speed,
|
'scroll_speed': self.scroll_speed,
|
||||||
'separator_width': self.separator_width,
|
'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,
|
'plugin_order': self.plugin_order,
|
||||||
'excluded_plugins': list(self.excluded_plugins),
|
'excluded_plugins': list(self.excluded_plugins),
|
||||||
'target_fps': self.target_fps,
|
'target_fps': self.target_fps,
|
||||||
@@ -157,6 +209,44 @@ class VegasModeConfig:
|
|||||||
if self.buffer_ahead > 5:
|
if self.buffer_ahead > 5:
|
||||||
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
|
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
|
||||||
|
|
||||||
|
if self.intra_plugin_gap < 0:
|
||||||
|
errors.append(
|
||||||
|
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
|
||||||
|
if self.intra_plugin_gap > 128:
|
||||||
|
errors.append(
|
||||||
|
f"intra_plugin_gap must be <= 128, got {self.intra_plugin_gap}")
|
||||||
|
|
||||||
|
if not 0 <= self.trim_threshold <= 254:
|
||||||
|
errors.append(
|
||||||
|
f"trim_threshold must be between 0 and 254, got {self.trim_threshold}")
|
||||||
|
|
||||||
|
if self.content_padding < 0:
|
||||||
|
errors.append(
|
||||||
|
f"content_padding must be >= 0, got {self.content_padding}")
|
||||||
|
if self.content_padding > 128:
|
||||||
|
errors.append(
|
||||||
|
f"content_padding must be <= 128, got {self.content_padding}")
|
||||||
|
|
||||||
|
if self.min_plugin_width < 0:
|
||||||
|
errors.append(
|
||||||
|
f"min_plugin_width must be >= 0, got {self.min_plugin_width}")
|
||||||
|
|
||||||
|
if self.lead_in_width < 0:
|
||||||
|
errors.append(
|
||||||
|
f"lead_in_width must be >= 0, got {self.lead_in_width}")
|
||||||
|
|
||||||
|
if self.plugins_per_cycle < 1:
|
||||||
|
errors.append(
|
||||||
|
f"plugins_per_cycle must be >= 1, got {self.plugins_per_cycle}")
|
||||||
|
if self.plugins_per_cycle > 50:
|
||||||
|
errors.append(
|
||||||
|
f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}")
|
||||||
|
|
||||||
|
if self.max_plugin_width_ratio < 0:
|
||||||
|
errors.append(
|
||||||
|
"max_plugin_width_ratio must be >= 0 "
|
||||||
|
f"(0 disables the cap), got {self.max_plugin_width_ratio}")
|
||||||
|
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
def update(self, new_config: Dict[str, Any]) -> None:
|
def update(self, new_config: Dict[str, Any]) -> None:
|
||||||
@@ -174,6 +264,23 @@ class VegasModeConfig:
|
|||||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||||
if 'separator_width' in vegas_config:
|
if 'separator_width' in vegas_config:
|
||||||
self.separator_width = int(vegas_config['separator_width'])
|
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:
|
if 'plugin_order' in vegas_config:
|
||||||
self.plugin_order = list(vegas_config['plugin_order'])
|
self.plugin_order = list(vegas_config['plugin_order'])
|
||||||
if 'excluded_plugins' in vegas_config:
|
if 'excluded_plugins' in vegas_config:
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class VegasModeCoordinator:
|
|||||||
self.plugin_manager = plugin_manager
|
self.plugin_manager = plugin_manager
|
||||||
|
|
||||||
# Initialize components
|
# Initialize components
|
||||||
self.plugin_adapter = PluginAdapter(display_manager)
|
self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
|
||||||
self.stream_manager = StreamManager(
|
self.stream_manager = StreamManager(
|
||||||
self.vegas_config,
|
self.vegas_config,
|
||||||
plugin_manager,
|
plugin_manager,
|
||||||
@@ -505,6 +505,10 @@ class VegasModeCoordinator:
|
|||||||
# Update components
|
# Update components
|
||||||
self.render_pipeline.update_config(new_vegas_config)
|
self.render_pipeline.update_config(new_vegas_config)
|
||||||
self.stream_manager.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
|
# Force refresh of stream manager to pick up plugin_order/buffer changes
|
||||||
self.stream_manager._last_refresh = 0
|
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 typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.vegas_mode.geometry import find_blank_cut, trim_to_content
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.plugin_system.base_plugin import BasePlugin
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
|
||||||
@@ -26,14 +28,21 @@ class PluginAdapter:
|
|||||||
2. Fallback: Capture display_manager.image after calling plugin.display()
|
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.
|
Initialize the plugin adapter.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
display_manager: DisplayManager instance for fallback capture
|
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
|
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
|
# Handle both property and method access patterns
|
||||||
self.display_width = (
|
self.display_width = (
|
||||||
display_manager.width() if callable(display_manager.width)
|
display_manager.width() if callable(display_manager.width)
|
||||||
@@ -49,6 +58,11 @@ class PluginAdapter:
|
|||||||
self._cache_lock = threading.Lock()
|
self._cache_lock = threading.Lock()
|
||||||
self._cache_ttl = 5.0 # Cache for 5 seconds
|
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(
|
logger.info(
|
||||||
"PluginAdapter initialized: display=%dx%d",
|
"PluginAdapter initialized: display=%dx%d",
|
||||||
self.display_width, self.display_height
|
self.display_width, self.display_height
|
||||||
@@ -93,8 +107,7 @@ class PluginAdapter:
|
|||||||
"[%s] Native content SUCCESS: %d images, %dpx total",
|
"[%s] Native content SUCCESS: %d images, %dpx total",
|
||||||
plugin_id, len(content), total_width
|
plugin_id, len(content), total_width
|
||||||
)
|
)
|
||||||
self._cache_content(plugin_id, content)
|
return self._finalize(content, plugin_id, 'native')
|
||||||
return content
|
|
||||||
logger.info("[%s] Native content returned None", plugin_id)
|
logger.info("[%s] Native content returned None", plugin_id)
|
||||||
|
|
||||||
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
|
# 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",
|
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
|
||||||
plugin_id, len(content), total_width
|
plugin_id, len(content), total_width
|
||||||
)
|
)
|
||||||
self._cache_content(plugin_id, content)
|
return self._finalize(content, plugin_id, 'scroll_helper')
|
||||||
return content
|
|
||||||
if has_scroll_helper:
|
if has_scroll_helper:
|
||||||
logger.info("[%s] ScrollHelper content returned None", plugin_id)
|
logger.info("[%s] ScrollHelper content returned None", plugin_id)
|
||||||
|
|
||||||
@@ -121,8 +133,7 @@ class PluginAdapter:
|
|||||||
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
|
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
|
||||||
plugin_id, len(content), total_width
|
plugin_id, len(content), total_width
|
||||||
)
|
)
|
||||||
self._cache_content(plugin_id, content)
|
return self._finalize(content, plugin_id, 'fallback')
|
||||||
return content
|
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
|
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
|
||||||
@@ -130,6 +141,181 @@ class PluginAdapter:
|
|||||||
)
|
)
|
||||||
return None
|
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(
|
def _get_native_content(
|
||||||
self, plugin: 'BasePlugin', plugin_id: str
|
self, plugin: 'BasePlugin', plugin_id: str
|
||||||
) -> Optional[List[Image.Image]]:
|
) -> Optional[List[Image.Image]]:
|
||||||
|
|||||||
@@ -66,10 +66,6 @@ class RenderPipeline:
|
|||||||
else display_manager.height
|
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
|
# ScrollHelper for optimized scrolling
|
||||||
self.scroll_helper = ScrollHelper(
|
self.scroll_helper = ScrollHelper(
|
||||||
self.display_width,
|
self.display_width,
|
||||||
@@ -141,23 +137,37 @@ class RenderPipeline:
|
|||||||
True if composition successful
|
True if composition successful
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get all buffered content
|
# Content grouped by plugin, so a separator can be placed at the
|
||||||
images = self.stream_manager.get_all_content_for_composition()
|
# 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")
|
logger.warning("No content available for composition")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Add separator gaps between images
|
# Collapse each plugin's rows into a single block, joined by
|
||||||
content_with_gaps = []
|
# intra_plugin_gap. ScrollHelper applies one uniform gap between the
|
||||||
for i, img in enumerate(images):
|
# items it is given, so handing it one item per plugin is what makes
|
||||||
content_with_gaps.append(img)
|
# 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(
|
self.scroll_helper.create_scrolling_image(
|
||||||
content_items=content_with_gaps,
|
content_items=blocks,
|
||||||
item_gap=self.config.separator_width,
|
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
|
# Verify scroll image was created successfully
|
||||||
@@ -177,11 +187,14 @@ class RenderPipeline:
|
|||||||
self._cycle_complete = False
|
self._cycle_complete = False
|
||||||
|
|
||||||
logger.info(
|
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.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
|
||||||
self.display_height,
|
self.display_height,
|
||||||
len(self._segments_in_scroll),
|
len(blocks),
|
||||||
len(images)
|
total_rows,
|
||||||
|
self.config.separator_width,
|
||||||
|
self.config.intra_plugin_gap,
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
@@ -191,6 +204,32 @@ class RenderPipeline:
|
|||||||
logger.exception("Error composing scroll content")
|
logger.exception("Error composing scroll content")
|
||||||
return False
|
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:
|
def render_frame(self) -> bool:
|
||||||
"""
|
"""
|
||||||
Render a single frame to the display.
|
Render a single frame to the display.
|
||||||
@@ -236,24 +275,17 @@ class RenderPipeline:
|
|||||||
"Scroll cycle complete after %.1fs",
|
"Scroll cycle complete after %.1fs",
|
||||||
time.time() - self._cycle_start_time
|
time.time() - self._cycle_start_time
|
||||||
)
|
)
|
||||||
# Push blank immediately so the hardware never shows any
|
# Deliberately leave the last rendered frame on the panel.
|
||||||
# post-wrap content while the coordinator recomposes the
|
#
|
||||||
# next cycle (~100 ms). The blank is allocated once and
|
# This used to push a blank frame so no post-wrap content
|
||||||
# reused across cycle wraps (fresh paste each time in case
|
# could be seen while the next cycle was composed. But
|
||||||
# a consumer drew on the previous one).
|
# recomposing is synchronous and fetches plugin content:
|
||||||
try:
|
# measured 84ms at best and 4.8s at worst on a 512px panel,
|
||||||
if self._blank_frame is None or self._blank_frame.size != (
|
# and every millisecond of it was black. Holding the last
|
||||||
self.display_width, self.display_height):
|
# frame instead turns that into a brief freeze, which reads
|
||||||
self._blank_frame = Image.new(
|
# as far less broken than the display switching off. The
|
||||||
'RGB', (self.display_width, self.display_height))
|
# frame is already past the end of the content, so there is
|
||||||
else:
|
# no second-pass content to leak.
|
||||||
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")
|
|
||||||
return True # Cycle done; coordinator starts new cycle next frame
|
return True # Cycle done; coordinator starts new cycle next frame
|
||||||
|
|
||||||
# Get visible portion
|
# Get visible portion
|
||||||
@@ -415,11 +447,12 @@ class RenderPipeline:
|
|||||||
result = self.compose_scroll_content()
|
result = self.compose_scroll_content()
|
||||||
|
|
||||||
if result and self.sync_manager:
|
if result and self.sync_manager:
|
||||||
# When sync is active, start the leader at display_width instead of 0.
|
# When sync is active, start the leader past the lead-in gap so it
|
||||||
# This skips the initial black gap so the leader immediately shows content.
|
# immediately shows content, leaving the follower on the blank gap
|
||||||
# The follower starts at position 0 (the gap) which looks like a clean
|
# for a clean transition rather than near-end content wrapping
|
||||||
# blank transition rather than near-end content wrapping around.
|
# around. This tracks lead_in_width rather than assuming a full
|
||||||
self.scroll_helper.scroll_position = float(self.display_width)
|
# 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:
|
if result and self.sync_manager:
|
||||||
# Signal follower that a new cycle started (triggers its own rebuild)
|
# Signal follower that a new cycle started (triggers its own rebuild)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Supports three display modes:
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
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 collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -116,8 +116,11 @@ class StreamManager:
|
|||||||
logger.warning("No plugins available for Vegas scroll")
|
logger.warning("No plugins available for Vegas scroll")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Prefetch initial content
|
# Fill the buffer to a whole cycle's worth of plugins. This used to be
|
||||||
self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins)))
|
# 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(
|
logger.info(
|
||||||
"StreamManager initialized with %d plugins, %d segments buffered",
|
"StreamManager initialized with %d plugins, %d segments buffered",
|
||||||
@@ -385,7 +388,7 @@ class StreamManager:
|
|||||||
return
|
return
|
||||||
|
|
||||||
for _ in range(count):
|
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
|
break
|
||||||
|
|
||||||
# Ensure index is valid (guard against empty list)
|
# 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)
|
logger.debug("Refreshed content for %s in staging buffer", plugin_id)
|
||||||
|
|
||||||
def _ensure_buffer_filled(self) -> None:
|
def _ensure_buffer_filled(self) -> None:
|
||||||
"""Ensure buffer has enough content prefetched."""
|
"""
|
||||||
if len(self._active_buffer) < self.config.buffer_ahead:
|
Top the buffer back up after segments have been served.
|
||||||
needed = self.config.buffer_ahead - len(self._active_buffer)
|
|
||||||
self._prefetch_content(count=needed)
|
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]:
|
def get_all_content_for_composition(self) -> List[Image.Image]:
|
||||||
"""
|
"""
|
||||||
Get all buffered content as a flat list of images.
|
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.
|
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:
|
Returns:
|
||||||
List of all images in buffer order
|
List of all images in buffer order
|
||||||
"""
|
"""
|
||||||
all_images = []
|
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:
|
with self._buffer_lock:
|
||||||
for segment in self._active_buffer:
|
for segment in self._active_buffer:
|
||||||
# Skip STATIC segments - they trigger pauses, not scroll content
|
if segment.display_mode == VegasDisplayMode.STATIC:
|
||||||
if segment.display_mode != VegasDisplayMode.STATIC:
|
continue
|
||||||
all_images.extend(segment.images)
|
if not segment.images:
|
||||||
return all_images
|
continue
|
||||||
|
grouped.append((segment.plugin_id, list(segment.images)))
|
||||||
|
return grouped
|
||||||
|
|
||||||
def advance_cycle(self) -> None:
|
def advance_cycle(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Vegas mode density work: dead-space trimming in PluginAdapter
|
||||||
|
and the configurable lead-in gap in ScrollHelper.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.common.scroll_helper import ScrollHelper
|
||||||
|
from src.vegas_mode.config import VegasModeConfig
|
||||||
|
from src.vegas_mode.geometry import column_has_ink
|
||||||
|
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||||
|
|
||||||
|
DISPLAY_W = 512
|
||||||
|
DISPLAY_H = 64
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDisplayManager:
|
||||||
|
"""Minimal stand-in; the native content path never touches the canvas."""
|
||||||
|
|
||||||
|
width = DISPLAY_W
|
||||||
|
height = DISPLAY_H
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H))
|
||||||
|
|
||||||
|
|
||||||
|
class NativePlugin:
|
||||||
|
"""Plugin that returns pre-rendered Vegas content."""
|
||||||
|
|
||||||
|
def __init__(self, images):
|
||||||
|
self._images = images
|
||||||
|
|
||||||
|
def get_vegas_content(self):
|
||||||
|
return self._images
|
||||||
|
|
||||||
|
|
||||||
|
def canvas(content_spans, width=DISPLAY_W, height=DISPLAY_H):
|
||||||
|
"""Full-display canvas with white content in the given [x0, x1) spans."""
|
||||||
|
img = Image.new('RGB', (width, height), (0, 0, 0))
|
||||||
|
for x0, x1 in content_spans:
|
||||||
|
img.paste(Image.new('RGB', (x1 - x0, height), (255, 255, 255)), (x0, 0))
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def adapter_with(**overrides):
|
||||||
|
cfg = VegasModeConfig(**overrides)
|
||||||
|
return PluginAdapter(FakeDisplayManager(), cfg)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdapterTrimming:
|
||||||
|
def test_of_the_day_case_is_reclaimed(self):
|
||||||
|
# Measured on devpi: "No Data" occupying 35px of a 512px canvas bought
|
||||||
|
# 9.5s of black at 50px/s.
|
||||||
|
adapter = adapter_with(content_padding=0)
|
||||||
|
plugin = NativePlugin([canvas([(4, 39)])])
|
||||||
|
images = adapter.get_content(plugin, 'of-the-day')
|
||||||
|
assert len(images) == 1
|
||||||
|
assert images[0].width == 35
|
||||||
|
|
||||||
|
def test_youtube_stats_case_is_reclaimed(self):
|
||||||
|
# Centred 142px of content on a 512px canvas: 185px black each side.
|
||||||
|
adapter = adapter_with(content_padding=0)
|
||||||
|
plugin = NativePlugin([canvas([(185, 327)])])
|
||||||
|
images = adapter.get_content(plugin, 'youtube-stats')
|
||||||
|
assert images[0].width == 142
|
||||||
|
|
||||||
|
def test_padding_is_applied_within_available_margin(self):
|
||||||
|
adapter = adapter_with(content_padding=8)
|
||||||
|
plugin = NativePlugin([canvas([(185, 327)])])
|
||||||
|
images = adapter.get_content(plugin, 'youtube-stats')
|
||||||
|
assert images[0].width == 142 + 16
|
||||||
|
|
||||||
|
def test_interior_layout_gap_survives(self):
|
||||||
|
# A logo far left and a score far right is deliberate layout; closing
|
||||||
|
# the gap would corrupt the design rather than reclaim dead space.
|
||||||
|
adapter = adapter_with(content_padding=0)
|
||||||
|
plugin = NativePlugin([canvas([(10, 40), (400, 460)])])
|
||||||
|
images = adapter.get_content(plugin, 'scoreboard')
|
||||||
|
assert images[0].width == 450 # 10..459
|
||||||
|
assert int(column_has_ink(images[0]).sum()) == 90
|
||||||
|
|
||||||
|
def test_wide_legitimate_content_is_left_alone(self):
|
||||||
|
# geochron uses 444 of 512 columns; only the real tail should go.
|
||||||
|
adapter = adapter_with(content_padding=0)
|
||||||
|
plugin = NativePlugin([canvas([(0, 444)])])
|
||||||
|
images = adapter.get_content(plugin, 'geochron')
|
||||||
|
assert images[0].width == 444
|
||||||
|
|
||||||
|
def test_non_black_background_is_untouched(self):
|
||||||
|
adapter = adapter_with(content_padding=0)
|
||||||
|
bg = Image.new('RGB', (DISPLAY_W, DISPLAY_H), (0, 0, 40))
|
||||||
|
plugin = NativePlugin([bg])
|
||||||
|
images = adapter.get_content(plugin, 'weather')
|
||||||
|
assert images[0].width == DISPLAY_W
|
||||||
|
|
||||||
|
def test_each_image_of_a_multi_item_segment_is_trimmed(self):
|
||||||
|
# compose_scroll_content treats every image as its own item, so a
|
||||||
|
# per-image trim is what makes separator_width the real gap.
|
||||||
|
adapter = adapter_with(content_padding=0)
|
||||||
|
plugin = NativePlugin([
|
||||||
|
canvas([(168, 344)]),
|
||||||
|
canvas([(200, 300)]),
|
||||||
|
canvas([(0, 512)]),
|
||||||
|
])
|
||||||
|
images = adapter.get_content(plugin, 'ledmatrix-flights')
|
||||||
|
assert [img.width for img in images] == [176, 100, 512]
|
||||||
|
|
||||||
|
def test_fully_blank_segment_contributes_nothing(self):
|
||||||
|
adapter = adapter_with()
|
||||||
|
plugin = NativePlugin([Image.new('RGB', (DISPLAY_W, DISPLAY_H))])
|
||||||
|
assert adapter.get_content(plugin, 'empty') is None
|
||||||
|
|
||||||
|
def test_blank_images_are_dropped_but_others_kept(self):
|
||||||
|
adapter = adapter_with(content_padding=0)
|
||||||
|
plugin = NativePlugin([
|
||||||
|
canvas([(100, 150)]),
|
||||||
|
Image.new('RGB', (DISPLAY_W, DISPLAY_H)),
|
||||||
|
canvas([(200, 260)]),
|
||||||
|
])
|
||||||
|
images = adapter.get_content(plugin, 'mixed')
|
||||||
|
assert [img.width for img in images] == [50, 60]
|
||||||
|
|
||||||
|
def test_min_plugin_width_rejects_noise(self):
|
||||||
|
adapter = adapter_with(content_padding=0, min_plugin_width=32)
|
||||||
|
plugin = NativePlugin([canvas([(10, 14)])])
|
||||||
|
assert adapter.get_content(plugin, 'sliver') is None
|
||||||
|
|
||||||
|
def test_min_plugin_width_of_zero_keeps_everything(self):
|
||||||
|
adapter = adapter_with(content_padding=0, min_plugin_width=0)
|
||||||
|
plugin = NativePlugin([canvas([(10, 14)])])
|
||||||
|
images = adapter.get_content(plugin, 'sliver')
|
||||||
|
assert images[0].width == 4
|
||||||
|
|
||||||
|
def test_auto_trim_off_preserves_original_behaviour(self):
|
||||||
|
adapter = adapter_with(auto_trim=False)
|
||||||
|
plugin = NativePlugin([canvas([(4, 39)])])
|
||||||
|
images = adapter.get_content(plugin, 'of-the-day')
|
||||||
|
assert images[0].width == DISPLAY_W
|
||||||
|
|
||||||
|
def test_trim_threshold_ignores_near_black_noise(self):
|
||||||
|
# A very dark band should not be mistaken for content.
|
||||||
|
img = Image.new('RGB', (DISPLAY_W, DISPLAY_H), (0, 0, 0))
|
||||||
|
img.paste(Image.new('RGB', (100, DISPLAY_H), (3, 3, 3)), (0, 0))
|
||||||
|
img.paste(Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)), (200, 0))
|
||||||
|
adapter = adapter_with(content_padding=0, trim_threshold=10)
|
||||||
|
images = adapter.get_content(NativePlugin([img]), 'noisy')
|
||||||
|
assert images[0].width == 50
|
||||||
|
|
||||||
|
def test_height_is_preserved_through_trim(self):
|
||||||
|
adapter = adapter_with(content_padding=0)
|
||||||
|
plugin = NativePlugin([canvas([(100, 200)])])
|
||||||
|
images = adapter.get_content(plugin, 'x')
|
||||||
|
assert images[0].height == DISPLAY_H
|
||||||
|
|
||||||
|
def test_trimmed_result_is_cached(self):
|
||||||
|
adapter = adapter_with(content_padding=0)
|
||||||
|
plugin = NativePlugin([canvas([(100, 200)])])
|
||||||
|
first = adapter.get_content(plugin, 'cached')
|
||||||
|
# Swap the plugin's content; the cache should still serve the old size.
|
||||||
|
plugin._images = [canvas([(0, 512)])]
|
||||||
|
second = adapter.get_content(plugin, 'cached')
|
||||||
|
assert first[0].width == second[0].width == 100
|
||||||
|
|
||||||
|
def test_default_adapter_construction_still_works(self):
|
||||||
|
# Existing callers pass only the display manager.
|
||||||
|
adapter = PluginAdapter(FakeDisplayManager())
|
||||||
|
assert adapter.config.auto_trim is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestWidthBudget:
|
||||||
|
def test_segment_within_budget_is_untouched(self):
|
||||||
|
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
|
||||||
|
plugin = NativePlugin([canvas([(0, 400)])])
|
||||||
|
assert adapter.get_content(plugin, 'small')[0].width == 400
|
||||||
|
|
||||||
|
def test_oversized_multi_item_segment_is_capped(self):
|
||||||
|
# 10 items of 400px = 4000px against a 1536px budget (3 x 512).
|
||||||
|
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
|
||||||
|
items = [canvas([(0, 400)], width=400) for _ in range(10)]
|
||||||
|
images = adapter.get_content(NativePlugin(items), 'stock-news')
|
||||||
|
assert sum(i.width for i in images) <= 3 * DISPLAY_W
|
||||||
|
assert len(images) == 3 # 1200px; a 4th would exceed 1536
|
||||||
|
|
||||||
|
def test_deferred_items_appear_on_the_next_cycle(self):
|
||||||
|
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||||
|
# Distinguish items by width so the rotation is observable.
|
||||||
|
items = [canvas([(0, w)], width=w) for w in (200, 210, 220, 230, 240)]
|
||||||
|
plugin = NativePlugin(items)
|
||||||
|
|
||||||
|
first = adapter.get_content(plugin, 'ticker')
|
||||||
|
adapter.invalidate_cache('ticker')
|
||||||
|
second = adapter.get_content(plugin, 'ticker')
|
||||||
|
assert [i.width for i in first] != [i.width for i in second]
|
||||||
|
|
||||||
|
def test_rotation_eventually_covers_every_item(self):
|
||||||
|
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||||
|
widths = (200, 210, 220, 230, 240)
|
||||||
|
items = [canvas([(0, w)], width=w) for w in widths]
|
||||||
|
plugin = NativePlugin(items)
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for _ in range(10):
|
||||||
|
adapter.invalidate_cache('ticker')
|
||||||
|
for img in adapter.get_content(plugin, 'ticker'):
|
||||||
|
seen.add(img.width)
|
||||||
|
assert seen == set(widths)
|
||||||
|
|
||||||
|
def test_single_oversized_image_is_cropped_to_budget(self):
|
||||||
|
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||||
|
# A 6898px leaderboard strip, as measured on devpi. Solid ink means
|
||||||
|
# there is no blank column to snap to, so the cut lands on the budget.
|
||||||
|
plugin = NativePlugin([canvas([(0, 6898)], width=6898)])
|
||||||
|
images = adapter.get_content(plugin, 'ledmatrix-leaderboard')
|
||||||
|
assert len(images) == 1
|
||||||
|
assert images[0].width == DISPLAY_W
|
||||||
|
|
||||||
|
def test_single_image_crop_snaps_to_a_blank_column(self):
|
||||||
|
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||||
|
# Content blocks with gaps; the cut should land in a gap, not mid-block.
|
||||||
|
spans = [(x, x + 90) for x in range(0, 2000, 100)]
|
||||||
|
plugin = NativePlugin([canvas(spans, width=2000)])
|
||||||
|
images = adapter.get_content(plugin, 'gapped')
|
||||||
|
assert images[0].width != DISPLAY_W
|
||||||
|
assert abs(images[0].width - DISPLAY_W) <= DISPLAY_W // 16 + 1
|
||||||
|
|
||||||
|
def test_single_image_window_advances_across_cycles(self):
|
||||||
|
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||||
|
plugin = NativePlugin([canvas([(0, 3000)], width=3000)])
|
||||||
|
adapter.get_content(plugin, 'strip')
|
||||||
|
assert adapter._item_offsets['strip'] == DISPLAY_W
|
||||||
|
|
||||||
|
def test_budget_of_zero_disables_the_cap(self):
|
||||||
|
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0)
|
||||||
|
plugin = NativePlugin([canvas([(0, 6898)], width=6898)])
|
||||||
|
assert adapter.get_content(plugin, 'huge')[0].width == 6898
|
||||||
|
|
||||||
|
def test_rotation_resets_when_content_shrinks_to_fit(self):
|
||||||
|
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||||
|
big = [canvas([(0, 300)], width=300) for _ in range(5)]
|
||||||
|
adapter.get_content(NativePlugin(big), 'shrink')
|
||||||
|
assert 'shrink' in adapter._item_offsets
|
||||||
|
|
||||||
|
adapter.invalidate_cache('shrink')
|
||||||
|
adapter.get_content(NativePlugin([canvas([(0, 100)], width=100)]), 'shrink')
|
||||||
|
assert 'shrink' not in adapter._item_offsets
|
||||||
|
|
||||||
|
def test_one_item_wider_than_budget_is_still_shown(self):
|
||||||
|
# Never return nothing just because the first whole item overflows.
|
||||||
|
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||||
|
items = [canvas([(0, 900)], width=900), canvas([(0, 100)], width=100)]
|
||||||
|
images = adapter.get_content(NativePlugin(items), 'wide-first')
|
||||||
|
assert len(images) >= 1
|
||||||
|
assert images[0].width == 900
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginBoundaryGaps:
|
||||||
|
"""separator_width belongs between plugins; intra_plugin_gap within one."""
|
||||||
|
|
||||||
|
def _pipeline(self, grouped, **cfg):
|
||||||
|
from src.vegas_mode.render_pipeline import RenderPipeline
|
||||||
|
|
||||||
|
class FakeStream:
|
||||||
|
def get_grouped_content_for_composition(self):
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
def get_active_plugin_ids(self):
|
||||||
|
return [pid for pid, _ in grouped]
|
||||||
|
|
||||||
|
class DM:
|
||||||
|
width = DISPLAY_W
|
||||||
|
height = DISPLAY_H
|
||||||
|
|
||||||
|
def set_scrolling_state(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream())
|
||||||
|
|
||||||
|
def test_separator_only_at_plugin_boundaries(self):
|
||||||
|
# Two plugins, two rows each. Expect: row row [sep] row row, with the
|
||||||
|
# small intra gap inside each pair.
|
||||||
|
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)]
|
||||||
|
pipeline = self._pipeline(
|
||||||
|
[('a', rows[:2]), ('b', rows[2:])],
|
||||||
|
separator_width=32, intra_plugin_gap=8, lead_in_width=0,
|
||||||
|
)
|
||||||
|
assert pipeline.compose_scroll_content()
|
||||||
|
|
||||||
|
ink = column_has_ink(pipeline.scroll_helper.cached_image)
|
||||||
|
assert ink[:100].all()
|
||||||
|
assert not ink[100:108].any() # intra gap inside plugin a
|
||||||
|
assert ink[108:208].all()
|
||||||
|
assert not ink[208:240].any() # separator between a and b
|
||||||
|
assert ink[240:340].all()
|
||||||
|
assert not ink[340:348].any() # intra gap inside plugin b
|
||||||
|
assert ink[348:448].all()
|
||||||
|
|
||||||
|
def test_total_width_uses_both_gap_sizes(self):
|
||||||
|
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)]
|
||||||
|
pipeline = self._pipeline(
|
||||||
|
[('a', rows[:2]), ('b', rows[2:])],
|
||||||
|
separator_width=32, intra_plugin_gap=8, lead_in_width=0,
|
||||||
|
)
|
||||||
|
pipeline.compose_scroll_content()
|
||||||
|
# 4 rows + 2 intra gaps + 1 separator
|
||||||
|
assert pipeline.scroll_helper.cached_image.width == 400 + 16 + 32
|
||||||
|
|
||||||
|
def test_f1_shaped_case_reclaims_the_chasms(self):
|
||||||
|
# 12 rows from one plugin: previously 11 separators at 32px = 352px of
|
||||||
|
# gap; now 11 intra gaps at 8px = 88px.
|
||||||
|
rows = [Image.new('RGB', (128, DISPLAY_H), (255, 255, 255)) for _ in range(12)]
|
||||||
|
pipeline = self._pipeline(
|
||||||
|
[('f1-scoreboard', rows)],
|
||||||
|
separator_width=32, intra_plugin_gap=8, lead_in_width=0,
|
||||||
|
)
|
||||||
|
pipeline.compose_scroll_content()
|
||||||
|
assert pipeline.scroll_helper.cached_image.width == 12 * 128 + 11 * 8
|
||||||
|
|
||||||
|
def test_single_row_plugin_image_is_not_copied(self):
|
||||||
|
row = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255))
|
||||||
|
pipeline = self._pipeline([('solo', [row])], lead_in_width=0)
|
||||||
|
assert pipeline._join_plugin_rows([row]) is row
|
||||||
|
|
||||||
|
def test_zero_intra_gap_butts_rows_together(self):
|
||||||
|
rows = [Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)) for _ in range(3)]
|
||||||
|
pipeline = self._pipeline(
|
||||||
|
[('a', rows)], separator_width=32, intra_plugin_gap=0, lead_in_width=0)
|
||||||
|
pipeline.compose_scroll_content()
|
||||||
|
assert pipeline.scroll_helper.cached_image.width == 150
|
||||||
|
assert column_has_ink(pipeline.scroll_helper.cached_image).all()
|
||||||
|
|
||||||
|
def test_empty_grouping_fails_composition(self):
|
||||||
|
pipeline = self._pipeline([])
|
||||||
|
assert pipeline.compose_scroll_content() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestWidthBudgetCountsGaps:
|
||||||
|
def test_budget_accounts_for_intra_plugin_gaps(self):
|
||||||
|
# 8 rows of 200px = 1600px of pixels, but with 8px gaps the real
|
||||||
|
# occupancy is 1600 + 56 = 1656px. Against a 512px budget the row count
|
||||||
|
# must be chosen using the gap-inclusive cost.
|
||||||
|
adapter = adapter_with(
|
||||||
|
content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=8)
|
||||||
|
items = [canvas([(0, 200)], width=200) for _ in range(8)]
|
||||||
|
images = adapter.get_content(NativePlugin(items), 'rows')
|
||||||
|
n = len(images)
|
||||||
|
assert 200 * n + 8 * (n - 1) <= DISPLAY_W
|
||||||
|
|
||||||
|
def test_gap_free_config_fits_more_rows(self):
|
||||||
|
items = [canvas([(0, 200)], width=200) for _ in range(8)]
|
||||||
|
with_gap = adapter_with(
|
||||||
|
content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=64)
|
||||||
|
without = adapter_with(
|
||||||
|
content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=0)
|
||||||
|
assert len(without.get_content(NativePlugin(items), 'r')) >= \
|
||||||
|
len(with_gap.get_content(NativePlugin(items), 'r'))
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamGrouping:
|
||||||
|
def _stream(self, segments):
|
||||||
|
from src.vegas_mode.stream_manager import StreamManager
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
sm = StreamManager.__new__(StreamManager)
|
||||||
|
import threading
|
||||||
|
sm._buffer_lock = threading.RLock()
|
||||||
|
sm._active_buffer = deque(segments)
|
||||||
|
return sm
|
||||||
|
|
||||||
|
def _seg(self, plugin_id, count, mode=None):
|
||||||
|
from src.vegas_mode.stream_manager import ContentSegment
|
||||||
|
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||||
|
imgs = [Image.new('RGB', (10, 8)) for _ in range(count)]
|
||||||
|
return ContentSegment(
|
||||||
|
plugin_id=plugin_id, images=imgs, total_width=10 * count,
|
||||||
|
display_mode=mode or VegasDisplayMode.SCROLL)
|
||||||
|
|
||||||
|
def test_grouping_preserves_plugin_boundaries(self):
|
||||||
|
sm = self._stream([self._seg('a', 3), self._seg('b', 1)])
|
||||||
|
grouped = sm.get_grouped_content_for_composition()
|
||||||
|
assert [(pid, len(imgs)) for pid, imgs in grouped] == [('a', 3), ('b', 1)]
|
||||||
|
|
||||||
|
def test_static_segments_are_skipped(self):
|
||||||
|
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||||
|
sm = self._stream([
|
||||||
|
self._seg('a', 2),
|
||||||
|
self._seg('paused', 1, VegasDisplayMode.STATIC),
|
||||||
|
self._seg('b', 1),
|
||||||
|
])
|
||||||
|
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['a', 'b']
|
||||||
|
|
||||||
|
def test_imageless_segments_are_skipped(self):
|
||||||
|
sm = self._stream([self._seg('a', 0), self._seg('b', 2)])
|
||||||
|
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b']
|
||||||
|
|
||||||
|
def test_flat_accessor_still_matches_grouped_total(self):
|
||||||
|
sm = self._stream([self._seg('a', 3), self._seg('b', 2)])
|
||||||
|
assert len(sm.get_all_content_for_composition()) == 5
|
||||||
|
|
||||||
|
|
||||||
|
class TestCycleSizing:
|
||||||
|
def test_plugins_per_cycle_defaults_above_buffer_ahead(self):
|
||||||
|
cfg = VegasModeConfig()
|
||||||
|
assert cfg.plugins_per_cycle == 6
|
||||||
|
assert cfg.plugins_per_cycle > cfg.buffer_ahead + 1
|
||||||
|
|
||||||
|
def test_plugins_per_cycle_parses(self):
|
||||||
|
cfg = VegasModeConfig.from_config(
|
||||||
|
{'display': {'vegas_scroll': {'plugins_per_cycle': 10}}})
|
||||||
|
assert cfg.plugins_per_cycle == 10
|
||||||
|
|
||||||
|
def test_max_plugin_width_ratio_parses(self):
|
||||||
|
cfg = VegasModeConfig.from_config(
|
||||||
|
{'display': {'vegas_scroll': {'max_plugin_width_ratio': 1.5}}})
|
||||||
|
assert cfg.max_plugin_width_ratio == 1.5
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('overrides,bad_key', [
|
||||||
|
({'plugins_per_cycle': 0}, 'plugins_per_cycle'),
|
||||||
|
({'plugins_per_cycle': 99}, 'plugins_per_cycle'),
|
||||||
|
({'max_plugin_width_ratio': -1.0}, 'max_plugin_width_ratio'),
|
||||||
|
])
|
||||||
|
def test_validate_rejects_out_of_range(self, overrides, bad_key):
|
||||||
|
errors = VegasModeConfig(**overrides).validate()
|
||||||
|
assert any(bad_key in e for e in errors), errors
|
||||||
|
|
||||||
|
|
||||||
|
class TestScrollHelperLeadGap:
|
||||||
|
def test_default_lead_gap_is_display_width(self):
|
||||||
|
# Standalone tickers rely on scrolling in from off-screen; that
|
||||||
|
# behaviour must not change for the many non-Vegas callers.
|
||||||
|
sh = ScrollHelper(128, 32)
|
||||||
|
sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))],
|
||||||
|
item_gap=0, element_gap=0)
|
||||||
|
assert sh.cached_image.width == 128 + 100
|
||||||
|
assert not column_has_ink(sh.cached_image)[:128].any()
|
||||||
|
|
||||||
|
def test_zero_lead_gap_starts_on_content(self):
|
||||||
|
sh = ScrollHelper(128, 32)
|
||||||
|
sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))],
|
||||||
|
item_gap=0, element_gap=0, lead_gap=0)
|
||||||
|
assert sh.cached_image.width == 100
|
||||||
|
assert column_has_ink(sh.cached_image)[0]
|
||||||
|
|
||||||
|
def test_explicit_lead_gap_is_honoured(self):
|
||||||
|
sh = ScrollHelper(128, 32)
|
||||||
|
sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))],
|
||||||
|
item_gap=0, element_gap=0, lead_gap=16)
|
||||||
|
assert sh.cached_image.width == 116
|
||||||
|
ink = column_has_ink(sh.cached_image)
|
||||||
|
assert not ink[:16].any()
|
||||||
|
assert ink[16:].all()
|
||||||
|
|
||||||
|
def test_negative_lead_gap_is_clamped(self):
|
||||||
|
sh = ScrollHelper(128, 32)
|
||||||
|
sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))],
|
||||||
|
item_gap=0, element_gap=0, lead_gap=-50)
|
||||||
|
assert sh.cached_image.width == 100
|
||||||
|
|
||||||
|
def test_total_scroll_width_matches_image(self):
|
||||||
|
# The cycle-complete check compares against total_scroll_width, so a
|
||||||
|
# mismatch here would cut cycles short or overrun them.
|
||||||
|
sh = ScrollHelper(128, 32)
|
||||||
|
items = [Image.new('RGB', (60, 32), (255, 0, 0)) for _ in range(3)]
|
||||||
|
sh.create_scrolling_image(items, item_gap=32, element_gap=0, lead_gap=0)
|
||||||
|
assert sh.total_scroll_width == sh.cached_image.width
|
||||||
|
assert sh.cached_image.width == 60 * 3 + 32 * 2
|
||||||
|
|
||||||
|
def test_item_gaps_are_unaffected_by_lead_gap(self):
|
||||||
|
sh = ScrollHelper(128, 32)
|
||||||
|
items = [Image.new('RGB', (10, 32), (255, 0, 0)) for _ in range(2)]
|
||||||
|
sh.create_scrolling_image(items, item_gap=20, element_gap=0, lead_gap=0)
|
||||||
|
ink = column_has_ink(sh.cached_image)
|
||||||
|
assert ink[:10].all()
|
||||||
|
assert not ink[10:30].any()
|
||||||
|
assert ink[30:40].all()
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigSurface:
|
||||||
|
def test_new_keys_parse_from_config(self):
|
||||||
|
cfg = VegasModeConfig.from_config({'display': {'vegas_scroll': {
|
||||||
|
'auto_trim': False,
|
||||||
|
'trim_threshold': 25,
|
||||||
|
'content_padding': 4,
|
||||||
|
'min_plugin_width': 64,
|
||||||
|
'lead_in_width': 32,
|
||||||
|
}}})
|
||||||
|
assert cfg.auto_trim is False
|
||||||
|
assert cfg.trim_threshold == 25
|
||||||
|
assert cfg.content_padding == 4
|
||||||
|
assert cfg.min_plugin_width == 64
|
||||||
|
assert cfg.lead_in_width == 32
|
||||||
|
|
||||||
|
def test_defaults_favour_trimming(self):
|
||||||
|
cfg = VegasModeConfig.from_config({})
|
||||||
|
assert cfg.auto_trim is True
|
||||||
|
assert cfg.lead_in_width == 0
|
||||||
|
assert cfg.content_padding == 8
|
||||||
|
|
||||||
|
def test_round_trips_through_to_dict(self):
|
||||||
|
cfg = VegasModeConfig(trim_threshold=20, lead_in_width=64)
|
||||||
|
restored = VegasModeConfig.from_config(
|
||||||
|
{'display': {'vegas_scroll': cfg.to_dict()}})
|
||||||
|
assert restored.trim_threshold == 20
|
||||||
|
assert restored.lead_in_width == 64
|
||||||
|
|
||||||
|
def test_update_applies_new_keys(self):
|
||||||
|
cfg = VegasModeConfig()
|
||||||
|
cfg.update({'display': {'vegas_scroll': {'content_padding': 16}}})
|
||||||
|
assert cfg.content_padding == 16
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('overrides,bad_key', [
|
||||||
|
({'trim_threshold': 300}, 'trim_threshold'),
|
||||||
|
({'trim_threshold': -1}, 'trim_threshold'),
|
||||||
|
({'content_padding': -5}, 'content_padding'),
|
||||||
|
({'content_padding': 500}, 'content_padding'),
|
||||||
|
({'min_plugin_width': -1}, 'min_plugin_width'),
|
||||||
|
({'lead_in_width': -1}, 'lead_in_width'),
|
||||||
|
])
|
||||||
|
def test_validate_rejects_out_of_range(self, overrides, bad_key):
|
||||||
|
errors = VegasModeConfig(**overrides).validate()
|
||||||
|
assert any(bad_key in e for e in errors), errors
|
||||||
|
|
||||||
|
def test_valid_config_has_no_errors(self):
|
||||||
|
assert VegasModeConfig(
|
||||||
|
trim_threshold=10, content_padding=8,
|
||||||
|
min_plugin_width=8, lead_in_width=0,
|
||||||
|
).validate() == []
|
||||||
@@ -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
|
# Handle Vegas scroll mode settings
|
||||||
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
|
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 any(k in data for k in vegas_fields):
|
||||||
if 'display' not in current_config:
|
if 'display' not in current_config:
|
||||||
@@ -933,13 +938,43 @@ def save_main_config():
|
|||||||
# was submitted (any vegas field present) but enabled key is missing,
|
# was submitted (any vegas field present) but enabled key is missing,
|
||||||
# the checkbox was unchecked and we should set enabled=False
|
# the checkbox was unchecked and we should set enabled=False
|
||||||
vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled'))
|
vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled'))
|
||||||
|
vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim'))
|
||||||
|
vegas_config['dynamic_duration_enabled'] = _coerce_to_bool(
|
||||||
|
data.get('vegas_dynamic_duration_enabled'))
|
||||||
|
|
||||||
|
# max_plugin_width_ratio is the one fractional setting, so it is
|
||||||
|
# handled outside the integer loop below.
|
||||||
|
if data.get('vegas_max_plugin_width_ratio') not in ('', None):
|
||||||
|
try:
|
||||||
|
ratio = float(data['vegas_max_plugin_width_ratio'])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': "Invalid value for vegas_max_plugin_width_ratio: "
|
||||||
|
"must be a number"
|
||||||
|
}), 400
|
||||||
|
if not (0 <= ratio <= 20):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': "Invalid value for vegas_max_plugin_width_ratio: "
|
||||||
|
"must be between 0 and 20 (0 disables the cap)"
|
||||||
|
}), 400
|
||||||
|
vegas_config['max_plugin_width_ratio'] = ratio
|
||||||
|
|
||||||
# Handle numeric settings with validation
|
# Handle numeric settings with validation
|
||||||
numeric_fields = {
|
numeric_fields = {
|
||||||
'vegas_scroll_speed': ('scroll_speed', 1, 100),
|
'vegas_scroll_speed': ('scroll_speed', 1, 100),
|
||||||
'vegas_separator_width': ('separator_width', 0, 500),
|
'vegas_separator_width': ('separator_width', 0, 500),
|
||||||
|
'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
|
||||||
'vegas_target_fps': ('target_fps', 1, 200),
|
'vegas_target_fps': ('target_fps', 1, 200),
|
||||||
'vegas_buffer_ahead': ('buffer_ahead', 1, 20),
|
'vegas_buffer_ahead': ('buffer_ahead', 1, 20),
|
||||||
|
'vegas_trim_threshold': ('trim_threshold', 0, 254),
|
||||||
|
'vegas_content_padding': ('content_padding', 0, 128),
|
||||||
|
'vegas_min_plugin_width': ('min_plugin_width', 0, 512),
|
||||||
|
'vegas_lead_in_width': ('lead_in_width', 0, 2048),
|
||||||
|
'vegas_plugins_per_cycle': ('plugins_per_cycle', 1, 50),
|
||||||
|
'vegas_min_cycle_duration': ('min_cycle_duration', 5, 3600),
|
||||||
|
'vegas_max_cycle_duration': ('max_cycle_duration', 10, 3600),
|
||||||
}
|
}
|
||||||
for field_name, (config_key, min_val, max_val) in numeric_fields.items():
|
for field_name, (config_key, min_val, max_val) in numeric_fields.items():
|
||||||
if field_name in data:
|
if field_name in data:
|
||||||
|
|||||||
@@ -425,7 +425,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
|
<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"
|
<input type="number"
|
||||||
id="vegas_separator_width"
|
id="vegas_separator_width"
|
||||||
name="vegas_separator_width"
|
name="vegas_separator_width"
|
||||||
@@ -436,6 +436,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="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">
|
<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>
|
<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>
|
||||||
</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 -->
|
<!-- Plugin Order Section -->
|
||||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
<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>
|
<h4 class="text-sm font-medium text-gray-900 mb-3">Plugin Order</h4>
|
||||||
|
|||||||
Reference in New Issue
Block a user