Compare commits

...
Author SHA1 Message Date
ChuckBuildsandClaude 105c6df019 Fix IndexError in find_blank_cut when the cut lands on the image edge
A cut position after the last column is legitimate — _crop_to_budget asks for
min(start + budget, img.width), which equals the width whenever the remaining
strip is shorter than the budget. find_blank_cut clamped target to width but
then walked leftwards starting at target itself, so ink[width] raised
IndexError.

Caught on hardware: it killed the ledmatrix-stocks fetch, and because
_fetch_plugin_content catches broadly that surfaced as the plugin silently
contributing nothing for the cycle.

Only reachable on the second or later pass of the rotating window over a single
oversized image, which is why the existing tests missed it — they all exercised
the first pass, where start is 0 and start + budget is comfortably inside the
image. Added TestRotationAcrossMultipleCycles, which walks the window round
several times and asserts content is never lost, plus direct coverage of
find_blank_cut at and beyond the image edge.

Both bounds now stop at width - 1 so neither direction can index past the end.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-29 09:42:51 -04:00
ChuckBuildsandClaude 62919a13e3 Vegas mode: end cycles before the wrap, keep the width budget honest
Three fixes, the first a regression from lead_in_width defaulting to 0.

get_visible_portion wraps: once scroll_position + display_width passes the end
of the strip it fills the right of the frame from the *head* of the same strip.
So the final display_width of travel showed the cycle's first plugin re-entering
on the right while its last plugin exited on the left, and the recompose that
followed replaced both at once. On a 512px panel at 50px/s that was 10.2s of
two plugins on screen at once, ending in a hard cut — reported as the ticker
"switching mid-scroll" from F1 to news.

That used to be invisible because the strip began with a full display_width of
blank, so the wrapped-in region was black. Removing that blank (it was 10s of
dead panel per cycle) exposed the wrap. Cycles now end one display width
earlier, before any wrapped content appears, clamped for strips no wider than
the display so they don't complete instantly and spin the recompose loop.

Verified on hardware: a 3936px strip now completes at 68.5s, exactly
(3936 - 512) / 50.

Second, auto_trim=False also skipped the width budget, which is an unrelated
concern — turning off margin cropping should not let one plugin hold the panel
for minutes. Seen in the field: the F1 scoreboard contributed 116 images and
14,848px untouched, giving a 33,821px cycle (11 minutes of content). The budget
now applies regardless of trimming; with it restored that cycle is 6,362px.

Third, the budget accounted for row gaps using the flat intra_plugin_gap while
the compositor had moved to measured separation, so it under-counted by up to
(min_content_separation - intra_plugin_gap) per row and a many-row plugin
overran its cap. Both now use the same separation_gap() rule, and a test
asserts the composed block fits the budget end to end rather than trusting the
two paths to agree.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-29 09:35:23 -04:00
ChuckBuildsandClaude 616d21c6d3 Vegas mode: render plugins narrower, space rows by measured separation
Trimming reclaims blank margins but cannot compact a layout that genuinely
spans the display — a five-column forecast, a progress bar drawn at 100%
width, a stat block with the panel's whole width between its elements. Those
need the plugin to make different layout decisions, which means telling it the
screen is narrower while it renders.

DisplayManager.render_size() presents a smaller logical canvas for the
duration of a Vegas content fetch, reusing the same _LogicalMatrix
indirection double-sided mode already relies on so plugins see a consistent
size from every accessor. Plugins that size themselves from matrix.width need
no changes at all; one that wants to be explicit can read the new
BasePlugin.get_vegas_render_width().

Width is a percentage so a single setting travels across panel sizes:
vegas_scroll.render_width_pct globally, or vegas_width_pct in an individual
plugin's config. Measured on a 512x64 panel with real data:

  ledmatrix-weather   1536px -> 576px   (forecast becomes narrow cards)
  youtube-stats        353px -> 199px   (2% blank left, so genuinely compact)
  geochron             453px -> 153px   (ink density rises to 100%)
  ledmatrix-flights    950px -> 740px

The youtube-stats figure is the clearest evidence the layout itself changed
rather than being cropped: at full width the content had to be trimmed from
512px to 353px, whereas at 40% it arrives with almost no blank to reclaim.

Row spacing is now measured rather than added. A flat gap gets it wrong in
both directions at once — content drawn flush to its own edges ends up nearly
touching (reported for recent sports scores, which sat 8px apart), while
content already carrying wide margins gets pushed even further out.
separation_gap() measures the blank each pair already has and adds only the
shortfall, up to min_content_separation (default 24). intra_plugin_gap stays
as a floor applied regardless.

Two tests shipped in the previous commit encoded the old flat-gap arithmetic
and are updated to the measured semantics, including one renamed to reflect
that zero intra_plugin_gap alone no longer butts rows together.

Also fixes a real bug found while testing: the harness display manager had no
render_size(), and because the adapter catches broadly that surfaced as "no
content" rather than an error, silently dropping five plugins. Added the
context to VisualTestDisplayManager for parity, and _render_at() now degrades
to a no-op on any display manager lacking it, so a third-party or older
harness loses the narrowing rather than the content.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-29 09:01:28 -04:00
ChuckBuildsandClaude d69dfbbaee Align Vegas API bounds with validate(), fix audit config plumbing
Both from review feedback on #423.

The web API's accepted ranges disagreed with VegasModeConfig.validate(),
which is what actually gates Vegas starting:

  scroll_speed      1-100  -> 1-200   (a slider value of 150 returned 400)
  separator_width   0-500  -> 0-128
  target_fps        1-200  -> 30-200
  buffer_ahead      1-20   -> 1-5

The three loose ones were the dangerous direction: the value saved with a
200, then VegasModeCoordinator.start() failed validation with only a log
line, so the ticker silently never ran. The UI already matched validate() in
all four cases, so the API was the odd one out.

test_vegas_api_bounds_match_validate parses the numeric_fields map out of
api_v3 and asserts every bound against validate(), plus that validate()
accepts both endpoints and rejects just outside them, so these cannot drift
apart again. That test immediately caught a missing upper bound on
min_plugin_width, now added — unbounded it would drop every segment and
leave a blank ticker.

Separately, vegas_audit.py constructed PluginAdapter without the config, so
it fell back to VegasModeConfig() defaults and would report trimming and
width-budget behaviour that differed from the user's config.json. It now
passes the loaded config exactly as the coordinator does. This is the same
class of drift the explicit lead_gap and grouping arguments already guard
against. Output is unchanged on a rig whose config matches the defaults.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-28 20:32:09 -04:00
ChuckBuildsandClaude ec96422803 Drop unused Optional import from the vegas audit script
Flagged by Codacy (F401). Any, Dict and List are all still used.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-28 20:26:22 -04:00
ChuckBuildsandClaude 8d57a748a7 Vegas mode: reclaim dead space and pace the rotation
On a wide panel Vegas mode spent much of its time showing black. At 50px/s
on a 512px display, one display width of blank is 10.2 seconds, which makes
several long-standing behaviours expensive:

- ScrollHelper prepended a full display width of black as an "initial gap",
  charged once per cycle — 10.2s of black at the start of every rotation.
- Plugins without get_vegas_content() are captured off a full-display canvas,
  so their blank margins entered the ticker too. Measured: of-the-day drew
  35px of "No Data" on a 512px canvas (92% blank), youtube-stats 142px of
  content with 185px of black either side. Only the scroll_helper path had
  any trimming.
- Cycle transitions deliberately pushed a blank frame and then recomposed
  synchronously: 84ms at best, 4.8s at worst, every millisecond of it black.
- buffer_ahead doubled as the cycle size, so a 21-plugin install showed 3
  plugins per cycle and took ~7 cycles to come around.
- separator_width was applied between every image rather than at plugin
  boundaries, so a per-row ticker like the F1 scoreboard (116 images, which
  it renders 4px apart internally) got a 32px chasm between each row — and
  the width budget didn't count those gaps, so the plugin quietly occupied
  far more of the panel than intended.

Changes:

- src/vegas_mode/geometry.py: numpy column-ink primitives shared by the
  trimmer and the audit tool, so the number reported is the number acted on.
  A Python per-column loop over a 17,000px strip is far too slow for the
  render path.
- PluginAdapter trims every content path, not just scroll_helper. Only outer
  edges are cropped: interior blank columns are the plugin's own layout
  (logo left, score right) and closing them would corrupt the design. A
  plugin on a non-black background is inherently unaffected.
- ScrollHelper.create_scrolling_image takes an explicit lead_gap, still
  defaulting to display_width so the many standalone-ticker callers are
  unchanged. Vegas passes lead_in_width (default 0).
- Cycle end holds the last rendered frame instead of blanking, turning the
  recompose into a brief freeze rather than the panel switching off.
- plugins_per_cycle (default 6) is split from buffer_ahead, which goes back
  to being only a prefetch low-water mark.
- max_plugin_width_ratio (default 3x display width) caps one plugin's share
  of a cycle. Overflow is deferred, not discarded: a rotation offset advances
  each fetch so later rows appear on subsequent cycles. Single oversized
  images are cropped at a blank column so the cut misses glyphs.
- Composition groups images by plugin: rows are joined by intra_plugin_gap
  (default 8) and separator_width applies only between plugins. The width
  budget now counts those gaps.
- Plugin data updates no longer run on the Vegas render path.

All new settings are user-configurable in Display -> Vegas Scroll, including
min/max cycle duration and dynamic duration, which previously existed in code
but were reachable only by hand-editing config.json.

Measured with scripts/dev/vegas_audit.py on a 512x64 panel:

  mean ink coverage    42.7% -> 69.4%
  fully blank           5.9% -> 0%
  reads as empty        13.6% -> 0%
  worst blank stretch    4.8s -> 0s
  full rotation          414s -> 123s
  plugins per cycle         3 -> 6

Note the metric choice: a "fully blank" scan (>=95% black viewport) reported
only 0.4% and badly understated the problem, because two full-width segments
with mid-canvas content never fully blank the viewport — they hold it at ~28%.
window_coverage_stats grades every viewport position by how much ink it
carries, which is what tracks perceived dead time.

Known remaining: cycle transitions still freeze ~3.5s while the next cycle is
fetched. Fixing that needs background prefetch, which is deferred because the
fallback-capture path mutates the shared display_manager.image and racing it
against the render loop risks torn frames.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-28 20:18:51 -04:00
e2acbfb566 fix(web): don't validate double-sided settings when the feature is disabled (#422)
* fix(web): don't validate double-sided settings when the feature is disabled

Saving anything on the Display tab failed with a 400 when double-sided
mode was off:

    Double-sided copies (2) must divide chain length (3) evenly

The Display form posts every field in one request, including
double_sided_copies (default 2) and double_sided_axis, whether or not
the Enabled checkbox is ticked. The server block was gated only on "is
any double-sided field present in the payload" — it wrote
ds_config['enabled'] but never read it. A user with chain_length: 3 and
the untouched default copies: 2 was locked out of saving any display
setting at all: brightness, GPIO slowdown, Vegas, sync.

Gate the checks on the enabled flag:

- Divisibility against chain_length/parallel is hardware-relational and
  only runs when the feature is on.
- Structural checks (copies parses as an int in 2..8, axis in the
  whitelist) still 400 when enabled; when disabled they drop the value
  and leave the stored one untouched rather than rejecting the save.

The runtime already gated correctly (_resolve_double_sided returns None
when disabled), so nothing there changes.

Also in the Display tab:

- Hide Copies / Split Axis until Enabled is ticked, mirroring the Vegas
  Scroll pattern. Hidden rather than disabled, so the fields keep
  submitting and the server still sees an 'off' state to persist.
- Fix the save toast: the form's handler read xhr.responseJSON, a jQuery
  property that doesn't exist on a native XMLHttpRequest, so it was
  always undefined and every save reported a green "Display settings
  saved" — even the 400s. Parse responseText and use the real status.

Tests: two existing double-sided tests asserted 200 on payloads that the
divisibility check (added later, in #373) turns into 400s; the first now
supplies matching hardware values and the second passes as written now
that a disabled save skips the check. Added coverage for the reported
regression, for bad values while disabled, and for the check still
firing when enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aUvzTXpEYhNEWYQvFqQU9

* fix(web): treat only 2xx as a successful display save

Review feedback on #422.

- showDisplaySaveResult tested `xhr.status >= 400` for failure, so a
  network error — which reports status 0 — was waved through as
  "Display settings saved". That's the same class of false-success bug
  this branch set out to fix. Test the 2xx range instead, and let a
  response body refine a successful verdict without overturning a
  failed one.
- Annotate the _copies_fits_hardware helper, matching the annotated
  helpers already in api_v3.py.
- Cover the vertical divisibility branch: chain_length 2 would divide
  evenly, so only parallel 3 can produce the rejection, which pins the
  branch to the right hardware dimension.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aUvzTXpEYhNEWYQvFqQU9

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-28 16:54:42 -04:00
17 changed files with 3318 additions and 122 deletions
+14 -1
View File
@@ -130,7 +130,20 @@
"plugin_order": [], "plugin_order": [],
"excluded_plugins": [], "excluded_plugins": [],
"target_fps": 125, "target_fps": 125,
"buffer_ahead": 2 "buffer_ahead": 2,
"intra_plugin_gap": 8,
"render_width_pct": 100,
"min_content_separation": 24,
"auto_trim": true,
"trim_threshold": 10,
"content_padding": 8,
"min_plugin_width": 8,
"lead_in_width": 0,
"plugins_per_cycle": 6,
"max_plugin_width_ratio": 3.0,
"dynamic_duration_enabled": true,
"min_cycle_duration": 60,
"max_cycle_duration": 240
} }
}, },
"sync": { "sync": {
+384
View File
@@ -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())
+18 -8
View File
@@ -110,20 +110,30 @@ class ScrollHelper:
self.is_scrolling = False self.is_scrolling = False
self.scroll_complete = False self.scroll_complete = False
def create_scrolling_image(self, content_items: list, def create_scrolling_image(self, content_items: list,
item_gap: int = 32, item_gap: int = 32,
element_gap: int = 16) -> Image.Image: element_gap: int = 16,
lead_gap: Optional[int] = None) -> Image.Image:
""" """
Create a wide image containing all content items for scrolling. Create a wide image containing all content items for scrolling.
Args: Args:
content_items: List of PIL Images to include in scroll content_items: List of PIL Images to include in scroll
item_gap: Gap between different items item_gap: Gap between different items
element_gap: Gap between elements within an item element_gap: Gap between elements within an item
lead_gap: Blank columns before the first item. Defaults to a full
display width, which makes a standalone ticker scroll in from
off-screen. Callers that loop many plugins back-to-back (Vegas
mode) pass a smaller value, since a full display width of black
reads as the panel being switched off at the start of every
cycle.
Returns: Returns:
PIL Image containing all content arranged horizontally PIL Image containing all content arranged horizontally
""" """
if lead_gap is None:
lead_gap = self.display_width
lead_gap = max(0, int(lead_gap))
if not content_items: if not content_items:
# Create empty image if no content # Create empty image if no content
# Still set total_scroll_width to 0 to indicate no scrollable content # Still set total_scroll_width to 0 to indicate no scrollable content
@@ -144,13 +154,13 @@ class ScrollHelper:
total_width += element_gap * len(content_items) total_width += element_gap * len(content_items)
# Add initial gap before first item # Add initial gap before first item
total_width += self.display_width total_width += lead_gap
# Create the full scrolling image # Create the full scrolling image
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0)) full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
# Position items # Position items
current_x = self.display_width # Start with initial gap current_x = lead_gap # Start with initial gap
for i, img in enumerate(content_items): for i, img in enumerate(content_items):
# Paste the item image # Paste the item image
+53
View File
@@ -536,6 +536,59 @@ class DisplayManager:
finally: finally:
self._capture_mode_active = False 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): def _composite_double_sided(self):
"""Tile the logical screen across the full physical chain. """Tile the logical screen across the full physical chain.
+34
View File
@@ -505,6 +505,40 @@ class BasePlugin(ABC):
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Vegas scroll mode support # 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]: def get_vegas_content(self) -> Optional[Any]:
""" """
Get content for Vegas-style continuous scroll mode. 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 math
import os import os
import time import time
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import Any, List, Optional, Tuple from typing import Any, List, Optional, Tuple
@@ -62,6 +63,9 @@ class VisualTestDisplayManager:
# Matrix proxy (plugins access display_manager.matrix.width/height) # Matrix proxy (plugins access display_manager.matrix.width/height)
self.matrix = _MatrixProxy(width, height) self.matrix = _MatrixProxy(width, height)
# Set while inside capture_mode(); mirrors DisplayManager's flag.
self._capture_mode_active = False
# Scrolling state (interface compat, no-op) # Scrolling state (interface compat, no-op)
self._scrolling_state = { self._scrolling_state = {
'is_scrolling': False, 'is_scrolling': False,
@@ -174,6 +178,50 @@ class VisualTestDisplayManager:
"""No-op for hardware; marks that display was updated.""" """No-op for hardware; marks that display was updated."""
self.update_called = True self.update_called = True
@contextmanager
def 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, def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False, color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
font: Optional[Any] = None, centered: bool = False) -> None: font: Optional[Any] = None, centered: bool = False) -> None:
+146
View File
@@ -21,6 +21,55 @@ class VegasModeConfig:
scroll_speed: float = 50.0 # Pixels per second scroll_speed: float = 50.0 # Pixels per second
separator_width: int = 32 # Gap between plugins (pixels) separator_width: int = 32 # Gap between plugins (pixels)
# 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
# How many plugins are composed into one scroll cycle. Kept separate from
# buffer_ahead (which is only a prefetch low-water mark) because the two
# were previously the same number: a buffer_ahead of 2 meant just 3 plugins
# per cycle, so a 20-plugin install took seven cycles to come around.
plugins_per_cycle: int = 6
# Cap on one plugin's share of a cycle, as a multiple of display width.
# A single ticker returning 7,000px would otherwise hold the panel for over
# two minutes. Overflow is deferred to later cycles rather than discarded.
# 0 disables the cap.
max_plugin_width_ratio: float = 3.0
# Plugin management # Plugin management
plugin_order: List[str] = field(default_factory=list) plugin_order: List[str] = field(default_factory=list)
excluded_plugins: Set[str] = field(default_factory=set) excluded_plugins: Set[str] = field(default_factory=set)
@@ -55,6 +104,18 @@ class VegasModeConfig:
enabled=vegas_config.get('enabled', False), enabled=vegas_config.get('enabled', False),
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)), scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
separator_width=int(vegas_config.get('separator_width', 32)), separator_width=int(vegas_config.get('separator_width', 32)),
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
min_content_separation=int(
vegas_config.get('min_content_separation', 24)),
auto_trim=vegas_config.get('auto_trim', True),
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
content_padding=int(vegas_config.get('content_padding', 8)),
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)),
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
max_plugin_width_ratio=float(
vegas_config.get('max_plugin_width_ratio', 3.0)),
plugin_order=list(vegas_config.get('plugin_order', [])), plugin_order=list(vegas_config.get('plugin_order', [])),
excluded_plugins=set(vegas_config.get('excluded_plugins', [])), excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
target_fps=int(vegas_config.get('target_fps', 125)), target_fps=int(vegas_config.get('target_fps', 125)),
@@ -72,6 +133,16 @@ class VegasModeConfig:
'enabled': self.enabled, 'enabled': self.enabled,
'scroll_speed': self.scroll_speed, 'scroll_speed': self.scroll_speed,
'separator_width': self.separator_width, 'separator_width': self.separator_width,
'intra_plugin_gap': self.intra_plugin_gap,
'render_width_pct': self.render_width_pct,
'min_content_separation': self.min_content_separation,
'auto_trim': self.auto_trim,
'trim_threshold': self.trim_threshold,
'content_padding': self.content_padding,
'min_plugin_width': self.min_plugin_width,
'lead_in_width': self.lead_in_width,
'plugins_per_cycle': self.plugins_per_cycle,
'max_plugin_width_ratio': self.max_plugin_width_ratio,
'plugin_order': self.plugin_order, 'plugin_order': self.plugin_order,
'excluded_plugins': list(self.excluded_plugins), 'excluded_plugins': list(self.excluded_plugins),
'target_fps': self.target_fps, 'target_fps': self.target_fps,
@@ -157,6 +228,59 @@ class VegasModeConfig:
if self.buffer_ahead > 5: if self.buffer_ahead > 5:
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}") errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
if 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 self.intra_plugin_gap < 0:
errors.append(
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
if self.intra_plugin_gap > 128:
errors.append(
f"intra_plugin_gap must be <= 128, got {self.intra_plugin_gap}")
if not 0 <= self.trim_threshold <= 254:
errors.append(
f"trim_threshold must be between 0 and 254, got {self.trim_threshold}")
if self.content_padding < 0:
errors.append(
f"content_padding must be >= 0, got {self.content_padding}")
if self.content_padding > 128:
errors.append(
f"content_padding must be <= 128, got {self.content_padding}")
if self.min_plugin_width < 0:
errors.append(
f"min_plugin_width must be >= 0, got {self.min_plugin_width}")
# Bounded because every segment narrower than this is dropped — an
# unbounded value would discard every plugin and leave a blank ticker.
if self.min_plugin_width > 512:
errors.append(
f"min_plugin_width must be <= 512, got {self.min_plugin_width}")
if self.lead_in_width < 0:
errors.append(
f"lead_in_width must be >= 0, got {self.lead_in_width}")
if self.plugins_per_cycle < 1:
errors.append(
f"plugins_per_cycle must be >= 1, got {self.plugins_per_cycle}")
if self.plugins_per_cycle > 50:
errors.append(
f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}")
if self.max_plugin_width_ratio < 0:
errors.append(
"max_plugin_width_ratio must be >= 0 "
f"(0 disables the cap), got {self.max_plugin_width_ratio}")
return errors return errors
def update(self, new_config: Dict[str, Any]) -> None: def update(self, new_config: Dict[str, Any]) -> None:
@@ -174,6 +298,28 @@ class VegasModeConfig:
self.scroll_speed = float(vegas_config['scroll_speed']) self.scroll_speed = float(vegas_config['scroll_speed'])
if 'separator_width' in vegas_config: if 'separator_width' in vegas_config:
self.separator_width = int(vegas_config['separator_width']) self.separator_width = int(vegas_config['separator_width'])
if 'intra_plugin_gap' in vegas_config:
self.intra_plugin_gap = int(vegas_config['intra_plugin_gap'])
if '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 'auto_trim' in vegas_config:
self.auto_trim = vegas_config['auto_trim']
if 'trim_threshold' in vegas_config:
self.trim_threshold = int(vegas_config['trim_threshold'])
if 'content_padding' in vegas_config:
self.content_padding = int(vegas_config['content_padding'])
if 'min_plugin_width' in vegas_config:
self.min_plugin_width = int(vegas_config['min_plugin_width'])
if 'lead_in_width' in vegas_config:
self.lead_in_width = int(vegas_config['lead_in_width'])
if 'plugins_per_cycle' in vegas_config:
self.plugins_per_cycle = int(vegas_config['plugins_per_cycle'])
if 'max_plugin_width_ratio' in vegas_config:
self.max_plugin_width_ratio = float(
vegas_config['max_plugin_width_ratio'])
if 'plugin_order' in vegas_config: if 'plugin_order' in vegas_config:
self.plugin_order = list(vegas_config['plugin_order']) self.plugin_order = list(vegas_config['plugin_order'])
if 'excluded_plugins' in vegas_config: if 'excluded_plugins' in vegas_config:
+5 -1
View File
@@ -64,7 +64,7 @@ class VegasModeCoordinator:
self.plugin_manager = plugin_manager self.plugin_manager = plugin_manager
# Initialize components # Initialize components
self.plugin_adapter = PluginAdapter(display_manager) self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
self.stream_manager = StreamManager( self.stream_manager = StreamManager(
self.vegas_config, self.vegas_config,
plugin_manager, plugin_manager,
@@ -505,6 +505,10 @@ class VegasModeCoordinator:
# Update components # Update components
self.render_pipeline.update_config(new_vegas_config) self.render_pipeline.update_config(new_vegas_config)
self.stream_manager.config = new_vegas_config self.stream_manager.config = new_vegas_config
self.plugin_adapter.config = new_vegas_config
# Cached segments were trimmed under the old settings, so drop them
# or a changed trim/padding value would not visibly take effect.
self.plugin_adapter.invalidate_cache()
# Force refresh of stream manager to pick up plugin_order/buffer changes # Force refresh of stream manager to pick up plugin_order/buffer changes
self.stream_manager._last_refresh = 0 self.stream_manager._last_refresh = 0
+397
View File
@@ -0,0 +1,397 @@
"""
Geometry primitives for Vegas Mode.
Pure, side-effect-free measurements over PIL images. Two consumers:
- ``PluginAdapter`` trims the blank margins plugins bake into their content
before it enters the ticker (see ``trim_to_content``).
- ``scripts/dev/vegas_audit.py`` reports how much of the composed ticker is
dead space (see ``dead_window_stats``).
Keeping both on the same primitives means the number the audit reports is the
number the trimmer acted on.
All column scans go through numpy: a Python-level per-column loop over a
17,000px-wide ticker image takes seconds, which is far too slow for the render
path.
"""
from typing import NamedTuple, Optional, Tuple
import numpy as np
from PIL import Image
# A pixel counts as "ink" when any channel exceeds this. Chosen to ignore the
# 1-2/255 noise that JPEG-sourced logos and alpha compositing leave behind in
# nominally black areas, while still treating any deliberately drawn dark grey
# as real content.
DEFAULT_INK_THRESHOLD = 10
# A window counts as "dead" when this fraction of its columns carry no ink.
DEFAULT_DEAD_WINDOW_RATIO = 0.95
def column_has_ink(img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD) -> np.ndarray:
"""
Return a boolean array, one entry per image column, True where the column
contains at least one pixel brighter than ``threshold`` in any channel.
Args:
img: Image to scan (converted to RGB internally)
threshold: Per-channel value a pixel must exceed to count as ink
Returns:
Bool array of shape (width,)
"""
arr = np.asarray(img if img.mode == 'RGB' else img.convert('RGB'))
if arr.ndim != 3:
# Degenerate/empty image — treat every column as blank.
return np.zeros(img.width, dtype=bool)
# Collapse rows and channels: a column is ink if any pixel in it is bright.
return arr.max(axis=(0, 2)) > threshold
def content_bounds(
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
) -> Optional[Tuple[int, int]]:
"""
Find the first and last columns containing ink.
Args:
img: Image to measure
threshold: Ink threshold
Returns:
(first_col, last_col) inclusive, or None if the image is entirely blank
"""
ink = column_has_ink(img, threshold)
if not ink.any():
return None
first = int(ink.argmax())
last = len(ink) - 1 - int(ink[::-1].argmax())
return first, last
class TrimResult(NamedTuple):
"""Outcome of a ``trim_to_content`` call."""
image: Optional[Image.Image] # None when the source was entirely blank
original_width: int
trimmed_left: int
trimmed_right: int
@property
def is_blank(self) -> bool:
"""True when the source image carried no ink at all."""
return self.image is None
@property
def width(self) -> int:
"""Width after trimming (0 for a blank source)."""
return 0 if self.image is None else self.image.width
@property
def removed(self) -> int:
"""Total columns removed."""
return self.trimmed_left + self.trimmed_right
def trim_to_content(
img: Image.Image,
threshold: int = DEFAULT_INK_THRESHOLD,
padding: int = 0,
) -> TrimResult:
"""
Crop blank columns off the left and right edges of an image.
Only the outer edges are considered. Blank columns *between* two pieces of
content are deliberately preserved those are the plugin's own layout
(e.g. a logo on the left and a score on the right), and closing them up
would corrupt the design rather than reclaim dead space.
A plugin drawing on a non-black background is unaffected: every column of a
filled background carries ink, so there is nothing to trim.
Args:
img: Image to trim
threshold: Ink threshold
padding: Columns of the original blank margin to keep on each side, as
breathing room. Capped at what the margin actually contains, so
this never widens the image beyond its original bounds.
Returns:
TrimResult. When the image is entirely blank, ``image`` is None and the
caller decides whether to skip the plugin.
"""
bounds = content_bounds(img, threshold)
if bounds is None:
return TrimResult(None, img.width, 0, 0)
first, last = bounds
pad = max(0, padding)
left = max(0, first - pad)
right = min(img.width, last + 1 + pad)
if left == 0 and right == img.width:
return TrimResult(img, img.width, 0, 0)
cropped = img.crop((left, 0, right, img.height))
return TrimResult(cropped, img.width, left, img.width - right)
def 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 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())
+319 -10
View File
@@ -8,9 +8,16 @@ implement get_vegas_content() and fallback capture of display() output.
import logging import logging
import threading import threading
import time import time
from contextlib import nullcontext
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
from PIL import Image from PIL import Image
from src.vegas_mode.geometry import (
find_blank_cut,
separation_gap,
trim_to_content,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from src.plugin_system.base_plugin import BasePlugin from src.plugin_system.base_plugin import BasePlugin
@@ -26,14 +33,21 @@ class PluginAdapter:
2. Fallback: Capture display_manager.image after calling plugin.display() 2. Fallback: Capture display_manager.image after calling plugin.display()
""" """
def __init__(self, display_manager: Any): def __init__(self, display_manager: Any, config: Optional[Any] = None):
""" """
Initialize the plugin adapter. Initialize the plugin adapter.
Args: Args:
display_manager: DisplayManager instance for fallback capture display_manager: DisplayManager instance for fallback capture
config: VegasModeConfig controlling trim behaviour. When omitted,
trimming runs with the dataclass defaults, so existing callers
and tests keep working unchanged.
""" """
self.display_manager = display_manager self.display_manager = display_manager
if config is None:
from src.vegas_mode.config import VegasModeConfig
config = VegasModeConfig()
self.config = config
# Handle both property and method access patterns # Handle both property and method access patterns
self.display_width = ( self.display_width = (
display_manager.width() if callable(display_manager.width) display_manager.width() if callable(display_manager.width)
@@ -49,6 +63,11 @@ class PluginAdapter:
self._cache_lock = threading.Lock() self._cache_lock = threading.Lock()
self._cache_ttl = 5.0 # Cache for 5 seconds self._cache_ttl = 5.0 # Cache for 5 seconds
# Per-plugin rotation offset, so a plugin whose content exceeds its
# width budget shows a different slice on each cycle rather than
# always the same opening items.
self._item_offsets: dict = {}
logger.info( logger.info(
"PluginAdapter initialized: display=%dx%d", "PluginAdapter initialized: display=%dx%d",
self.display_width, self.display_height self.display_width, self.display_height
@@ -93,8 +112,7 @@ class PluginAdapter:
"[%s] Native content SUCCESS: %d images, %dpx total", "[%s] Native content SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width plugin_id, len(content), total_width
) )
self._cache_content(plugin_id, content) return self._finalize(content, plugin_id, 'native')
return content
logger.info("[%s] Native content returned None", plugin_id) logger.info("[%s] Native content returned None", plugin_id)
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds) # Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
@@ -107,8 +125,7 @@ class PluginAdapter:
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total", "[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width plugin_id, len(content), total_width
) )
self._cache_content(plugin_id, content) return self._finalize(content, plugin_id, 'scroll_helper')
return content
if has_scroll_helper: if has_scroll_helper:
logger.info("[%s] ScrollHelper content returned None", plugin_id) logger.info("[%s] ScrollHelper content returned None", plugin_id)
@@ -121,8 +138,7 @@ class PluginAdapter:
"[%s] Fallback capture SUCCESS: %d images, %dpx total", "[%s] Fallback capture SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width plugin_id, len(content), total_width
) )
self._cache_content(plugin_id, content) return self._finalize(content, plugin_id, 'fallback')
return content
logger.warning( logger.warning(
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)", "[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
@@ -130,6 +146,266 @@ class PluginAdapter:
) )
return None return None
def _finalize(
self, images: List[Image.Image], plugin_id: str, source: str
) -> Optional[List[Image.Image]]:
"""
Trim dead space off a segment, then cache it.
Every content path funnels through here so trimming is applied
uniformly. Previously only the scroll_helper path had its margins
stripped, which left plugins that render onto a full-display canvas
contributing their entire blank canvas to the ticker.
Each image is trimmed independently because compose_scroll_content()
treats every image as its own item and inserts separator_width between
them so a per-image trim is what makes that separator the real gap.
Args:
images: Raw content from one of the fetch paths
plugin_id: Plugin identifier for logging
source: Which path produced the content, for logging
Returns:
Trimmed image list, or None if nothing worth showing remains
"""
if not self.config.auto_trim:
# 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)
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)
self._cache_content(plugin_id, kept)
return kept
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 _width_budget(self) -> int:
"""Maximum columns one plugin may occupy in a cycle. 0 means unlimited."""
ratio = self.config.max_plugin_width_ratio
if ratio <= 0:
return 0
return int(self.display_width * ratio)
def _apply_width_budget(
self, images: List[Image.Image], plugin_id: str
) -> List[Image.Image]:
"""
Hold one plugin to its share of a cycle.
A ticker returning 7,000px would otherwise own the panel for over two
minutes, which defeats the point of a rotation. Overflow is deferred
rather than discarded: the starting offset advances each time this
plugin is fetched, so later items appear on subsequent cycles instead
of never being seen.
Args:
images: Trimmed images for this plugin
plugin_id: Plugin identifier, used to track its rotation offset
Returns:
Images that fit the budget, starting from the plugin's current
rotation offset.
"""
budget = self._width_budget()
# Count the gaps the compositor will 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)]
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
self._item_offsets[plugin_id] = (start + consumed) % len(images)
logger.info(
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
"from offset %d; remainder deferred to a later cycle",
plugin_id, budget, len(selected), len(images), used, start
)
return selected
def _crop_to_budget(
self, img: Image.Image, budget: int, plugin_id: str
) -> Image.Image:
"""
Narrow a single oversized image to the budget, advancing a window
through it across cycles.
The cut is snapped to the nearest blank column so it does not slice
through a glyph or logo and leave half a character at the panel edge.
"""
offset = self._item_offsets.get(plugin_id, 0)
if offset >= img.width:
offset = 0
# Snap both edges to blank columns. The search radius is generous
# enough to clear a wide glyph but small enough not to distort the
# requested budget much.
snap = max(8, self.display_width // 16)
start = find_blank_cut(img, offset, snap, self.config.trim_threshold)
end = find_blank_cut(
img, min(start + budget, img.width), snap, self.config.trim_threshold)
if end <= start:
end = min(start + budget, img.width)
# Next cycle resumes where this one stopped; wrap when the strip ends.
self._item_offsets[plugin_id] = 0 if end >= img.width else end
logger.info(
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
"(%dpx), window advances next cycle",
plugin_id, budget, img.width, start, end, end - start
)
return img.crop((start, 0, end, img.height))
def _get_native_content( def _get_native_content(
self, plugin: 'BasePlugin', plugin_id: str self, plugin: 'BasePlugin', plugin_id: str
) -> Optional[List[Image.Image]]: ) -> Optional[List[Image.Image]]:
@@ -145,7 +421,27 @@ class PluginAdapter:
""" """
try: try:
logger.info("[%s] Native: calling get_vegas_content()", plugin_id) logger.info("[%s] Native: calling get_vegas_content()", plugin_id)
result = plugin.get_vegas_content()
# Tell the plugin how much width the ticker wants it to use, and
# narrow the canvas for the duration of the call. A plugin that
# sizes its own images from display_manager.matrix.width picks up
# the narrower value with no changes of its own; one that wants to
# be explicit can read get_vegas_render_width().
render_width = self.resolve_render_width(plugin, plugin_id)
plugin._vegas_render_width = render_width
try:
if render_width == self.display_width:
result = plugin.get_vegas_content()
else:
logger.info(
"[%s] Native: requesting %dpx instead of %dpx",
plugin_id, render_width, self.display_width
)
with self.display_manager.capture_mode(), \
self._render_at(render_width):
result = plugin.get_vegas_content()
finally:
plugin._vegas_render_width = None
if result is None: if result is None:
logger.info("[%s] Native: get_vegas_content() returned None", plugin_id) logger.info("[%s] Native: get_vegas_content() returned None", plugin_id)
@@ -497,7 +793,19 @@ class PluginAdapter:
# Clear and call plugin display — use capture_mode to suppress hardware writes # Clear and call plugin display — use capture_mode to suppress hardware writes
# that plugins may trigger internally via update_display(). # 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.display_manager.capture_mode(), \
self._render_at(render_width):
self.display_manager.clear() self.display_manager.clear()
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id) logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
@@ -531,7 +839,8 @@ class PluginAdapter:
plugin_id plugin_id
) )
# Try once more with force_clear=True # Try once more with force_clear=True
with self.display_manager.capture_mode(): with self.display_manager.capture_mode(), \
self._render_at(render_width):
self.display_manager.clear() self.display_manager.clear()
plugin.display(force_clear=True) plugin.display(force_clear=True)
captured = self.display_manager.image.copy() captured = self.display_manager.image.copy()
+108 -52
View File
@@ -14,6 +14,7 @@ from PIL import Image
from src.common.scroll_helper import ScrollHelper from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.config import VegasModeConfig from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.geometry import separation_gap
from src.vegas_mode.stream_manager import StreamManager from src.vegas_mode.stream_manager import StreamManager
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -66,10 +67,6 @@ class RenderPipeline:
else display_manager.height else display_manager.height
) )
# Reusable blank frame for cycle-end pushes (allocated lazily,
# re-blacked before each reuse)
self._blank_frame = None
# ScrollHelper for optimized scrolling # ScrollHelper for optimized scrolling
self.scroll_helper = ScrollHelper( self.scroll_helper = ScrollHelper(
self.display_width, self.display_width,
@@ -141,23 +138,37 @@ class RenderPipeline:
True if composition successful True if composition successful
""" """
try: try:
# Get all buffered content # Content grouped by plugin, so a separator can be placed at the
images = self.stream_manager.get_all_content_for_composition() # plugin boundaries only.
grouped = self.stream_manager.get_grouped_content_for_composition()
if not images: if not grouped:
logger.warning("No content available for composition") logger.warning("No content available for composition")
return False return False
# Add separator gaps between images # Collapse each plugin's rows into a single block, joined by
content_with_gaps = [] # intra_plugin_gap. ScrollHelper applies one uniform gap between the
for i, img in enumerate(images): # items it is given, so handing it one item per plugin is what makes
content_with_gaps.append(img) # separator_width mean "between plugins" instead of "between every
# row". Without this, a per-row ticker such as the F1 scoreboard got
# the full separator between each of its ~116 rows.
blocks = []
total_rows = 0
for plugin_id, images in grouped:
total_rows += len(images)
blocks.append(self._join_plugin_rows(images))
# Create scrolling image via ScrollHelper # Create scrolling image via ScrollHelper.
#
# lead_gap is explicit because ScrollHelper otherwise prepends a
# full display width of black — appropriate for a standalone ticker
# scrolling in from off-screen, but in Vegas mode it is charged
# once per cycle and reads as the panel switching off.
self.scroll_helper.create_scrolling_image( self.scroll_helper.create_scrolling_image(
content_items=content_with_gaps, content_items=blocks,
item_gap=self.config.separator_width, item_gap=self.config.separator_width,
element_gap=0 element_gap=0,
lead_gap=self.config.lead_in_width
) )
# Verify scroll image was created successfully # Verify scroll image was created successfully
@@ -177,11 +188,16 @@ class RenderPipeline:
self._cycle_complete = False self._cycle_complete = False
logger.info( logger.info(
"Composed scroll image: %dx%d, %d plugins, %d items", "Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
"separator=%dpx between plugins, rows spaced to %dpx of ink "
"(min added %dpx)",
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0, self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
self.display_height, self.display_height,
len(self._segments_in_scroll), len(blocks),
len(images) total_rows,
self.config.separator_width,
self.config.min_content_separation,
self.config.intra_plugin_gap,
) )
return True return True
@@ -191,6 +207,44 @@ class RenderPipeline:
logger.exception("Error composing scroll content") logger.exception("Error composing scroll content")
return False return False
def _join_plugin_rows(self, images: List[Image.Image]) -> Image.Image:
"""
Concatenate one plugin's images into a single block.
Args:
images: That plugin's content, in order
Returns:
A single image with the rows laid out left to right, separated by
``intra_plugin_gap``. Returned unchanged when there is only one row,
which is the common case and avoids a pointless copy.
"""
if len(images) == 1:
return images[0]
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: def render_frame(self) -> bool:
""" """
Render a single frame to the display. Render a single frame to the display.
@@ -211,21 +265,29 @@ class RenderPipeline:
# Determine if the cycle is done. # Determine if the cycle is done.
# #
# scroll_helper considers a cycle complete only after # get_visible_portion wraps: once scroll_position + display_width
# total_distance_scrolled >= total_scroll_width + display_width. # passes the end of the strip it fills the right-hand side of the
# That extra display_width of travel causes a "wrap-around" phase # frame from the *head* of the same strip. So the last
# where scroll_position resets to ~0 and the first plugin's content # display_width of travel shows the cycle's first plugin re-entering
# re-enters from the right — the user sees this 2-3 s of re-entry # on the right while its last plugin exits on the left, and the
# as "a plugin partially displaying before the next one starts." # 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 # This used to be hidden because the strip began with a full
# total_scroll_width (the wrap-around point), before any second-pass # display_width of blank, so the wrapped-in region was black.
# content becomes visible. The scroll_helper's own is_scroll_complete() # lead_in_width now defaults to 0 (that blank was 10s of dead panel
# check is kept as a fallback for any edge-cases where that threshold # at 50px/s), which exposed the wrap — so the cycle has to end
# is never hit. # 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.
wrap_point = self.scroll_helper.total_scroll_width
if wrap_point > self.display_width:
wrap_point -= self.display_width
at_wrap_point = ( at_wrap_point = (
not self._cycle_complete and 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(): if at_wrap_point or self.scroll_helper.is_scroll_complete():
@@ -236,24 +298,17 @@ class RenderPipeline:
"Scroll cycle complete after %.1fs", "Scroll cycle complete after %.1fs",
time.time() - self._cycle_start_time time.time() - self._cycle_start_time
) )
# Push blank immediately so the hardware never shows any # Deliberately leave the last rendered frame on the panel.
# post-wrap content while the coordinator recomposes the #
# next cycle (~100 ms). The blank is allocated once and # This used to push a blank frame so no post-wrap content
# reused across cycle wraps (fresh paste each time in case # could be seen while the next cycle was composed. But
# a consumer drew on the previous one). # recomposing is synchronous and fetches plugin content:
try: # measured 84ms at best and 4.8s at worst on a 512px panel,
if self._blank_frame is None or self._blank_frame.size != ( # and every millisecond of it was black. Holding the last
self.display_width, self.display_height): # frame instead turns that into a brief freeze, which reads
self._blank_frame = Image.new( # as far less broken than the display switching off. The
'RGB', (self.display_width, self.display_height)) # frame is already past the end of the content, so there is
else: # no second-pass content to leak.
self._blank_frame.paste(
(0, 0, 0),
(0, 0, self.display_width, self.display_height))
self.display_manager.image = self._blank_frame
self.display_manager.update_display()
except Exception:
logger.exception("Failed to write blank frame to display at cycle end")
return True # Cycle done; coordinator starts new cycle next frame return True # Cycle done; coordinator starts new cycle next frame
# Get visible portion # Get visible portion
@@ -415,11 +470,12 @@ class RenderPipeline:
result = self.compose_scroll_content() result = self.compose_scroll_content()
if result and self.sync_manager: if result and self.sync_manager:
# When sync is active, start the leader at display_width instead of 0. # When sync is active, start the leader past the lead-in gap so it
# This skips the initial black gap so the leader immediately shows content. # immediately shows content, leaving the follower on the blank gap
# The follower starts at position 0 (the gap) which looks like a clean # for a clean transition rather than near-end content wrapping
# blank transition rather than near-end content wrapping around. # around. This tracks lead_in_width rather than assuming a full
self.scroll_helper.scroll_position = float(self.display_width) # display width of gap, which is no longer the default.
self.scroll_helper.scroll_position = float(self.config.lead_in_width)
if result and self.sync_manager: if result and self.sync_manager:
# Signal follower that a new cycle started (triggers its own rebuild) # Signal follower that a new cycle started (triggers its own rebuild)
+49 -13
View File
@@ -14,7 +14,7 @@ Supports three display modes:
import logging import logging
import threading import threading
import time import time
from typing import Optional, List, Dict, Any, Deque, TYPE_CHECKING from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
from collections import deque from collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from PIL import Image from PIL import Image
@@ -116,8 +116,11 @@ class StreamManager:
logger.warning("No plugins available for Vegas scroll") logger.warning("No plugins available for Vegas scroll")
return False return False
# Prefetch initial content # Fill the buffer to a whole cycle's worth of plugins. This used to be
self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins))) # buffer_ahead + 1, which conflated prefetch depth with cycle size and
# meant a 20-plugin install only showed 3 plugins before recomposing.
self._prefetch_content(
count=min(self.config.plugins_per_cycle, len(self._ordered_plugins)))
logger.info( logger.info(
"StreamManager initialized with %d plugins, %d segments buffered", "StreamManager initialized with %d plugins, %d segments buffered",
@@ -385,7 +388,7 @@ class StreamManager:
return return
for _ in range(count): for _ in range(count):
if len(self._active_buffer) >= self.config.buffer_ahead + 1: if len(self._active_buffer) >= self.config.plugins_per_cycle:
break break
# Ensure index is valid (guard against empty list) # Ensure index is valid (guard against empty list)
@@ -521,28 +524,61 @@ class StreamManager:
logger.debug("Refreshed content for %s in staging buffer", plugin_id) logger.debug("Refreshed content for %s in staging buffer", plugin_id)
def _ensure_buffer_filled(self) -> None: def _ensure_buffer_filled(self) -> None:
"""Ensure buffer has enough content prefetched.""" """
if len(self._active_buffer) < self.config.buffer_ahead: Top the buffer back up after segments have been served.
needed = self.config.buffer_ahead - len(self._active_buffer)
self._prefetch_content(count=needed) buffer_ahead is the low-water mark only; plugins_per_cycle is the
ceiling and is enforced inside _prefetch_content.
"""
low_water = min(self.config.buffer_ahead, self.config.plugins_per_cycle)
if len(self._active_buffer) < low_water:
self._prefetch_content(count=low_water - len(self._active_buffer))
def get_all_content_for_composition(self) -> List[Image.Image]: def get_all_content_for_composition(self) -> List[Image.Image]:
""" """
Get all buffered content as a flat list of images. Get all buffered content as a flat list of images.
Used when composing the full scroll image.
Skips STATIC segments as they don't have images to compose. Skips STATIC segments as they don't have images to compose.
Prefer get_grouped_content_for_composition(): flattening loses the
plugin boundaries, which is what tells the compositor where a
separator belongs and where it does not.
Returns: Returns:
List of all images in buffer order List of all images in buffer order
""" """
all_images = [] all_images = []
for _plugin_id, images in self.get_grouped_content_for_composition():
all_images.extend(images)
return all_images
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
"""
Get buffered content grouped by the plugin that produced it.
The grouping matters: separator_width is meant to mark the handoff from
one plugin to the next, not to sit between every row a single plugin
contributes. A per-row ticker like the F1 scoreboard returns over a
hundred images that it renders 4px apart internally, so flattening them
into one list and applying a uniform gap forced 32px between each of
its rows both inconsistent with how the plugin looks standalone, and
a large hidden addition to the width it occupies.
Skips STATIC segments, which trigger a pause rather than contributing
scroll content, and segments left with no images.
Returns:
List of (plugin_id, images) in buffer order
"""
grouped: List[Tuple[str, List[Image.Image]]] = []
with self._buffer_lock: with self._buffer_lock:
for segment in self._active_buffer: for segment in self._active_buffer:
# Skip STATIC segments - they trigger pauses, not scroll content if segment.display_mode == VegasDisplayMode.STATIC:
if segment.display_mode != VegasDisplayMode.STATIC: continue
all_images.extend(segment.images) if not segment.images:
return all_images continue
grouped.append((segment.plugin_id, list(segment.images)))
return grouped
def advance_cycle(self) -> None: def advance_cycle(self) -> None:
""" """
+971
View File
@@ -0,0 +1,971 @@
"""
Tests for the Vegas mode density work: dead-space trimming in PluginAdapter
and the configurable lead-in gap in ScrollHelper.
"""
import pytest
from PIL import Image
from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.geometry import column_has_ink
from src.vegas_mode.plugin_adapter import PluginAdapter
DISPLAY_W = 512
DISPLAY_H = 64
class FakeDisplayManager:
"""Minimal stand-in; the native content path never touches the canvas."""
width = DISPLAY_W
height = DISPLAY_H
def __init__(self):
self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H))
class NativePlugin:
"""Plugin that returns pre-rendered Vegas content."""
def __init__(self, images):
self._images = images
def get_vegas_content(self):
return self._images
def canvas(content_spans, width=DISPLAY_W, height=DISPLAY_H):
"""Full-display canvas with white content in the given [x0, x1) spans."""
img = Image.new('RGB', (width, height), (0, 0, 0))
for x0, x1 in content_spans:
img.paste(Image.new('RGB', (x1 - x0, height), (255, 255, 255)), (x0, 0))
return img
def adapter_with(**overrides):
cfg = VegasModeConfig(**overrides)
return PluginAdapter(FakeDisplayManager(), cfg)
class TestAdapterTrimming:
def test_of_the_day_case_is_reclaimed(self):
# Measured on devpi: "No Data" occupying 35px of a 512px canvas bought
# 9.5s of black at 50px/s.
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([canvas([(4, 39)])])
images = adapter.get_content(plugin, 'of-the-day')
assert len(images) == 1
assert images[0].width == 35
def test_youtube_stats_case_is_reclaimed(self):
# Centred 142px of content on a 512px canvas: 185px black each side.
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([canvas([(185, 327)])])
images = adapter.get_content(plugin, 'youtube-stats')
assert images[0].width == 142
def test_padding_is_applied_within_available_margin(self):
adapter = adapter_with(content_padding=8)
plugin = NativePlugin([canvas([(185, 327)])])
images = adapter.get_content(plugin, 'youtube-stats')
assert images[0].width == 142 + 16
def test_interior_layout_gap_survives(self):
# A logo far left and a score far right is deliberate layout; closing
# the gap would corrupt the design rather than reclaim dead space.
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([canvas([(10, 40), (400, 460)])])
images = adapter.get_content(plugin, 'scoreboard')
assert images[0].width == 450 # 10..459
assert int(column_has_ink(images[0]).sum()) == 90
def test_wide_legitimate_content_is_left_alone(self):
# geochron uses 444 of 512 columns; only the real tail should go.
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([canvas([(0, 444)])])
images = adapter.get_content(plugin, 'geochron')
assert images[0].width == 444
def test_non_black_background_is_untouched(self):
adapter = adapter_with(content_padding=0)
bg = Image.new('RGB', (DISPLAY_W, DISPLAY_H), (0, 0, 40))
plugin = NativePlugin([bg])
images = adapter.get_content(plugin, 'weather')
assert images[0].width == DISPLAY_W
def test_each_image_of_a_multi_item_segment_is_trimmed(self):
# compose_scroll_content treats every image as its own item, so a
# per-image trim is what makes separator_width the real gap.
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([
canvas([(168, 344)]),
canvas([(200, 300)]),
canvas([(0, 512)]),
])
images = adapter.get_content(plugin, 'ledmatrix-flights')
assert [img.width for img in images] == [176, 100, 512]
def test_fully_blank_segment_contributes_nothing(self):
adapter = adapter_with()
plugin = NativePlugin([Image.new('RGB', (DISPLAY_W, DISPLAY_H))])
assert adapter.get_content(plugin, 'empty') is None
def test_blank_images_are_dropped_but_others_kept(self):
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([
canvas([(100, 150)]),
Image.new('RGB', (DISPLAY_W, DISPLAY_H)),
canvas([(200, 260)]),
])
images = adapter.get_content(plugin, 'mixed')
assert [img.width for img in images] == [50, 60]
def test_min_plugin_width_rejects_noise(self):
adapter = adapter_with(content_padding=0, min_plugin_width=32)
plugin = NativePlugin([canvas([(10, 14)])])
assert adapter.get_content(plugin, 'sliver') is None
def test_min_plugin_width_of_zero_keeps_everything(self):
adapter = adapter_with(content_padding=0, min_plugin_width=0)
plugin = NativePlugin([canvas([(10, 14)])])
images = adapter.get_content(plugin, 'sliver')
assert images[0].width == 4
def test_auto_trim_off_preserves_original_behaviour(self):
adapter = adapter_with(auto_trim=False)
plugin = NativePlugin([canvas([(4, 39)])])
images = adapter.get_content(plugin, 'of-the-day')
assert images[0].width == DISPLAY_W
def test_trim_threshold_ignores_near_black_noise(self):
# A very dark band should not be mistaken for content.
img = Image.new('RGB', (DISPLAY_W, DISPLAY_H), (0, 0, 0))
img.paste(Image.new('RGB', (100, DISPLAY_H), (3, 3, 3)), (0, 0))
img.paste(Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)), (200, 0))
adapter = adapter_with(content_padding=0, trim_threshold=10)
images = adapter.get_content(NativePlugin([img]), 'noisy')
assert images[0].width == 50
def test_height_is_preserved_through_trim(self):
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([canvas([(100, 200)])])
images = adapter.get_content(plugin, 'x')
assert images[0].height == DISPLAY_H
def test_trimmed_result_is_cached(self):
adapter = adapter_with(content_padding=0)
plugin = NativePlugin([canvas([(100, 200)])])
first = adapter.get_content(plugin, 'cached')
# Swap the plugin's content; the cache should still serve the old size.
plugin._images = [canvas([(0, 512)])]
second = adapter.get_content(plugin, 'cached')
assert first[0].width == second[0].width == 100
def test_default_adapter_construction_still_works(self):
# Existing callers pass only the display manager.
adapter = PluginAdapter(FakeDisplayManager())
assert adapter.config.auto_trim is True
class TestWidthBudget:
def test_segment_within_budget_is_untouched(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
plugin = NativePlugin([canvas([(0, 400)])])
assert adapter.get_content(plugin, 'small')[0].width == 400
def test_oversized_multi_item_segment_is_capped(self):
# 10 items of 400px = 4000px against a 1536px budget (3 x 512).
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
items = [canvas([(0, 400)], width=400) for _ in range(10)]
images = adapter.get_content(NativePlugin(items), 'stock-news')
assert sum(i.width for i in images) <= 3 * DISPLAY_W
assert len(images) == 3 # 1200px; a 4th would exceed 1536
def test_deferred_items_appear_on_the_next_cycle(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
# Distinguish items by width so the rotation is observable.
items = [canvas([(0, w)], width=w) for w in (200, 210, 220, 230, 240)]
plugin = NativePlugin(items)
first = adapter.get_content(plugin, 'ticker')
adapter.invalidate_cache('ticker')
second = adapter.get_content(plugin, 'ticker')
assert [i.width for i in first] != [i.width for i in second]
def test_rotation_eventually_covers_every_item(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
widths = (200, 210, 220, 230, 240)
items = [canvas([(0, w)], width=w) for w in widths]
plugin = NativePlugin(items)
seen = set()
for _ in range(10):
adapter.invalidate_cache('ticker')
for img in adapter.get_content(plugin, 'ticker'):
seen.add(img.width)
assert seen == set(widths)
def test_single_oversized_image_is_cropped_to_budget(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
# A 6898px leaderboard strip, as measured on devpi. Solid ink means
# there is no blank column to snap to, so the cut lands on the budget.
plugin = NativePlugin([canvas([(0, 6898)], width=6898)])
images = adapter.get_content(plugin, 'ledmatrix-leaderboard')
assert len(images) == 1
assert images[0].width == DISPLAY_W
def test_single_image_crop_snaps_to_a_blank_column(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
# Content blocks with gaps; the cut should land in a gap, not mid-block.
spans = [(x, x + 90) for x in range(0, 2000, 100)]
plugin = NativePlugin([canvas(spans, width=2000)])
images = adapter.get_content(plugin, 'gapped')
assert images[0].width != DISPLAY_W
assert abs(images[0].width - DISPLAY_W) <= DISPLAY_W // 16 + 1
def test_single_image_window_advances_across_cycles(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
plugin = NativePlugin([canvas([(0, 3000)], width=3000)])
adapter.get_content(plugin, 'strip')
assert adapter._item_offsets['strip'] == DISPLAY_W
def test_budget_of_zero_disables_the_cap(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0)
plugin = NativePlugin([canvas([(0, 6898)], width=6898)])
assert adapter.get_content(plugin, 'huge')[0].width == 6898
def test_rotation_resets_when_content_shrinks_to_fit(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
big = [canvas([(0, 300)], width=300) for _ in range(5)]
adapter.get_content(NativePlugin(big), 'shrink')
assert 'shrink' in adapter._item_offsets
adapter.invalidate_cache('shrink')
adapter.get_content(NativePlugin([canvas([(0, 100)], width=100)]), 'shrink')
assert 'shrink' not in adapter._item_offsets
def test_one_item_wider_than_budget_is_still_shown(self):
# Never return nothing just because the first whole item overflows.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
items = [canvas([(0, 900)], width=900), canvas([(0, 100)], width=100)]
images = adapter.get_content(NativePlugin(items), 'wide-first')
assert len(images) >= 1
assert images[0].width == 900
class TestPluginBoundaryGaps:
"""separator_width belongs between plugins; intra_plugin_gap within one."""
def _pipeline(self, grouped, **cfg):
from src.vegas_mode.render_pipeline import RenderPipeline
class FakeStream:
def get_grouped_content_for_composition(self):
return grouped
def get_active_plugin_ids(self):
return [pid for pid, _ in grouped]
class DM:
width = DISPLAY_W
height = DISPLAY_H
def set_scrolling_state(self, *a):
pass
return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream())
def test_separator_only_at_plugin_boundaries(self):
# Two plugins, two rows each. Expect: row row [sep] row row. These rows
# are drawn flush to their edges, so the intra-plugin gap is the full
# min_content_separation.
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)]
pipeline = self._pipeline(
[('a', rows[:2]), ('b', rows[2:])],
separator_width=32, intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0,
)
assert pipeline.compose_scroll_content()
ink = column_has_ink(pipeline.scroll_helper.cached_image)
assert ink[:100].all()
assert not ink[100:124].any() # measured gap inside plugin a
assert ink[124:224].all()
assert not ink[224:256].any() # separator between a and b
assert ink[256:356].all()
assert not ink[356:380].any() # measured gap inside plugin b
assert ink[380:480].all()
def test_total_width_uses_both_gap_sizes(self):
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)]
pipeline = self._pipeline(
[('a', rows[:2]), ('b', rows[2:])],
separator_width=32, intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0,
)
pipeline.compose_scroll_content()
# 4 rows + 2 measured intra gaps (24 each) + 1 separator
assert pipeline.scroll_helper.cached_image.width == 400 + 48 + 32
def test_f1_shaped_case_stays_below_the_separator_width(self):
# 12 rows from one plugin. Previously each boundary got the full 32px
# separator (352px of gap); rows are now spaced by measured separation,
# which for flush rows is min_content_separation.
rows = [Image.new('RGB', (128, DISPLAY_H), (255, 255, 255)) for _ in range(12)]
pipeline = self._pipeline(
[('f1-scoreboard', rows)],
separator_width=32, intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0,
)
pipeline.compose_scroll_content()
width = pipeline.scroll_helper.cached_image.width
assert width == 12 * 128 + 11 * 24
assert width < 12 * 128 + 11 * 32 # cheaper than the old flat separator
def test_single_row_plugin_image_is_not_copied(self):
row = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255))
pipeline = self._pipeline([('solo', [row])], lead_in_width=0)
assert pipeline._join_plugin_rows([row]) is row
def test_rows_butt_together_only_when_both_gap_settings_are_zero(self):
# intra_plugin_gap alone no longer decides this: min_content_separation
# would still push flush rows apart, which is the point of it.
rows = [Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)) for _ in range(3)]
pipeline = self._pipeline(
[('a', rows)], separator_width=32, intra_plugin_gap=0,
min_content_separation=0, lead_in_width=0)
pipeline.compose_scroll_content()
assert pipeline.scroll_helper.cached_image.width == 150
assert column_has_ink(pipeline.scroll_helper.cached_image).all()
def test_empty_grouping_fails_composition(self):
pipeline = self._pipeline([])
assert pipeline.compose_scroll_content() is False
class TestWidthBudgetCountsGaps:
def test_budget_accounts_for_intra_plugin_gaps(self):
# 8 rows of 200px = 1600px of pixels, but with 8px gaps the real
# occupancy is 1600 + 56 = 1656px. Against a 512px budget the row count
# must be chosen using the gap-inclusive cost.
adapter = adapter_with(
content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=8)
items = [canvas([(0, 200)], width=200) for _ in range(8)]
images = adapter.get_content(NativePlugin(items), 'rows')
n = len(images)
assert 200 * n + 8 * (n - 1) <= DISPLAY_W
def test_gap_free_config_fits_more_rows(self):
items = [canvas([(0, 200)], width=200) for _ in range(8)]
with_gap = adapter_with(
content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=64)
without = adapter_with(
content_padding=0, max_plugin_width_ratio=1.0, intra_plugin_gap=0)
assert len(without.get_content(NativePlugin(items), 'r')) >= \
len(with_gap.get_content(NativePlugin(items), 'r'))
class TestStreamGrouping:
def _stream(self, segments):
from src.vegas_mode.stream_manager import StreamManager
from collections import deque
sm = StreamManager.__new__(StreamManager)
import threading
sm._buffer_lock = threading.RLock()
sm._active_buffer = deque(segments)
return sm
def _seg(self, plugin_id, count, mode=None):
from src.vegas_mode.stream_manager import ContentSegment
from src.plugin_system.base_plugin import VegasDisplayMode
imgs = [Image.new('RGB', (10, 8)) for _ in range(count)]
return ContentSegment(
plugin_id=plugin_id, images=imgs, total_width=10 * count,
display_mode=mode or VegasDisplayMode.SCROLL)
def test_grouping_preserves_plugin_boundaries(self):
sm = self._stream([self._seg('a', 3), self._seg('b', 1)])
grouped = sm.get_grouped_content_for_composition()
assert [(pid, len(imgs)) for pid, imgs in grouped] == [('a', 3), ('b', 1)]
def test_static_segments_are_skipped(self):
from src.plugin_system.base_plugin import VegasDisplayMode
sm = self._stream([
self._seg('a', 2),
self._seg('paused', 1, VegasDisplayMode.STATIC),
self._seg('b', 1),
])
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['a', 'b']
def test_imageless_segments_are_skipped(self):
sm = self._stream([self._seg('a', 0), self._seg('b', 2)])
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['b']
def test_flat_accessor_still_matches_grouped_total(self):
sm = self._stream([self._seg('a', 3), self._seg('b', 2)])
assert len(sm.get_all_content_for_composition()) == 5
class TestApiBoundsMatchValidate:
"""
The web API's accepted range for each Vegas setting must agree with
VegasModeConfig.validate(), which is what actually gates Vegas starting.
A looser API bound saves a value with a 200 and then makes
VegasModeCoordinator.start() bail out with only a log line, so the ticker
silently never runs. A tighter one rejects a legitimate value with a 400.
Both happened before this test existed.
"""
# (config key, min, max) as validate() enforces them.
EXPECTED = {
'scroll_speed': (1, 200),
'separator_width': (0, 128),
'intra_plugin_gap': (0, 128),
'target_fps': (30, 200),
'buffer_ahead': (1, 5),
'trim_threshold': (0, 254),
'content_padding': (0, 128),
'min_plugin_width': (0, 512),
'plugins_per_cycle': (1, 50),
}
def _api_numeric_fields(self):
"""Extract the numeric_fields map from api_v3 without importing Flask."""
import ast
import pathlib
src = pathlib.Path('web_interface/blueprints/api_v3.py').read_text()
tree = ast.parse(src)
for node in ast.walk(tree):
if not isinstance(node, ast.Assign):
continue
targets = [t.id for t in node.targets if isinstance(t, ast.Name)]
if 'numeric_fields' not in targets:
continue
if not isinstance(node.value, ast.Dict):
continue
found = {}
for key, value in zip(node.value.keys, node.value.values):
if not isinstance(key, ast.Constant):
continue
if not str(key.value).startswith('vegas_'):
break
cfg_key, lo, hi = [ast.literal_eval(e) for e in value.elts]
found[cfg_key] = (lo, hi)
if found:
return found
raise AssertionError("could not locate the vegas numeric_fields map")
def test_every_bound_matches_validate(self):
api = self._api_numeric_fields()
mismatched = {
key: (api[key], expected)
for key, expected in self.EXPECTED.items()
if key in api and api[key] != expected
}
assert not mismatched, f"API bounds disagree with validate(): {mismatched}"
@pytest.mark.parametrize('key,bounds', sorted(EXPECTED.items()))
def test_validate_accepts_both_endpoints(self, key, bounds):
lo, hi = bounds
for value in (lo, hi):
cfg = VegasModeConfig(**{key: value})
errors = [e for e in cfg.validate() if key in e]
assert not errors, f"{key}={value} should be valid, got {errors}"
@pytest.mark.parametrize('key,bounds', sorted(EXPECTED.items()))
def test_validate_rejects_just_outside(self, key, bounds):
lo, hi = bounds
for value in (lo - 1, hi + 1):
cfg = VegasModeConfig(**{key: value})
errors = [e for e in cfg.validate() if key in e]
assert errors, f"{key}={value} should be rejected"
class TestCycleSizing:
def test_plugins_per_cycle_defaults_above_buffer_ahead(self):
cfg = VegasModeConfig()
assert cfg.plugins_per_cycle == 6
assert cfg.plugins_per_cycle > cfg.buffer_ahead + 1
def test_plugins_per_cycle_parses(self):
cfg = VegasModeConfig.from_config(
{'display': {'vegas_scroll': {'plugins_per_cycle': 10}}})
assert cfg.plugins_per_cycle == 10
def test_max_plugin_width_ratio_parses(self):
cfg = VegasModeConfig.from_config(
{'display': {'vegas_scroll': {'max_plugin_width_ratio': 1.5}}})
assert cfg.max_plugin_width_ratio == 1.5
@pytest.mark.parametrize('overrides,bad_key', [
({'plugins_per_cycle': 0}, 'plugins_per_cycle'),
({'plugins_per_cycle': 99}, 'plugins_per_cycle'),
({'max_plugin_width_ratio': -1.0}, 'max_plugin_width_ratio'),
])
def test_validate_rejects_out_of_range(self, overrides, bad_key):
errors = VegasModeConfig(**overrides).validate()
assert any(bad_key in e for e in errors), errors
class TestScrollHelperLeadGap:
def test_default_lead_gap_is_display_width(self):
# Standalone tickers rely on scrolling in from off-screen; that
# behaviour must not change for the many non-Vegas callers.
sh = ScrollHelper(128, 32)
sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))],
item_gap=0, element_gap=0)
assert sh.cached_image.width == 128 + 100
assert not column_has_ink(sh.cached_image)[:128].any()
def test_zero_lead_gap_starts_on_content(self):
sh = ScrollHelper(128, 32)
sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))],
item_gap=0, element_gap=0, lead_gap=0)
assert sh.cached_image.width == 100
assert column_has_ink(sh.cached_image)[0]
def test_explicit_lead_gap_is_honoured(self):
sh = ScrollHelper(128, 32)
sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))],
item_gap=0, element_gap=0, lead_gap=16)
assert sh.cached_image.width == 116
ink = column_has_ink(sh.cached_image)
assert not ink[:16].any()
assert ink[16:].all()
def test_negative_lead_gap_is_clamped(self):
sh = ScrollHelper(128, 32)
sh.create_scrolling_image([Image.new('RGB', (100, 32), (255, 0, 0))],
item_gap=0, element_gap=0, lead_gap=-50)
assert sh.cached_image.width == 100
def test_total_scroll_width_matches_image(self):
# The cycle-complete check compares against total_scroll_width, so a
# mismatch here would cut cycles short or overrun them.
sh = ScrollHelper(128, 32)
items = [Image.new('RGB', (60, 32), (255, 0, 0)) for _ in range(3)]
sh.create_scrolling_image(items, item_gap=32, element_gap=0, lead_gap=0)
assert sh.total_scroll_width == sh.cached_image.width
assert sh.cached_image.width == 60 * 3 + 32 * 2
def test_item_gaps_are_unaffected_by_lead_gap(self):
sh = ScrollHelper(128, 32)
items = [Image.new('RGB', (10, 32), (255, 0, 0)) for _ in range(2)]
sh.create_scrolling_image(items, item_gap=20, element_gap=0, lead_gap=0)
ink = column_has_ink(sh.cached_image)
assert ink[:10].all()
assert not ink[10:30].any()
assert ink[30:40].all()
class TestConfigSurface:
def test_new_keys_parse_from_config(self):
cfg = VegasModeConfig.from_config({'display': {'vegas_scroll': {
'auto_trim': False,
'trim_threshold': 25,
'content_padding': 4,
'min_plugin_width': 64,
'lead_in_width': 32,
}}})
assert cfg.auto_trim is False
assert cfg.trim_threshold == 25
assert cfg.content_padding == 4
assert cfg.min_plugin_width == 64
assert cfg.lead_in_width == 32
def test_defaults_favour_trimming(self):
cfg = VegasModeConfig.from_config({})
assert cfg.auto_trim is True
assert cfg.lead_in_width == 0
assert cfg.content_padding == 8
def test_round_trips_through_to_dict(self):
cfg = VegasModeConfig(trim_threshold=20, lead_in_width=64)
restored = VegasModeConfig.from_config(
{'display': {'vegas_scroll': cfg.to_dict()}})
assert restored.trim_threshold == 20
assert restored.lead_in_width == 64
def test_update_applies_new_keys(self):
cfg = VegasModeConfig()
cfg.update({'display': {'vegas_scroll': {'content_padding': 16}}})
assert cfg.content_padding == 16
@pytest.mark.parametrize('overrides,bad_key', [
({'trim_threshold': 300}, 'trim_threshold'),
({'trim_threshold': -1}, 'trim_threshold'),
({'content_padding': -5}, 'content_padding'),
({'content_padding': 500}, 'content_padding'),
({'min_plugin_width': -1}, 'min_plugin_width'),
({'lead_in_width': -1}, 'lead_in_width'),
])
def test_validate_rejects_out_of_range(self, overrides, bad_key):
errors = VegasModeConfig(**overrides).validate()
assert any(bad_key in e for e in errors), errors
def test_valid_config_has_no_errors(self):
assert VegasModeConfig(
trim_threshold=10, content_padding=8,
min_plugin_width=8, lead_in_width=0,
).validate() == []
class TestRenderWidthResolution:
"""Vegas asks plugins to render narrower so layouts compact, not crop."""
class CfgPlugin:
def __init__(self, cfg=None):
self.config = cfg or {}
def get_vegas_content(self):
return None
def test_defaults_to_full_width(self):
adapter = adapter_with()
assert adapter.resolve_render_width(self.CfgPlugin(), 'p') == DISPLAY_W
def test_global_percentage_applies(self):
adapter = adapter_with(render_width_pct=50)
assert adapter.resolve_render_width(self.CfgPlugin(), 'p') == DISPLAY_W // 2
def test_per_plugin_override_beats_global(self):
adapter = adapter_with(render_width_pct=50)
plugin = self.CfgPlugin({'vegas_width_pct': 30})
assert adapter.resolve_render_width(plugin, 'p') == int(DISPLAY_W * 0.3)
def test_per_plugin_can_opt_back_to_full_width(self):
adapter = adapter_with(render_width_pct=30)
plugin = self.CfgPlugin({'vegas_width_pct': 100})
assert adapter.resolve_render_width(plugin, 'p') == DISPLAY_W
@pytest.mark.parametrize('bad', [0, 5, 150, -10, 'wide', None, ''])
def test_invalid_override_falls_back_to_global(self, bad):
adapter = adapter_with(render_width_pct=50)
plugin = self.CfgPlugin({'vegas_width_pct': bad})
assert adapter.resolve_render_width(plugin, 'p') == DISPLAY_W // 2
def test_plugin_without_config_is_safe(self):
adapter = adapter_with(render_width_pct=50)
class NoCfg:
pass
assert adapter.resolve_render_width(NoCfg(), 'p') == DISPLAY_W // 2
def test_never_exceeds_panel_width(self):
adapter = adapter_with(render_width_pct=100)
assert adapter.resolve_render_width(self.CfgPlugin(), 'p') <= DISPLAY_W
class TestMeasuredSeparation:
"""Rows are spaced by measured blank, not a flat additive gap."""
def _pipeline(self, grouped, **cfg):
from src.vegas_mode.render_pipeline import RenderPipeline
class FakeStream:
def get_grouped_content_for_composition(self):
return grouped
def get_active_plugin_ids(self):
return [pid for pid, _ in grouped]
class DM:
width = DISPLAY_W
height = DISPLAY_H
def set_scrolling_state(self, *a):
pass
return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream())
def test_flush_rows_are_pushed_to_the_target(self):
# The reported problem: score cards drawn edge to edge sat 8px apart.
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(3)]
p = self._pipeline([('scores', rows)],
intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0)
block = p._join_plugin_rows(rows)
assert block.width == 300 + 24 * 2
ink = column_has_ink(block)
assert not ink[100:124].any()
assert ink[124:224].all()
def test_rows_with_margins_are_not_pushed_further(self):
# Each row already carries 12px blank per side = 24px facing total,
# which meets the target, so only the floor is added.
rows = [canvas([(12, 88)], width=100) for _ in range(3)]
p = self._pipeline([('padded', rows)],
intra_plugin_gap=0, min_content_separation=24,
lead_in_width=0)
block = p._join_plugin_rows(rows)
assert block.width == 300
def test_floor_still_applies_when_target_is_met(self):
rows = [canvas([(12, 88)], width=100) for _ in range(2)]
p = self._pipeline([('padded', rows)],
intra_plugin_gap=6, min_content_separation=24,
lead_in_width=0)
assert p._join_plugin_rows(rows).width == 200 + 6
def test_gaps_are_per_pair_not_uniform(self):
# Flush row then a padded row: the two gaps must differ.
flush = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255))
padded = canvas([(20, 80)], width=100)
p = self._pipeline([('mixed', [flush, padded, flush])],
intra_plugin_gap=0, min_content_separation=24,
lead_in_width=0)
block = p._join_plugin_rows([flush, padded, flush])
# gap1: flush right(0) + padded left(20) = 20 -> add 4
# gap2: padded right(20) + flush left(0) = 20 -> add 4
assert block.width == 300 + 4 + 4
def test_zero_target_falls_back_to_the_floor(self):
rows = [Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)) for _ in range(2)]
p = self._pipeline([('a', rows)],
intra_plugin_gap=5, min_content_separation=0,
lead_in_width=0)
assert p._join_plugin_rows(rows).width == 100 + 5
class TestNewConfigKeys:
def test_render_width_pct_parses(self):
cfg = VegasModeConfig.from_config(
{'display': {'vegas_scroll': {'render_width_pct': 40}}})
assert cfg.render_width_pct == 40
def test_min_content_separation_parses(self):
cfg = VegasModeConfig.from_config(
{'display': {'vegas_scroll': {'min_content_separation': 16}}})
assert cfg.min_content_separation == 16
def test_defaults(self):
cfg = VegasModeConfig()
assert cfg.render_width_pct == 100
assert cfg.min_content_separation == 24
@pytest.mark.parametrize('overrides,bad_key', [
({'render_width_pct': 5}, 'render_width_pct'),
({'render_width_pct': 101}, 'render_width_pct'),
({'min_content_separation': -1}, 'min_content_separation'),
({'min_content_separation': 300}, 'min_content_separation'),
])
def test_validate_rejects_out_of_range(self, overrides, bad_key):
errors = VegasModeConfig(**overrides).validate()
assert any(bad_key in e for e in errors), errors
class TestCycleEndsBeforeWrap:
"""
get_visible_portion wraps the head of the strip into the right side of the
frame once scroll_position + display_width passes the end. With a leading
blank that was invisible; with lead_in_width=0 it showed the cycle's first
plugin re-entering while the last one exited, then a recompose replaced
both the reported "switched mid-scroll".
"""
def _pipeline(self, strip_width, **cfg):
from src.vegas_mode.render_pipeline import RenderPipeline
class FakeStream:
def get_grouped_content_for_composition(self):
return [('a', [Image.new('RGB', (strip_width, DISPLAY_H), (255, 255, 255))])]
def get_active_plugin_ids(self):
return ['a']
class DM:
width = DISPLAY_W
height = DISPLAY_H
def __init__(self):
self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H))
def set_scrolling_state(self, *a):
pass
def update_display(self):
pass
p = RenderPipeline(VegasModeConfig(lead_in_width=0, **cfg), DM(), FakeStream())
assert p.compose_scroll_content()
return p
def _advance_to(self, pipeline, distance):
pipeline.scroll_helper.total_distance_scrolled = distance
pipeline.scroll_helper.scroll_position = float(distance)
def test_cycle_is_not_complete_before_the_wrap_point(self):
p = self._pipeline(2000)
self._advance_to(p, 2000 - DISPLAY_W - 1)
p.render_frame()
assert not p.is_cycle_complete()
def test_cycle_completes_exactly_at_the_wrap_point(self):
p = self._pipeline(2000)
self._advance_to(p, 2000 - DISPLAY_W)
p.render_frame()
assert p.is_cycle_complete()
def test_completes_a_full_display_width_earlier_than_the_strip_end(self):
# The whole point: it must not run to total_scroll_width, which is
# where the wrapped content has already been on screen for 10s at
# 50px/s on a 512px panel.
p = self._pipeline(3000)
self._advance_to(p, 3000 - DISPLAY_W - 1)
p.render_frame()
assert not p.is_cycle_complete()
self._advance_to(p, 3000 - DISPLAY_W)
p.render_frame()
assert p.is_cycle_complete()
def test_strip_narrower_than_the_display_does_not_complete_instantly(self):
# Subtracting the display width would go negative and end the cycle on
# the very first frame, spinning the recompose loop.
p = self._pipeline(200)
self._advance_to(p, 0)
p.render_frame()
assert not p.is_cycle_complete()
def test_strip_narrower_than_the_display_still_completes(self):
p = self._pipeline(200)
self._advance_to(p, 200)
p.render_frame()
assert p.is_cycle_complete()
def test_strip_exactly_the_display_width(self):
p = self._pipeline(DISPLAY_W)
self._advance_to(p, 0)
p.render_frame()
assert not p.is_cycle_complete()
self._advance_to(p, DISPLAY_W)
p.render_frame()
assert p.is_cycle_complete()
class TestBudgetIndependentOfTrim:
"""
Turning off margin trimming must not disable the per-plugin width cap
they are unrelated concerns. Found in the field: with auto_trim off, the F1
scoreboard contributed 116 images / 14,848px untouched, producing a 33,821px
cycle.
"""
def test_budget_still_applies_with_trim_off(self):
adapter = adapter_with(auto_trim=False, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0)
items = [canvas([(0, 400)], width=400) for _ in range(10)]
images = adapter.get_content(NativePlugin(items), 'f1-scoreboard')
assert sum(i.width for i in images) <= DISPLAY_W
def test_trim_off_still_leaves_content_untrimmed(self):
# The margins must survive; only the cap should act.
adapter = adapter_with(auto_trim=False, max_plugin_width_ratio=0)
images = adapter.get_content(NativePlugin([canvas([(4, 39)])]), 'x')
assert images[0].width == DISPLAY_W
def test_single_oversized_image_capped_with_trim_off(self):
adapter = adapter_with(auto_trim=False, max_plugin_width_ratio=1.0)
images = adapter.get_content(
NativePlugin([canvas([(0, 6898)], width=6898)]), 'leaderboard')
assert images[0].width <= DISPLAY_W + DISPLAY_W // 16
class TestBudgetUsesMeasuredGaps:
"""
The budget must count the gaps the compositor actually inserts. Assuming the
flat intra_plugin_gap under-counted by up to
(min_content_separation - intra_plugin_gap) per row, so a many-row plugin
overran its cap.
"""
def test_flush_rows_are_budgeted_with_the_measured_gap(self):
# 6 flush rows of 100px against a 512px budget. With 24px measured gaps
# only 4 fit (400 + 3*24 = 472; a 5th would be 596).
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=8, min_content_separation=24)
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(6)]
images = adapter.get_content(NativePlugin(rows), 'rows')
n = len(images)
assert 100 * n + 24 * (n - 1) <= DISPLAY_W
assert n == 4
def test_larger_separation_fits_fewer_rows(self):
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(8)]
tight = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=0)
loose = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=0, min_content_separation=48)
assert len(loose.get_content(NativePlugin(rows), 'r')) < \
len(tight.get_content(NativePlugin(rows), 'r'))
def test_composed_block_respects_the_budget_end_to_end(self):
# The real invariant: what the compositor produces must fit the cap.
from src.vegas_mode.render_pipeline import RenderPipeline
cfg = dict(content_padding=0, max_plugin_width_ratio=1.0,
intra_plugin_gap=8, min_content_separation=24)
adapter = adapter_with(**cfg)
rows = [Image.new('RGB', (90, DISPLAY_H), (255, 255, 255)) for _ in range(9)]
selected = adapter.get_content(NativePlugin(rows), 'rows')
class FakeStream:
def get_grouped_content_for_composition(self):
return [('rows', selected)]
def get_active_plugin_ids(self):
return ['rows']
class DM:
width = DISPLAY_W
height = DISPLAY_H
def set_scrolling_state(self, *a):
pass
p = RenderPipeline(VegasModeConfig(lead_in_width=0, **cfg), DM(), FakeStream())
assert p._join_plugin_rows(selected).width <= DISPLAY_W
class TestRotationAcrossMultipleCycles:
"""
The single-image crop advances a window across cycles. The second and later
passes are where start + budget can land exactly on the image width, which
crashed find_blank_cut in the field and lost that plugin's content for the
cycle. First-pass-only tests never reach it.
"""
def test_window_advances_over_many_cycles_without_error(self):
# Mirrors the field case: 1840px stocks strip, 1536px budget, so the
# second pass starts at 1536 and start + budget == 3072 -> clamped to
# the 1840 width, i.e. target == img.width.
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
strip = canvas([(0, 1840)], width=1840)
widths = []
for _ in range(8):
adapter.invalidate_cache('ledmatrix-stocks')
images = adapter.get_content(NativePlugin([strip]), 'ledmatrix-stocks')
assert images, "content must never be lost mid-rotation"
widths.append(images[0].width)
assert all(w > 0 for w in widths)
def test_offset_wraps_back_to_zero_at_the_end(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=3.0)
strip = canvas([(0, 1840)], width=1840)
seen_reset = False
for _ in range(6):
adapter.invalidate_cache('s')
adapter.get_content(NativePlugin([strip]), 's')
if adapter._item_offsets.get('s', 0) == 0:
seen_reset = True
assert seen_reset, "window should wrap round rather than stall at the end"
def test_multi_row_rotation_never_returns_empty(self):
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
rows = [canvas([(0, 200)], width=200) for _ in range(7)]
for _ in range(10):
adapter.invalidate_cache('rows')
assert adapter.get_content(NativePlugin(rows), 'rows')
+370
View File
@@ -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
+90
View File
@@ -146,6 +146,11 @@ class TestConfigAPI:
def test_save_double_sided_settings(self, client, mock_config_manager): def test_save_double_sided_settings(self, client, mock_config_manager):
"""Double-sided form fields are persisted under display.double_sided.""" """Double-sided form fields are persisted under display.double_sided."""
# 2 copies on the vertical axis needs parallel to be a multiple of 2.
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 2, 'parallel': 2,
}
response = client.post( response = client.post(
'/api/v3/config/main', '/api/v3/config/main',
data={ data={
@@ -175,6 +180,91 @@ class TestConfigAPI:
assert ds['enabled'] is False assert ds['enabled'] is False
assert ds['copies'] == 4 assert ds['copies'] == 4
def test_save_double_sided_disabled_skips_divisibility_check(self, client, mock_config_manager):
"""A copies/chain_length mismatch must not block saves while disabled.
The Display form posts copies/axis on every save, so validating them
with the feature off locked users out of every other display setting.
"""
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 3, 'parallel': 1,
}
response = client.post(
'/api/v3/config/main',
data={
'double_sided_copies': '2',
'double_sided_axis': 'horizontal',
'brightness': '75',
},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
assert ds['enabled'] is False
assert ds['copies'] == 2
def test_save_double_sided_enabled_enforces_divisibility(self, client, mock_config_manager):
"""The same mismatch is still rejected once the feature is turned on."""
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 3, 'parallel': 1,
}
response = client.post(
'/api/v3/config/main',
data={
'double_sided_enabled': 'true',
'double_sided_copies': '2',
'double_sided_axis': 'horizontal',
},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 400
assert 'chain length' in response.get_json()['message']
mock_config_manager.save_config_atomic.assert_not_called()
def test_save_double_sided_vertical_checks_parallel(self, client, mock_config_manager):
"""The vertical axis is checked against parallel, not chain_length."""
mock_config_manager.load_config.return_value['display']['hardware'] = {
'chain_length': 2, 'parallel': 3,
}
response = client.post(
'/api/v3/config/main',
data={
'double_sided_enabled': 'true',
'double_sided_copies': '2',
'double_sided_axis': 'vertical',
},
content_type='application/x-www-form-urlencoded',
)
# chain_length 2 would divide evenly — only parallel 3 rejects this.
assert response.status_code == 400
assert 'parallel' in response.get_json()['message']
mock_config_manager.save_config_atomic.assert_not_called()
def test_save_double_sided_disabled_ignores_bad_values(self, client, mock_config_manager):
"""While disabled, unusable copies/axis are dropped rather than rejected."""
mock_config_manager.load_config.return_value['display']['double_sided'] = {
'enabled': True, 'copies': 2, 'axis': 'horizontal',
}
response = client.post(
'/api/v3/config/main',
data={'double_sided_copies': 'abc', 'double_sided_axis': 'diagonal'},
content_type='application/x-www-form-urlencoded',
)
assert response.status_code == 200
ds = mock_config_manager.save_config_atomic.call_args[0][0]['display']['double_sided']
assert ds['enabled'] is False
# Stored values left untouched rather than overwritten with junk.
assert ds['copies'] == 2
assert ds['axis'] == 'horizontal'
def test_save_double_sided_invalid_copies_rejected(self, client, mock_config_manager): def test_save_double_sided_invalid_copies_rejected(self, client, mock_config_manager):
"""copies < 2 is rejected with a 400 before any save.""" """copies < 2 is rejected with a 400 before any save."""
response = client.post( response = client.post(
+89 -21
View File
@@ -13,7 +13,7 @@ import uuid
import logging import logging
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, Any from typing import Dict, Any, Optional
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -864,16 +864,15 @@ def save_main_config():
ds_config = current_config['display']['double_sided'] ds_config = current_config['display']['double_sided']
# Enabled checkbox: omitted from the form when unchecked. # Enabled checkbox: omitted from the form when unchecked.
ds_config['enabled'] = _coerce_to_bool(data.get('double_sided_enabled')) # The Display form posts copies/axis on every save regardless of this
# checkbox, so when the feature is off we accept the values without
# rejecting the whole save — otherwise a stale copies/chain_length
# mismatch locks the user out of every other display setting.
enabled = _coerce_to_bool(data.get('double_sided_enabled'))
ds_config['enabled'] = enabled
if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None): def _copies_fits_hardware(copies: int) -> Optional[str]:
try: """Error message if copies doesn't divide the panel evenly, else None."""
copies = int(data['double_sided_copies'])
except (ValueError, TypeError):
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
if not (2 <= copies <= 8):
return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400
# Validate divisibility against the relevant hardware dimension.
# Use axis from this request if provided, else from stored config. # Use axis from this request if provided, else from stored config.
hw = current_config.get('display', {}).get('hardware', {}) hw = current_config.get('display', {}).get('hardware', {})
effective_axis = (data.get('double_sided_axis') effective_axis = (data.get('double_sided_axis')
@@ -881,22 +880,51 @@ def save_main_config():
if effective_axis == 'horizontal': if effective_axis == 'horizontal':
chain_length = int(hw.get('chain_length', 2) or 2) chain_length = int(hw.get('chain_length', 2) or 2)
if chain_length % copies != 0: if chain_length % copies != 0:
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"}), 400 return f"Double-sided copies ({copies}) must divide chain length ({chain_length}) evenly"
elif effective_axis == 'vertical': elif effective_axis == 'vertical':
parallel = int(hw.get('parallel', 1) or 1) parallel = int(hw.get('parallel', 1) or 1)
if parallel % copies != 0: if parallel % copies != 0:
return jsonify({'status': 'error', 'message': f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"}), 400 return f"Double-sided copies ({copies}) must divide parallel ({parallel}) evenly"
ds_config['copies'] = copies return None
if 'double_sided_copies' in data and data['double_sided_copies'] not in ('', None):
copies = None
try:
copies = int(data['double_sided_copies'])
except (ValueError, TypeError):
if enabled:
return jsonify({'status': 'error', 'message': "Double-sided copies must be an integer"}), 400
if copies is not None and not (2 <= copies <= 8):
if enabled:
return jsonify({'status': 'error', 'message': "Double-sided copies must be between 2 and 8"}), 400
# Disabled: leave the stored value alone rather than writing junk.
copies = None
if copies is not None:
# Divisibility is a hardware-relational check — only meaningful
# when the feature is actually on.
if enabled:
fit_error = _copies_fits_hardware(copies)
if fit_error:
return jsonify({'status': 'error', 'message': fit_error}), 400
ds_config['copies'] = copies
if 'double_sided_axis' in data: if 'double_sided_axis' in data:
axis = data['double_sided_axis'] axis = data['double_sided_axis']
if axis not in ('horizontal', 'vertical'): if axis not in ('horizontal', 'vertical'):
return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400 if enabled:
ds_config['axis'] = axis return jsonify({'status': 'error', 'message': "Double-sided axis must be 'horizontal' or 'vertical'"}), 400
else:
ds_config['axis'] = axis
# Handle Vegas scroll mode settings # Handle Vegas scroll mode settings
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width', vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins'] 'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins',
'vegas_auto_trim', 'vegas_trim_threshold', 'vegas_content_padding',
'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle',
'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled',
'vegas_min_cycle_duration', 'vegas_max_cycle_duration',
'vegas_intra_plugin_gap', 'vegas_render_width_pct',
'vegas_min_content_separation']
if any(k in data for k in vegas_fields): if any(k in data for k in vegas_fields):
if 'display' not in current_config: if 'display' not in current_config:
@@ -911,13 +939,53 @@ def save_main_config():
# was submitted (any vegas field present) but enabled key is missing, # was submitted (any vegas field present) but enabled key is missing,
# the checkbox was unchecked and we should set enabled=False # the checkbox was unchecked and we should set enabled=False
vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled')) vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled'))
vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim'))
vegas_config['dynamic_duration_enabled'] = _coerce_to_bool(
data.get('vegas_dynamic_duration_enabled'))
# Handle numeric settings with validation # max_plugin_width_ratio is the one fractional setting, so it is
# handled outside the integer loop below.
if data.get('vegas_max_plugin_width_ratio') not in ('', None):
try:
ratio = float(data['vegas_max_plugin_width_ratio'])
except (ValueError, TypeError):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_max_plugin_width_ratio: "
"must be a number"
}), 400
if not (0 <= ratio <= 20):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_max_plugin_width_ratio: "
"must be between 0 and 20 (0 disables the cap)"
}), 400
vegas_config['max_plugin_width_ratio'] = ratio
# Handle numeric settings with validation.
#
# These bounds must match VegasModeConfig.validate(), which is what
# actually gates Vegas starting. Where they were looser, a value
# saved with a 200 and then made VegasModeCoordinator.start() bail
# out with only a log line, so the ticker silently never ran.
# Where they were tighter (scroll_speed capped at 100 against a
# slider that goes to 200), a legitimate value was rejected with a
# 400. See test_vegas_api_bounds_match_validate.
numeric_fields = { numeric_fields = {
'vegas_scroll_speed': ('scroll_speed', 1, 100), 'vegas_scroll_speed': ('scroll_speed', 1, 200),
'vegas_separator_width': ('separator_width', 0, 500), 'vegas_separator_width': ('separator_width', 0, 128),
'vegas_target_fps': ('target_fps', 1, 200), 'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
'vegas_buffer_ahead': ('buffer_ahead', 1, 20), 'vegas_render_width_pct': ('render_width_pct', 10, 100),
'vegas_min_content_separation': ('min_content_separation', 0, 256),
'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(): for field_name, (config_key, min_val, max_val) in numeric_fields.items():
if field_name in data: if field_name in data:
+223 -16
View File
@@ -30,7 +30,7 @@
hx-ext="json-enc" hx-ext="json-enc"
hx-headers='{"Content-Type": "application/json"}' hx-headers='{"Content-Type": "application/json"}'
hx-swap="none" hx-swap="none"
hx-on:htmx:after-request="showNotification(event.detail.xhr.responseJSON?.message || 'Display settings saved', event.detail.xhr.responseJSON?.status || 'success')" hx-on:htmx:after-request="showDisplaySaveResult(event.detail.xhr)"
class="space-y-6" class="space-y-6"
novalidate novalidate
onsubmit="fixInvalidNumberInputs(this); return true;"> onsubmit="fixInvalidNumberInputs(this); return true;">
@@ -425,7 +425,7 @@
</div> </div>
<div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width"> <div class="form-group" id="setting-display-vegas_separator_width" data-setting-key="display.vegas_scroll.separator_width">
<label for="vegas_separator_width" class="block text-sm font-medium text-gray-700">Separator Width (pixels){{ ui.help_tip('Blank gap inserted between each plugin block in the ticker (0128 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 (0128 px).\nDefault: 32. Larger values make the boundary between plugins clearer. This does not apply between rows of the same plugin — see Row Gap for that.', 'Separator Width') }}</label>
<input type="number" <input type="number"
id="vegas_separator_width" id="vegas_separator_width"
name="vegas_separator_width" name="vegas_separator_width"
@@ -436,6 +436,44 @@
</div> </div>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_intra_plugin_gap" data-setting-key="display.vegas_scroll.intra_plugin_gap">
<label for="vegas_intra_plugin_gap" class="block text-sm font-medium text-gray-700">Row Gap (pixels){{ ui.help_tip('Extra gap always added between rows contributed by the same plugin (0128 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 (0256 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 (10100%).\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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps"> <div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps">
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label> <label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label>
@@ -456,6 +494,137 @@
</div> </div>
</div> </div>
<!-- Cycle Pacing -->
<div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-3">Cycle Pacing</h4>
<p class="text-sm text-gray-600 mb-3">How long one pass through the ticker lasts, and how many plugins it covers.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-group" id="setting-display-vegas_plugins_per_cycle" data-setting-key="display.vegas_scroll.plugins_per_cycle">
<label for="vegas_plugins_per_cycle" class="block text-sm font-medium text-gray-700">Plugins Per Cycle{{ ui.help_tip('How many plugins are composed into one pass of the ticker (150).\nDefault: 6. Higher means more variety before the ticker restarts, and fewer recompose pauses. Lower means each plugin comes around sooner.', 'Plugins Per Cycle') }}</label>
<input type="number"
id="vegas_plugins_per_cycle"
name="vegas_plugins_per_cycle"
value="{{ main_config.display.get('vegas_scroll', {}).get('plugins_per_cycle', 6) }}"
min="1"
max="50"
class="form-control">
</div>
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (020).\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 (53600 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 (103600 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 (0128 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 (02048 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 (0254).\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 (0512 px).\nDefault: 8. Raise it to hide plugins showing only a tiny placeholder such as "No Data" until they have real content.', 'Min Plugin Width') }}</label>
<input type="number"
id="vegas_min_plugin_width"
name="vegas_min_plugin_width"
value="{{ main_config.display.get('vegas_scroll', {}).get('min_plugin_width', 8) }}"
min="0"
max="512"
class="form-control">
</div>
</div>
</div>
<!-- Plugin Order Section --> <!-- Plugin Order Section -->
<div class="mt-4 pt-4 border-t border-gray-200"> <div class="mt-4 pt-4 border-t border-gray-200">
<h4 class="text-sm font-medium text-gray-900 mb-3">Plugin Order</h4> <h4 class="text-sm font-medium text-gray-900 mb-3">Plugin Order</h4>
@@ -475,22 +644,26 @@
<h3 class="text-md font-medium text-gray-900 mb-1">Double-Sided Display</h3> <h3 class="text-md font-medium text-gray-900 mb-1">Double-Sided Display</h3>
<p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain &mdash; e.g. two 64&times;32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p> <p class="text-sm text-gray-600 mb-4">Show the same content on every panel in the chain &mdash; e.g. two 64&times;32 panels mirrored, or four panels as two identical screens. Rendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="form-group mb-4" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled">
<div class="form-group" id="setting-display-double_sided_enabled" data-setting-key="display.double_sided.enabled"> <label class="flex items-center gap-2">
<label class="flex items-center gap-2"> <input type="checkbox"
<input type="checkbox" id="double_sided_enabled"
id="double_sided_enabled" name="double_sided_enabled"
name="double_sided_enabled" value="true"
value="true" {% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %}
{% if main_config.display.get('double_sided', {}).get('enabled') %}checked{% endif %} class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
class="form-control h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"> <span class="text-sm font-medium text-gray-700">Enabled</span>
<span class="text-sm font-medium text-gray-700">Enabled</span> {{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }}
{{ ui.help_tip('Show the same content mirrored across every panel in the chain.\nRendered once and duplicated, so it adds no extra CPU. Takes effect after a display restart.', 'Double-Sided Enabled') }} </label>
</label> </div>
</div>
<!-- Hidden (not disabled) when the feature is off, so the values are
still submitted and round-trip through a save. -->
<div id="double_sided_settings"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
{% if not main_config.display.get('double_sided', {}).get('enabled') %}style="display: none;"{% endif %}>
<div class="form-group" id="setting-display-double_sided_copies" data-setting-key="display.double_sided.copies"> <div class="form-group" id="setting-display-double_sided_copies" data-setting-key="display.double_sided.copies">
<label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (28).\nMust divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label> <label for="double_sided_copies" class="block text-sm font-medium text-gray-700">Copies{{ ui.help_tip('How many identical screens to split the panel area into (28).\nWhen enabled, this must divide the panel evenly — e.g. 2 for a two-sided cube.', 'Copies') }}</label>
<input type="number" <input type="number"
id="double_sided_copies" id="double_sided_copies"
name="double_sided_copies" name="double_sided_copies"
@@ -607,6 +780,28 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
}; };
} }
// Report the outcome of a display-settings save. XMLHttpRequest has no
// `responseJSON` (that's a jQuery property) — read `responseText` and the real
// status code, otherwise a failed save reports success.
window.showDisplaySaveResult = function(xhr) {
// Only 2xx counts as saved. A network failure reports status 0, which any
// `>= 400` test would wave through as success.
const httpSuccess = xhr.status >= 200 && xhr.status < 300;
let message = httpSuccess
? 'Display settings saved'
: 'Display settings were not saved. Check your connection and try again.';
let status = httpSuccess ? 'success' : 'error';
try {
const data = JSON.parse(xhr.responseText);
if (data.message) message = data.message;
// A body can refine a successful verdict but never overturn a failed one.
if (httpSuccess && data.status) status = data.status;
} catch {
// Non-JSON body — fall back to the status-code verdict above.
}
showNotification(message, status);
};
// Vegas Scroll Mode Settings // Vegas Scroll Mode Settings
(function() { (function() {
// Escape HTML to prevent XSS // Escape HTML to prevent XSS
@@ -631,6 +826,18 @@ if (typeof window.fixInvalidNumberInputs !== 'function') {
}); });
} }
// Double-sided: copies/axis only mean anything while the feature is on.
// Hidden rather than disabled so the fields keep submitting and the server
// still sees an 'off' state to persist.
const doubleSidedCheckbox = document.getElementById('double_sided_enabled');
const doubleSidedSettings = document.getElementById('double_sided_settings');
if (doubleSidedCheckbox && doubleSidedSettings) {
doubleSidedCheckbox.addEventListener('change', function() {
doubleSidedSettings.style.display = this.checked ? 'grid' : 'none';
});
}
// Update scroll speed display // Update scroll speed display
const scrollSpeedSlider = document.getElementById('vegas_scroll_speed'); const scrollSpeedSlider = document.getElementById('vegas_scroll_speed');
const scrollSpeedValue = document.getElementById('vegas_scroll_speed_value'); const scrollSpeedValue = document.getElementById('vegas_scroll_speed_value');