Compare commits

...
3 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
12 changed files with 901 additions and 54 deletions
+2
View File
@@ -132,6 +132,8 @@
"target_fps": 125, "target_fps": 125,
"buffer_ahead": 2, "buffer_ahead": 2,
"intra_plugin_gap": 8, "intra_plugin_gap": 8,
"render_width_pct": 100,
"min_content_separation": 24,
"auto_trim": true, "auto_trim": true,
"trim_threshold": 10, "trim_threshold": 10,
"content_padding": 8, "content_padding": 8,
+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.
@@ -178,6 +178,35 @@ 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 @contextmanager
def capture_mode(self): def capture_mode(self):
""" """
+34
View File
@@ -21,6 +21,20 @@ 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 # Gap between rows contributed by the *same* plugin. separator_width marks
# the handoff from one plugin to the next; applying it between every image # 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 # forced a 32px chasm between each row of a per-row ticker (the F1
@@ -91,6 +105,9 @@ class VegasModeConfig:
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)), 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), auto_trim=vegas_config.get('auto_trim', True),
trim_threshold=int(vegas_config.get('trim_threshold', 10)), trim_threshold=int(vegas_config.get('trim_threshold', 10)),
content_padding=int(vegas_config.get('content_padding', 8)), content_padding=int(vegas_config.get('content_padding', 8)),
@@ -117,6 +134,8 @@ class VegasModeConfig:
'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, '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, 'auto_trim': self.auto_trim,
'trim_threshold': self.trim_threshold, 'trim_threshold': self.trim_threshold,
'content_padding': self.content_padding, 'content_padding': self.content_padding,
@@ -209,6 +228,16 @@ 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: if self.intra_plugin_gap < 0:
errors.append( errors.append(
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}") f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
@@ -271,6 +300,11 @@ class VegasModeConfig:
self.separator_width = int(vegas_config['separator_width']) self.separator_width = int(vegas_config['separator_width'])
if 'intra_plugin_gap' in vegas_config: if 'intra_plugin_gap' in vegas_config:
self.intra_plugin_gap = int(vegas_config['intra_plugin_gap']) 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: if 'auto_trim' in vegas_config:
self.auto_trim = vegas_config['auto_trim'] self.auto_trim = vegas_config['auto_trim']
if 'trim_threshold' in vegas_config: if 'trim_threshold' in vegas_config:
+60 -4
View File
@@ -139,6 +139,59 @@ def trim_to_content(
return TrimResult(cropped, img.width, left, img.width - right) 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( def find_blank_cut(
img: Image.Image, img: Image.Image,
target: int, target: int,
@@ -169,16 +222,19 @@ def find_blank_cut(
return target return target
ink = column_has_ink(img, threshold) ink = column_has_ink(img, threshold)
lo = max(0, target - search_radius)
hi = min(width - 1, target + search_radius) # 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. # Walk outwards from target so the nearest gap wins.
for offset in range(0, search_radius + 1): for offset in range(0, search_radius + 1):
right = target + offset right = target + offset
if right <= hi and not ink[right]: if lo <= right <= hi and not ink[right]:
return right return right
left = target - offset left = target - offset
if left >= lo and not ink[left]: if lo <= left <= hi and not ink[left]:
return left return left
return target return target
+135 -12
View File
@@ -8,10 +8,15 @@ 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, trim_to_content 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
@@ -165,8 +170,13 @@ class PluginAdapter:
Trimmed image list, or None if nothing worth showing remains Trimmed image list, or None if nothing worth showing remains
""" """
if not self.config.auto_trim: if not self.config.auto_trim:
self._cache_content(plugin_id, images) # Trimming is off, but the width budget is a separate concern —
return images # 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) original_width = sum(img.width for img in images)
kept: List[Image.Image] = [] kept: List[Image.Image] = []
@@ -214,6 +224,80 @@ class PluginAdapter:
self._cache_content(plugin_id, kept) self._cache_content(plugin_id, kept)
return 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: def _width_budget(self) -> int:
"""Maximum columns one plugin may occupy in a cycle. 0 means unlimited.""" """Maximum columns one plugin may occupy in a cycle. 0 means unlimited."""
ratio = self.config.max_plugin_width_ratio ratio = self.config.max_plugin_width_ratio
@@ -243,11 +327,15 @@ class PluginAdapter:
""" """
budget = self._width_budget() budget = self._width_budget()
# Count the gaps the compositor will insert between these rows, not # Count the gaps the compositor will actually insert, not just the
# just the pixels of the rows themselves — otherwise a plugin with many # pixels of the rows — otherwise a plugin with many rows quietly
# rows quietly occupies far more of the panel than its budget allows. # occupies far more of the panel than its budget allows. These must use
gap = max(0, self.config.intra_plugin_gap) # the same measured rule as RenderPipeline._join_plugin_rows; assuming
total = sum(img.width for img in images) + gap * (len(images) - 1) # 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: if not budget or total <= budget:
# Fits, so reset rotation — the whole segment is being shown. # Fits, so reset rotation — the whole segment is being shown.
@@ -266,7 +354,9 @@ class PluginAdapter:
# cut never lands in the middle of one. # cut never lands in the middle of one.
for step in range(len(images)): for step in range(len(images)):
img = images[(start + step) % len(images)] img = images[(start + step) % len(images)]
cost = img.width + (gap if selected else 0) cost = img.width
if selected:
cost += self._row_gap(selected[-1], img)
if selected and used + cost > budget: if selected and used + cost > budget:
break break
selected.append(img) selected.append(img)
@@ -331,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)
@@ -683,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)
@@ -717,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()
+40 -17
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:
@@ -188,12 +189,14 @@ class RenderPipeline:
logger.info( logger.info(
"Composed scroll image: %dx%d, %d plugin block(s), %d rows, " "Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
"separator=%dpx between plugins / %dpx within", "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(blocks), len(blocks),
total_rows, total_rows,
self.config.separator_width, self.config.separator_width,
self.config.min_content_separation,
self.config.intra_plugin_gap, self.config.intra_plugin_gap,
) )
@@ -219,15 +222,27 @@ class RenderPipeline:
if len(images) == 1: if len(images) == 1:
return images[0] return images[0]
gap = max(0, self.config.intra_plugin_gap) floor = max(0, self.config.intra_plugin_gap)
width = sum(img.width for img in images) + gap * (len(images) - 1) 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) height = max(img.height for img in images)
block = Image.new('RGB', (width, height), (0, 0, 0)) block = Image.new('RGB', (width, height), (0, 0, 0))
x = 0 x = 0
for img in images: for i, img in enumerate(images):
block.paste(img, (x, 0)) block.paste(img, (x, 0))
x += img.width + gap x += img.width + (gaps[i] if i < len(gaps) else 0)
return block return block
def render_frame(self) -> bool: def render_frame(self) -> bool:
@@ -250,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():
+387 -19
View File
@@ -277,54 +277,64 @@ class TestPluginBoundaryGaps:
return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream()) return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream())
def test_separator_only_at_plugin_boundaries(self): def test_separator_only_at_plugin_boundaries(self):
# Two plugins, two rows each. Expect: row row [sep] row row, with the # Two plugins, two rows each. Expect: row row [sep] row row. These rows
# small intra gap inside each pair. # 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)] rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)]
pipeline = self._pipeline( pipeline = self._pipeline(
[('a', rows[:2]), ('b', rows[2:])], [('a', rows[:2]), ('b', rows[2:])],
separator_width=32, intra_plugin_gap=8, lead_in_width=0, separator_width=32, intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0,
) )
assert pipeline.compose_scroll_content() assert pipeline.compose_scroll_content()
ink = column_has_ink(pipeline.scroll_helper.cached_image) ink = column_has_ink(pipeline.scroll_helper.cached_image)
assert ink[:100].all() assert ink[:100].all()
assert not ink[100:108].any() # intra gap inside plugin a assert not ink[100:124].any() # measured gap inside plugin a
assert ink[108:208].all() assert ink[124:224].all()
assert not ink[208:240].any() # separator between a and b assert not ink[224:256].any() # separator between a and b
assert ink[240:340].all() assert ink[256:356].all()
assert not ink[340:348].any() # intra gap inside plugin b assert not ink[356:380].any() # measured gap inside plugin b
assert ink[348:448].all() assert ink[380:480].all()
def test_total_width_uses_both_gap_sizes(self): def test_total_width_uses_both_gap_sizes(self):
rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)] rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(4)]
pipeline = self._pipeline( pipeline = self._pipeline(
[('a', rows[:2]), ('b', rows[2:])], [('a', rows[:2]), ('b', rows[2:])],
separator_width=32, intra_plugin_gap=8, lead_in_width=0, separator_width=32, intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0,
) )
pipeline.compose_scroll_content() pipeline.compose_scroll_content()
# 4 rows + 2 intra gaps + 1 separator # 4 rows + 2 measured intra gaps (24 each) + 1 separator
assert pipeline.scroll_helper.cached_image.width == 400 + 16 + 32 assert pipeline.scroll_helper.cached_image.width == 400 + 48 + 32
def test_f1_shaped_case_reclaims_the_chasms(self): def test_f1_shaped_case_stays_below_the_separator_width(self):
# 12 rows from one plugin: previously 11 separators at 32px = 352px of # 12 rows from one plugin. Previously each boundary got the full 32px
# gap; now 11 intra gaps at 8px = 88px. # 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)] rows = [Image.new('RGB', (128, DISPLAY_H), (255, 255, 255)) for _ in range(12)]
pipeline = self._pipeline( pipeline = self._pipeline(
[('f1-scoreboard', rows)], [('f1-scoreboard', rows)],
separator_width=32, intra_plugin_gap=8, lead_in_width=0, separator_width=32, intra_plugin_gap=8, min_content_separation=24,
lead_in_width=0,
) )
pipeline.compose_scroll_content() pipeline.compose_scroll_content()
assert pipeline.scroll_helper.cached_image.width == 12 * 128 + 11 * 8 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): def test_single_row_plugin_image_is_not_copied(self):
row = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) row = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255))
pipeline = self._pipeline([('solo', [row])], lead_in_width=0) pipeline = self._pipeline([('solo', [row])], lead_in_width=0)
assert pipeline._join_plugin_rows([row]) is row assert pipeline._join_plugin_rows([row]) is row
def test_zero_intra_gap_butts_rows_together(self): 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)] rows = [Image.new('RGB', (50, DISPLAY_H), (255, 255, 255)) for _ in range(3)]
pipeline = self._pipeline( pipeline = self._pipeline(
[('a', rows)], separator_width=32, intra_plugin_gap=0, lead_in_width=0) [('a', rows)], separator_width=32, intra_plugin_gap=0,
min_content_separation=0, lead_in_width=0)
pipeline.compose_scroll_content() pipeline.compose_scroll_content()
assert pipeline.scroll_helper.cached_image.width == 150 assert pipeline.scroll_helper.cached_image.width == 150
assert column_has_ink(pipeline.scroll_helper.cached_image).all() assert column_has_ink(pipeline.scroll_helper.cached_image).all()
@@ -601,3 +611,361 @@ class TestConfigSurface:
trim_threshold=10, content_padding=8, trim_threshold=10, content_padding=8,
min_plugin_width=8, lead_in_width=0, min_plugin_width=8, lead_in_width=0,
).validate() == [] ).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')
+97
View File
@@ -9,6 +9,9 @@ from src.vegas_mode.geometry import (
column_has_ink, column_has_ink,
content_bounds, content_bounds,
dead_window_stats, dead_window_stats,
edge_blank,
find_blank_cut,
separation_gap,
trim_to_content, trim_to_content,
window_coverage_stats, window_coverage_stats,
) )
@@ -271,3 +274,97 @@ class TestLongestRunHelper:
def test_run_lengths(self, flags, expected): def test_run_lengths(self, flags, expected):
from src.vegas_mode.geometry import _longest_true_run from src.vegas_mode.geometry import _longest_true_run
assert _longest_true_run(np.array(flags, dtype=bool)) == expected 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
+4 -1
View File
@@ -923,7 +923,8 @@ def save_main_config():
'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle', 'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle',
'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled', 'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled',
'vegas_min_cycle_duration', 'vegas_max_cycle_duration', 'vegas_min_cycle_duration', 'vegas_max_cycle_duration',
'vegas_intra_plugin_gap'] '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:
@@ -974,6 +975,8 @@ def save_main_config():
'vegas_scroll_speed': ('scroll_speed', 1, 200), 'vegas_scroll_speed': ('scroll_speed', 1, 200),
'vegas_separator_width': ('separator_width', 0, 128), 'vegas_separator_width': ('separator_width', 0, 128),
'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128), 'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
'vegas_render_width_pct': ('render_width_pct', 10, 100),
'vegas_min_content_separation': ('min_content_separation', 0, 256),
'vegas_target_fps': ('target_fps', 30, 200), 'vegas_target_fps': ('target_fps', 30, 200),
'vegas_buffer_ahead': ('buffer_ahead', 1, 5), 'vegas_buffer_ahead': ('buffer_ahead', 1, 5),
'vegas_trim_threshold': ('trim_threshold', 0, 254), 'vegas_trim_threshold': ('trim_threshold', 0, 254),
@@ -438,7 +438,7 @@
<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_intra_plugin_gap" data-setting-key="display.vegas_scroll.intra_plugin_gap"> <div class="form-group" id="setting-display-vegas_intra_plugin_gap" data-setting-key="display.vegas_scroll.intra_plugin_gap">
<label for="vegas_intra_plugin_gap" class="block text-sm font-medium text-gray-700">Row Gap (pixels){{ ui.help_tip('Gap between rows contributed by the same plugin (0128 px).\nDefault: 8. Multi-row plugins such as sports scoreboards, news feeds and the F1 standings return one image per row; this keeps those rows close together while Separator Width still marks the jump to the next plugin. Set 0 to butt rows directly together.', 'Row Gap') }}</label> <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" <input type="number"
id="vegas_intra_plugin_gap" id="vegas_intra_plugin_gap"
name="vegas_intra_plugin_gap" name="vegas_intra_plugin_gap"
@@ -447,6 +447,31 @@
max="128" max="128"
class="form-control"> class="form-control">
</div> </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>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">