mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Compare commits
1
Commits
d69dfbbaee
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b45f35888 |
@@ -130,7 +130,25 @@
|
||||
"plugin_order": [],
|
||||
"excluded_plugins": [],
|
||||
"target_fps": 125,
|
||||
"buffer_ahead": 2
|
||||
"buffer_ahead": 2,
|
||||
"intra_plugin_gap": 8,
|
||||
"render_width_pct": 100,
|
||||
"min_content_separation": 24,
|
||||
"min_cut_gap": 6,
|
||||
"continuous_scroll": true,
|
||||
"smooth_scroll": true,
|
||||
"extend_threshold_screens": 2.0,
|
||||
"auto_trim": true,
|
||||
"trim_threshold": 10,
|
||||
"content_padding": 8,
|
||||
"min_plugin_width": 8,
|
||||
"lead_in_width": 0,
|
||||
"plugins_per_cycle": 6,
|
||||
"max_plugin_width_ratio": 3.0,
|
||||
"overflow_mode": "rotate",
|
||||
"dynamic_duration_enabled": true,
|
||||
"min_cycle_duration": 60,
|
||||
"max_cycle_duration": 240
|
||||
}
|
||||
},
|
||||
"sync": {
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Vegas Mode Density Audit
|
||||
|
||||
Reports how much of the Vegas ticker is actually showing something. Loads the
|
||||
real enabled plugins, pulls each one's content through the real
|
||||
``PluginAdapter``, composes the strip through the real ``ScrollHelper``, then
|
||||
measures the result.
|
||||
|
||||
The headline number is the **dead-frame ratio**: the fraction of viewport
|
||||
positions across a full cycle that are effectively blank. Because the panel
|
||||
only ever shows ``display_width`` columns at a time, a blank stretch wider than
|
||||
the viewport is a stretch where the display looks switched off — so this ratio
|
||||
tracks perceived dead time rather than just counting unlit pixels.
|
||||
|
||||
Runs entirely off-hardware, so it is safe to run alongside a live display.
|
||||
|
||||
Usage:
|
||||
# Audit every enabled plugin at the display size from config.json
|
||||
python scripts/dev/vegas_audit.py
|
||||
|
||||
# Specific plugins, dump each segment as a PNG for eyeballing
|
||||
python scripts/dev/vegas_audit.py -p of-the-day,youtube-stats --dump-dir /tmp/vg
|
||||
|
||||
# Machine-readable, for before/after comparison
|
||||
python scripts/dev/vegas_audit.py --json > after.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
# Must precede any src import that may reach for hardware.
|
||||
os.environ.setdefault('EMULATOR', 'true')
|
||||
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper # noqa: E402
|
||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
build_full_config,
|
||||
find_plugin_dir,
|
||||
load_manifest,
|
||||
)
|
||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||
from src.vegas_mode.geometry import ( # noqa: E402
|
||||
DEFAULT_INK_THRESHOLD,
|
||||
column_has_ink,
|
||||
content_bounds,
|
||||
dead_window_stats,
|
||||
window_coverage_stats,
|
||||
)
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402
|
||||
|
||||
# Sampling stride for the dead-window scan. A full cycle can be 30,000px wide;
|
||||
# 4px granularity keeps the scan instant while staying well under the ~10px a
|
||||
# single scroll step ever covers, so no dead stretch is missed.
|
||||
DEAD_SCAN_STEP = 4
|
||||
|
||||
|
||||
def load_main_config(path: Path) -> Dict[str, Any]:
|
||||
with open(path, 'r') as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def display_size_from_config(config: Dict[str, Any]) -> tuple:
|
||||
"""Derive the logical ticker size the way DisplayManager does."""
|
||||
hw = config.get('display', {}).get('hardware', {})
|
||||
cols = int(hw.get('cols', 64))
|
||||
chain = int(hw.get('chain_length', 1))
|
||||
rows = int(hw.get('rows', 32))
|
||||
parallel = int(hw.get('parallel', 1))
|
||||
return cols * chain, rows * parallel
|
||||
|
||||
|
||||
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
|
||||
"""Plugin IDs that are enabled in config, excluding non-plugin sections."""
|
||||
ids = []
|
||||
for key, value in config.items():
|
||||
if isinstance(value, dict) and value.get('enabled') is True:
|
||||
ids.append(key)
|
||||
return ids
|
||||
|
||||
|
||||
def instantiate(plugin_id: str, display_manager, cache_manager, plugin_manager):
|
||||
"""Load one plugin offline. Returns the instance or None."""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
|
||||
search_dirs = [
|
||||
str(PROJECT_ROOT / 'plugin-repos'),
|
||||
str(PROJECT_ROOT / 'plugins'),
|
||||
]
|
||||
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
|
||||
if not plugin_dir:
|
||||
return None
|
||||
|
||||
try:
|
||||
manifest = load_manifest(Path(plugin_dir))
|
||||
cfg = build_full_config(Path(plugin_dir))
|
||||
instance, _ = PluginLoader().load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=Path(plugin_dir),
|
||||
config=cfg,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
plugin_manager=plugin_manager,
|
||||
install_deps=False,
|
||||
)
|
||||
return instance
|
||||
except Exception as exc: # noqa: BLE001 - audit tool must survive any plugin
|
||||
print(f" ! {plugin_id}: load failed ({type(exc).__name__}: {exc})",
|
||||
file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def join_rows(images: List[Image.Image], gap: int) -> Image.Image:
|
||||
"""Concatenate one plugin's rows, matching RenderPipeline._join_plugin_rows."""
|
||||
if len(images) == 1:
|
||||
return images[0]
|
||||
gap = max(0, gap)
|
||||
width = sum(img.width for img in images) + gap * (len(images) - 1)
|
||||
height = max(img.height for img in images)
|
||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
x = 0
|
||||
for img in images:
|
||||
block.paste(img, (x, 0))
|
||||
x += img.width + gap
|
||||
return block
|
||||
|
||||
|
||||
def measure_segment(images: List[Image.Image], display_width: int,
|
||||
scroll_speed: float, threshold: int) -> Dict[str, Any]:
|
||||
"""Geometry of one plugin's contribution to the ticker."""
|
||||
total_width = sum(img.width for img in images)
|
||||
combined = Image.new('RGB', (max(1, total_width), images[0].height))
|
||||
x = 0
|
||||
for img in images:
|
||||
combined.paste(img, (x, 0))
|
||||
x += img.width
|
||||
|
||||
ink = column_has_ink(combined, threshold)
|
||||
bounds = content_bounds(combined, threshold)
|
||||
ink_cols = int(ink.sum())
|
||||
|
||||
return {
|
||||
'images': len(images),
|
||||
'width_px': total_width,
|
||||
'ink_cols': ink_cols,
|
||||
'ink_pct': round(100.0 * ink_cols / total_width, 1) if total_width else 0.0,
|
||||
'lead_black_px': bounds[0] if bounds else total_width,
|
||||
'trail_black_px': (total_width - 1 - bounds[1]) if bounds else 0,
|
||||
'seconds_on_screen': round(total_width / scroll_speed, 1) if scroll_speed else 0.0,
|
||||
'widths': [img.width for img in images],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Audit Vegas mode content density')
|
||||
parser.add_argument('--config', default=str(PROJECT_ROOT / 'config' / 'config.json'),
|
||||
help='Path to main config.json')
|
||||
parser.add_argument('-p', '--plugins', default=None,
|
||||
help='Comma-separated plugin IDs (default: all enabled)')
|
||||
parser.add_argument('--width', type=int, default=None,
|
||||
help='Override display width (default: from config hardware)')
|
||||
parser.add_argument('--height', type=int, default=None,
|
||||
help='Override display height (default: from config hardware)')
|
||||
parser.add_argument('--dump-dir', default=None,
|
||||
help='Write each segment and the composed strip as PNGs here')
|
||||
parser.add_argument('--threshold', type=int, default=DEFAULT_INK_THRESHOLD,
|
||||
help=f'Ink threshold (default: {DEFAULT_INK_THRESHOLD})')
|
||||
parser.add_argument('--per-cycle', type=int, default=None,
|
||||
help='Plugins composed per cycle '
|
||||
'(default: buffer_ahead + 1, matching production)')
|
||||
parser.add_argument('--json', action='store_true',
|
||||
help='Emit JSON instead of a text report')
|
||||
args = parser.parse_args()
|
||||
|
||||
config = load_main_config(Path(args.config))
|
||||
vegas = VegasModeConfig.from_config(config)
|
||||
|
||||
cfg_w, cfg_h = display_size_from_config(config)
|
||||
width = args.width or cfg_w
|
||||
height = args.height or cfg_h
|
||||
speed = vegas.scroll_speed
|
||||
|
||||
if args.plugins:
|
||||
plugin_ids = [p.strip() for p in args.plugins.split(',') if p.strip()]
|
||||
else:
|
||||
plugin_ids = vegas.get_ordered_plugins(enabled_plugin_ids(config))
|
||||
|
||||
dump_dir = Path(args.dump_dir) if args.dump_dir else None
|
||||
if dump_dir:
|
||||
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
from src.plugin_system.testing import (
|
||||
MockCacheManager, MockPluginManager, VisualTestDisplayManager,
|
||||
)
|
||||
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
cache_manager = MockCacheManager()
|
||||
plugin_manager = MockPluginManager()
|
||||
# Pass the loaded config, exactly as VegasModeCoordinator does. Omitting it
|
||||
# makes PluginAdapter fall back to VegasModeConfig() defaults, so the audit
|
||||
# would silently report trimming and width-budget behaviour that differs
|
||||
# from the user's config.json — the same drift the lead_gap and grouping
|
||||
# arguments below exist to avoid.
|
||||
adapter = PluginAdapter(display_manager, vegas)
|
||||
|
||||
if not args.json:
|
||||
print(f"Vegas audit — display {width}x{height}, scroll {speed:g}px/s, "
|
||||
f"separator {vegas.separator_width}px")
|
||||
print(f"One display width = {width / speed:.1f}s of screen time\n")
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
segments: List[Image.Image] = []
|
||||
|
||||
for plugin_id in plugin_ids:
|
||||
started = time.time()
|
||||
instance = instantiate(plugin_id, display_manager, cache_manager, plugin_manager)
|
||||
if instance is None:
|
||||
results.append({'plugin': plugin_id, 'status': 'load_failed'})
|
||||
continue
|
||||
|
||||
plugin_manager.plugins[plugin_id] = instance
|
||||
adapter.invalidate_cache(plugin_id)
|
||||
|
||||
try:
|
||||
images = adapter.get_content(instance, plugin_id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
results.append({'plugin': plugin_id, 'status': 'fetch_error',
|
||||
'error': f'{type(exc).__name__}: {exc}'})
|
||||
continue
|
||||
|
||||
fetch_ms = round((time.time() - started) * 1000)
|
||||
|
||||
if not images:
|
||||
results.append({'plugin': plugin_id, 'status': 'no_content',
|
||||
'fetch_ms': fetch_ms})
|
||||
if not args.json:
|
||||
print(f" {plugin_id:28s} NO CONTENT ({fetch_ms}ms)")
|
||||
continue
|
||||
|
||||
entry = {'plugin': plugin_id, 'status': 'ok', 'fetch_ms': fetch_ms}
|
||||
entry.update(measure_segment(images, width, speed, args.threshold))
|
||||
results.append(entry)
|
||||
segments.extend(images)
|
||||
|
||||
if dump_dir:
|
||||
for idx, img in enumerate(images):
|
||||
img.save(dump_dir / f"{plugin_id}__{idx:02d}.png")
|
||||
|
||||
if not args.json:
|
||||
print(f" {plugin_id:28s} {entry['width_px']:>6d}px "
|
||||
f"{entry['images']:>2d} img ink {entry['ink_pct']:>5.1f}% "
|
||||
f"lead {entry['lead_black_px']:>4d} tail {entry['trail_black_px']:>4d} "
|
||||
f"{entry['seconds_on_screen']:>6.1f}s ({fetch_ms}ms)")
|
||||
|
||||
summary: Dict[str, Any] = {
|
||||
'display_width': width,
|
||||
'display_height': height,
|
||||
'scroll_speed': speed,
|
||||
'separator_width': vegas.separator_width,
|
||||
'plugins_audited': len(plugin_ids),
|
||||
'plugins_with_content': sum(1 for r in results if r.get('status') == 'ok'),
|
||||
}
|
||||
|
||||
# Production composes only the plugins sitting in the active buffer, so
|
||||
# measuring one giant strip of every plugin would hide the per-cycle costs
|
||||
# (most importantly the leading gap, which is charged once per cycle).
|
||||
# Group the segments the way the running service does.
|
||||
per_cycle = max(1, args.per_cycle or vegas.plugins_per_cycle)
|
||||
|
||||
cycles: List[Dict[str, Any]] = []
|
||||
with_content = [r for r in results if r.get('status') == 'ok']
|
||||
|
||||
if segments:
|
||||
logger = logging.getLogger('vegas_audit')
|
||||
seg_index = 0
|
||||
for start in range(0, len(with_content), per_cycle):
|
||||
group = with_content[start:start + per_cycle]
|
||||
|
||||
# Mirror RenderPipeline: each plugin's rows are joined by
|
||||
# intra_plugin_gap into one block, and separator_width is applied
|
||||
# only between blocks. Measuring a flat list here would report gaps
|
||||
# the service does not emit.
|
||||
blocks: List[Image.Image] = []
|
||||
for entry in group:
|
||||
count = entry['images']
|
||||
rows = segments[seg_index:seg_index + count]
|
||||
seg_index += count
|
||||
if rows:
|
||||
blocks.append(join_rows(rows, vegas.intra_plugin_gap))
|
||||
if not blocks:
|
||||
continue
|
||||
|
||||
# ScrollHelper logs unconditionally, so it needs a real logger.
|
||||
helper = ScrollHelper(width, height, logger)
|
||||
helper.create_scrolling_image(
|
||||
content_items=blocks,
|
||||
item_gap=vegas.separator_width,
|
||||
element_gap=0,
|
||||
# Must match RenderPipeline. Omitting this made the audit
|
||||
# measure a full-display-width leading gap the service no
|
||||
# longer emits, overstating dead space by 512px per cycle.
|
||||
lead_gap=vegas.lead_in_width,
|
||||
)
|
||||
composed = helper.cached_image
|
||||
if composed is None:
|
||||
continue
|
||||
|
||||
dead = dead_window_stats(composed, width, args.threshold, step=DEAD_SCAN_STEP)
|
||||
cover = window_coverage_stats(
|
||||
composed, width, args.threshold, step=DEAD_SCAN_STEP)
|
||||
|
||||
if dump_dir:
|
||||
composed.save(dump_dir / f"_cycle{len(cycles):02d}.png")
|
||||
|
||||
cycles.append({
|
||||
'plugins': [e['plugin'] for e in group],
|
||||
'width_px': composed.width,
|
||||
'seconds': round(composed.width / speed, 1) if speed else 0.0,
|
||||
'dead_pct': round(100 * dead.dead_ratio, 1),
|
||||
'longest_dead_seconds': round(
|
||||
dead.longest_dead_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
|
||||
'mean_ink_pct': round(100 * cover.mean_ink_ratio, 1),
|
||||
'sparse_pct': round(100 * cover.sparse_ratio, 1),
|
||||
'longest_sparse_seconds': round(
|
||||
cover.longest_sparse_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
|
||||
})
|
||||
|
||||
if cycles:
|
||||
total_px = sum(c['width_px'] for c in cycles)
|
||||
# Weight each cycle by its width so a long cycle counts proportionally.
|
||||
summary.update({
|
||||
'cycles': len(cycles),
|
||||
'total_px': total_px,
|
||||
'full_rotation_seconds': round(total_px / speed, 1) if speed else 0.0,
|
||||
'dead_pct': round(
|
||||
sum(c['dead_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
||||
'mean_ink_pct': round(
|
||||
sum(c['mean_ink_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
||||
'sparse_pct': round(
|
||||
sum(c['sparse_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
||||
'worst_dead_seconds': max(c['longest_dead_seconds'] for c in cycles),
|
||||
'worst_sparse_seconds': max(c['longest_sparse_seconds'] for c in cycles),
|
||||
})
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({'summary': summary, 'cycles': cycles, 'plugins': results},
|
||||
indent=2))
|
||||
else:
|
||||
print(f"\n Cycles ({per_cycle} plugins each, as production composes them):")
|
||||
for idx, cyc in enumerate(cycles):
|
||||
print(f" [{idx}] {cyc['width_px']:>6d}px {cyc['seconds']:>6.1f}s "
|
||||
f"ink {cyc['mean_ink_pct']:>5.1f}% blank {cyc['dead_pct']:>5.1f}% "
|
||||
f"worst blank {cyc['longest_dead_seconds']:>5.1f}s "
|
||||
f"| {', '.join(cyc['plugins'])}")
|
||||
|
||||
print(f"\n {'-' * 66}")
|
||||
print(f" full rotation {summary.get('full_rotation_seconds', 0):>7.1f}s "
|
||||
f"over {summary.get('cycles', 0)} cycles")
|
||||
print(f" mean ink coverage {summary.get('mean_ink_pct', 0):>7.1f}% "
|
||||
f"(higher is better; target >25%)")
|
||||
print(f" fully blank {summary.get('dead_pct', 0):>7.1f}% (target <2%)")
|
||||
print(f" reads as empty {summary.get('sparse_pct', 0):>7.1f}% (target <15%)")
|
||||
print(f" worst blank stretch {summary.get('worst_dead_seconds', 0):>7.1f}s "
|
||||
f"(target <1.5s)")
|
||||
print(f" plugins w/ content {summary.get('plugins_with_content', 0):>7d}"
|
||||
f" of {summary['plugins_audited']}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
+196
-5
@@ -112,7 +112,8 @@ class ScrollHelper:
|
||||
|
||||
def create_scrolling_image(self, content_items: list,
|
||||
item_gap: int = 32,
|
||||
element_gap: int = 16) -> Image.Image:
|
||||
element_gap: int = 16,
|
||||
lead_gap: Optional[int] = None) -> Image.Image:
|
||||
"""
|
||||
Create a wide image containing all content items for scrolling.
|
||||
|
||||
@@ -120,10 +121,19 @@ class ScrollHelper:
|
||||
content_items: List of PIL Images to include in scroll
|
||||
item_gap: Gap between different items
|
||||
element_gap: Gap between elements within an item
|
||||
lead_gap: Blank columns before the first item. Defaults to a full
|
||||
display width, which makes a standalone ticker scroll in from
|
||||
off-screen. Callers that loop many plugins back-to-back (Vegas
|
||||
mode) pass a smaller value, since a full display width of black
|
||||
reads as the panel being switched off at the start of every
|
||||
cycle.
|
||||
|
||||
Returns:
|
||||
PIL Image containing all content arranged horizontally
|
||||
"""
|
||||
if lead_gap is None:
|
||||
lead_gap = self.display_width
|
||||
lead_gap = max(0, int(lead_gap))
|
||||
if not content_items:
|
||||
# Create empty image if no content
|
||||
# Still set total_scroll_width to 0 to indicate no scrollable content
|
||||
@@ -144,13 +154,13 @@ class ScrollHelper:
|
||||
total_width += element_gap * len(content_items)
|
||||
|
||||
# Add initial gap before first item
|
||||
total_width += self.display_width
|
||||
total_width += lead_gap
|
||||
|
||||
# Create the full scrolling image
|
||||
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
|
||||
|
||||
# Position items
|
||||
current_x = self.display_width # Start with initial gap
|
||||
current_x = lead_gap # Start with initial gap
|
||||
|
||||
for i, img in enumerate(content_items):
|
||||
# Paste the item image
|
||||
@@ -339,13 +349,72 @@ class ScrollHelper:
|
||||
if not self.cached_image or self.cached_array is None:
|
||||
return None
|
||||
|
||||
# Use integer pixel positioning for high FPS scrolling (like stock ticker)
|
||||
start_x_int = int(self.scroll_position)
|
||||
end_x_int = start_x_int + self.display_width
|
||||
|
||||
# Fast integer pixel path (no interpolation - high frame rate provides smoothness)
|
||||
# Integer positioning quantises motion to whole pixels, so the number of
|
||||
# distinct frames per second equals the scroll speed in px/s, no matter
|
||||
# how fast the loop renders. At 50px/s and 78fps that made 36% of frames
|
||||
# identical: the extra frames cost work and bought nothing. Blending
|
||||
# between the two neighbouring positions gives motion at the frame rate
|
||||
# instead of the step rate.
|
||||
if self.sub_pixel_scrolling:
|
||||
fractional = self.scroll_position - start_x_int
|
||||
if fractional > 0.0:
|
||||
return self._blend_visible_portion(start_x_int, fractional)
|
||||
|
||||
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
||||
|
||||
def _blend_visible_portion(self, start_x: int, fractional: float) -> Image.Image:
|
||||
"""
|
||||
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
|
||||
|
||||
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
|
||||
installed on the target devices (HAS_SCIPY is False there), which is why
|
||||
the pre-existing sub-pixel path was dead code — get_visible_portion never
|
||||
consulted the flag, and the scipy fallback would not have interpolated
|
||||
anyway.
|
||||
|
||||
Args:
|
||||
start_x: Left column of the earlier of the two frames
|
||||
fractional: How far between the two, in [0, 1)
|
||||
|
||||
Returns:
|
||||
The blended frame
|
||||
"""
|
||||
width = self.display_width
|
||||
strip_width = self.cached_array.shape[1]
|
||||
|
||||
if start_x + width + 1 <= strip_width:
|
||||
# Slice the backing array directly. Going via
|
||||
# _get_visible_portion_integer would build two PIL images only for
|
||||
# them to be converted straight back to arrays, which measured 15x
|
||||
# the cost of the integer path.
|
||||
near = self.cached_array[:, start_x:start_x + width]
|
||||
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
|
||||
else:
|
||||
# Close enough to the end that one of the slices wraps; let the
|
||||
# integer path handle that and pay the conversion. Continuous mode
|
||||
# extends the strip before reaching here, so this is the rare case.
|
||||
near = np.asarray(
|
||||
self._get_visible_portion_integer(start_x, start_x + width))
|
||||
far = np.asarray(
|
||||
self._get_visible_portion_integer(start_x + 1, start_x + 1 + width))
|
||||
|
||||
# Fixed-point rather than float32: integer multiply-add on uint16 is
|
||||
# markedly faster than float maths on the Pi's ARM cores, and 8 bits of
|
||||
# weight is finer than the panel can show.
|
||||
weight = int(fractional * 256.0)
|
||||
blended = (
|
||||
(near.astype(np.uint16) * (256 - weight)
|
||||
+ far.astype(np.uint16) * weight) >> 8
|
||||
).astype(np.uint8)
|
||||
|
||||
return Image.frombytes(
|
||||
'RGB', (width, self.display_height),
|
||||
np.ascontiguousarray(blended).tobytes()
|
||||
)
|
||||
|
||||
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
||||
"""Fast integer pixel extraction (no interpolation).
|
||||
|
||||
@@ -638,6 +707,128 @@ class ScrollHelper:
|
||||
"""
|
||||
return self.scroll_complete
|
||||
|
||||
def append_content(self, content_items: list,
|
||||
item_gap: int = 32,
|
||||
element_gap: int = 0) -> bool:
|
||||
"""
|
||||
Append items to the right of the existing strip, preserving scroll state.
|
||||
|
||||
Lets a caller keep one continuous strip instead of replacing it. Vegas
|
||||
mode uses this so the next group of plugins scrolls in from the right
|
||||
rather than the strip being swapped out underneath the viewer — a swap
|
||||
shows as a flash and a hard cut to already-full-screen content.
|
||||
|
||||
``scroll_position`` and ``total_distance_scrolled`` are untouched, so
|
||||
motion continues uninterrupted; only the strip gets longer. Because
|
||||
completion is measured against ``total_scroll_width``, extending the
|
||||
strip also defers completion, which is the intent.
|
||||
|
||||
Args:
|
||||
content_items: Images to append, in order
|
||||
item_gap: Gap between appended items, and between the existing
|
||||
content and the first appended item
|
||||
element_gap: Extra gap after each item, mirroring
|
||||
create_scrolling_image
|
||||
|
||||
Returns:
|
||||
True if content was appended
|
||||
"""
|
||||
if not content_items:
|
||||
return False
|
||||
|
||||
if self.cached_image is None or self.cached_array is None:
|
||||
# Nothing to extend yet — this is just the first build.
|
||||
self.create_scrolling_image(
|
||||
content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0)
|
||||
return True
|
||||
|
||||
gap = max(0, item_gap)
|
||||
addition_width = (
|
||||
sum(img.width for img in content_items)
|
||||
+ gap * len(content_items) # one leading gap per item
|
||||
+ element_gap * len(content_items)
|
||||
)
|
||||
|
||||
addition = Image.new('RGB', (addition_width, self.display_height), (0, 0, 0))
|
||||
x = 0
|
||||
for img in content_items:
|
||||
x += gap # separate from whatever precedes
|
||||
addition.paste(img, (x, 0))
|
||||
x += img.width + element_gap
|
||||
|
||||
# numpy concatenate then one conversion back, rather than allocating a
|
||||
# full-width PIL image and pasting twice: the strip can be tens of
|
||||
# thousands of columns wide and this runs on the render path.
|
||||
self.cached_array = np.concatenate(
|
||||
(self.cached_array, np.array(addition)), axis=1)
|
||||
self.cached_image = Image.fromarray(self.cached_array)
|
||||
self.total_scroll_width = self.cached_image.width
|
||||
self.scroll_complete = False
|
||||
|
||||
self.logger.info(
|
||||
"Appended %d item(s) (%dpx) to scroll strip: now %dpx, position %.0f",
|
||||
len(content_items), addition_width, self.total_scroll_width,
|
||||
self.scroll_position
|
||||
)
|
||||
return True
|
||||
|
||||
def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
|
||||
"""
|
||||
Discard columns that have already scrolled past, to bound memory.
|
||||
|
||||
A continuously extended strip would otherwise grow without limit. All
|
||||
the positional state is shifted by the amount removed so the visible
|
||||
frame and the completion arithmetic are unchanged:
|
||||
``total_distance_scrolled`` and ``total_scroll_width`` both shrink by the
|
||||
same amount, preserving their difference.
|
||||
|
||||
Args:
|
||||
keep_before: Columns to retain behind the current position, as a
|
||||
safety margin against a caller reading slightly behind it
|
||||
|
||||
Returns:
|
||||
Number of columns actually removed
|
||||
"""
|
||||
if self.cached_image is None or self.cached_array is None:
|
||||
return 0
|
||||
|
||||
# While the viewport wraps, get_visible_portion fills its right-hand side
|
||||
# from the *head* of the strip, so trimming the head would change what
|
||||
# is on screen. Continuous mode extends before ever reaching that state;
|
||||
# refusing here keeps "trimming is invisible" true unconditionally.
|
||||
if self.scroll_position + self.display_width > self.cached_image.width:
|
||||
return 0
|
||||
|
||||
cut = int(self.scroll_position) - max(0, keep_before)
|
||||
if cut <= 0:
|
||||
return 0
|
||||
# Never trim so far that the remaining strip is narrower than the
|
||||
# viewport, or get_visible_portion has nothing to slice.
|
||||
cut = min(cut, max(0, self.cached_image.width - self.display_width))
|
||||
if cut <= 0:
|
||||
return 0
|
||||
|
||||
# .copy() so the original buffer is released rather than kept alive by
|
||||
# a numpy view.
|
||||
self.cached_array = self.cached_array[:, cut:].copy()
|
||||
self.cached_image = Image.fromarray(self.cached_array)
|
||||
self.total_scroll_width = self.cached_image.width
|
||||
self.scroll_position -= cut
|
||||
self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut)
|
||||
|
||||
self.logger.debug(
|
||||
"Dropped %dpx of scrolled strip: now %dpx, position %.0f",
|
||||
cut, self.total_scroll_width, self.scroll_position
|
||||
)
|
||||
return cut
|
||||
|
||||
def remaining_unscrolled(self) -> int:
|
||||
"""Columns of strip still to the right of the viewport."""
|
||||
if self.cached_image is None:
|
||||
return 0
|
||||
return max(0, self.total_scroll_width - int(self.scroll_position)
|
||||
- self.display_width)
|
||||
|
||||
def reset_scroll(self) -> None:
|
||||
"""
|
||||
Reset scroll position to beginning.
|
||||
|
||||
+70
-2
@@ -186,8 +186,14 @@ class DisplayManager:
|
||||
self.config = config or {}
|
||||
self._force_fallback = force_fallback
|
||||
self._suppress_test_pattern = suppress_test_pattern
|
||||
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
||||
self._capture_mode_active = False
|
||||
# Per-thread capture state. update_display() and clear() skip hardware
|
||||
# writes while the *calling* thread is capturing content off-screen.
|
||||
#
|
||||
# Thread-local rather than a plain flag because Vegas mode prepares
|
||||
# upcoming content on a background thread: a shared flag set there would
|
||||
# suppress the render loop's own frame pushes for the duration, freezing
|
||||
# the panel exactly when the point was to avoid a freeze.
|
||||
self._capture_state = threading.local()
|
||||
# Double-sided mode state (resolved in _setup_matrix). When disabled,
|
||||
# the logical image is blitted to the matrix unchanged.
|
||||
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
||||
@@ -520,6 +526,15 @@ class DisplayManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
|
||||
|
||||
@property
|
||||
def _capture_mode_active(self) -> bool:
|
||||
"""True while the calling thread is capturing content off-screen."""
|
||||
return getattr(self._capture_state, 'active', False)
|
||||
|
||||
@_capture_mode_active.setter
|
||||
def _capture_mode_active(self, value: bool) -> None:
|
||||
self._capture_state.active = bool(value)
|
||||
|
||||
@contextmanager
|
||||
def capture_mode(self):
|
||||
"""Suppress hardware output during off-screen content capture.
|
||||
@@ -536,6 +551,59 @@ class DisplayManager:
|
||||
finally:
|
||||
self._capture_mode_active = False
|
||||
|
||||
@contextmanager
|
||||
def render_size(self, width: int, height: Optional[int] = None):
|
||||
"""Temporarily present a smaller logical canvas to plugins.
|
||||
|
||||
Plugins lay out against ``display_manager.matrix.width`` (and the
|
||||
``width``/``height`` properties, which defer to it), so the only way to
|
||||
get a *narrower layout* rather than a cropped one is to tell the plugin
|
||||
the screen is narrower while it renders. Trimming after the fact cannot
|
||||
fix a forecast spread across five columns or a progress bar drawn at
|
||||
100% width — those need the plugin to make different layout decisions.
|
||||
|
||||
Vegas mode uses this so a plugin can occupy a fraction of a wide panel
|
||||
and still look deliberately composed. Reuses the same _LogicalMatrix
|
||||
indirection that double-sided mode relies on, so plugins see a
|
||||
consistent size from every accessor.
|
||||
|
||||
Only meaningful inside :meth:`capture_mode` — this swaps the shared
|
||||
image buffer, so the render loop must not be writing to it concurrently.
|
||||
|
||||
Args:
|
||||
width: Logical width to report, clamped to at least 1 and to the
|
||||
real panel width (a larger canvas would overflow the hardware).
|
||||
height: Logical height, defaulting to the current height.
|
||||
"""
|
||||
real_matrix = self.matrix
|
||||
prev_image = getattr(self, 'image', None)
|
||||
prev_draw = getattr(self, 'draw', None)
|
||||
|
||||
current_w = self.width
|
||||
current_h = self.height
|
||||
target_w = max(1, min(int(width), current_w))
|
||||
target_h = max(1, min(int(height) if height else current_h, current_h))
|
||||
|
||||
if target_w == current_w and target_h == current_h:
|
||||
# Nothing to do; avoid pointless wrapping and buffer churn.
|
||||
yield
|
||||
return
|
||||
|
||||
try:
|
||||
if real_matrix is not None:
|
||||
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
|
||||
# With no hardware, the width/height properties fall through to
|
||||
# self.image, so swapping the buffer below is enough on its own.
|
||||
self.image = Image.new('RGB', (target_w, target_h))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
yield
|
||||
finally:
|
||||
self.matrix = real_matrix
|
||||
if prev_image is not None:
|
||||
self.image = prev_image
|
||||
if prev_draw is not None:
|
||||
self.draw = prev_draw
|
||||
|
||||
def _composite_double_sided(self):
|
||||
"""Tile the logical screen across the full physical chain.
|
||||
|
||||
|
||||
@@ -505,6 +505,40 @@ class BasePlugin(ABC):
|
||||
# -------------------------------------------------------------------------
|
||||
# Vegas scroll mode support
|
||||
# -------------------------------------------------------------------------
|
||||
def get_vegas_render_width(self) -> int:
|
||||
"""
|
||||
Width the Vegas ticker wants this plugin's content to occupy.
|
||||
|
||||
On a wide panel a layout built to fill the screen reads as sparse in a
|
||||
ticker — a forecast spread over five columns, a progress bar drawn at
|
||||
100% width, a stat block with the panel's whole width between its
|
||||
elements. Vegas asks for a narrower render so the plugin can choose a
|
||||
tighter arrangement instead of being cropped afterwards.
|
||||
|
||||
Vegas also narrows ``display_manager`` for the duration of the call, so
|
||||
a plugin that already sizes itself from ``matrix.width`` needs no
|
||||
changes. Read this only when you size content some other way.
|
||||
|
||||
Controlled by the plugin's own ``vegas_width_pct`` config value, else
|
||||
the global ``display.vegas_scroll.render_width_pct``.
|
||||
|
||||
Returns:
|
||||
Target width in pixels. Outside a Vegas content request, the full
|
||||
display width.
|
||||
"""
|
||||
requested = getattr(self, '_vegas_render_width', None)
|
||||
if isinstance(requested, int) and requested > 0:
|
||||
return requested
|
||||
|
||||
display_manager = getattr(self, 'display_manager', None)
|
||||
matrix = getattr(display_manager, 'matrix', None)
|
||||
if matrix is not None and getattr(matrix, 'width', None):
|
||||
return int(matrix.width)
|
||||
width = getattr(display_manager, 'width', None)
|
||||
if callable(width):
|
||||
width = width()
|
||||
return int(width) if width else 128
|
||||
|
||||
def get_vegas_content(self) -> Optional[Any]:
|
||||
"""
|
||||
Get content for Vegas-style continuous scroll mode.
|
||||
|
||||
@@ -15,6 +15,7 @@ PIL Image canvas and draws text using the actual project fonts.
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
@@ -62,6 +63,9 @@ class VisualTestDisplayManager:
|
||||
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
||||
self.matrix = _MatrixProxy(width, height)
|
||||
|
||||
# Set while inside capture_mode(); mirrors DisplayManager's flag.
|
||||
self._capture_mode_active = False
|
||||
|
||||
# Scrolling state (interface compat, no-op)
|
||||
self._scrolling_state = {
|
||||
'is_scrolling': False,
|
||||
@@ -174,6 +178,50 @@ class VisualTestDisplayManager:
|
||||
"""No-op for hardware; marks that display was updated."""
|
||||
self.update_called = True
|
||||
|
||||
@contextmanager
|
||||
def render_size(self, width: int, height: Optional[int] = None):
|
||||
"""
|
||||
Interface parity with DisplayManager.render_size().
|
||||
|
||||
Vegas mode narrows the canvas so plugins lay out compactly instead of
|
||||
being cropped. The harness must offer the same context or that path
|
||||
cannot be exercised offline — and because the adapter catches broadly,
|
||||
a missing method shows up as "no content" rather than an error.
|
||||
"""
|
||||
prev_image = self.image
|
||||
prev_draw = self.draw
|
||||
prev_w, prev_h = self._width, self._height
|
||||
|
||||
target_w = max(1, min(int(width), prev_w))
|
||||
target_h = max(1, min(int(height) if height else prev_h, prev_h))
|
||||
|
||||
try:
|
||||
self._width, self._height = target_w, target_h
|
||||
self.matrix = _MatrixProxy(target_w, target_h)
|
||||
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
yield
|
||||
finally:
|
||||
self._width, self._height = prev_w, prev_h
|
||||
self.matrix = _MatrixProxy(prev_w, prev_h)
|
||||
self.image = prev_image
|
||||
self.draw = prev_draw
|
||||
|
||||
@contextmanager
|
||||
def capture_mode(self):
|
||||
"""
|
||||
Interface parity with DisplayManager.capture_mode().
|
||||
|
||||
There is no hardware to suppress here, but Vegas mode's PluginAdapter
|
||||
wraps every off-screen content fetch in this context, so the harness
|
||||
must provide it for that code path to be exercisable in tests.
|
||||
"""
|
||||
self._capture_mode_active = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._capture_mode_active = False
|
||||
|
||||
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
|
||||
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
|
||||
font: Optional[Any] = None, centered: bool = False) -> None:
|
||||
|
||||
@@ -21,6 +21,94 @@ class VegasModeConfig:
|
||||
scroll_speed: float = 50.0 # Pixels per second
|
||||
separator_width: int = 32 # Gap between plugins (pixels)
|
||||
|
||||
# Fraction of the panel width a plugin is told it has while rendering for
|
||||
# the ticker, as a percentage. Trimming can only remove blank margins; it
|
||||
# cannot compact a layout that genuinely spans the display — a five-column
|
||||
# forecast, a full-width progress bar, a centred stat block with the panel's
|
||||
# whole width between its elements. Rendering at a narrower size makes the
|
||||
# plugin choose a tighter layout instead. 100 disables it.
|
||||
render_width_pct: int = 100
|
||||
|
||||
# Minimum blank columns guaranteed between adjacent content, measured from
|
||||
# actual ink rather than added blindly. A flat additive gap leaves
|
||||
# card-style content nearly touching when the cards are drawn flush to their
|
||||
# own edges, while padding out content that already has wide margins.
|
||||
min_content_separation: int = 24
|
||||
|
||||
# Gap between rows contributed by the *same* plugin. separator_width marks
|
||||
# the handoff from one plugin to the next; applying it between every image
|
||||
# forced a 32px chasm between each row of a per-row ticker (the F1
|
||||
# scoreboard renders its own rows 4px apart), which both looked wrong and
|
||||
# silently inflated the width that plugin occupied.
|
||||
intra_plugin_gap: int = 8
|
||||
|
||||
# Content density
|
||||
#
|
||||
# Plugins that render onto a full-display canvas contribute that whole
|
||||
# canvas to the ticker, blank margins included. On a wide panel that is the
|
||||
# dominant source of dead air: a plugin drawing 35px of text on a 512px
|
||||
# canvas otherwise buys 9.5s of black at 50px/s. Trimming reclaims it.
|
||||
auto_trim: bool = True
|
||||
trim_threshold: int = 10 # Per-channel value a pixel must exceed to be "ink"
|
||||
content_padding: int = 8 # Blank columns kept either side of trimmed content
|
||||
min_plugin_width: int = 8 # Segments narrower than this after trim are dropped
|
||||
|
||||
# Columns of blank lead-in before the first item of a cycle. ScrollHelper
|
||||
# defaults this to a full display width, which reads as the display being
|
||||
# switched off at the start of every cycle.
|
||||
lead_in_width: int = 0
|
||||
|
||||
# Blend between neighbouring pixel positions so motion happens at the frame
|
||||
# rate rather than the scroll speed. With integer positioning the number of
|
||||
# distinct frames per second equals scroll_speed, so at 50px/s the motion is
|
||||
# 50 discrete 1px steps however fast the loop runs. The trade is a slight
|
||||
# horizontal softening of text, since each frame is a blend of two positions.
|
||||
smooth_scroll: bool = True
|
||||
|
||||
# Keep one continuous strip, extending it with the next group of plugins as
|
||||
# the scroll approaches the end, instead of composing a fresh strip and
|
||||
# swapping it in. A swap stops the motion, substitutes every pixel at once
|
||||
# and restarts with the viewport already full — read as a freeze, a flash
|
||||
# and a jump. Extending means the next group simply scrolls in from the
|
||||
# right. Set false to restore the swap behaviour.
|
||||
continuous_scroll: bool = True
|
||||
|
||||
# Extend once the unscrolled remainder falls below this many screen widths.
|
||||
# Needs to be more than one so the join is prepared before it is on screen.
|
||||
extend_threshold_screens: float = 2.0
|
||||
|
||||
# How many plugins are composed into one scroll cycle. Kept separate from
|
||||
# buffer_ahead (which is only a prefetch low-water mark) because the two
|
||||
# were previously the same number: a buffer_ahead of 2 meant just 3 plugins
|
||||
# per cycle, so a 20-plugin install took seven cycles to come around.
|
||||
plugins_per_cycle: int = 6
|
||||
|
||||
# Minimum run of blank columns that counts as a boundary between items when
|
||||
# an oversized segment has to be narrowed. Measured on rendered text, the
|
||||
# gaps between characters are a single column while gaps between items are
|
||||
# 8px and up, so anything above 1 stops a cut landing inside a word. Cutting
|
||||
# mid-word orphaned the tail into the next cycle, which showed up as a lone
|
||||
# letter floating between two unrelated plugins.
|
||||
min_cut_gap: int = 6
|
||||
|
||||
# What to do when a plugin's content exceeds its width budget.
|
||||
#
|
||||
# "rotate" — advance a window each cycle so everything is seen eventually.
|
||||
# Right for interchangeable items: news headlines, odds, stocks.
|
||||
# "truncate" — always show the start. Right for ordered content, where a
|
||||
# window into the middle is meaningless: a league table that
|
||||
# shows ranks 1-6 then resumes at 7 two rotations later reads
|
||||
# as out of order and out of context.
|
||||
#
|
||||
# Override per plugin with vegas_overflow.
|
||||
overflow_mode: str = "rotate"
|
||||
|
||||
# Cap on one plugin's share of a cycle, as a multiple of display width.
|
||||
# A single ticker returning 7,000px would otherwise hold the panel for over
|
||||
# two minutes. Overflow is deferred to later cycles rather than discarded.
|
||||
# 0 disables the cap.
|
||||
max_plugin_width_ratio: float = 3.0
|
||||
|
||||
# Plugin management
|
||||
plugin_order: List[str] = field(default_factory=list)
|
||||
excluded_plugins: Set[str] = field(default_factory=set)
|
||||
@@ -55,6 +143,24 @@ class VegasModeConfig:
|
||||
enabled=vegas_config.get('enabled', False),
|
||||
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
|
||||
separator_width=int(vegas_config.get('separator_width', 32)),
|
||||
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
|
||||
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
|
||||
min_content_separation=int(
|
||||
vegas_config.get('min_content_separation', 24)),
|
||||
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
|
||||
smooth_scroll=vegas_config.get('smooth_scroll', True),
|
||||
continuous_scroll=vegas_config.get('continuous_scroll', True),
|
||||
extend_threshold_screens=float(
|
||||
vegas_config.get('extend_threshold_screens', 2.0)),
|
||||
auto_trim=vegas_config.get('auto_trim', True),
|
||||
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
|
||||
content_padding=int(vegas_config.get('content_padding', 8)),
|
||||
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)),
|
||||
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
|
||||
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
|
||||
max_plugin_width_ratio=float(
|
||||
vegas_config.get('max_plugin_width_ratio', 3.0)),
|
||||
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
||||
target_fps=int(vegas_config.get('target_fps', 125)),
|
||||
@@ -72,6 +178,21 @@ class VegasModeConfig:
|
||||
'enabled': self.enabled,
|
||||
'scroll_speed': self.scroll_speed,
|
||||
'separator_width': self.separator_width,
|
||||
'intra_plugin_gap': self.intra_plugin_gap,
|
||||
'render_width_pct': self.render_width_pct,
|
||||
'min_content_separation': self.min_content_separation,
|
||||
'min_cut_gap': self.min_cut_gap,
|
||||
'smooth_scroll': self.smooth_scroll,
|
||||
'continuous_scroll': self.continuous_scroll,
|
||||
'extend_threshold_screens': self.extend_threshold_screens,
|
||||
'auto_trim': self.auto_trim,
|
||||
'trim_threshold': self.trim_threshold,
|
||||
'content_padding': self.content_padding,
|
||||
'min_plugin_width': self.min_plugin_width,
|
||||
'lead_in_width': self.lead_in_width,
|
||||
'plugins_per_cycle': self.plugins_per_cycle,
|
||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
||||
'overflow_mode': self.overflow_mode,
|
||||
'plugin_order': self.plugin_order,
|
||||
'excluded_plugins': list(self.excluded_plugins),
|
||||
'target_fps': self.target_fps,
|
||||
@@ -157,6 +278,74 @@ class VegasModeConfig:
|
||||
if self.buffer_ahead > 5:
|
||||
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
|
||||
|
||||
if not 10 <= self.render_width_pct <= 100:
|
||||
errors.append(
|
||||
"render_width_pct must be between 10 and 100, "
|
||||
f"got {self.render_width_pct}")
|
||||
|
||||
if not 0 <= self.min_content_separation <= 256:
|
||||
errors.append(
|
||||
"min_content_separation must be between 0 and 256, "
|
||||
f"got {self.min_content_separation}")
|
||||
|
||||
if not 1.0 <= self.extend_threshold_screens <= 10.0:
|
||||
errors.append(
|
||||
"extend_threshold_screens must be between 1.0 and 10.0, "
|
||||
f"got {self.extend_threshold_screens}")
|
||||
|
||||
if not 1 <= self.min_cut_gap <= 128:
|
||||
errors.append(
|
||||
"min_cut_gap must be between 1 and 128, "
|
||||
f"got {self.min_cut_gap}")
|
||||
|
||||
if self.intra_plugin_gap < 0:
|
||||
errors.append(
|
||||
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
|
||||
if self.intra_plugin_gap > 128:
|
||||
errors.append(
|
||||
f"intra_plugin_gap must be <= 128, got {self.intra_plugin_gap}")
|
||||
|
||||
if not 0 <= self.trim_threshold <= 254:
|
||||
errors.append(
|
||||
f"trim_threshold must be between 0 and 254, got {self.trim_threshold}")
|
||||
|
||||
if self.content_padding < 0:
|
||||
errors.append(
|
||||
f"content_padding must be >= 0, got {self.content_padding}")
|
||||
if self.content_padding > 128:
|
||||
errors.append(
|
||||
f"content_padding must be <= 128, got {self.content_padding}")
|
||||
|
||||
if self.min_plugin_width < 0:
|
||||
errors.append(
|
||||
f"min_plugin_width must be >= 0, got {self.min_plugin_width}")
|
||||
# Bounded because every segment narrower than this is dropped — an
|
||||
# unbounded value would discard every plugin and leave a blank ticker.
|
||||
if self.min_plugin_width > 512:
|
||||
errors.append(
|
||||
f"min_plugin_width must be <= 512, got {self.min_plugin_width}")
|
||||
|
||||
if self.lead_in_width < 0:
|
||||
errors.append(
|
||||
f"lead_in_width must be >= 0, got {self.lead_in_width}")
|
||||
|
||||
if self.plugins_per_cycle < 1:
|
||||
errors.append(
|
||||
f"plugins_per_cycle must be >= 1, got {self.plugins_per_cycle}")
|
||||
if self.plugins_per_cycle > 50:
|
||||
errors.append(
|
||||
f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}")
|
||||
|
||||
if self.overflow_mode not in ('rotate', 'truncate'):
|
||||
errors.append(
|
||||
"overflow_mode must be 'rotate' or 'truncate', "
|
||||
f"got {self.overflow_mode!r}")
|
||||
|
||||
if self.max_plugin_width_ratio < 0:
|
||||
errors.append(
|
||||
"max_plugin_width_ratio must be >= 0 "
|
||||
f"(0 disables the cap), got {self.max_plugin_width_ratio}")
|
||||
|
||||
return errors
|
||||
|
||||
def update(self, new_config: Dict[str, Any]) -> None:
|
||||
@@ -174,6 +363,39 @@ class VegasModeConfig:
|
||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||
if 'separator_width' in vegas_config:
|
||||
self.separator_width = int(vegas_config['separator_width'])
|
||||
if 'intra_plugin_gap' in vegas_config:
|
||||
self.intra_plugin_gap = int(vegas_config['intra_plugin_gap'])
|
||||
if 'render_width_pct' in vegas_config:
|
||||
self.render_width_pct = int(vegas_config['render_width_pct'])
|
||||
if 'min_content_separation' in vegas_config:
|
||||
self.min_content_separation = int(
|
||||
vegas_config['min_content_separation'])
|
||||
if 'min_cut_gap' in vegas_config:
|
||||
self.min_cut_gap = int(vegas_config['min_cut_gap'])
|
||||
if 'smooth_scroll' in vegas_config:
|
||||
self.smooth_scroll = vegas_config['smooth_scroll']
|
||||
if 'continuous_scroll' in vegas_config:
|
||||
self.continuous_scroll = vegas_config['continuous_scroll']
|
||||
if 'extend_threshold_screens' in vegas_config:
|
||||
self.extend_threshold_screens = float(
|
||||
vegas_config['extend_threshold_screens'])
|
||||
if 'auto_trim' in vegas_config:
|
||||
self.auto_trim = vegas_config['auto_trim']
|
||||
if 'trim_threshold' in vegas_config:
|
||||
self.trim_threshold = int(vegas_config['trim_threshold'])
|
||||
if 'content_padding' in vegas_config:
|
||||
self.content_padding = int(vegas_config['content_padding'])
|
||||
if 'min_plugin_width' in vegas_config:
|
||||
self.min_plugin_width = int(vegas_config['min_plugin_width'])
|
||||
if 'lead_in_width' in vegas_config:
|
||||
self.lead_in_width = int(vegas_config['lead_in_width'])
|
||||
if 'plugins_per_cycle' in vegas_config:
|
||||
self.plugins_per_cycle = int(vegas_config['plugins_per_cycle'])
|
||||
if 'max_plugin_width_ratio' in vegas_config:
|
||||
self.max_plugin_width_ratio = float(
|
||||
vegas_config['max_plugin_width_ratio'])
|
||||
if 'overflow_mode' in vegas_config:
|
||||
self.overflow_mode = str(vegas_config['overflow_mode'])
|
||||
if 'plugin_order' in vegas_config:
|
||||
self.plugin_order = list(vegas_config['plugin_order'])
|
||||
if 'excluded_plugins' in vegas_config:
|
||||
|
||||
@@ -64,7 +64,7 @@ class VegasModeCoordinator:
|
||||
self.plugin_manager = plugin_manager
|
||||
|
||||
# Initialize components
|
||||
self.plugin_adapter = PluginAdapter(display_manager)
|
||||
self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
|
||||
self.stream_manager = StreamManager(
|
||||
self.vegas_config,
|
||||
plugin_manager,
|
||||
@@ -233,6 +233,11 @@ class VegasModeCoordinator:
|
||||
self._should_stop = False
|
||||
self._start_time = time.time()
|
||||
|
||||
# Line up the next group immediately, so the first extension is already
|
||||
# warm rather than stalling the scroll to fetch it.
|
||||
if self.vegas_config.continuous_scroll:
|
||||
self.render_pipeline.start_prefetch()
|
||||
|
||||
logger.info("Vegas mode started")
|
||||
return True
|
||||
|
||||
@@ -301,6 +306,33 @@ class VegasModeCoordinator:
|
||||
if has_pending_update:
|
||||
self._apply_pending_config()
|
||||
|
||||
if self.vegas_config.continuous_scroll:
|
||||
# Drop cached content for plugins whose data just changed, so the
|
||||
# next time each comes round it is composed from current data. The
|
||||
# swap path's hot_swap_content() does this via process_updates(),
|
||||
# but it also rebuilds and repositions the whole strip, which is
|
||||
# the freeze-and-jump this mode exists to avoid. Without this the
|
||||
# pending-update flags are never consumed and a segment keeps
|
||||
# rendering whatever it was first built from — last night's live
|
||||
# game still shown as live the next morning.
|
||||
self.render_pipeline.refresh_updated_plugins()
|
||||
|
||||
# Extend the strip before the scroll can reach its end, so the next
|
||||
# group arrives from the right and motion never stops. No cycle
|
||||
# boundary, so no freeze, no substitution and no restart with the
|
||||
# viewport already full.
|
||||
# Trickle in the plugins that can only be fetched here, one per
|
||||
# frame, before considering a further extension.
|
||||
if self.render_pipeline.has_deferred():
|
||||
self.render_pipeline.drain_deferred()
|
||||
elif self.render_pipeline.needs_extension():
|
||||
if self.render_pipeline.extend_scroll_content():
|
||||
self.stats['cycles_completed'] += 1
|
||||
elif self.render_pipeline.is_cycle_complete():
|
||||
# Extension failed and the strip has run out: fall back to
|
||||
# the swap rather than sitting on a dead frame.
|
||||
self.render_pipeline.start_new_cycle()
|
||||
else:
|
||||
# Check if we need to start a new cycle
|
||||
if self.render_pipeline.is_cycle_complete():
|
||||
if not self.render_pipeline.start_new_cycle():
|
||||
@@ -337,6 +369,13 @@ class VegasModeCoordinator:
|
||||
self._update_static_mode_plugins()
|
||||
|
||||
frame_interval = self.vegas_config.get_frame_interval()
|
||||
if self.vegas_config.continuous_scroll:
|
||||
# The strip is continuously extended and trimmed, so its width says
|
||||
# nothing about how long to run. This is only how often control
|
||||
# returns to the display controller; interrupts are still checked
|
||||
# every few frames, so it costs nothing to make it a fixed period.
|
||||
duration = float(self.vegas_config.max_cycle_duration)
|
||||
else:
|
||||
duration = self.render_pipeline.get_dynamic_duration()
|
||||
start_time = time.time()
|
||||
frame_count = 0
|
||||
@@ -347,6 +386,8 @@ class VegasModeCoordinator:
|
||||
logger.info("Starting Vegas iteration for %.1fs", duration)
|
||||
|
||||
while True:
|
||||
frame_started = time.time()
|
||||
|
||||
# Check for STATIC mode plugin that should pause scroll
|
||||
static_plugin = self._check_static_plugin_trigger()
|
||||
if static_plugin:
|
||||
@@ -367,8 +408,14 @@ class VegasModeCoordinator:
|
||||
# Paused for live priority - let caller handle
|
||||
return False
|
||||
|
||||
# Sleep for frame interval
|
||||
time.sleep(frame_interval)
|
||||
# Sleep only the remainder of the frame budget. This used to sleep
|
||||
# the whole interval on top of however long the frame took, so at a
|
||||
# measured 31.6ms per frame a fixed 8ms of that was pure idle — a
|
||||
# quarter of the budget spent not rendering. Subtracting the work
|
||||
# already done keeps the pacing target while reclaiming that time,
|
||||
# and yields the GIL either way so other threads still run.
|
||||
frame_elapsed = time.time() - frame_started
|
||||
time.sleep(max(0.0, frame_interval - frame_elapsed))
|
||||
|
||||
# Increment frame count and check for interrupt periodically
|
||||
frame_count += 1
|
||||
@@ -505,6 +552,10 @@ class VegasModeCoordinator:
|
||||
# Update components
|
||||
self.render_pipeline.update_config(new_vegas_config)
|
||||
self.stream_manager.config = new_vegas_config
|
||||
self.plugin_adapter.config = new_vegas_config
|
||||
# Cached segments were trimmed under the old settings, so drop them
|
||||
# or a changed trim/padding value would not visibly take effect.
|
||||
self.plugin_adapter.invalidate_cache()
|
||||
|
||||
# Force refresh of stream manager to pick up plugin_order/buffer changes
|
||||
self.stream_manager._last_refresh = 0
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
"""
|
||||
Geometry primitives for Vegas Mode.
|
||||
|
||||
Pure, side-effect-free measurements over PIL images. Two consumers:
|
||||
|
||||
- ``PluginAdapter`` trims the blank margins plugins bake into their content
|
||||
before it enters the ticker (see ``trim_to_content``).
|
||||
- ``scripts/dev/vegas_audit.py`` reports how much of the composed ticker is
|
||||
dead space (see ``dead_window_stats``).
|
||||
|
||||
Keeping both on the same primitives means the number the audit reports is the
|
||||
number the trimmer acted on.
|
||||
|
||||
All column scans go through numpy: a Python-level per-column loop over a
|
||||
17,000px-wide ticker image takes seconds, which is far too slow for the render
|
||||
path.
|
||||
"""
|
||||
|
||||
from typing import List, NamedTuple, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# A pixel counts as "ink" when any channel exceeds this. Chosen to ignore the
|
||||
# 1-2/255 noise that JPEG-sourced logos and alpha compositing leave behind in
|
||||
# nominally black areas, while still treating any deliberately drawn dark grey
|
||||
# as real content.
|
||||
DEFAULT_INK_THRESHOLD = 10
|
||||
|
||||
# A window counts as "dead" when this fraction of its columns carry no ink.
|
||||
DEFAULT_DEAD_WINDOW_RATIO = 0.95
|
||||
|
||||
|
||||
def column_has_ink(img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD) -> np.ndarray:
|
||||
"""
|
||||
Return a boolean array, one entry per image column, True where the column
|
||||
contains at least one pixel brighter than ``threshold`` in any channel.
|
||||
|
||||
Args:
|
||||
img: Image to scan (converted to RGB internally)
|
||||
threshold: Per-channel value a pixel must exceed to count as ink
|
||||
|
||||
Returns:
|
||||
Bool array of shape (width,)
|
||||
"""
|
||||
arr = np.asarray(img if img.mode == 'RGB' else img.convert('RGB'))
|
||||
if arr.ndim != 3:
|
||||
# Degenerate/empty image — treat every column as blank.
|
||||
return np.zeros(img.width, dtype=bool)
|
||||
# Collapse rows and channels: a column is ink if any pixel in it is bright.
|
||||
return arr.max(axis=(0, 2)) > threshold
|
||||
|
||||
|
||||
def content_bounds(
|
||||
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""
|
||||
Find the first and last columns containing ink.
|
||||
|
||||
Args:
|
||||
img: Image to measure
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
(first_col, last_col) inclusive, or None if the image is entirely blank
|
||||
"""
|
||||
ink = column_has_ink(img, threshold)
|
||||
if not ink.any():
|
||||
return None
|
||||
first = int(ink.argmax())
|
||||
last = len(ink) - 1 - int(ink[::-1].argmax())
|
||||
return first, last
|
||||
|
||||
|
||||
class TrimResult(NamedTuple):
|
||||
"""Outcome of a ``trim_to_content`` call."""
|
||||
|
||||
image: Optional[Image.Image] # None when the source was entirely blank
|
||||
original_width: int
|
||||
trimmed_left: int
|
||||
trimmed_right: int
|
||||
|
||||
@property
|
||||
def is_blank(self) -> bool:
|
||||
"""True when the source image carried no ink at all."""
|
||||
return self.image is None
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
"""Width after trimming (0 for a blank source)."""
|
||||
return 0 if self.image is None else self.image.width
|
||||
|
||||
@property
|
||||
def removed(self) -> int:
|
||||
"""Total columns removed."""
|
||||
return self.trimmed_left + self.trimmed_right
|
||||
|
||||
|
||||
def trim_to_content(
|
||||
img: Image.Image,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
padding: int = 0,
|
||||
) -> TrimResult:
|
||||
"""
|
||||
Crop blank columns off the left and right edges of an image.
|
||||
|
||||
Only the outer edges are considered. Blank columns *between* two pieces of
|
||||
content are deliberately preserved — those are the plugin's own layout
|
||||
(e.g. a logo on the left and a score on the right), and closing them up
|
||||
would corrupt the design rather than reclaim dead space.
|
||||
|
||||
A plugin drawing on a non-black background is unaffected: every column of a
|
||||
filled background carries ink, so there is nothing to trim.
|
||||
|
||||
Args:
|
||||
img: Image to trim
|
||||
threshold: Ink threshold
|
||||
padding: Columns of the original blank margin to keep on each side, as
|
||||
breathing room. Capped at what the margin actually contains, so
|
||||
this never widens the image beyond its original bounds.
|
||||
|
||||
Returns:
|
||||
TrimResult. When the image is entirely blank, ``image`` is None and the
|
||||
caller decides whether to skip the plugin.
|
||||
"""
|
||||
bounds = content_bounds(img, threshold)
|
||||
if bounds is None:
|
||||
return TrimResult(None, img.width, 0, 0)
|
||||
|
||||
first, last = bounds
|
||||
pad = max(0, padding)
|
||||
left = max(0, first - pad)
|
||||
right = min(img.width, last + 1 + pad)
|
||||
|
||||
if left == 0 and right == img.width:
|
||||
return TrimResult(img, img.width, 0, 0)
|
||||
|
||||
cropped = img.crop((left, 0, right, img.height))
|
||||
return TrimResult(cropped, img.width, left, img.width - right)
|
||||
|
||||
|
||||
def edge_blank(
|
||||
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
Blank column counts at the left and right edges of an image.
|
||||
|
||||
Used to space items by *measured* separation rather than a flat added gap.
|
||||
A fixed gap gets this wrong in both directions at once: card-style content
|
||||
drawn flush to its own edges ends up nearly touching its neighbour, while
|
||||
content that already carries wide margins gets pushed even further apart.
|
||||
|
||||
Args:
|
||||
img: Image to measure
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
(left_blank, right_blank). For an entirely blank image both are the
|
||||
full width, since there is no ink to be close to.
|
||||
"""
|
||||
bounds = content_bounds(img, threshold)
|
||||
if bounds is None:
|
||||
return img.width, img.width
|
||||
first, last = bounds
|
||||
return first, img.width - 1 - last
|
||||
|
||||
|
||||
def separation_gap(
|
||||
left_img: Image.Image,
|
||||
right_img: Image.Image,
|
||||
target: int,
|
||||
minimum: int = 0,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> int:
|
||||
"""
|
||||
Columns to insert between two images so their ink is ``target`` apart.
|
||||
|
||||
Only the shortfall is added: if the two images already carry enough blank
|
||||
at the facing edges, nothing (beyond ``minimum``) is inserted.
|
||||
|
||||
Args:
|
||||
left_img: Image on the left
|
||||
right_img: Image on the right
|
||||
target: Desired blank columns between the two pieces of ink
|
||||
minimum: Floor applied regardless of what the images already have
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
Number of columns to insert, never negative
|
||||
"""
|
||||
existing = edge_blank(left_img, threshold)[1] + edge_blank(right_img, threshold)[0]
|
||||
return max(minimum, target - existing, 0)
|
||||
|
||||
|
||||
def blank_runs(
|
||||
img: Image.Image,
|
||||
min_run: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> List[Tuple[int, int]]:
|
||||
"""
|
||||
Find maximal runs of blank columns at least ``min_run`` wide.
|
||||
|
||||
Distinguishes item boundaries from letter spacing. Measured on real
|
||||
rendered text, the gaps *between characters* are a single column, while the
|
||||
gaps a plugin puts *between items* are 8px and up (the stocks ticker uses
|
||||
32px, baseball 48px). Treating any blank column as a cut point therefore
|
||||
slices words in half; requiring a run excludes letter spacing.
|
||||
|
||||
Args:
|
||||
img: Image to scan
|
||||
min_run: Minimum consecutive blank columns to qualify
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
List of (start, end) half-open column ranges, in left-to-right order
|
||||
"""
|
||||
blank = ~column_has_ink(img, threshold)
|
||||
if not blank.any():
|
||||
return []
|
||||
|
||||
# Vectorised run detection: pad with False so runs touching either edge get
|
||||
# a boundary, then read starts and ends off the first difference. A Python
|
||||
# loop here would be far too slow on a 17,000px ticker strip.
|
||||
padded = np.concatenate(([False], blank, [False]))
|
||||
diff = np.diff(padded.astype(np.int8))
|
||||
starts = np.flatnonzero(diff == 1)
|
||||
ends = np.flatnonzero(diff == -1)
|
||||
|
||||
long_enough = (ends - starts) >= max(1, min_run)
|
||||
return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist()))
|
||||
|
||||
|
||||
def find_item_boundary(
|
||||
img: Image.Image,
|
||||
target: int,
|
||||
min_run: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Find the column nearest ``target`` that sits inside a gap between items.
|
||||
|
||||
Used to narrow an oversized segment without cutting through a word. Only
|
||||
runs of at least ``min_run`` blank columns are considered, so the
|
||||
single-column gaps between characters are never chosen — cutting there
|
||||
orphaned the tail of a word into the following cycle, which is how a lone
|
||||
"y" from "Wednesday" ended up floating between two unrelated plugins.
|
||||
|
||||
Args:
|
||||
img: Image to cut
|
||||
target: Preferred cut column
|
||||
min_run: Minimum blank-run width that counts as an item boundary
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
A column inside a qualifying gap, or None when the image has no such
|
||||
gap at all — in which case the caller must not cut it.
|
||||
"""
|
||||
runs = blank_runs(img, min_run, threshold)
|
||||
if not runs:
|
||||
return None
|
||||
|
||||
# Nearest point of the nearest run. For a run left of target that is its
|
||||
# end (content resumes just after), for a run right of target its start
|
||||
# (content stopped just before) — the right choice in both directions.
|
||||
def clamp_to_run(run: Tuple[int, int]) -> int:
|
||||
start, end = run
|
||||
return max(start, min(target, end - 1))
|
||||
|
||||
return min((clamp_to_run(r) for r in runs), key=lambda c: abs(c - target))
|
||||
|
||||
|
||||
def find_blank_cut(
|
||||
img: Image.Image,
|
||||
target: int,
|
||||
search_radius: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> int:
|
||||
"""
|
||||
Find a column near ``target`` that carries no ink, so an image can be cut
|
||||
there without slicing through a glyph or logo.
|
||||
|
||||
Used when a single oversized segment has to be narrowed to fit a width
|
||||
budget. Cutting at an arbitrary column would leave half a character
|
||||
hanging at the panel edge; snapping to the nearest gap hides the cut.
|
||||
|
||||
Args:
|
||||
img: Image to cut
|
||||
target: Preferred cut column
|
||||
search_radius: How far either side of ``target`` to look
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
A blank column within the search window, or ``target`` clamped to the
|
||||
image bounds when the window contains no blank column at all.
|
||||
"""
|
||||
width = img.width
|
||||
target = max(0, min(target, width))
|
||||
if search_radius <= 0 or width == 0:
|
||||
return target
|
||||
|
||||
ink = column_has_ink(img, threshold)
|
||||
|
||||
# target may legitimately equal width (a cut after the last column), but
|
||||
# there is no column to inspect there, so both bounds stop at width - 1.
|
||||
lo = max(0, min(target - search_radius, width - 1))
|
||||
hi = max(0, min(target + search_radius, width - 1))
|
||||
|
||||
# Walk outwards from target so the nearest gap wins.
|
||||
for offset in range(0, search_radius + 1):
|
||||
right = target + offset
|
||||
if lo <= right <= hi and not ink[right]:
|
||||
return right
|
||||
left = target - offset
|
||||
if lo <= left <= hi and not ink[left]:
|
||||
return left
|
||||
|
||||
return target
|
||||
|
||||
|
||||
class DeadWindowStats(NamedTuple):
|
||||
"""How much of a composed ticker reads as blank to a viewer."""
|
||||
|
||||
total_windows: int
|
||||
dead_windows: int
|
||||
longest_dead_run: int # consecutive dead windows (i.e. scroll steps)
|
||||
|
||||
@property
|
||||
def dead_ratio(self) -> float:
|
||||
"""Fraction of viewport positions that are effectively blank."""
|
||||
if self.total_windows <= 0:
|
||||
return 0.0
|
||||
return self.dead_windows / self.total_windows
|
||||
|
||||
|
||||
def dead_window_stats(
|
||||
img: Image.Image,
|
||||
viewport_width: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
dead_ratio: float = DEFAULT_DEAD_WINDOW_RATIO,
|
||||
step: int = 1,
|
||||
) -> DeadWindowStats:
|
||||
"""
|
||||
Slide a viewport across a composed ticker image and count how many
|
||||
positions are effectively blank.
|
||||
|
||||
This models what the viewer actually experiences: the ticker is only ever
|
||||
seen ``viewport_width`` columns at a time, so a stretch of blank wider than
|
||||
the viewport becomes a period where the panel looks switched off. Measuring
|
||||
per-window rather than per-column is what makes the result correspond to
|
||||
perceived dead time.
|
||||
|
||||
Args:
|
||||
img: Composed ticker image
|
||||
viewport_width: Display width in pixels
|
||||
threshold: Ink threshold
|
||||
dead_ratio: Fraction of blank columns for a window to count as dead
|
||||
step: Column stride between sampled windows. 1 is exact; larger values
|
||||
trade precision for speed on very wide images.
|
||||
|
||||
Returns:
|
||||
DeadWindowStats. ``longest_dead_run`` is in units of ``step`` columns,
|
||||
so multiply by ``step`` for pixels.
|
||||
"""
|
||||
if viewport_width <= 0 or img.width <= 0:
|
||||
return DeadWindowStats(0, 0, 0)
|
||||
|
||||
ink = column_has_ink(img, threshold)
|
||||
step = max(1, step)
|
||||
|
||||
# Prefix sum of ink counts lets each window be evaluated in constant time,
|
||||
# instead of re-summing viewport_width columns per position.
|
||||
prefix = np.concatenate(([0], np.cumsum(ink)))
|
||||
|
||||
# Only whole windows are sampled; a partial tail window would report
|
||||
# artificially dead because it has fewer columns to draw ink from.
|
||||
last_start = img.width - viewport_width
|
||||
if last_start < 0:
|
||||
# Image narrower than the viewport — evaluate it as a single window.
|
||||
blank_cols = len(ink) - int(prefix[-1])
|
||||
is_dead = blank_cols >= dead_ratio * len(ink)
|
||||
return DeadWindowStats(1, 1 if is_dead else 0, 1 if is_dead else 0)
|
||||
|
||||
starts = np.arange(0, last_start + 1, step)
|
||||
ink_counts = prefix[starts + viewport_width] - prefix[starts]
|
||||
blank_counts = viewport_width - ink_counts
|
||||
dead = blank_counts >= dead_ratio * viewport_width
|
||||
|
||||
longest = _longest_true_run(dead)
|
||||
return DeadWindowStats(len(starts), int(dead.sum()), longest)
|
||||
|
||||
|
||||
class CoverageStats(NamedTuple):
|
||||
"""How well-filled the viewport stays as the ticker scrolls past."""
|
||||
|
||||
total_windows: int
|
||||
mean_ink_ratio: float # average fraction of the viewport carrying ink
|
||||
min_ink_ratio: float # worst viewport position in the cycle
|
||||
sparse_windows: int # positions below the "looks empty" threshold
|
||||
longest_sparse_run: int # consecutive sparse positions, in steps
|
||||
|
||||
@property
|
||||
def sparse_ratio(self) -> float:
|
||||
"""Fraction of viewport positions that read as near-empty."""
|
||||
if self.total_windows <= 0:
|
||||
return 0.0
|
||||
return self.sparse_windows / self.total_windows
|
||||
|
||||
|
||||
def window_coverage_stats(
|
||||
img: Image.Image,
|
||||
viewport_width: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
sparse_ink_ratio: float = 0.10,
|
||||
step: int = 1,
|
||||
) -> CoverageStats:
|
||||
"""
|
||||
Measure how full the viewport stays across a whole scroll cycle.
|
||||
|
||||
``dead_window_stats`` only catches viewport positions that are *entirely*
|
||||
blank. That misses the more common complaint: a position holding one narrow
|
||||
sliver of content at the very edge, with the other 90% black. Such a
|
||||
position is not "dead" by that definition but still looks switched off.
|
||||
This function grades every position by how much ink it carries, so
|
||||
"there is always something to see" becomes measurable.
|
||||
|
||||
Args:
|
||||
img: Composed ticker image
|
||||
viewport_width: Display width in pixels
|
||||
threshold: Ink threshold
|
||||
sparse_ink_ratio: A position with less than this fraction of inked
|
||||
columns counts as reading near-empty
|
||||
step: Column stride between sampled positions
|
||||
|
||||
Returns:
|
||||
CoverageStats
|
||||
"""
|
||||
if viewport_width <= 0 or img.width <= 0:
|
||||
return CoverageStats(0, 0.0, 0.0, 0, 0)
|
||||
|
||||
ink = column_has_ink(img, threshold)
|
||||
step = max(1, step)
|
||||
prefix = np.concatenate(([0], np.cumsum(ink)))
|
||||
|
||||
last_start = img.width - viewport_width
|
||||
if last_start < 0:
|
||||
ratio = float(prefix[-1]) / viewport_width
|
||||
sparse = ratio < sparse_ink_ratio
|
||||
return CoverageStats(1, ratio, ratio, 1 if sparse else 0, 1 if sparse else 0)
|
||||
|
||||
starts = np.arange(0, last_start + 1, step)
|
||||
ratios = (prefix[starts + viewport_width] - prefix[starts]) / viewport_width
|
||||
sparse_flags = ratios < sparse_ink_ratio
|
||||
|
||||
return CoverageStats(
|
||||
total_windows=len(starts),
|
||||
mean_ink_ratio=float(ratios.mean()),
|
||||
min_ink_ratio=float(ratios.min()),
|
||||
sparse_windows=int(sparse_flags.sum()),
|
||||
longest_sparse_run=_longest_true_run(sparse_flags),
|
||||
)
|
||||
|
||||
|
||||
def _longest_true_run(flags: np.ndarray) -> int:
|
||||
"""Length of the longest consecutive run of True in a boolean array."""
|
||||
if flags.size == 0 or not flags.any():
|
||||
return 0
|
||||
# Reset a running counter at every False by subtracting the cumulative max
|
||||
# of the counter's value at the preceding False positions.
|
||||
idx = np.arange(len(flags))
|
||||
not_flag = ~flags
|
||||
# For each position, the index of the most recent False at or before it.
|
||||
last_false = np.maximum.accumulate(np.where(not_flag, idx, -1))
|
||||
run_lengths = idx - last_false
|
||||
return int(run_lengths[flags].max())
|
||||
@@ -8,9 +8,16 @@ implement get_vegas_content() and fallback capture of display() output.
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from contextlib import nullcontext
|
||||
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.geometry import (
|
||||
blank_runs,
|
||||
separation_gap,
|
||||
trim_to_content,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
@@ -26,14 +33,21 @@ class PluginAdapter:
|
||||
2. Fallback: Capture display_manager.image after calling plugin.display()
|
||||
"""
|
||||
|
||||
def __init__(self, display_manager: Any):
|
||||
def __init__(self, display_manager: Any, config: Optional[Any] = None):
|
||||
"""
|
||||
Initialize the plugin adapter.
|
||||
|
||||
Args:
|
||||
display_manager: DisplayManager instance for fallback capture
|
||||
config: VegasModeConfig controlling trim behaviour. When omitted,
|
||||
trimming runs with the dataclass defaults, so existing callers
|
||||
and tests keep working unchanged.
|
||||
"""
|
||||
self.display_manager = display_manager
|
||||
if config is None:
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
config = VegasModeConfig()
|
||||
self.config = config
|
||||
# Handle both property and method access patterns
|
||||
self.display_width = (
|
||||
display_manager.width() if callable(display_manager.width)
|
||||
@@ -49,12 +63,18 @@ class PluginAdapter:
|
||||
self._cache_lock = threading.Lock()
|
||||
self._cache_ttl = 5.0 # Cache for 5 seconds
|
||||
|
||||
# Per-plugin rotation offset, so a plugin whose content exceeds its
|
||||
# width budget shows a different slice on each cycle rather than
|
||||
# always the same opening items.
|
||||
self._item_offsets: dict = {}
|
||||
|
||||
logger.info(
|
||||
"PluginAdapter initialized: display=%dx%d",
|
||||
self.display_width, self.display_height
|
||||
)
|
||||
|
||||
def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Image.Image]]:
|
||||
def get_content(self, plugin: 'BasePlugin', plugin_id: str,
|
||||
offscreen_only: bool = False) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Get scrollable content from a plugin.
|
||||
|
||||
@@ -63,6 +83,13 @@ class PluginAdapter:
|
||||
Args:
|
||||
plugin: Plugin instance to get content from
|
||||
plugin_id: Plugin identifier for logging
|
||||
offscreen_only: Skip every path that touches the shared display
|
||||
canvas, for callers running off the render thread. The canvas
|
||||
and the matrix proxy are process-wide mutable state, so
|
||||
narrowing or capturing through them from another thread would
|
||||
corrupt the frame the render loop is pushing. Returns None when
|
||||
the plugin can only be served that way, leaving the caller to
|
||||
fetch it on the render thread.
|
||||
|
||||
Returns:
|
||||
List of PIL Images representing plugin content, or None if no content
|
||||
@@ -86,32 +113,38 @@ class PluginAdapter:
|
||||
has_native = hasattr(plugin, 'get_vegas_content')
|
||||
logger.info("[%s] Has get_vegas_content: %s", plugin_id, has_native)
|
||||
if has_native:
|
||||
content = self._get_native_content(plugin, plugin_id)
|
||||
content = self._get_native_content(plugin, plugin_id, offscreen_only)
|
||||
if content:
|
||||
total_width = sum(img.width for img in content)
|
||||
logger.info(
|
||||
"[%s] Native content SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
return self._finalize(content, plugin_id, 'native', plugin)
|
||||
logger.info("[%s] Native content returned None", plugin_id)
|
||||
|
||||
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
|
||||
has_scroll_helper = hasattr(plugin, 'scroll_helper')
|
||||
logger.info("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper)
|
||||
content = self._get_scroll_helper_content(plugin, plugin_id)
|
||||
content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only)
|
||||
if content:
|
||||
total_width = sum(img.width for img in content)
|
||||
logger.info(
|
||||
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
return self._finalize(content, plugin_id, 'scroll_helper', plugin)
|
||||
if has_scroll_helper:
|
||||
logger.info("[%s] ScrollHelper content returned None", plugin_id)
|
||||
|
||||
if offscreen_only:
|
||||
# Display capture needs the shared canvas; leave it to the caller.
|
||||
logger.info(
|
||||
"[%s] Needs display capture, deferring to the render thread",
|
||||
plugin_id
|
||||
)
|
||||
return None
|
||||
|
||||
# Fall back to display capture
|
||||
logger.info("[%s] Trying fallback display capture...", plugin_id)
|
||||
content = self._capture_display_content(plugin, plugin_id)
|
||||
@@ -121,8 +154,7 @@ class PluginAdapter:
|
||||
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
return self._finalize(content, plugin_id, 'fallback', plugin)
|
||||
|
||||
logger.warning(
|
||||
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
|
||||
@@ -130,8 +162,397 @@ class PluginAdapter:
|
||||
)
|
||||
return None
|
||||
|
||||
def _finalize(
|
||||
self, images: List[Image.Image], plugin_id: str, source: str,
|
||||
plugin: Optional['BasePlugin'] = None
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Trim dead space off a segment, then cache it.
|
||||
|
||||
Every content path funnels through here so trimming is applied
|
||||
uniformly. Previously only the scroll_helper path had its margins
|
||||
stripped, which left plugins that render onto a full-display canvas
|
||||
contributing their entire blank canvas to the ticker.
|
||||
|
||||
Each image is trimmed independently because compose_scroll_content()
|
||||
treats every image as its own item and inserts separator_width between
|
||||
them — so a per-image trim is what makes that separator the real gap.
|
||||
|
||||
Args:
|
||||
images: Raw content from one of the fetch paths
|
||||
plugin_id: Plugin identifier for logging
|
||||
source: Which path produced the content, for logging
|
||||
|
||||
Returns:
|
||||
Trimmed image list, or None if nothing worth showing remains
|
||||
"""
|
||||
if not self.config.auto_trim:
|
||||
# Trimming is off, but the width budget is a separate concern —
|
||||
# turning off margin cropping should not let one plugin hold the
|
||||
# panel for minutes. Skipping it here previously let a 14,848px
|
||||
# segment through untouched.
|
||||
kept = self._apply_width_budget(list(images), plugin_id, plugin)
|
||||
self._cache_content(plugin_id, kept)
|
||||
return kept
|
||||
|
||||
original_width = sum(img.width for img in images)
|
||||
kept: List[Image.Image] = []
|
||||
dropped_blank = 0
|
||||
|
||||
for img in images:
|
||||
result = trim_to_content(
|
||||
img,
|
||||
threshold=self.config.trim_threshold,
|
||||
padding=self.config.content_padding,
|
||||
)
|
||||
if result.is_blank:
|
||||
dropped_blank += 1
|
||||
continue
|
||||
kept.append(result.image)
|
||||
|
||||
if not kept:
|
||||
logger.info(
|
||||
"[%s] All %d image(s) from %s were blank — contributing nothing",
|
||||
plugin_id, len(images), source
|
||||
)
|
||||
return None
|
||||
|
||||
trimmed_width = sum(img.width for img in kept)
|
||||
|
||||
if trimmed_width < self.config.min_plugin_width:
|
||||
logger.info(
|
||||
"[%s] Trimmed content %dpx is below min_plugin_width %dpx — skipping",
|
||||
plugin_id, trimmed_width, self.config.min_plugin_width
|
||||
)
|
||||
return None
|
||||
|
||||
if trimmed_width != original_width or dropped_blank:
|
||||
logger.info(
|
||||
"[%s] Trimmed %s content: %dpx -> %dpx (%.0f%% reclaimed), "
|
||||
"%d image(s) kept, %d blank dropped",
|
||||
plugin_id, source, original_width, trimmed_width,
|
||||
100.0 * (original_width - trimmed_width) / original_width
|
||||
if original_width else 0.0,
|
||||
len(kept), dropped_blank
|
||||
)
|
||||
|
||||
kept = self._apply_width_budget(kept, plugin_id, plugin)
|
||||
|
||||
self._cache_content(plugin_id, kept)
|
||||
return kept
|
||||
|
||||
def _capture(self):
|
||||
"""
|
||||
Context manager suppressing hardware writes while plugin render code runs.
|
||||
|
||||
Degrades to a no-op when the display manager predates capture_mode. As
|
||||
with _render_at, losing the suppression risks a visible flash, whereas
|
||||
raising would be swallowed by the broad handlers upstream and drop the
|
||||
plugin's content entirely — much worse.
|
||||
"""
|
||||
capture_mode = getattr(self.display_manager, 'capture_mode', None)
|
||||
if capture_mode is None:
|
||||
logger.debug(
|
||||
"display_manager has no capture_mode(); plugin writes during "
|
||||
"content capture may reach the panel"
|
||||
)
|
||||
return nullcontext()
|
||||
return capture_mode()
|
||||
|
||||
def _render_at(self, width: int):
|
||||
"""
|
||||
Context manager narrowing the plugin-facing canvas to ``width``.
|
||||
|
||||
Degrades to a no-op when the display manager predates render_size (a
|
||||
third-party or older test harness). Losing the narrowing is a cosmetic
|
||||
regression; raising here would be caught by the broad handlers upstream
|
||||
and silently drop the plugin's content entirely.
|
||||
"""
|
||||
render_size = getattr(self.display_manager, 'render_size', None)
|
||||
if render_size is None:
|
||||
logger.debug(
|
||||
"display_manager has no render_size(); Vegas width requests "
|
||||
"will be ignored"
|
||||
)
|
||||
return nullcontext()
|
||||
return render_size(width)
|
||||
|
||||
def resolve_render_width(self, plugin: 'BasePlugin', plugin_id: str) -> int:
|
||||
"""
|
||||
Width to tell a plugin it has while it renders for the ticker.
|
||||
|
||||
Resolution order, most specific first:
|
||||
1. the plugin's own ``vegas_width_pct`` config value
|
||||
2. the global ``vegas_scroll.render_width_pct``
|
||||
3. the full panel width
|
||||
|
||||
A percentage rather than an absolute width so one setting travels
|
||||
across panel sizes.
|
||||
|
||||
Args:
|
||||
plugin: Plugin instance, consulted for a per-plugin override
|
||||
plugin_id: Plugin identifier for logging
|
||||
|
||||
Returns:
|
||||
Target width in pixels, never wider than the panel
|
||||
"""
|
||||
pct = self.config.render_width_pct
|
||||
|
||||
plugin_cfg = getattr(plugin, 'config', None)
|
||||
if isinstance(plugin_cfg, dict):
|
||||
raw = plugin_cfg.get('vegas_width_pct')
|
||||
if raw not in (None, ''):
|
||||
try:
|
||||
candidate = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"[%s] Invalid vegas_width_pct %r, ignoring", plugin_id, raw)
|
||||
else:
|
||||
if 10 <= candidate <= 100:
|
||||
pct = candidate
|
||||
else:
|
||||
logger.warning(
|
||||
"[%s] vegas_width_pct %d out of range 10-100, ignoring",
|
||||
plugin_id, candidate)
|
||||
|
||||
if pct >= 100:
|
||||
return self.display_width
|
||||
return max(1, int(self.display_width * pct / 100))
|
||||
|
||||
def _row_gap(self, left: Image.Image, right: Image.Image) -> int:
|
||||
"""
|
||||
Gap the compositor will insert between two of a plugin's rows.
|
||||
|
||||
Mirrors RenderPipeline._join_plugin_rows so the width budget measures
|
||||
what will actually be rendered.
|
||||
"""
|
||||
return separation_gap(
|
||||
left, right,
|
||||
target=max(0, self.config.min_content_separation),
|
||||
minimum=max(0, self.config.intra_plugin_gap),
|
||||
threshold=self.config.trim_threshold,
|
||||
)
|
||||
|
||||
def _plugin_setting(self, plugin: 'BasePlugin', key: str):
|
||||
"""Read a per-plugin config override, or None if absent."""
|
||||
plugin_cfg = getattr(plugin, 'config', None)
|
||||
if not isinstance(plugin_cfg, dict):
|
||||
return None
|
||||
value = plugin_cfg.get(key)
|
||||
return None if value in (None, '') else value
|
||||
|
||||
def resolve_overflow_mode(self, plugin: 'BasePlugin', plugin_id: str) -> str:
|
||||
"""
|
||||
How to handle content that exceeds this plugin's width budget.
|
||||
|
||||
'rotate' advances a window each cycle so everything is seen eventually,
|
||||
which suits interchangeable items. 'truncate' always shows the start,
|
||||
which suits ordered content — a league table that shows ranks 1-6 and
|
||||
then resumes at 7 two rotations later reads as out of order, and nobody
|
||||
needs rank 23 in a ticker anyway.
|
||||
|
||||
Per-plugin ``vegas_overflow`` wins over the global ``overflow_mode``.
|
||||
"""
|
||||
raw = self._plugin_setting(plugin, 'vegas_overflow')
|
||||
if raw is not None:
|
||||
candidate = str(raw).strip().lower()
|
||||
if candidate in ('rotate', 'truncate'):
|
||||
return candidate
|
||||
logger.warning(
|
||||
"[%s] Invalid vegas_overflow %r, expected 'rotate' or 'truncate'",
|
||||
plugin_id, raw
|
||||
)
|
||||
return self.config.overflow_mode
|
||||
|
||||
def _width_budget(self, plugin: Optional['BasePlugin'] = None,
|
||||
plugin_id: str = '') -> int:
|
||||
"""
|
||||
Maximum columns one plugin may occupy in a cycle. 0 means unlimited.
|
||||
|
||||
A per-plugin ``vegas_max_width_screens`` overrides the global ratio, so
|
||||
content that has to stay whole can be given room (or uncapped with 0)
|
||||
without lifting the cap on every ticker.
|
||||
"""
|
||||
ratio = self.config.max_plugin_width_ratio
|
||||
|
||||
if plugin is not None:
|
||||
raw = self._plugin_setting(plugin, 'vegas_max_width_screens')
|
||||
if raw is not None:
|
||||
try:
|
||||
candidate = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"[%s] Invalid vegas_max_width_screens %r, ignoring",
|
||||
plugin_id, raw
|
||||
)
|
||||
else:
|
||||
if candidate >= 0:
|
||||
ratio = candidate
|
||||
else:
|
||||
logger.warning(
|
||||
"[%s] vegas_max_width_screens must be >= 0, got %s",
|
||||
plugin_id, candidate
|
||||
)
|
||||
|
||||
if ratio <= 0:
|
||||
return 0
|
||||
return int(self.display_width * ratio)
|
||||
|
||||
def _apply_width_budget(
|
||||
self, images: List[Image.Image], plugin_id: str,
|
||||
plugin: Optional['BasePlugin'] = None
|
||||
) -> List[Image.Image]:
|
||||
"""
|
||||
Hold one plugin to its share of a cycle.
|
||||
|
||||
A ticker returning 7,000px would otherwise own the panel for over two
|
||||
minutes, which defeats the point of a rotation. Overflow is deferred
|
||||
rather than discarded: the starting offset advances each time this
|
||||
plugin is fetched, so later items appear on subsequent cycles instead
|
||||
of never being seen.
|
||||
|
||||
Args:
|
||||
images: Trimmed images for this plugin
|
||||
plugin_id: Plugin identifier, used to track its rotation offset
|
||||
|
||||
Returns:
|
||||
Images that fit the budget, starting from the plugin's current
|
||||
rotation offset.
|
||||
"""
|
||||
budget = self._width_budget(plugin, plugin_id)
|
||||
mode = (self.resolve_overflow_mode(plugin, plugin_id)
|
||||
if plugin is not None else self.config.overflow_mode)
|
||||
|
||||
# Count the gaps the compositor will actually insert, not just the
|
||||
# pixels of the rows — otherwise a plugin with many rows quietly
|
||||
# occupies far more of the panel than its budget allows. These must use
|
||||
# the same measured rule as RenderPipeline._join_plugin_rows; assuming
|
||||
# the flat intra_plugin_gap here under-counted by up to
|
||||
# (min_content_separation - intra_plugin_gap) per row.
|
||||
total = sum(img.width for img in images) + sum(
|
||||
self._row_gap(images[i], images[i + 1]) for i in range(len(images) - 1)
|
||||
)
|
||||
|
||||
if not budget or total <= budget:
|
||||
# Fits, so reset rotation — the whole segment is being shown.
|
||||
self._item_offsets.pop(plugin_id, None)
|
||||
return images
|
||||
|
||||
if len(images) == 1:
|
||||
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
||||
|
||||
if mode == 'truncate':
|
||||
# Ordered content: always show from the top. Deliberately does not
|
||||
# advance the offset, so the same opening items appear every time
|
||||
# rather than the viewer being shown the middle of a ranked list.
|
||||
start = 0
|
||||
else:
|
||||
start = self._item_offsets.get(plugin_id, 0) % len(images)
|
||||
selected: List[Image.Image] = []
|
||||
used = 0
|
||||
consumed = 0
|
||||
|
||||
# Walk forward from the rotation offset, taking whole items only, so a
|
||||
# cut never lands in the middle of one.
|
||||
for step in range(len(images)):
|
||||
img = images[(start + step) % len(images)]
|
||||
cost = img.width
|
||||
if selected:
|
||||
cost += self._row_gap(selected[-1], img)
|
||||
if selected and used + cost > budget:
|
||||
break
|
||||
selected.append(img)
|
||||
used += cost
|
||||
consumed += 1
|
||||
|
||||
if mode == 'truncate':
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: showing the first %d of %d row(s) "
|
||||
"(%dpx incl. gaps); the rest are not shown (overflow=truncate)",
|
||||
plugin_id, budget, len(selected), len(images), used
|
||||
)
|
||||
else:
|
||||
self._item_offsets[plugin_id] = (start + consumed) % len(images)
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
||||
"from offset %d; remainder deferred to a later cycle",
|
||||
plugin_id, budget, len(selected), len(images), used, start
|
||||
)
|
||||
return selected
|
||||
|
||||
def _crop_to_budget(
|
||||
self, img: Image.Image, budget: int, plugin_id: str,
|
||||
mode: str = 'rotate'
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Narrow a single oversized image to the budget, advancing a window
|
||||
through it across cycles.
|
||||
|
||||
The cut is snapped to the nearest blank column so it does not slice
|
||||
through a glyph or logo and leave half a character at the panel edge.
|
||||
"""
|
||||
if mode == 'truncate':
|
||||
# Always the start of the strip, so a ranked table is never entered
|
||||
# from the middle.
|
||||
offset = 0
|
||||
else:
|
||||
offset = self._item_offsets.get(plugin_id, 0)
|
||||
if offset >= img.width:
|
||||
offset = 0
|
||||
|
||||
# Cut only where the plugin left a real gap between items. Snapping to
|
||||
# any blank column used to pick the single-column gaps between
|
||||
# characters, splitting a word and orphaning its tail into the next
|
||||
# cycle — a lone "y" from "Wednesday" floating between two unrelated
|
||||
# plugins. Overshooting the budget is the lesser evil.
|
||||
min_run = max(2, self.config.min_cut_gap)
|
||||
gaps = blank_runs(img, min_run, self.config.trim_threshold)
|
||||
|
||||
if not gaps:
|
||||
# No internal gaps means continuous content — a map, a chart, a
|
||||
# photo — where any column is as good as any other, so cut to the
|
||||
# budget exactly. The gap rule exists to protect discrete items
|
||||
# (words, ticker entries); it would be wrong to let a solid image
|
||||
# escape the cap in its name.
|
||||
end = min(offset + budget, img.width)
|
||||
if mode != 'truncate':
|
||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: cropped continuous %dpx image to "
|
||||
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
|
||||
plugin_id, budget, img.width, offset, end, min_run,
|
||||
"" if mode != 'truncate' else "; showing the start only"
|
||||
)
|
||||
return img.crop((offset, 0, end, img.height))
|
||||
|
||||
# Cut mid-gap so the content either side keeps some breathing room.
|
||||
cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps})
|
||||
|
||||
start = max((c for c in cuts if c <= offset), default=0)
|
||||
later = [c for c in cuts if c > start]
|
||||
if not later:
|
||||
end = img.width
|
||||
else:
|
||||
within = [c for c in later if c <= start + budget]
|
||||
# No boundary inside the budget: take the next one and overrun,
|
||||
# because the alternative is cutting through an item.
|
||||
end = max(within) if within else min(later)
|
||||
|
||||
if mode != 'truncate':
|
||||
# Next cycle resumes where this one stopped; wrap when the strip ends.
|
||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
|
||||
"(%dpx) at item boundaries, %s",
|
||||
plugin_id, budget, img.width, start, end, end - start,
|
||||
"showing the start only (overflow=truncate)"
|
||||
if mode == 'truncate' else "window advances next cycle"
|
||||
)
|
||||
return img.crop((start, 0, end, img.height))
|
||||
|
||||
def _get_native_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Get content via plugin's native get_vegas_content() method.
|
||||
@@ -145,7 +566,40 @@ class PluginAdapter:
|
||||
"""
|
||||
try:
|
||||
logger.info("[%s] Native: calling get_vegas_content()", plugin_id)
|
||||
|
||||
# Tell the plugin how much width the ticker wants it to use, and
|
||||
# narrow the canvas for the duration of the call. A plugin that
|
||||
# sizes its own images from display_manager.matrix.width picks up
|
||||
# the narrower value with no changes of its own; one that wants to
|
||||
# be explicit can read get_vegas_render_width().
|
||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||
if render_width != self.display_width:
|
||||
logger.info(
|
||||
"[%s] Native: requesting %dpx instead of %dpx",
|
||||
plugin_id, render_width, self.display_width
|
||||
)
|
||||
|
||||
plugin._vegas_render_width = render_width
|
||||
try:
|
||||
# capture_mode unconditionally, even at full width. Building
|
||||
# Vegas content is an off-screen operation, but a plugin is free
|
||||
# to call update_display() while doing it — and outside
|
||||
# capture_mode that write lands on the hardware, flashing the
|
||||
# panel mid-scroll. The narrowing context is separate because it
|
||||
# is a no-op at full width.
|
||||
if offscreen_only:
|
||||
# _render_at swaps the shared canvas, so it is unsafe here.
|
||||
# _vegas_render_width is set regardless: a plugin reading
|
||||
# get_vegas_render_width() still gets its narrow size, and
|
||||
# one that only reads matrix.width renders full width and is
|
||||
# trimmed instead.
|
||||
with self._capture():
|
||||
result = plugin.get_vegas_content()
|
||||
else:
|
||||
with self._capture(), self._render_at(render_width):
|
||||
result = plugin.get_vegas_content()
|
||||
finally:
|
||||
plugin._vegas_render_width = None
|
||||
|
||||
if result is None:
|
||||
logger.info("[%s] Native: get_vegas_content() returned None", plugin_id)
|
||||
@@ -223,7 +677,7 @@ class PluginAdapter:
|
||||
return None
|
||||
|
||||
def _get_scroll_helper_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Get content from plugin's scroll_helper if available.
|
||||
@@ -257,6 +711,13 @@ class PluginAdapter:
|
||||
"[%s] scroll_helper.cached_image is None, triggering content generation",
|
||||
plugin_id
|
||||
)
|
||||
if offscreen_only:
|
||||
# Generating it calls display(), which needs the canvas.
|
||||
logger.info(
|
||||
"[%s] scroll_helper cache empty; deferring generation "
|
||||
"to the render thread", plugin_id
|
||||
)
|
||||
return None
|
||||
# Try to trigger scroll content generation
|
||||
cached_image = self._trigger_scroll_content_generation(
|
||||
plugin, plugin_id, scroll_helper
|
||||
@@ -405,7 +866,7 @@ class PluginAdapter:
|
||||
# Save display state to restore after
|
||||
original_image = self.display_manager.image.copy()
|
||||
|
||||
with self.display_manager.capture_mode():
|
||||
with self._capture():
|
||||
# Method 1: Try _create_scrolling_display (stocks pattern)
|
||||
if hasattr(plugin, '_create_scrolling_display'):
|
||||
logger.info(
|
||||
@@ -497,7 +958,18 @@ class PluginAdapter:
|
||||
|
||||
# Clear and call plugin display — use capture_mode to suppress hardware writes
|
||||
# that plugins may trigger internally via update_display().
|
||||
with self.display_manager.capture_mode():
|
||||
#
|
||||
# render_size narrows the canvas the plugin lays out against, so a
|
||||
# plugin that spreads across the whole panel produces a compact
|
||||
# arrangement rather than one that has to be cropped afterwards.
|
||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||
if render_width != self.display_width:
|
||||
logger.info(
|
||||
"[%s] Fallback: rendering at %dpx instead of %dpx",
|
||||
plugin_id, render_width, self.display_width
|
||||
)
|
||||
|
||||
with self._capture(), self._render_at(render_width):
|
||||
self.display_manager.clear()
|
||||
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
|
||||
|
||||
@@ -531,7 +1003,7 @@ class PluginAdapter:
|
||||
plugin_id
|
||||
)
|
||||
# Try once more with force_clear=True
|
||||
with self.display_manager.capture_mode():
|
||||
with self._capture(), self._render_at(render_width):
|
||||
self.display_manager.clear()
|
||||
plugin.display(force_clear=True)
|
||||
captured = self.display_manager.image.copy()
|
||||
@@ -663,6 +1135,53 @@ class PluginAdapter:
|
||||
else:
|
||||
self._content_cache.clear()
|
||||
|
||||
def invalidate_plugin_scroll_cache(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
) -> bool:
|
||||
"""
|
||||
Drop a plugin's own cached scroll image so its visual is rebuilt.
|
||||
|
||||
Invalidating only this adapter's cache is not enough. A plugin that
|
||||
composes a scroll strip hands back the *same* image every time until its
|
||||
own cache is cleared — the sports plugins' ``get_vegas_content()``
|
||||
regenerates only "if the cache is empty" — so without this a segment
|
||||
keeps rendering whatever data it was first built from. That is how a
|
||||
game that was live last night can still be displayed as live the next
|
||||
morning.
|
||||
|
||||
Two layouts to cover: a helper directly on the plugin (stocks, news,
|
||||
odds-ticker) and one owned by a scroll-display manager (the sports
|
||||
scoreboards). ``cached_image`` and ``cached_array`` must be cleared
|
||||
together, since the array is the image's numpy mirror and code paths
|
||||
read whichever is convenient.
|
||||
|
||||
Returns:
|
||||
True if a cache was found and cleared.
|
||||
"""
|
||||
cleared = False
|
||||
for owner in (plugin, getattr(plugin, '_scroll_manager', None),
|
||||
getattr(plugin, 'scroll_manager', None)):
|
||||
if owner is None:
|
||||
continue
|
||||
helper = getattr(owner, 'scroll_helper', None)
|
||||
if helper is None:
|
||||
continue
|
||||
try:
|
||||
if getattr(helper, 'cached_image', None) is not None:
|
||||
helper.cached_image = None
|
||||
cleared = True
|
||||
if getattr(helper, 'cached_array', None) is not None:
|
||||
helper.cached_array = None
|
||||
cleared = True
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"[%s] Could not clear scroll cache on %s",
|
||||
plugin_id, type(owner).__name__
|
||||
)
|
||||
if cleared:
|
||||
logger.debug("[%s] Cleared plugin scroll cache", plugin_id)
|
||||
return cleared
|
||||
|
||||
def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
|
||||
"""
|
||||
Get the type of content a plugin provides.
|
||||
|
||||
@@ -6,6 +6,7 @@ Uses the existing ScrollHelper for numpy-optimized scroll operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from collections import deque
|
||||
@@ -14,6 +15,7 @@ from PIL import Image
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.geometry import separation_gap
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -34,6 +36,10 @@ class RenderPipeline:
|
||||
- Track scroll cycle completion
|
||||
"""
|
||||
|
||||
# Minimum gap between fetches of canvas-bound plugins, so their individual
|
||||
# stalls land in separate moments rather than one run of hitches.
|
||||
DEFERRED_DRAIN_INTERVAL = 2.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: VegasModeConfig,
|
||||
@@ -66,10 +72,6 @@ class RenderPipeline:
|
||||
else display_manager.height
|
||||
)
|
||||
|
||||
# Reusable blank frame for cycle-end pushes (allocated lazily,
|
||||
# re-blacked before each reuse)
|
||||
self._blank_frame = None
|
||||
|
||||
# ScrollHelper for optimized scrolling
|
||||
self.scroll_helper = ScrollHelper(
|
||||
self.display_width,
|
||||
@@ -85,6 +87,14 @@ class RenderPipeline:
|
||||
self._staging_scroll_image: Optional[Image.Image] = None
|
||||
self._buffer_lock = threading.Lock()
|
||||
|
||||
# Group prepared off the render thread, waiting to be appended.
|
||||
self._prepared_group = None
|
||||
# Plugins that need the shared canvas, appended one at a time.
|
||||
self._deferred_queue: List[str] = []
|
||||
self._last_drain_time = 0.0
|
||||
self._prefetch_thread: Optional[threading.Thread] = None
|
||||
self._prefetch_lock = threading.Lock()
|
||||
|
||||
# Render state
|
||||
self._is_rendering = False
|
||||
self._cycle_complete = False
|
||||
@@ -114,6 +124,7 @@ class RenderPipeline:
|
||||
"""Configure ScrollHelper with current settings."""
|
||||
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
|
||||
self.scroll_helper.set_scroll_delay(self.config.scroll_delay)
|
||||
self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll)
|
||||
|
||||
# Config scroll_speed is always pixels per second, but ScrollHelper
|
||||
# interprets it differently based on frame_based_scrolling mode:
|
||||
@@ -141,23 +152,37 @@ class RenderPipeline:
|
||||
True if composition successful
|
||||
"""
|
||||
try:
|
||||
# Get all buffered content
|
||||
images = self.stream_manager.get_all_content_for_composition()
|
||||
# Content grouped by plugin, so a separator can be placed at the
|
||||
# plugin boundaries only.
|
||||
grouped = self.stream_manager.get_grouped_content_for_composition()
|
||||
|
||||
if not images:
|
||||
if not grouped:
|
||||
logger.warning("No content available for composition")
|
||||
return False
|
||||
|
||||
# Add separator gaps between images
|
||||
content_with_gaps = []
|
||||
for i, img in enumerate(images):
|
||||
content_with_gaps.append(img)
|
||||
# Collapse each plugin's rows into a single block, joined by
|
||||
# intra_plugin_gap. ScrollHelper applies one uniform gap between the
|
||||
# items it is given, so handing it one item per plugin is what makes
|
||||
# separator_width mean "between plugins" instead of "between every
|
||||
# row". Without this, a per-row ticker such as the F1 scoreboard got
|
||||
# the full separator between each of its ~116 rows.
|
||||
blocks = []
|
||||
total_rows = 0
|
||||
for plugin_id, images in grouped:
|
||||
total_rows += len(images)
|
||||
blocks.append(self._join_plugin_rows(images))
|
||||
|
||||
# Create scrolling image via ScrollHelper
|
||||
# Create scrolling image via ScrollHelper.
|
||||
#
|
||||
# lead_gap is explicit because ScrollHelper otherwise prepends a
|
||||
# full display width of black — appropriate for a standalone ticker
|
||||
# scrolling in from off-screen, but in Vegas mode it is charged
|
||||
# once per cycle and reads as the panel switching off.
|
||||
self.scroll_helper.create_scrolling_image(
|
||||
content_items=content_with_gaps,
|
||||
content_items=blocks,
|
||||
item_gap=self.config.separator_width,
|
||||
element_gap=0
|
||||
element_gap=0,
|
||||
lead_gap=self.config.lead_in_width
|
||||
)
|
||||
|
||||
# Verify scroll image was created successfully
|
||||
@@ -177,11 +202,16 @@ class RenderPipeline:
|
||||
self._cycle_complete = False
|
||||
|
||||
logger.info(
|
||||
"Composed scroll image: %dx%d, %d plugins, %d items",
|
||||
"Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
|
||||
"separator=%dpx between plugins, rows spaced to %dpx of ink "
|
||||
"(min added %dpx)",
|
||||
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
|
||||
self.display_height,
|
||||
len(self._segments_in_scroll),
|
||||
len(images)
|
||||
len(blocks),
|
||||
total_rows,
|
||||
self.config.separator_width,
|
||||
self.config.min_content_separation,
|
||||
self.config.intra_plugin_gap,
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -191,6 +221,264 @@ class RenderPipeline:
|
||||
logger.exception("Error composing scroll content")
|
||||
return False
|
||||
|
||||
def needs_extension(self) -> bool:
|
||||
"""
|
||||
Whether the strip should be extended with the next group of plugins.
|
||||
|
||||
Cheap enough to call every frame: it is arithmetic over cached state.
|
||||
"""
|
||||
if not self.config.continuous_scroll or not self.scroll_helper.cached_image:
|
||||
return False
|
||||
threshold = int(self.display_width * self.config.extend_threshold_screens)
|
||||
return self.scroll_helper.remaining_unscrolled() <= threshold
|
||||
|
||||
def start_prefetch(self) -> None:
|
||||
"""
|
||||
Begin preparing the next group in the background, if not already doing so.
|
||||
|
||||
This is what makes the join seamless rather than merely continuous:
|
||||
fetching a group costs 0.5-4.8s (rendering leaderboard and baseball cards
|
||||
dominates), and doing it on the render thread stalls the scroll for that
|
||||
long. Off the render thread there is a whole group's scroll time to work
|
||||
in, so by the time the strip needs extending the content is already sat
|
||||
waiting.
|
||||
|
||||
Only paths that avoid the shared display canvas run here; anything
|
||||
needing it is marked and picked up on the render thread, where it is
|
||||
safe. Those are the cheap ones — display capture measured 12-14ms
|
||||
against seconds for the native renders.
|
||||
"""
|
||||
if not self.config.continuous_scroll:
|
||||
return
|
||||
|
||||
with self._prefetch_lock:
|
||||
if self._prefetch_thread is not None and self._prefetch_thread.is_alive():
|
||||
return
|
||||
if self._prepared_group is not None:
|
||||
return # already have one waiting
|
||||
|
||||
def _work():
|
||||
# Deprioritise against the render loop. Linux applies nice
|
||||
# per-thread, and the heavy lifting here is PIL and numpy work
|
||||
# that releases the GIL, so the scheduler can actually act on
|
||||
# it — without this the prefetch competes for the same cores and
|
||||
# costs frames.
|
||||
try:
|
||||
os.nice(10)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
try:
|
||||
group = self.stream_manager.take_next_group(offscreen_only=True)
|
||||
except Exception:
|
||||
logger.exception("Background prefetch failed")
|
||||
group = []
|
||||
with self._prefetch_lock:
|
||||
self._prepared_group = group
|
||||
|
||||
self._prefetch_thread = threading.Thread(
|
||||
target=_work, daemon=True, name="vegas-strip-prefetch")
|
||||
self._prefetch_thread.start()
|
||||
|
||||
def drain_deferred(self) -> bool:
|
||||
"""
|
||||
Fetch one queued canvas-bound plugin and append it to the strip.
|
||||
|
||||
Called once per frame. These plugins cannot be prepared off the render
|
||||
thread — display capture and scroll-content generation both need the
|
||||
shared canvas — so each costs roughly 290ms here. Doing one at a time
|
||||
spreads that out instead of stalling for the whole group at once, and the
|
||||
strip's lookahead means nothing runs dry while they arrive.
|
||||
|
||||
The cost is that a deferred plugin appears slightly after the group it
|
||||
came with, which is a fair trade for a smooth scroll.
|
||||
|
||||
Returns:
|
||||
True if a plugin was appended
|
||||
"""
|
||||
if not self._deferred_queue:
|
||||
return False
|
||||
|
||||
# Space the drains out. Each costs 40-600ms, and taking them back to
|
||||
# back turns one long stall into a train of short ones — barely better.
|
||||
# With a healthy lookahead there is no hurry, so wait a beat between
|
||||
# them; when the strip is actually running short, fetch immediately.
|
||||
threshold = int(self.display_width * self.config.extend_threshold_screens)
|
||||
urgent = self.scroll_helper.remaining_unscrolled() <= threshold
|
||||
if not urgent:
|
||||
now = time.time()
|
||||
if now - self._last_drain_time < self.DEFERRED_DRAIN_INTERVAL:
|
||||
return False
|
||||
self._last_drain_time = now
|
||||
else:
|
||||
self._last_drain_time = time.time()
|
||||
|
||||
plugin_id = self._deferred_queue.pop(0)
|
||||
plugins = getattr(self.stream_manager.plugin_manager, 'plugins', {})
|
||||
plugin = plugins.get(plugin_id)
|
||||
if plugin is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
images = self.stream_manager.plugin_adapter.get_content(plugin, plugin_id)
|
||||
except Exception:
|
||||
logger.exception("[%s] Error fetching deferred content", plugin_id)
|
||||
return False
|
||||
|
||||
if not images:
|
||||
return False
|
||||
|
||||
appended = self.scroll_helper.append_content(
|
||||
content_items=[self._join_plugin_rows(images)],
|
||||
item_gap=self.config.separator_width,
|
||||
element_gap=0,
|
||||
)
|
||||
if appended:
|
||||
with self._buffer_lock:
|
||||
self._active_scroll_image = self.scroll_helper.cached_image
|
||||
logger.info(
|
||||
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
|
||||
plugin_id, self.scroll_helper.total_scroll_width,
|
||||
self.scroll_helper.remaining_unscrolled()
|
||||
)
|
||||
return appended
|
||||
|
||||
def has_deferred(self) -> bool:
|
||||
"""Whether any canvas-bound plugins are still queued."""
|
||||
return bool(self._deferred_queue)
|
||||
|
||||
def _claim_prepared_group(self):
|
||||
"""Take the prefetched group, if one is ready."""
|
||||
with self._prefetch_lock:
|
||||
group = self._prepared_group
|
||||
self._prepared_group = None
|
||||
return group
|
||||
|
||||
def extend_scroll_content(self) -> bool:
|
||||
"""
|
||||
Append the next group of plugins to the strip, without interrupting motion.
|
||||
|
||||
This is what replaces the swap. Scroll position is untouched, so the new
|
||||
content simply arrives from the right; there is no substitution to see
|
||||
and no restart with the viewport already full.
|
||||
|
||||
Consumed columns behind the viewport are then released, keeping the strip
|
||||
bounded however long Vegas runs.
|
||||
|
||||
Returns:
|
||||
True if the strip was extended
|
||||
"""
|
||||
try:
|
||||
grouped = self._claim_prepared_group()
|
||||
if grouped is None:
|
||||
# Nothing prepared (first extension, or prefetch still running).
|
||||
# Fetch inline; the scroll hitches, but content keeps flowing.
|
||||
logger.info("No prepared group ready; fetching inline")
|
||||
grouped = self.stream_manager.take_next_group()
|
||||
|
||||
if not grouped:
|
||||
logger.warning("No content available to extend the scroll strip")
|
||||
return False
|
||||
|
||||
# Plugins the background thread had to defer need the shared canvas,
|
||||
# so they can only be fetched here. Queue them rather than doing all
|
||||
# of them now: measured, six in one go held the render thread for
|
||||
# 1.75s. They are trickled in one per frame by drain_deferred(),
|
||||
# which the strip's lookahead comfortably absorbs.
|
||||
deferred = [pid for pid, images in grouped if images is None]
|
||||
if deferred:
|
||||
self._deferred_queue.extend(deferred)
|
||||
logger.info(
|
||||
"Queued %d plugin(s) needing the render thread: %s",
|
||||
len(deferred), ', '.join(deferred)
|
||||
)
|
||||
|
||||
grouped = [(pid, imgs) for pid, imgs in grouped if imgs]
|
||||
|
||||
if not grouped:
|
||||
# Everything in this group is queued; the queue will extend the
|
||||
# strip as it drains, so this is not a failure.
|
||||
logger.info("Whole group deferred; strip will extend as it drains")
|
||||
self.start_prefetch()
|
||||
return bool(deferred)
|
||||
|
||||
blocks = []
|
||||
total_rows = 0
|
||||
for _plugin_id, images in grouped:
|
||||
total_rows += len(images)
|
||||
blocks.append(self._join_plugin_rows(images))
|
||||
|
||||
appended = self.scroll_helper.append_content(
|
||||
content_items=blocks,
|
||||
item_gap=self.config.separator_width,
|
||||
element_gap=0,
|
||||
)
|
||||
if not appended:
|
||||
return False
|
||||
|
||||
# Keep a screen's worth behind the viewport as a safety margin.
|
||||
self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
|
||||
|
||||
with self._buffer_lock:
|
||||
self._active_scroll_image = self.scroll_helper.cached_image
|
||||
|
||||
self._segments_in_scroll = [pid for pid, _ in grouped]
|
||||
self.stats['composition_count'] += 1
|
||||
self.stats['extensions'] = self.stats.get('extensions', 0) + 1
|
||||
|
||||
logger.info(
|
||||
"Extended scroll strip with %d plugin block(s), %d rows: "
|
||||
"strip now %dpx, %dpx still ahead of the viewport",
|
||||
len(blocks), total_rows, self.scroll_helper.total_scroll_width,
|
||||
self.scroll_helper.remaining_unscrolled()
|
||||
)
|
||||
|
||||
# Line up the group after this one straight away, so it is ready
|
||||
# well before the strip runs short again.
|
||||
self.start_prefetch()
|
||||
return True
|
||||
|
||||
except (ValueError, TypeError, OSError, RuntimeError):
|
||||
logger.exception("Error extending scroll content")
|
||||
return False
|
||||
|
||||
def _join_plugin_rows(self, images: List[Image.Image]) -> Image.Image:
|
||||
"""
|
||||
Concatenate one plugin's images into a single block.
|
||||
|
||||
Args:
|
||||
images: That plugin's content, in order
|
||||
|
||||
Returns:
|
||||
A single image with the rows laid out left to right, separated by
|
||||
``intra_plugin_gap``. Returned unchanged when there is only one row,
|
||||
which is the common case and avoids a pointless copy.
|
||||
"""
|
||||
if len(images) == 1:
|
||||
return images[0]
|
||||
|
||||
floor = max(0, self.config.intra_plugin_gap)
|
||||
target = max(0, self.config.min_content_separation)
|
||||
threshold = self.config.trim_threshold
|
||||
|
||||
# Space by measured separation, not a flat gap. Rows drawn flush to
|
||||
# their own edges (sports score cards) would otherwise end up nearly
|
||||
# touching, while rows that already carry wide margins would be pushed
|
||||
# needlessly further apart.
|
||||
gaps = [
|
||||
separation_gap(images[i], images[i + 1], target, floor, threshold)
|
||||
for i in range(len(images) - 1)
|
||||
]
|
||||
|
||||
width = sum(img.width for img in images) + sum(gaps)
|
||||
height = max(img.height for img in images)
|
||||
|
||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
x = 0
|
||||
for i, img in enumerate(images):
|
||||
block.paste(img, (x, 0))
|
||||
x += img.width + (gaps[i] if i < len(gaps) else 0)
|
||||
return block
|
||||
|
||||
def render_frame(self) -> bool:
|
||||
"""
|
||||
Render a single frame to the display.
|
||||
@@ -211,21 +499,33 @@ class RenderPipeline:
|
||||
|
||||
# Determine if the cycle is done.
|
||||
#
|
||||
# scroll_helper considers a cycle complete only after
|
||||
# total_distance_scrolled >= total_scroll_width + display_width.
|
||||
# That extra display_width of travel causes a "wrap-around" phase
|
||||
# where scroll_position resets to ~0 and the first plugin's content
|
||||
# re-enters from the right — the user sees this 2-3 s of re-entry
|
||||
# as "a plugin partially displaying before the next one starts."
|
||||
# get_visible_portion wraps: once scroll_position + display_width
|
||||
# passes the end of the strip it fills the right-hand side of the
|
||||
# frame from the *head* of the same strip. So the last
|
||||
# display_width of travel shows the cycle's first plugin re-entering
|
||||
# on the right while its last plugin exits on the left, and the
|
||||
# recompose that follows then replaces both at once. That reads as
|
||||
# the ticker "switching mid-scroll".
|
||||
#
|
||||
# We end the cycle as soon as total_distance_scrolled reaches
|
||||
# total_scroll_width (the wrap-around point), before any second-pass
|
||||
# content becomes visible. The scroll_helper's own is_scroll_complete()
|
||||
# check is kept as a fallback for any edge-cases where that threshold
|
||||
# is never hit.
|
||||
# This used to be hidden because the strip began with a full
|
||||
# display_width of blank, so the wrapped-in region was black.
|
||||
# lead_in_width now defaults to 0 (that blank was 10s of dead panel
|
||||
# at 50px/s), which exposed the wrap — so the cycle has to end
|
||||
# before it, one display width earlier.
|
||||
#
|
||||
# A strip no wider than the display never wraps, and subtracting
|
||||
# would make the cycle complete instantly, so clamp in that case.
|
||||
# In continuous mode there is no cycle to complete: the strip is
|
||||
# extended before the scroll can reach its end, so the wrap is never
|
||||
# entered and motion never stops. The completion path below stays for
|
||||
# the swap behaviour and as a backstop if an extension fails.
|
||||
wrap_point = self.scroll_helper.total_scroll_width
|
||||
if wrap_point > self.display_width:
|
||||
wrap_point -= self.display_width
|
||||
|
||||
at_wrap_point = (
|
||||
not self._cycle_complete and
|
||||
self.scroll_helper.total_distance_scrolled >= self.scroll_helper.total_scroll_width
|
||||
self.scroll_helper.total_distance_scrolled >= wrap_point
|
||||
)
|
||||
|
||||
if at_wrap_point or self.scroll_helper.is_scroll_complete():
|
||||
@@ -236,24 +536,17 @@ class RenderPipeline:
|
||||
"Scroll cycle complete after %.1fs",
|
||||
time.time() - self._cycle_start_time
|
||||
)
|
||||
# Push blank immediately so the hardware never shows any
|
||||
# post-wrap content while the coordinator recomposes the
|
||||
# next cycle (~100 ms). The blank is allocated once and
|
||||
# reused across cycle wraps (fresh paste each time in case
|
||||
# a consumer drew on the previous one).
|
||||
try:
|
||||
if self._blank_frame is None or self._blank_frame.size != (
|
||||
self.display_width, self.display_height):
|
||||
self._blank_frame = Image.new(
|
||||
'RGB', (self.display_width, self.display_height))
|
||||
else:
|
||||
self._blank_frame.paste(
|
||||
(0, 0, 0),
|
||||
(0, 0, self.display_width, self.display_height))
|
||||
self.display_manager.image = self._blank_frame
|
||||
self.display_manager.update_display()
|
||||
except Exception:
|
||||
logger.exception("Failed to write blank frame to display at cycle end")
|
||||
# Deliberately leave the last rendered frame on the panel.
|
||||
#
|
||||
# This used to push a blank frame so no post-wrap content
|
||||
# could be seen while the next cycle was composed. But
|
||||
# recomposing is synchronous and fetches plugin content:
|
||||
# measured 84ms at best and 4.8s at worst on a 512px panel,
|
||||
# and every millisecond of it was black. Holding the last
|
||||
# frame instead turns that into a brief freeze, which reads
|
||||
# as far less broken than the display switching off. The
|
||||
# frame is already past the end of the content, so there is
|
||||
# no second-pass content to leak.
|
||||
return True # Cycle done; coordinator starts new cycle next frame
|
||||
|
||||
# Get visible portion
|
||||
@@ -336,6 +629,25 @@ class RenderPipeline:
|
||||
|
||||
return False
|
||||
|
||||
def refresh_updated_plugins(self) -> bool:
|
||||
"""
|
||||
Let changed plugin data reach the strip without interrupting motion.
|
||||
|
||||
Used instead of :meth:`hot_swap_content` when scrolling continuously.
|
||||
The swap rebuilds the whole image and repositions the scroll, which is
|
||||
visible as a freeze and a jump; the strip is extended here rather than
|
||||
replaced, so it is enough to drop the stale caches and let the plugin
|
||||
recompose when it next comes round.
|
||||
|
||||
Returns:
|
||||
True if any plugin's cached content was dropped.
|
||||
"""
|
||||
try:
|
||||
return bool(self.stream_manager.invalidate_pending_updates())
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Failed to refresh updated plugins")
|
||||
return False
|
||||
|
||||
def hot_swap_content(self) -> bool:
|
||||
"""
|
||||
Hot-swap to new composed content.
|
||||
@@ -415,11 +727,12 @@ class RenderPipeline:
|
||||
result = self.compose_scroll_content()
|
||||
|
||||
if result and self.sync_manager:
|
||||
# When sync is active, start the leader at display_width instead of 0.
|
||||
# This skips the initial black gap so the leader immediately shows content.
|
||||
# The follower starts at position 0 (the gap) which looks like a clean
|
||||
# blank transition rather than near-end content wrapping around.
|
||||
self.scroll_helper.scroll_position = float(self.display_width)
|
||||
# When sync is active, start the leader past the lead-in gap so it
|
||||
# immediately shows content, leaving the follower on the blank gap
|
||||
# for a clean transition rather than near-end content wrapping
|
||||
# around. This tracks lead_in_width rather than assuming a full
|
||||
# display width of gap, which is no longer the default.
|
||||
self.scroll_helper.scroll_position = float(self.config.lead_in_width)
|
||||
|
||||
if result and self.sync_manager:
|
||||
# Signal follower that a new cycle started (triggers its own rebuild)
|
||||
|
||||
@@ -14,7 +14,7 @@ Supports three display modes:
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any, Deque, TYPE_CHECKING
|
||||
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from PIL import Image
|
||||
@@ -116,8 +116,11 @@ class StreamManager:
|
||||
logger.warning("No plugins available for Vegas scroll")
|
||||
return False
|
||||
|
||||
# Prefetch initial content
|
||||
self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins)))
|
||||
# Fill the buffer to a whole cycle's worth of plugins. This used to be
|
||||
# buffer_ahead + 1, which conflated prefetch depth with cycle size and
|
||||
# meant a 20-plugin install only showed 3 plugins before recomposing.
|
||||
self._prefetch_content(
|
||||
count=min(self.config.plugins_per_cycle, len(self._ordered_plugins)))
|
||||
|
||||
logger.info(
|
||||
"StreamManager initialized with %d plugins, %d segments buffered",
|
||||
@@ -198,6 +201,47 @@ class StreamManager:
|
||||
|
||||
logger.debug("Plugin %s marked for update", plugin_id)
|
||||
|
||||
def invalidate_pending_updates(self) -> List[str]:
|
||||
"""
|
||||
Drop cached content for plugins whose data changed, without refetching.
|
||||
|
||||
The continuous-scroll counterpart to :meth:`process_updates`. That method
|
||||
belongs to the swap path: it refetches immediately and merges into the
|
||||
active buffer, which continuous mode bypasses entirely, and doing that
|
||||
work on the render thread would hitch the scroll.
|
||||
|
||||
Here it is enough to clear the caches and let the plugin come round in
|
||||
the rotation, which recomposes it from current data a moment later. Left
|
||||
uncalled, ``_pending_updates`` simply accumulates and no visual ever
|
||||
refreshes — a game that was live last night keeps being drawn as live.
|
||||
|
||||
Returns:
|
||||
The plugin ids whose caches were dropped.
|
||||
"""
|
||||
with self._buffer_lock:
|
||||
if not self._pending_updates:
|
||||
return []
|
||||
updated = list(self._pending_updates.keys())
|
||||
self._pending_updates.clear()
|
||||
|
||||
plugins = getattr(self.plugin_manager, 'plugins', {})
|
||||
for plugin_id in updated:
|
||||
try:
|
||||
self.plugin_adapter.invalidate_cache(plugin_id)
|
||||
plugin = plugins.get(plugin_id)
|
||||
if plugin is not None:
|
||||
self.plugin_adapter.invalidate_plugin_scroll_cache(
|
||||
plugin, plugin_id)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"[%s] Could not invalidate cached content", plugin_id)
|
||||
|
||||
logger.info(
|
||||
"Vegas: dropped cached content for %d updated plugin(s): %s",
|
||||
len(updated), ', '.join(updated)
|
||||
)
|
||||
return updated
|
||||
|
||||
def has_pending_updates(self) -> bool:
|
||||
"""Check if any plugins have pending updates awaiting processing."""
|
||||
with self._buffer_lock:
|
||||
@@ -385,7 +429,7 @@ class StreamManager:
|
||||
return
|
||||
|
||||
for _ in range(count):
|
||||
if len(self._active_buffer) >= self.config.buffer_ahead + 1:
|
||||
if len(self._active_buffer) >= self.config.plugins_per_cycle:
|
||||
break
|
||||
|
||||
# Ensure index is valid (guard against empty list)
|
||||
@@ -521,28 +565,117 @@ class StreamManager:
|
||||
logger.debug("Refreshed content for %s in staging buffer", plugin_id)
|
||||
|
||||
def _ensure_buffer_filled(self) -> None:
|
||||
"""Ensure buffer has enough content prefetched."""
|
||||
if len(self._active_buffer) < self.config.buffer_ahead:
|
||||
needed = self.config.buffer_ahead - len(self._active_buffer)
|
||||
self._prefetch_content(count=needed)
|
||||
"""
|
||||
Top the buffer back up after segments have been served.
|
||||
|
||||
buffer_ahead is the low-water mark only; plugins_per_cycle is the
|
||||
ceiling and is enforced inside _prefetch_content.
|
||||
"""
|
||||
low_water = min(self.config.buffer_ahead, self.config.plugins_per_cycle)
|
||||
if len(self._active_buffer) < low_water:
|
||||
self._prefetch_content(count=low_water - len(self._active_buffer))
|
||||
|
||||
def get_all_content_for_composition(self) -> List[Image.Image]:
|
||||
"""
|
||||
Get all buffered content as a flat list of images.
|
||||
|
||||
Used when composing the full scroll image.
|
||||
Skips STATIC segments as they don't have images to compose.
|
||||
|
||||
Prefer get_grouped_content_for_composition(): flattening loses the
|
||||
plugin boundaries, which is what tells the compositor where a
|
||||
separator belongs and where it does not.
|
||||
|
||||
Returns:
|
||||
List of all images in buffer order
|
||||
"""
|
||||
all_images = []
|
||||
for _plugin_id, images in self.get_grouped_content_for_composition():
|
||||
all_images.extend(images)
|
||||
return all_images
|
||||
|
||||
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
|
||||
"""
|
||||
Get buffered content grouped by the plugin that produced it.
|
||||
|
||||
The grouping matters: separator_width is meant to mark the handoff from
|
||||
one plugin to the next, not to sit between every row a single plugin
|
||||
contributes. A per-row ticker like the F1 scoreboard returns over a
|
||||
hundred images that it renders 4px apart internally, so flattening them
|
||||
into one list and applying a uniform gap forced 32px between each of
|
||||
its rows — both inconsistent with how the plugin looks standalone, and
|
||||
a large hidden addition to the width it occupies.
|
||||
|
||||
Skips STATIC segments, which trigger a pause rather than contributing
|
||||
scroll content, and segments left with no images.
|
||||
|
||||
Returns:
|
||||
List of (plugin_id, images) in buffer order
|
||||
"""
|
||||
grouped: List[Tuple[str, List[Image.Image]]] = []
|
||||
with self._buffer_lock:
|
||||
for segment in self._active_buffer:
|
||||
# Skip STATIC segments - they trigger pauses, not scroll content
|
||||
if segment.display_mode != VegasDisplayMode.STATIC:
|
||||
all_images.extend(segment.images)
|
||||
return all_images
|
||||
if segment.display_mode == VegasDisplayMode.STATIC:
|
||||
continue
|
||||
if not segment.images:
|
||||
continue
|
||||
grouped.append((segment.plugin_id, list(segment.images)))
|
||||
return grouped
|
||||
|
||||
def take_next_group(
|
||||
self, count: Optional[int] = None, offscreen_only: bool = False
|
||||
) -> List[Tuple[str, Optional[List[Image.Image]]]]:
|
||||
"""
|
||||
Fetch and hand over the next slice of the rotation.
|
||||
|
||||
For continuous scrolling, where the strip is extended rather than
|
||||
replaced. Advances the rotation index so plugins come round in order
|
||||
across an unbroken strip, and bypasses the active buffer entirely — that
|
||||
buffer exists to stage a *replacement* cycle, which continuous mode has
|
||||
no use for.
|
||||
|
||||
Args:
|
||||
count: Number of plugins to gather, defaulting to plugins_per_cycle
|
||||
offscreen_only: Only use content paths that avoid the shared display
|
||||
canvas, for use off the render thread
|
||||
|
||||
Returns:
|
||||
Ordered list of (plugin_id, images). ``images`` is None when the
|
||||
plugin could not be served under ``offscreen_only``, so the caller
|
||||
can fetch just those on the render thread while keeping the order.
|
||||
"""
|
||||
if count is None:
|
||||
count = self.config.plugins_per_cycle
|
||||
|
||||
self.refresh()
|
||||
|
||||
with self._buffer_lock:
|
||||
if not self._ordered_plugins:
|
||||
return []
|
||||
total = len(self._ordered_plugins)
|
||||
ids = []
|
||||
for _ in range(min(max(1, count), total)):
|
||||
ids.append(self._ordered_plugins[self._prefetch_index])
|
||||
self._prefetch_index = (self._prefetch_index + 1) % total
|
||||
|
||||
plugins = getattr(self.plugin_manager, 'plugins', {})
|
||||
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
|
||||
|
||||
for plugin_id in ids:
|
||||
plugin = plugins.get(plugin_id)
|
||||
if not plugin:
|
||||
continue
|
||||
try:
|
||||
images = self.plugin_adapter.get_content(
|
||||
plugin, plugin_id, offscreen_only=offscreen_only)
|
||||
except Exception:
|
||||
logger.exception("[%s] ERROR fetching content", plugin_id)
|
||||
self.stats['fetch_errors'] += 1
|
||||
continue
|
||||
if images:
|
||||
self.stats['segments_fetched'] += 1
|
||||
group.append((plugin_id, images if images else None))
|
||||
|
||||
return group
|
||||
|
||||
def advance_cycle(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
Tests for ScrollHelper's continuous-strip primitives.
|
||||
|
||||
append_content extends the strip to the right without disturbing motion, and
|
||||
drop_scrolled_prefix reclaims what has already gone past. Together they let a
|
||||
caller keep one endless strip instead of swapping a new one in, which is what
|
||||
shows as a flash and a hard cut to already-full-screen content.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.vegas_mode.geometry import column_has_ink
|
||||
|
||||
W, H = 128, 32
|
||||
|
||||
|
||||
def helper():
|
||||
return ScrollHelper(W, H)
|
||||
|
||||
|
||||
def block(width, colour=(255, 255, 255), height=H):
|
||||
return Image.new('RGB', (width, height), colour)
|
||||
|
||||
|
||||
class TestAppendContent:
|
||||
def test_first_append_builds_the_strip(self):
|
||||
sh = helper()
|
||||
assert sh.append_content([block(100)], item_gap=0)
|
||||
assert sh.cached_image is not None
|
||||
assert sh.total_scroll_width == sh.cached_image.width
|
||||
|
||||
def test_strip_grows_by_content_plus_gaps(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
assert sh.cached_image.width == 100
|
||||
|
||||
sh.append_content([block(50)], item_gap=10, element_gap=0)
|
||||
# one leading gap of 10 then the 50px block
|
||||
assert sh.cached_image.width == 160
|
||||
assert sh.total_scroll_width == 160
|
||||
|
||||
def test_scroll_position_is_preserved(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(400)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 137.0
|
||||
sh.total_distance_scrolled = 137.0
|
||||
|
||||
sh.append_content([block(200)], item_gap=16)
|
||||
assert sh.scroll_position == 137.0
|
||||
assert sh.total_distance_scrolled == 137.0
|
||||
|
||||
def test_appending_defers_completion(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_complete = True
|
||||
|
||||
sh.append_content([block(200)], item_gap=0)
|
||||
assert not sh.scroll_complete
|
||||
assert sh.total_distance_scrolled < sh.total_scroll_width
|
||||
|
||||
def test_existing_pixels_are_untouched(self):
|
||||
sh = helper()
|
||||
original = block(80, (10, 200, 10))
|
||||
sh.create_scrolling_image([original], item_gap=0, element_gap=0, lead_gap=0)
|
||||
before = sh.cached_image.crop((0, 0, 80, H)).tobytes()
|
||||
|
||||
sh.append_content([block(40, (200, 10, 10))], item_gap=8)
|
||||
assert sh.cached_image.crop((0, 0, 80, H)).tobytes() == before
|
||||
|
||||
def test_appended_content_sits_after_the_gap(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(50)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.append_content([block(30)], item_gap=12)
|
||||
|
||||
ink = column_has_ink(sh.cached_image)
|
||||
assert ink[:50].all()
|
||||
assert not ink[50:62].any() # the 12px gap
|
||||
assert ink[62:92].all()
|
||||
|
||||
def test_array_and_image_stay_consistent(self):
|
||||
# get_visible_portion slices cached_array but bounds-checks against
|
||||
# cached_image.width, so a mismatch corrupts frames.
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.append_content([block(100)], item_gap=8)
|
||||
assert sh.cached_array.shape[1] == sh.cached_image.width
|
||||
assert sh.cached_array.shape[0] == sh.cached_image.height
|
||||
|
||||
def test_visible_portion_still_renders_after_append(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(300)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.append_content([block(300)], item_gap=8)
|
||||
sh.scroll_position = 250.0
|
||||
frame = sh.get_visible_portion()
|
||||
assert frame is not None and frame.size == (W, H)
|
||||
|
||||
def test_empty_append_is_a_no_op(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
assert sh.append_content([]) is False
|
||||
assert sh.cached_image.width == 100
|
||||
|
||||
def test_repeated_appends_accumulate(self):
|
||||
sh = helper()
|
||||
sh.append_content([block(100)], item_gap=0)
|
||||
for _ in range(5):
|
||||
sh.append_content([block(100)], item_gap=0)
|
||||
assert sh.cached_image.width == 600
|
||||
|
||||
|
||||
class TestDropScrolledPrefix:
|
||||
def test_removes_consumed_columns(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 500.0
|
||||
sh.total_distance_scrolled = 500.0
|
||||
|
||||
removed = sh.drop_scrolled_prefix(keep_before=0)
|
||||
assert removed == 500
|
||||
assert sh.cached_image.width == 500
|
||||
assert sh.scroll_position == 0.0
|
||||
|
||||
def test_keeps_the_requested_margin(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 500.0
|
||||
sh.drop_scrolled_prefix(keep_before=100)
|
||||
assert sh.scroll_position == 100.0
|
||||
assert sh.cached_image.width == 600
|
||||
|
||||
def test_completion_difference_is_preserved(self):
|
||||
# total_distance_scrolled and total_scroll_width must shift together, or
|
||||
# trimming would spuriously complete or un-complete the cycle.
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 600.0
|
||||
sh.total_distance_scrolled = 600.0
|
||||
before = sh.total_scroll_width - sh.total_distance_scrolled
|
||||
|
||||
sh.drop_scrolled_prefix(keep_before=0)
|
||||
assert sh.total_scroll_width - sh.total_distance_scrolled == before
|
||||
|
||||
def test_never_trims_below_the_viewport(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 190.0
|
||||
sh.drop_scrolled_prefix(keep_before=0)
|
||||
assert sh.cached_image.width >= W
|
||||
|
||||
def test_no_op_before_anything_has_scrolled(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
assert sh.drop_scrolled_prefix(keep_before=0) == 0
|
||||
assert sh.cached_image.width == 500
|
||||
|
||||
def test_no_op_with_no_strip(self):
|
||||
assert helper().drop_scrolled_prefix() == 0
|
||||
|
||||
def test_visible_frame_is_unchanged_by_trimming(self):
|
||||
# The whole point: trimming is invisible. Same pixels on screen before
|
||||
# and after. Position chosen so the viewport is well clear of the end,
|
||||
# i.e. not wrapping.
|
||||
sh = helper()
|
||||
items = [block(200, (255, 0, 0)), block(200, (0, 255, 0)),
|
||||
block(200, (0, 0, 255))]
|
||||
sh.create_scrolling_image(items, item_gap=20, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 300.0
|
||||
before = sh.get_visible_portion().tobytes()
|
||||
|
||||
assert sh.drop_scrolled_prefix(keep_before=0) > 0, "trim should have run"
|
||||
after = sh.get_visible_portion().tobytes()
|
||||
assert after == before
|
||||
|
||||
def test_refuses_to_trim_while_the_viewport_wraps(self):
|
||||
# Wrapping reads the head of the strip into the right of the frame, so
|
||||
# trimming the head there would visibly change the picture.
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 150.0 # 150 + 128 > 200, so wrapping
|
||||
before = sh.get_visible_portion().tobytes()
|
||||
assert sh.drop_scrolled_prefix(keep_before=0) == 0
|
||||
assert sh.get_visible_portion().tobytes() == before
|
||||
|
||||
def test_array_and_image_stay_consistent_after_trim(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(900)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 400.0
|
||||
sh.drop_scrolled_prefix(keep_before=0)
|
||||
assert sh.cached_array.shape[1] == sh.cached_image.width
|
||||
|
||||
|
||||
class TestRemainingUnscrolled:
|
||||
def test_counts_content_right_of_the_viewport(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
assert sh.remaining_unscrolled() == 500 - W
|
||||
|
||||
def test_shrinks_as_the_strip_scrolls(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 200.0
|
||||
assert sh.remaining_unscrolled() == 500 - 200 - W
|
||||
|
||||
def test_never_negative(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 500.0
|
||||
assert sh.remaining_unscrolled() == 0
|
||||
|
||||
def test_zero_with_no_strip(self):
|
||||
assert helper().remaining_unscrolled() == 0
|
||||
|
||||
def test_grows_when_content_is_appended(self):
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
sh.scroll_position = 100.0
|
||||
before = sh.remaining_unscrolled()
|
||||
assert before > 0, "fixture should leave content ahead of the viewport"
|
||||
sh.append_content([block(400)], item_gap=0)
|
||||
assert sh.remaining_unscrolled() == before + 400
|
||||
|
||||
|
||||
class TestContinuousScrollingEndToEnd:
|
||||
def test_strip_can_be_extended_indefinitely_at_bounded_size(self):
|
||||
"""The invariant that makes this viable: extend + trim keeps the strip
|
||||
bounded while motion never stops."""
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0)
|
||||
|
||||
widths = []
|
||||
for _ in range(20):
|
||||
sh.scroll_position += 200
|
||||
sh.total_distance_scrolled += 200
|
||||
if sh.remaining_unscrolled() < 2 * W:
|
||||
sh.append_content([block(600)], item_gap=16)
|
||||
sh.drop_scrolled_prefix(keep_before=W)
|
||||
widths.append(sh.cached_image.width)
|
||||
# A frame must always be renderable.
|
||||
assert sh.get_visible_portion() is not None
|
||||
|
||||
assert max(widths) < 3000, f"strip grew unbounded: max {max(widths)}"
|
||||
assert not sh.scroll_complete, "continuous strip should never complete"
|
||||
|
||||
|
||||
class TestSubPixelBlending:
|
||||
"""
|
||||
Integer positioning quantises motion to whole pixels, so distinct frames per
|
||||
second equals scroll speed regardless of frame rate — at 50px/s and 78fps,
|
||||
36% of frames were identical. Blending between neighbouring positions gives
|
||||
motion at the frame rate instead.
|
||||
"""
|
||||
|
||||
def _strip(self, width=2000):
|
||||
rng = np.random.default_rng(0)
|
||||
arr = (rng.random((H, width, 3)) * 255).astype(np.uint8)
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([Image.fromarray(arr)],
|
||||
item_gap=0, element_gap=0, lead_gap=0)
|
||||
return sh
|
||||
|
||||
def _frame(self, sh, pos, subpixel):
|
||||
sh.sub_pixel_scrolling = subpixel
|
||||
sh.scroll_position = pos
|
||||
return np.asarray(sh.get_visible_portion()).astype(int)
|
||||
|
||||
def test_integer_mode_ignores_the_fraction(self):
|
||||
sh = self._strip()
|
||||
a = self._frame(sh, 500.0, False)
|
||||
b = self._frame(sh, 500.9, False)
|
||||
assert np.array_equal(a, b), "integer positioning should not move sub-pixel"
|
||||
|
||||
def test_blending_moves_within_a_pixel(self):
|
||||
sh = self._strip()
|
||||
a = self._frame(sh, 500.0, True)
|
||||
b = self._frame(sh, 500.5, True)
|
||||
assert not np.array_equal(a, b)
|
||||
|
||||
def test_zero_fraction_matches_the_integer_frame(self):
|
||||
# No interpolation to do, so it must be pixel-identical and take the
|
||||
# cheap path.
|
||||
sh = self._strip()
|
||||
assert np.array_equal(self._frame(sh, 700.0, True),
|
||||
self._frame(sh, 700.0, False))
|
||||
|
||||
def test_blend_is_monotonic_between_neighbours(self):
|
||||
# Marching the fraction from 0 to 1 should approach the next integer
|
||||
# frame, not wander.
|
||||
sh = self._strip()
|
||||
target = self._frame(sh, 501.0, False)
|
||||
dists = []
|
||||
for frac in (0.0, 0.25, 0.5, 0.75):
|
||||
f = self._frame(sh, 500.0 + frac, True)
|
||||
dists.append(np.abs(f - target).mean())
|
||||
assert dists == sorted(dists, reverse=True), f"not converging: {dists}"
|
||||
|
||||
def test_blend_endpoints_bracket_the_two_frames(self):
|
||||
sh = self._strip()
|
||||
near = self._frame(sh, 500.0, False)
|
||||
far = self._frame(sh, 501.0, False)
|
||||
mid = self._frame(sh, 500.5, True)
|
||||
# Every blended pixel must lie between its two sources.
|
||||
lo = np.minimum(near, far)
|
||||
hi = np.maximum(near, far)
|
||||
assert (mid >= lo - 1).all() and (mid <= hi + 1).all()
|
||||
|
||||
def test_output_size_and_mode_are_unchanged(self):
|
||||
sh = self._strip()
|
||||
sh.sub_pixel_scrolling = True
|
||||
sh.scroll_position = 300.4
|
||||
frame = sh.get_visible_portion()
|
||||
assert frame.size == (W, H)
|
||||
assert frame.mode == 'RGB'
|
||||
|
||||
def test_works_near_the_end_of_the_strip(self):
|
||||
# One of the two slices wraps here; must not raise or missize.
|
||||
sh = self._strip(width=600)
|
||||
sh.sub_pixel_scrolling = True
|
||||
sh.scroll_position = float(600 - W // 2) + 0.5
|
||||
frame = sh.get_visible_portion()
|
||||
assert frame is not None and frame.size == (W, H)
|
||||
|
||||
def test_works_at_the_very_last_column(self):
|
||||
sh = self._strip(width=600)
|
||||
sh.sub_pixel_scrolling = True
|
||||
sh.scroll_position = 599.5
|
||||
assert sh.get_visible_portion().size == (W, H)
|
||||
|
||||
@pytest.mark.parametrize("frac", [0.01, 0.1, 0.33, 0.5, 0.67, 0.9, 0.99])
|
||||
def test_never_raises_across_the_fraction_range(self, frac):
|
||||
sh = self._strip()
|
||||
sh.sub_pixel_scrolling = True
|
||||
sh.scroll_position = 400.0 + frac
|
||||
assert sh.get_visible_portion().size == (W, H)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Regression tests: changed plugin data must reach the strip in continuous mode.
|
||||
|
||||
Two faults combined to freeze Vegas content indefinitely.
|
||||
|
||||
PR #291 added a call to ``plugin_adapter.invalidate_plugin_scroll_cache()`` so a
|
||||
plugin's *own* cached scroll image would be rebuilt from fresh data. The method
|
||||
was never implemented, and ``hot_swap_content()`` wraps the call in a broad
|
||||
except, so every hot swap raised AttributeError and was silently swallowed.
|
||||
|
||||
Continuous scrolling then removed the only path that reached it at all:
|
||||
``should_recompose()``/``hot_swap_content()`` are called from the non-continuous
|
||||
branch, while ``continuous_scroll`` defaults to True.
|
||||
|
||||
Together, a plugin composed its scroll image once and handed back the same
|
||||
picture forever, because the sports plugins' ``get_vegas_content()`` regenerates
|
||||
only when its cache is empty. Symptom: a game that was live last night is still
|
||||
drawn as live the following morning.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
|
||||
|
||||
class FakeDisplayManager:
|
||||
width = 64
|
||||
height = 32
|
||||
|
||||
|
||||
def _helper():
|
||||
"""A stand-in ScrollHelper holding both halves of its cache."""
|
||||
image = Image.new('RGB', (128, 32), (10, 20, 30))
|
||||
return SimpleNamespace(cached_image=image, cached_array=np.array(image))
|
||||
|
||||
|
||||
class TestInvalidatePluginScrollCache:
|
||||
"""The method PR #291 called but never defined."""
|
||||
|
||||
def test_method_exists(self):
|
||||
# It was called for months without existing; the broad except in
|
||||
# hot_swap_content() meant nothing ever surfaced.
|
||||
assert hasattr(PluginAdapter, 'invalidate_plugin_scroll_cache')
|
||||
|
||||
def test_clears_helper_attached_to_the_plugin(self):
|
||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
||||
helper = _helper()
|
||||
plugin = SimpleNamespace(scroll_helper=helper)
|
||||
|
||||
assert adapter.invalidate_plugin_scroll_cache(plugin, 'stocks') is True
|
||||
assert helper.cached_image is None
|
||||
assert helper.cached_array is None
|
||||
|
||||
def test_clears_helper_owned_by_a_scroll_manager(self):
|
||||
# The sports scoreboards keep theirs on _scroll_manager, which is the
|
||||
# layout that produced the reported stale-scores bug.
|
||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
||||
helper = _helper()
|
||||
plugin = SimpleNamespace(_scroll_manager=SimpleNamespace(scroll_helper=helper))
|
||||
|
||||
assert adapter.invalidate_plugin_scroll_cache(plugin, 'baseball') is True
|
||||
assert helper.cached_image is None
|
||||
assert helper.cached_array is None
|
||||
|
||||
def test_clears_both_halves_together(self):
|
||||
# cached_array is the image's numpy mirror; leaving one behind lets a
|
||||
# reader pick up content the other no longer has.
|
||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
||||
helper = _helper()
|
||||
adapter.invalidate_plugin_scroll_cache(
|
||||
SimpleNamespace(scroll_helper=helper), 'news')
|
||||
assert (helper.cached_image, helper.cached_array) == (None, None)
|
||||
|
||||
def test_plugin_without_a_helper_is_not_an_error(self):
|
||||
adapter = PluginAdapter(FakeDisplayManager(), VegasModeConfig())
|
||||
assert adapter.invalidate_plugin_scroll_cache(SimpleNamespace(), 'clock') is False
|
||||
|
||||
|
||||
class TestInvalidatePendingUpdates:
|
||||
def _manager(self, plugins):
|
||||
stream = StreamManager(
|
||||
VegasModeConfig(),
|
||||
SimpleNamespace(plugins=plugins),
|
||||
MagicMock(),
|
||||
)
|
||||
stream.plugin_adapter = MagicMock()
|
||||
return stream
|
||||
|
||||
def test_drops_caches_for_updated_plugins(self):
|
||||
helper = _helper()
|
||||
plugin = SimpleNamespace(scroll_helper=helper)
|
||||
stream = self._manager({'baseball': plugin})
|
||||
stream.mark_plugin_updated('baseball')
|
||||
|
||||
assert stream.invalidate_pending_updates() == ['baseball']
|
||||
stream.plugin_adapter.invalidate_cache.assert_called_once_with('baseball')
|
||||
stream.plugin_adapter.invalidate_plugin_scroll_cache.assert_called_once_with(
|
||||
plugin, 'baseball')
|
||||
|
||||
def test_pending_flags_are_consumed(self):
|
||||
# Left unconsumed they accumulate forever and nothing ever refreshes.
|
||||
stream = self._manager({'baseball': SimpleNamespace()})
|
||||
stream.mark_plugin_updated('baseball')
|
||||
assert stream.has_pending_updates() is True
|
||||
|
||||
stream.invalidate_pending_updates()
|
||||
assert stream.has_pending_updates() is False
|
||||
assert stream.invalidate_pending_updates() == []
|
||||
|
||||
def test_no_pending_updates_does_no_work(self):
|
||||
stream = self._manager({})
|
||||
assert stream.invalidate_pending_updates() == []
|
||||
stream.plugin_adapter.invalidate_cache.assert_not_called()
|
||||
|
||||
def test_a_failing_plugin_does_not_stop_the_others(self):
|
||||
stream = self._manager({'a': SimpleNamespace(), 'b': SimpleNamespace()})
|
||||
stream.mark_plugin_updated('a')
|
||||
stream.mark_plugin_updated('b')
|
||||
stream.plugin_adapter.invalidate_cache.side_effect = [
|
||||
RuntimeError('boom'), None]
|
||||
|
||||
assert sorted(stream.invalidate_pending_updates()) == ['a', 'b']
|
||||
assert stream.plugin_adapter.invalidate_cache.call_count == 2
|
||||
|
||||
|
||||
class TestContinuousModeReachesTheRefresh:
|
||||
def _pipeline(self):
|
||||
stream = MagicMock()
|
||||
stream.get_buffer_status.return_value = {'staging_count': 0}
|
||||
return RenderPipeline(VegasModeConfig(), FakeDisplayManager(), stream), stream
|
||||
|
||||
def test_refresh_delegates_to_the_stream_manager(self):
|
||||
pipeline, stream = self._pipeline()
|
||||
stream.invalidate_pending_updates.return_value = ['baseball']
|
||||
assert pipeline.refresh_updated_plugins() is True
|
||||
|
||||
def test_refresh_reports_false_when_nothing_changed(self):
|
||||
pipeline, stream = self._pipeline()
|
||||
stream.invalidate_pending_updates.return_value = []
|
||||
assert pipeline.refresh_updated_plugins() is False
|
||||
|
||||
def test_refresh_never_raises_into_the_render_loop(self):
|
||||
pipeline, stream = self._pipeline()
|
||||
stream.invalidate_pending_updates.side_effect = RuntimeError('boom')
|
||||
assert pipeline.refresh_updated_plugins() is False
|
||||
|
||||
def test_refresh_does_not_reposition_the_scroll(self):
|
||||
# The whole point of preferring this over hot_swap_content(): that path
|
||||
# rebuilds and repositions, which reads as a freeze then a jump.
|
||||
pipeline, stream = self._pipeline()
|
||||
stream.invalidate_pending_updates.return_value = ['baseball']
|
||||
pipeline.scroll_helper.scroll_position = 1234
|
||||
|
||||
pipeline.refresh_updated_plugins()
|
||||
|
||||
assert pipeline.scroll_helper.scroll_position == 1234
|
||||
stream.swap_buffers.assert_not_called()
|
||||
stream.process_updates.assert_not_called()
|
||||
|
||||
|
||||
class TestCoordinatorWiring:
|
||||
"""
|
||||
The regression itself: continuous mode has to *call* the refresh.
|
||||
|
||||
should_recompose()/hot_swap_content() sit in the non-continuous branch, and
|
||||
continuous_scroll defaults to True, so before this fix the refresh was
|
||||
simply never reached on a default install.
|
||||
"""
|
||||
|
||||
def _coordinator(self, continuous):
|
||||
import threading
|
||||
|
||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
||||
|
||||
config = VegasModeConfig()
|
||||
config.continuous_scroll = continuous
|
||||
# Built without __init__ so the test exercises run_frame's branching
|
||||
# without standing up a display, stream and render stack.
|
||||
coordinator = VegasModeCoordinator.__new__(VegasModeCoordinator)
|
||||
coordinator.vegas_config = config
|
||||
coordinator.render_pipeline = MagicMock()
|
||||
coordinator.render_pipeline.has_deferred.return_value = False
|
||||
coordinator.render_pipeline.needs_extension.return_value = False
|
||||
coordinator.render_pipeline.is_cycle_complete.return_value = False
|
||||
coordinator.render_pipeline.should_recompose.return_value = False
|
||||
coordinator.stream_manager = MagicMock()
|
||||
coordinator.stats = {'cycles_completed': 0}
|
||||
coordinator._state_lock = threading.Lock()
|
||||
coordinator._is_active = True
|
||||
coordinator._is_paused = False
|
||||
coordinator._should_stop = False
|
||||
coordinator._pending_config_update = False
|
||||
coordinator._live_priority_check = None
|
||||
coordinator._interrupt_check = None
|
||||
coordinator.sync_manager = None
|
||||
return coordinator
|
||||
|
||||
def test_continuous_mode_refreshes_updated_plugins_every_frame(self):
|
||||
coordinator = self._coordinator(continuous=True)
|
||||
coordinator.run_frame()
|
||||
coordinator.render_pipeline.refresh_updated_plugins.assert_called_once()
|
||||
|
||||
def test_continuous_mode_does_not_use_the_disruptive_swap(self):
|
||||
coordinator = self._coordinator(continuous=True)
|
||||
coordinator.run_frame()
|
||||
coordinator.render_pipeline.hot_swap_content.assert_not_called()
|
||||
|
||||
def test_swap_mode_still_uses_hot_swap(self):
|
||||
# The non-continuous path must keep its original behaviour.
|
||||
coordinator = self._coordinator(continuous=False)
|
||||
coordinator.render_pipeline.should_recompose.return_value = True
|
||||
coordinator.run_frame()
|
||||
coordinator.render_pipeline.hot_swap_content.assert_called_once()
|
||||
coordinator.render_pipeline.refresh_updated_plugins.assert_not_called()
|
||||
|
||||
def test_a_frame_is_still_rendered_either_way(self):
|
||||
for continuous in (True, False):
|
||||
coordinator = self._coordinator(continuous=continuous)
|
||||
coordinator.run_frame()
|
||||
coordinator.render_pipeline.render_frame.assert_called_once()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
||||
"""Tests for Vegas mode geometry primitives."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.geometry import (
|
||||
DEFAULT_INK_THRESHOLD,
|
||||
column_has_ink,
|
||||
content_bounds,
|
||||
dead_window_stats,
|
||||
edge_blank,
|
||||
find_blank_cut,
|
||||
separation_gap,
|
||||
trim_to_content,
|
||||
window_coverage_stats,
|
||||
)
|
||||
|
||||
|
||||
def make_img(width, height=8, fill=(0, 0, 0)):
|
||||
return Image.new('RGB', (width, height), fill)
|
||||
|
||||
|
||||
def paint(img, x0, x1, color=(255, 255, 255)):
|
||||
"""Fill columns [x0, x1) with a colour."""
|
||||
block = Image.new('RGB', (x1 - x0, img.height), color)
|
||||
img.paste(block, (x0, 0))
|
||||
return img
|
||||
|
||||
|
||||
class TestColumnHasInk:
|
||||
def test_all_black_has_no_ink(self):
|
||||
assert not column_has_ink(make_img(16)).any()
|
||||
|
||||
def test_marks_only_painted_columns(self):
|
||||
img = paint(make_img(16), 4, 8)
|
||||
ink = column_has_ink(img)
|
||||
assert ink.tolist() == [False] * 4 + [True] * 4 + [False] * 8
|
||||
|
||||
def test_threshold_is_exclusive(self):
|
||||
# A pixel exactly at the threshold is not ink; one above it is.
|
||||
at = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD,) * 3)
|
||||
above = paint(make_img(4), 0, 4, (DEFAULT_INK_THRESHOLD + 1,) * 3)
|
||||
assert not column_has_ink(at).any()
|
||||
assert column_has_ink(above).all()
|
||||
|
||||
def test_single_bright_channel_counts(self):
|
||||
img = paint(make_img(4), 1, 2, (0, 0, 200))
|
||||
assert column_has_ink(img).tolist() == [False, True, False, False]
|
||||
|
||||
def test_one_lit_pixel_lights_the_column(self):
|
||||
img = make_img(4, height=8)
|
||||
img.putpixel((2, 5), (255, 255, 255))
|
||||
assert column_has_ink(img).tolist() == [False, False, True, False]
|
||||
|
||||
|
||||
class TestContentBounds:
|
||||
def test_blank_returns_none(self):
|
||||
assert content_bounds(make_img(16)) is None
|
||||
|
||||
def test_finds_inclusive_bounds(self):
|
||||
assert content_bounds(paint(make_img(20), 5, 12)) == (5, 11)
|
||||
|
||||
def test_full_width_content(self):
|
||||
assert content_bounds(paint(make_img(10), 0, 10)) == (0, 9)
|
||||
|
||||
def test_spans_interior_gap(self):
|
||||
img = paint(make_img(30), 2, 5)
|
||||
paint(img, 20, 25)
|
||||
assert content_bounds(img) == (2, 24)
|
||||
|
||||
|
||||
class TestTrimToContent:
|
||||
def test_blank_image_reports_blank(self):
|
||||
result = trim_to_content(make_img(512))
|
||||
assert result.is_blank
|
||||
assert result.image is None
|
||||
assert result.width == 0
|
||||
assert result.original_width == 512
|
||||
|
||||
def test_trims_both_edges(self):
|
||||
result = trim_to_content(paint(make_img(512), 100, 150))
|
||||
assert not result.is_blank
|
||||
assert result.width == 50
|
||||
assert result.trimmed_left == 100
|
||||
assert result.trimmed_right == 362
|
||||
assert result.removed == 462
|
||||
|
||||
def test_preserves_interior_gap(self):
|
||||
# Two content blocks with a wide blank between them: the gap is the
|
||||
# plugin's layout and must survive trimming.
|
||||
img = paint(make_img(400), 50, 80)
|
||||
paint(img, 300, 330)
|
||||
result = trim_to_content(img)
|
||||
assert result.width == 280 # 50..329 inclusive
|
||||
assert column_has_ink(result.image).sum() == 60
|
||||
|
||||
def test_full_width_content_is_returned_unchanged(self):
|
||||
img = paint(make_img(128), 0, 128)
|
||||
result = trim_to_content(img)
|
||||
assert result.image is img
|
||||
assert result.removed == 0
|
||||
|
||||
def test_non_black_background_is_never_trimmed(self):
|
||||
# A plugin drawing on a dark-but-not-black background fills every
|
||||
# column with ink, so there is nothing to reclaim.
|
||||
result = trim_to_content(make_img(256, fill=(0, 0, 40)))
|
||||
assert result.removed == 0
|
||||
assert result.width == 256
|
||||
|
||||
def test_padding_keeps_margin_up_to_what_exists(self):
|
||||
result = trim_to_content(paint(make_img(512), 100, 150), padding=8)
|
||||
assert result.trimmed_left == 92
|
||||
assert result.width == 66 # 50 content + 8 each side
|
||||
|
||||
def test_padding_cannot_widen_beyond_original(self):
|
||||
# Content starts 2px in; padding of 8 can only reclaim the 2 available.
|
||||
result = trim_to_content(paint(make_img(64), 2, 60), padding=8)
|
||||
assert result.trimmed_left == 0
|
||||
assert result.trimmed_right == 0
|
||||
assert result.width == 64
|
||||
|
||||
def test_height_is_preserved(self):
|
||||
result = trim_to_content(paint(make_img(200, height=64), 10, 20))
|
||||
assert result.image.height == 64
|
||||
|
||||
def test_real_world_of_the_day_case(self):
|
||||
# Measured on devpi: "No Data" occupying 35px of a 512px canvas.
|
||||
result = trim_to_content(paint(make_img(512, height=64), 4, 39))
|
||||
assert result.width == 35
|
||||
assert result.removed == 477
|
||||
|
||||
|
||||
class TestDeadWindowStats:
|
||||
def test_fully_inked_ticker_has_no_dead_windows(self):
|
||||
stats = dead_window_stats(paint(make_img(400), 0, 400), viewport_width=100)
|
||||
assert stats.dead_windows == 0
|
||||
assert stats.dead_ratio == 0.0
|
||||
assert stats.longest_dead_run == 0
|
||||
|
||||
def test_fully_blank_ticker_is_all_dead(self):
|
||||
stats = dead_window_stats(make_img(400), viewport_width=100)
|
||||
assert stats.total_windows == 301
|
||||
assert stats.dead_windows == 301
|
||||
assert stats.dead_ratio == 1.0
|
||||
assert stats.longest_dead_run == 301
|
||||
|
||||
def test_leading_blank_run_is_measured(self):
|
||||
# 512px of black then solid content: windows fully inside the black
|
||||
# stretch are dead. With a 100px viewport, starts 0..412 exist and a
|
||||
# window is dead while it holds >=95 blank columns.
|
||||
img = paint(make_img(1024), 512, 1024)
|
||||
stats = dead_window_stats(img, viewport_width=100)
|
||||
assert stats.dead_windows == 418 # starts 0..417 keep >=95 blank cols
|
||||
assert stats.longest_dead_run == 418
|
||||
|
||||
def test_narrow_content_island_still_leaves_dead_windows(self):
|
||||
# 35px of content in a 512px field, viewed 100px at a time: no window
|
||||
# can be 95% blank once it overlaps 35 lit columns, but the windows
|
||||
# clear of it are dead.
|
||||
img = paint(make_img(512), 100, 135)
|
||||
stats = dead_window_stats(img, viewport_width=100)
|
||||
assert stats.dead_windows > 0
|
||||
assert stats.dead_ratio == pytest.approx(
|
||||
stats.dead_windows / stats.total_windows
|
||||
)
|
||||
|
||||
def test_step_reduces_sampling(self):
|
||||
img = paint(make_img(1000), 500, 1000)
|
||||
exact = dead_window_stats(img, viewport_width=100, step=1)
|
||||
strided = dead_window_stats(img, viewport_width=100, step=10)
|
||||
assert strided.total_windows < exact.total_windows
|
||||
# Same underlying shape, so the ratios should stay close.
|
||||
assert strided.dead_ratio == pytest.approx(exact.dead_ratio, abs=0.02)
|
||||
|
||||
def test_image_narrower_than_viewport_is_one_window(self):
|
||||
stats = dead_window_stats(make_img(50), viewport_width=100)
|
||||
assert stats.total_windows == 1
|
||||
assert stats.dead_windows == 1
|
||||
|
||||
def test_zero_viewport_is_handled(self):
|
||||
stats = dead_window_stats(make_img(50), viewport_width=0)
|
||||
assert stats.total_windows == 0
|
||||
assert stats.dead_ratio == 0.0
|
||||
|
||||
def test_longest_run_picks_the_larger_of_two_gaps(self):
|
||||
# Short blank gap, content, then a long blank gap.
|
||||
img = make_img(1000)
|
||||
paint(img, 150, 400)
|
||||
paint(img, 500, 520)
|
||||
stats = dead_window_stats(img, viewport_width=100)
|
||||
# The 400..500 gap is only 100 wide; the tail from 520 is 480 wide.
|
||||
assert stats.longest_dead_run >= 380
|
||||
|
||||
|
||||
class TestWindowCoverageStats:
|
||||
def test_solid_content_is_fully_covered(self):
|
||||
stats = window_coverage_stats(paint(make_img(600), 0, 600), viewport_width=100)
|
||||
assert stats.mean_ink_ratio == 1.0
|
||||
assert stats.min_ink_ratio == 1.0
|
||||
assert stats.sparse_windows == 0
|
||||
|
||||
def test_blank_strip_is_entirely_sparse(self):
|
||||
stats = window_coverage_stats(make_img(600), viewport_width=100)
|
||||
assert stats.mean_ink_ratio == 0.0
|
||||
assert stats.sparse_ratio == 1.0
|
||||
|
||||
def test_catches_sliver_windows_that_dead_ratio_misses(self):
|
||||
# Narrow content islands separated by more than the viewport. A window
|
||||
# holding one whole 40px island carries 472 blank columns — under the
|
||||
# 486 needed to count as "dead" — yet only 7.8% ink, so it still reads
|
||||
# as an empty panel. Coverage must flag strictly more positions than
|
||||
# the dead-window scan does.
|
||||
img = paint(make_img(2000), 0, 40)
|
||||
paint(img, 1000, 1040)
|
||||
dead = dead_window_stats(img, viewport_width=512)
|
||||
cover = window_coverage_stats(img, viewport_width=512, sparse_ink_ratio=0.10)
|
||||
assert cover.sparse_windows > dead.dead_windows
|
||||
assert cover.min_ink_ratio == 0.0
|
||||
|
||||
def test_adjacent_full_width_segments_stay_partially_covered(self):
|
||||
# Documents why the dead-window scan alone understated the problem:
|
||||
# two 512px segments with mid-canvas content never fully blank the
|
||||
# viewport, they just hold it at a thin ~28%.
|
||||
img = paint(make_img(1024), 185, 330)
|
||||
paint(img, 697, 842)
|
||||
dead = dead_window_stats(img, viewport_width=512)
|
||||
cover = window_coverage_stats(img, viewport_width=512)
|
||||
assert dead.dead_windows == 0
|
||||
assert cover.mean_ink_ratio == pytest.approx(0.283, abs=0.01)
|
||||
|
||||
def test_min_ink_ratio_finds_the_worst_position(self):
|
||||
# A wide blank tail guarantees at least one totally empty viewport.
|
||||
img = paint(make_img(1200), 0, 200)
|
||||
stats = window_coverage_stats(img, viewport_width=200)
|
||||
assert stats.min_ink_ratio == 0.0
|
||||
assert stats.mean_ink_ratio > 0.0
|
||||
|
||||
def test_sparse_threshold_is_respected(self):
|
||||
# 40 inked columns in a 200px viewport = 20% coverage everywhere the
|
||||
# island is fully inside the window.
|
||||
img = paint(make_img(400), 100, 140)
|
||||
lenient = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.05)
|
||||
strict = window_coverage_stats(img, viewport_width=200, sparse_ink_ratio=0.50)
|
||||
assert strict.sparse_windows > lenient.sparse_windows
|
||||
|
||||
def test_step_approximates_exact_scan(self):
|
||||
img = paint(make_img(2000), 300, 500)
|
||||
paint(img, 1200, 1400)
|
||||
exact = window_coverage_stats(img, viewport_width=512, step=1)
|
||||
strided = window_coverage_stats(img, viewport_width=512, step=4)
|
||||
assert strided.mean_ink_ratio == pytest.approx(exact.mean_ink_ratio, abs=0.01)
|
||||
|
||||
def test_zero_viewport_is_handled(self):
|
||||
stats = window_coverage_stats(make_img(50), viewport_width=0)
|
||||
assert stats.total_windows == 0
|
||||
assert stats.sparse_ratio == 0.0
|
||||
|
||||
def test_image_narrower_than_viewport(self):
|
||||
stats = window_coverage_stats(paint(make_img(50), 0, 50), viewport_width=100)
|
||||
assert stats.total_windows == 1
|
||||
assert stats.mean_ink_ratio == pytest.approx(0.5)
|
||||
|
||||
|
||||
class TestLongestRunHelper:
|
||||
@pytest.mark.parametrize("flags,expected", [
|
||||
([], 0),
|
||||
([False, False], 0),
|
||||
([True], 1),
|
||||
([True, True, False, True], 2),
|
||||
([False, True, True, True, False, True], 3),
|
||||
([True, True, True], 3),
|
||||
])
|
||||
def test_run_lengths(self, flags, expected):
|
||||
from src.vegas_mode.geometry import _longest_true_run
|
||||
assert _longest_true_run(np.array(flags, dtype=bool)) == expected
|
||||
|
||||
|
||||
class TestEdgeBlank:
|
||||
def test_measures_both_edges(self):
|
||||
assert edge_blank(paint(make_img(100), 20, 60)) == (20, 40)
|
||||
|
||||
def test_flush_content_has_no_blank(self):
|
||||
assert edge_blank(paint(make_img(50), 0, 50)) == (0, 0)
|
||||
|
||||
def test_blank_image_reports_full_width_both_sides(self):
|
||||
# No ink means nothing to be close to.
|
||||
assert edge_blank(make_img(64)) == (64, 64)
|
||||
|
||||
|
||||
class TestSeparationGap:
|
||||
def test_flush_edges_get_the_full_target(self):
|
||||
a = paint(make_img(50), 0, 50)
|
||||
b = paint(make_img(50), 0, 50)
|
||||
assert separation_gap(a, b, target=24) == 24
|
||||
|
||||
def test_existing_margins_reduce_the_added_gap(self):
|
||||
# 8px blank on each facing edge already covers 16 of the 24 target.
|
||||
a = paint(make_img(50), 0, 42)
|
||||
b = paint(make_img(50), 8, 50)
|
||||
assert separation_gap(a, b, target=24) == 8
|
||||
|
||||
def test_ample_existing_margin_adds_nothing(self):
|
||||
a = paint(make_img(100), 0, 60)
|
||||
b = paint(make_img(100), 40, 100)
|
||||
assert separation_gap(a, b, target=24) == 0
|
||||
|
||||
def test_minimum_is_a_floor(self):
|
||||
a = paint(make_img(100), 0, 60)
|
||||
b = paint(make_img(100), 40, 100)
|
||||
assert separation_gap(a, b, target=24, minimum=4) == 4
|
||||
|
||||
def test_never_negative(self):
|
||||
a = paint(make_img(200), 0, 10)
|
||||
b = paint(make_img(200), 190, 200)
|
||||
assert separation_gap(a, b, target=8) == 0
|
||||
|
||||
def test_sports_card_case_gets_real_separation(self):
|
||||
# The reported problem: cards drawn edge to edge sat 8px apart under a
|
||||
# flat gap; measured separation lifts them to the 24px target.
|
||||
card = paint(make_img(150), 0, 150)
|
||||
assert separation_gap(card, card, target=24, minimum=8) == 24
|
||||
|
||||
|
||||
class TestFindBlankCut:
|
||||
def test_snaps_to_the_nearest_gap(self):
|
||||
img = paint(make_img(200), 0, 90)
|
||||
paint(img, 110, 200)
|
||||
# 100 is inside the 90..110 gap already.
|
||||
assert find_blank_cut(img, 100, 20) == 100
|
||||
|
||||
def test_walks_outwards_to_find_a_gap(self):
|
||||
img = paint(make_img(200), 0, 95)
|
||||
paint(img, 105, 200)
|
||||
cut = find_blank_cut(img, 90, 20)
|
||||
assert 95 <= cut < 105
|
||||
|
||||
def test_solid_ink_returns_the_target(self):
|
||||
assert find_blank_cut(paint(make_img(200), 0, 200), 100, 20) == 100
|
||||
|
||||
def test_target_at_image_width_does_not_index_past_the_end(self):
|
||||
# A cut after the last column is legal. Indexing ink[width] raised
|
||||
# IndexError in the field, losing that plugin's content for the cycle.
|
||||
# Reached once the rotation offset advances so start + budget lands
|
||||
# exactly on the image width.
|
||||
img = paint(make_img(1840), 0, 1840)
|
||||
assert find_blank_cut(img, 1840, 32) == 1840
|
||||
|
||||
def test_target_past_image_width_is_clamped(self):
|
||||
img = paint(make_img(100), 0, 100)
|
||||
assert find_blank_cut(img, 500, 32) == 100
|
||||
|
||||
def test_target_at_width_with_a_trailing_gap_snaps_back(self):
|
||||
# Content 0..179, blank 180..199. The nearest blank column to 200 is
|
||||
# 199, not the start of the gap — nearest is what keeps the cut as
|
||||
# close as possible to the requested budget.
|
||||
img = paint(make_img(200), 0, 180)
|
||||
assert find_blank_cut(img, 200, 32) == 199
|
||||
|
||||
def test_zero_radius_returns_the_target(self):
|
||||
assert find_blank_cut(paint(make_img(100), 0, 100), 50, 0) == 50
|
||||
|
||||
def test_negative_target_is_clamped_to_zero(self):
|
||||
assert find_blank_cut(paint(make_img(100), 0, 100), -20, 8) == 0
|
||||
|
||||
@pytest.mark.parametrize("target", [0, 1, 50, 99, 100])
|
||||
def test_never_raises_across_the_range(self, target):
|
||||
img = paint(make_img(100), 0, 100)
|
||||
cut = find_blank_cut(img, target, 16)
|
||||
assert 0 <= cut <= 100
|
||||
@@ -918,7 +918,15 @@ def save_main_config():
|
||||
|
||||
# Handle Vegas scroll mode settings
|
||||
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins']
|
||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins',
|
||||
'vegas_auto_trim', 'vegas_trim_threshold', 'vegas_content_padding',
|
||||
'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle',
|
||||
'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled',
|
||||
'vegas_min_cycle_duration', 'vegas_max_cycle_duration',
|
||||
'vegas_intra_plugin_gap', 'vegas_render_width_pct',
|
||||
'vegas_min_content_separation', 'vegas_min_cut_gap',
|
||||
'vegas_continuous_scroll', 'vegas_extend_threshold_screens',
|
||||
'vegas_smooth_scroll', 'vegas_overflow_mode']
|
||||
|
||||
if any(k in data for k in vegas_fields):
|
||||
if 'display' not in current_config:
|
||||
@@ -933,13 +941,85 @@ def save_main_config():
|
||||
# was submitted (any vegas field present) but enabled key is missing,
|
||||
# the checkbox was unchecked and we should set enabled=False
|
||||
vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled'))
|
||||
vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim'))
|
||||
vegas_config['dynamic_duration_enabled'] = _coerce_to_bool(
|
||||
data.get('vegas_dynamic_duration_enabled'))
|
||||
vegas_config['continuous_scroll'] = _coerce_to_bool(
|
||||
data.get('vegas_continuous_scroll'))
|
||||
vegas_config['smooth_scroll'] = _coerce_to_bool(
|
||||
data.get('vegas_smooth_scroll'))
|
||||
|
||||
# Handle numeric settings with validation
|
||||
# max_plugin_width_ratio is the one fractional setting, so it is
|
||||
# handled outside the integer loop below.
|
||||
if data.get('vegas_overflow_mode') not in ('', None):
|
||||
mode = str(data['vegas_overflow_mode']).strip().lower()
|
||||
if mode not in ('rotate', 'truncate'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for vegas_overflow_mode: "
|
||||
"must be 'rotate' or 'truncate'"
|
||||
}), 400
|
||||
vegas_config['overflow_mode'] = mode
|
||||
|
||||
if data.get('vegas_extend_threshold_screens') not in ('', None):
|
||||
try:
|
||||
screens = float(data['vegas_extend_threshold_screens'])
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for vegas_extend_threshold_screens: "
|
||||
"must be a number"
|
||||
}), 400
|
||||
if not (1.0 <= screens <= 10.0):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for vegas_extend_threshold_screens: "
|
||||
"must be between 1.0 and 10.0"
|
||||
}), 400
|
||||
vegas_config['extend_threshold_screens'] = screens
|
||||
|
||||
if data.get('vegas_max_plugin_width_ratio') not in ('', None):
|
||||
try:
|
||||
ratio = float(data['vegas_max_plugin_width_ratio'])
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for vegas_max_plugin_width_ratio: "
|
||||
"must be a number"
|
||||
}), 400
|
||||
if not (0 <= ratio <= 20):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for vegas_max_plugin_width_ratio: "
|
||||
"must be between 0 and 20 (0 disables the cap)"
|
||||
}), 400
|
||||
vegas_config['max_plugin_width_ratio'] = ratio
|
||||
|
||||
# Handle numeric settings with validation.
|
||||
#
|
||||
# These bounds must match VegasModeConfig.validate(), which is what
|
||||
# actually gates Vegas starting. Where they were looser, a value
|
||||
# saved with a 200 and then made VegasModeCoordinator.start() bail
|
||||
# out with only a log line, so the ticker silently never ran.
|
||||
# Where they were tighter (scroll_speed capped at 100 against a
|
||||
# slider that goes to 200), a legitimate value was rejected with a
|
||||
# 400. See test_vegas_api_bounds_match_validate.
|
||||
numeric_fields = {
|
||||
'vegas_scroll_speed': ('scroll_speed', 1, 100),
|
||||
'vegas_separator_width': ('separator_width', 0, 500),
|
||||
'vegas_target_fps': ('target_fps', 1, 200),
|
||||
'vegas_buffer_ahead': ('buffer_ahead', 1, 20),
|
||||
'vegas_scroll_speed': ('scroll_speed', 1, 200),
|
||||
'vegas_separator_width': ('separator_width', 0, 128),
|
||||
'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
|
||||
'vegas_render_width_pct': ('render_width_pct', 10, 100),
|
||||
'vegas_min_content_separation': ('min_content_separation', 0, 256),
|
||||
'vegas_min_cut_gap': ('min_cut_gap', 1, 128),
|
||||
'vegas_target_fps': ('target_fps', 30, 200),
|
||||
'vegas_buffer_ahead': ('buffer_ahead', 1, 5),
|
||||
'vegas_trim_threshold': ('trim_threshold', 0, 254),
|
||||
'vegas_content_padding': ('content_padding', 0, 128),
|
||||
'vegas_min_plugin_width': ('min_plugin_width', 0, 512),
|
||||
'vegas_lead_in_width': ('lead_in_width', 0, 2048),
|
||||
'vegas_plugins_per_cycle': ('plugins_per_cycle', 1, 50),
|
||||
'vegas_min_cycle_duration': ('min_cycle_duration', 5, 3600),
|
||||
'vegas_max_cycle_duration': ('max_cycle_duration', 10, 3600),
|
||||
}
|
||||
for field_name, (config_key, min_val, max_val) in numeric_fields.items():
|
||||
if field_name in data:
|
||||
|
||||
@@ -425,7 +425,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
|
||||
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap inserted between each plugin block in the ticker (0–128 px).\nDefault: 32. Larger values make the boundary between plugins clearer.', 'Separator Width') }}</label>
|
||||
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap where one plugin hands off to the next (0–128 px).\nDefault: 32. Larger values make the boundary between plugins clearer. This does not apply between rows of the same plugin — see Row Gap for that.', 'Separator Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_separator_width"
|
||||
name="vegas_separator_width"
|
||||
@@ -436,6 +436,44 @@
|
||||
</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('Extra gap always added between rows contributed by the same plugin (0–128 px).\nDefault: 8. This is a floor on top of Row Separation below, which does most of the work. Set both to 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 class="form-group" id="setting-display-vegas_min_content_separation" data-setting-key="display.vegas_scroll.min_content_separation">
|
||||
<label for="vegas_min_content_separation" class="block text-sm font-medium text-gray-700">Row Separation (pixels){{ ui.help_tip('Blank space guaranteed between rows of the same plugin, measured from the actual content rather than added blindly (0–256 px).\nDefault: 24. Rows already carrying wide margins get nothing added; rows drawn right up to their own edges — sports score cards, for instance — get the full amount, so they no longer look like they are touching. Raise it if items still feel cramped.', 'Row Separation') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_content_separation"
|
||||
name="vegas_min_content_separation"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_content_separation', 24) }}"
|
||||
min="0"
|
||||
max="256"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_render_width_pct" data-setting-key="display.vegas_scroll.render_width_pct">
|
||||
<label for="vegas_render_width_pct" class="block text-sm font-medium text-gray-700">Plugin Render Width (%){{ ui.help_tip('How much of the screen width each plugin is told it has while drawing for the ticker (10–100%).\nDefault: 100 (unchanged). Lowering it makes plugins choose a tighter layout rather than being cropped — a weather forecast becomes narrow cards instead of five columns spread across the panel. Useful on wide displays. Override per plugin with the vegas_width_pct setting in that plugin\'s own configuration.', 'Plugin Render Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_render_width_pct"
|
||||
name="vegas_render_width_pct"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('render_width_pct', 100) }}"
|
||||
min="10"
|
||||
max="100"
|
||||
step="5"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps">
|
||||
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label>
|
||||
@@ -456,6 +494,194 @@
|
||||
</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="form-group mb-4" id="setting-display-vegas_continuous_scroll" data-setting-key="display.vegas_scroll.continuous_scroll">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_continuous_scroll"
|
||||
name="vegas_continuous_scroll"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('continuous_scroll', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Scroll continuously between groups{{ ui.help_tip('Keep one endless strip, extending it with the next group of plugins as the scroll approaches the end, so they simply arrive from the right. Default: on.\nWith this off the ticker builds a fresh strip and swaps it in, which stops the motion, replaces everything at once and restarts with the screen already full — a freeze, a flash and a jump.', 'Continuous Scroll') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-vegas_smooth_scroll" data-setting-key="display.vegas_scroll.smooth_scroll">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_smooth_scroll"
|
||||
name="vegas_smooth_scroll"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('smooth_scroll', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Smooth sub-pixel motion{{ ui.help_tip('Blend between neighbouring pixel positions so the ticker moves once per rendered frame instead of once per pixel. Default: on.\nWithout it, motion happens only as often as the scroll speed in pixels per second — at 50 px/s that is 50 steps a second however fast the display renders, which reads as a slight judder. The trade is that text softens very slightly horizontally, since each frame blends two positions. Turn it off if you prefer maximum crispness.', 'Smooth Scrolling') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-group" id="setting-display-vegas_extend_threshold_screens" data-setting-key="display.vegas_scroll.extend_threshold_screens">
|
||||
<label for="vegas_extend_threshold_screens" class="block text-sm font-medium text-gray-700">Extend When (screens left){{ ui.help_tip('How much unscrolled content triggers loading the next group, measured in screen widths (1.0–10.0).\nDefault: 2. Higher loads earlier and leaves more slack, at the cost of holding more content in memory. Only applies when Continuous Scroll is on.', 'Extend Threshold') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_extend_threshold_screens"
|
||||
name="vegas_extend_threshold_screens"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('extend_threshold_screens', 2.0) }}"
|
||||
min="1"
|
||||
max="10"
|
||||
step="0.5"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_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_overflow_mode" data-setting-key="display.vegas_scroll.overflow_mode">
|
||||
<label for="vegas_overflow_mode" class="block text-sm font-medium text-gray-700">When A Plugin Is Too Wide{{ ui.help_tip('What to do when a plugin has more content than its width allowance.\nRotate through it: show a different slice each time round, so everything is seen eventually. Right for interchangeable items like news headlines, odds or stock prices.\nShow the start only: always display from the beginning and drop the rest. Right for ordered content — a league table that shows ranks 1-6 and then resumes at 7 two rotations later reads as out of order.\nDefault: rotate. Override for one plugin with vegas_overflow in its own settings.', 'Overflow Handling') }}</label>
|
||||
<select id="vegas_overflow_mode" name="vegas_overflow_mode" class="form-control">
|
||||
<option value="rotate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'rotate' %}selected{% endif %}>Rotate through it (default)</option>
|
||||
<option value="truncate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'truncate' %}selected{% endif %}>Show the start only</option>
|
||||
</select>
|
||||
</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 class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div class="form-group" id="setting-display-vegas_min_cut_gap" data-setting-key="display.vegas_scroll.min_cut_gap">
|
||||
<label for="vegas_min_cut_gap" class="block text-sm font-medium text-gray-700">Min Cut Gap (pixels){{ ui.help_tip('When a plugin is too wide for its share of a cycle and has to be narrowed, the cut is only made where there is at least this much blank space (1–128 px).\nDefault: 6. The gaps between letters are about 1px wide, so a smaller value lets a cut land inside a word and orphan its last letter into the next cycle. Raise it if cuts still land awkwardly. Continuous images such as maps have no gaps and are cut to fit regardless.', 'Min Cut Gap') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_cut_gap"
|
||||
name="vegas_min_cut_gap"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cut_gap', 6) }}"
|
||||
min="1"
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugin Order Section -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Plugin Order</h4>
|
||||
|
||||
Reference in New Issue
Block a user