mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Compare commits
3
Commits
d69dfbbaee
...
105c6df019
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
105c6df019 | ||
|
|
62919a13e3 | ||
|
|
616d21c6d3 |
@@ -132,6 +132,8 @@
|
||||
"target_fps": 125,
|
||||
"buffer_ahead": 2,
|
||||
"intra_plugin_gap": 8,
|
||||
"render_width_pct": 100,
|
||||
"min_content_separation": 24,
|
||||
"auto_trim": true,
|
||||
"trim_threshold": 10,
|
||||
"content_padding": 8,
|
||||
|
||||
@@ -536,6 +536,59 @@ class DisplayManager:
|
||||
finally:
|
||||
self._capture_mode_active = False
|
||||
|
||||
@contextmanager
|
||||
def render_size(self, width: int, height: Optional[int] = None):
|
||||
"""Temporarily present a smaller logical canvas to plugins.
|
||||
|
||||
Plugins lay out against ``display_manager.matrix.width`` (and the
|
||||
``width``/``height`` properties, which defer to it), so the only way to
|
||||
get a *narrower layout* rather than a cropped one is to tell the plugin
|
||||
the screen is narrower while it renders. Trimming after the fact cannot
|
||||
fix a forecast spread across five columns or a progress bar drawn at
|
||||
100% width — those need the plugin to make different layout decisions.
|
||||
|
||||
Vegas mode uses this so a plugin can occupy a fraction of a wide panel
|
||||
and still look deliberately composed. Reuses the same _LogicalMatrix
|
||||
indirection that double-sided mode relies on, so plugins see a
|
||||
consistent size from every accessor.
|
||||
|
||||
Only meaningful inside :meth:`capture_mode` — this swaps the shared
|
||||
image buffer, so the render loop must not be writing to it concurrently.
|
||||
|
||||
Args:
|
||||
width: Logical width to report, clamped to at least 1 and to the
|
||||
real panel width (a larger canvas would overflow the hardware).
|
||||
height: Logical height, defaulting to the current height.
|
||||
"""
|
||||
real_matrix = self.matrix
|
||||
prev_image = getattr(self, 'image', None)
|
||||
prev_draw = getattr(self, 'draw', None)
|
||||
|
||||
current_w = self.width
|
||||
current_h = self.height
|
||||
target_w = max(1, min(int(width), current_w))
|
||||
target_h = max(1, min(int(height) if height else current_h, current_h))
|
||||
|
||||
if target_w == current_w and target_h == current_h:
|
||||
# Nothing to do; avoid pointless wrapping and buffer churn.
|
||||
yield
|
||||
return
|
||||
|
||||
try:
|
||||
if real_matrix is not None:
|
||||
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
|
||||
# With no hardware, the width/height properties fall through to
|
||||
# self.image, so swapping the buffer below is enough on its own.
|
||||
self.image = Image.new('RGB', (target_w, target_h))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
yield
|
||||
finally:
|
||||
self.matrix = real_matrix
|
||||
if prev_image is not None:
|
||||
self.image = prev_image
|
||||
if prev_draw is not None:
|
||||
self.draw = prev_draw
|
||||
|
||||
def _composite_double_sided(self):
|
||||
"""Tile the logical screen across the full physical chain.
|
||||
|
||||
|
||||
@@ -505,6 +505,40 @@ class BasePlugin(ABC):
|
||||
# -------------------------------------------------------------------------
|
||||
# Vegas scroll mode support
|
||||
# -------------------------------------------------------------------------
|
||||
def get_vegas_render_width(self) -> int:
|
||||
"""
|
||||
Width the Vegas ticker wants this plugin's content to occupy.
|
||||
|
||||
On a wide panel a layout built to fill the screen reads as sparse in a
|
||||
ticker — a forecast spread over five columns, a progress bar drawn at
|
||||
100% width, a stat block with the panel's whole width between its
|
||||
elements. Vegas asks for a narrower render so the plugin can choose a
|
||||
tighter arrangement instead of being cropped afterwards.
|
||||
|
||||
Vegas also narrows ``display_manager`` for the duration of the call, so
|
||||
a plugin that already sizes itself from ``matrix.width`` needs no
|
||||
changes. Read this only when you size content some other way.
|
||||
|
||||
Controlled by the plugin's own ``vegas_width_pct`` config value, else
|
||||
the global ``display.vegas_scroll.render_width_pct``.
|
||||
|
||||
Returns:
|
||||
Target width in pixels. Outside a Vegas content request, the full
|
||||
display width.
|
||||
"""
|
||||
requested = getattr(self, '_vegas_render_width', None)
|
||||
if isinstance(requested, int) and requested > 0:
|
||||
return requested
|
||||
|
||||
display_manager = getattr(self, 'display_manager', None)
|
||||
matrix = getattr(display_manager, 'matrix', None)
|
||||
if matrix is not None and getattr(matrix, 'width', None):
|
||||
return int(matrix.width)
|
||||
width = getattr(display_manager, 'width', None)
|
||||
if callable(width):
|
||||
width = width()
|
||||
return int(width) if width else 128
|
||||
|
||||
def get_vegas_content(self) -> Optional[Any]:
|
||||
"""
|
||||
Get content for Vegas-style continuous scroll mode.
|
||||
|
||||
@@ -178,6 +178,35 @@ class VisualTestDisplayManager:
|
||||
"""No-op for hardware; marks that display was updated."""
|
||||
self.update_called = True
|
||||
|
||||
@contextmanager
|
||||
def render_size(self, width: int, height: Optional[int] = None):
|
||||
"""
|
||||
Interface parity with DisplayManager.render_size().
|
||||
|
||||
Vegas mode narrows the canvas so plugins lay out compactly instead of
|
||||
being cropped. The harness must offer the same context or that path
|
||||
cannot be exercised offline — and because the adapter catches broadly,
|
||||
a missing method shows up as "no content" rather than an error.
|
||||
"""
|
||||
prev_image = self.image
|
||||
prev_draw = self.draw
|
||||
prev_w, prev_h = self._width, self._height
|
||||
|
||||
target_w = max(1, min(int(width), prev_w))
|
||||
target_h = max(1, min(int(height) if height else prev_h, prev_h))
|
||||
|
||||
try:
|
||||
self._width, self._height = target_w, target_h
|
||||
self.matrix = _MatrixProxy(target_w, target_h)
|
||||
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
yield
|
||||
finally:
|
||||
self._width, self._height = prev_w, prev_h
|
||||
self.matrix = _MatrixProxy(prev_w, prev_h)
|
||||
self.image = prev_image
|
||||
self.draw = prev_draw
|
||||
|
||||
@contextmanager
|
||||
def capture_mode(self):
|
||||
"""
|
||||
|
||||
@@ -21,6 +21,20 @@ class VegasModeConfig:
|
||||
scroll_speed: float = 50.0 # Pixels per second
|
||||
separator_width: int = 32 # Gap between plugins (pixels)
|
||||
|
||||
# Fraction of the panel width a plugin is told it has while rendering for
|
||||
# the ticker, as a percentage. Trimming can only remove blank margins; it
|
||||
# cannot compact a layout that genuinely spans the display — a five-column
|
||||
# forecast, a full-width progress bar, a centred stat block with the panel's
|
||||
# whole width between its elements. Rendering at a narrower size makes the
|
||||
# plugin choose a tighter layout instead. 100 disables it.
|
||||
render_width_pct: int = 100
|
||||
|
||||
# Minimum blank columns guaranteed between adjacent content, measured from
|
||||
# actual ink rather than added blindly. A flat additive gap leaves
|
||||
# card-style content nearly touching when the cards are drawn flush to their
|
||||
# own edges, while padding out content that already has wide margins.
|
||||
min_content_separation: int = 24
|
||||
|
||||
# Gap between rows contributed by the *same* plugin. separator_width marks
|
||||
# the handoff from one plugin to the next; applying it between every image
|
||||
# forced a 32px chasm between each row of a per-row ticker (the F1
|
||||
@@ -91,6 +105,9 @@ class VegasModeConfig:
|
||||
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
|
||||
separator_width=int(vegas_config.get('separator_width', 32)),
|
||||
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
|
||||
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
|
||||
min_content_separation=int(
|
||||
vegas_config.get('min_content_separation', 24)),
|
||||
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)),
|
||||
@@ -117,6 +134,8 @@ class VegasModeConfig:
|
||||
'scroll_speed': self.scroll_speed,
|
||||
'separator_width': self.separator_width,
|
||||
'intra_plugin_gap': self.intra_plugin_gap,
|
||||
'render_width_pct': self.render_width_pct,
|
||||
'min_content_separation': self.min_content_separation,
|
||||
'auto_trim': self.auto_trim,
|
||||
'trim_threshold': self.trim_threshold,
|
||||
'content_padding': self.content_padding,
|
||||
@@ -209,6 +228,16 @@ class VegasModeConfig:
|
||||
if self.buffer_ahead > 5:
|
||||
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
|
||||
|
||||
if not 10 <= self.render_width_pct <= 100:
|
||||
errors.append(
|
||||
"render_width_pct must be between 10 and 100, "
|
||||
f"got {self.render_width_pct}")
|
||||
|
||||
if not 0 <= self.min_content_separation <= 256:
|
||||
errors.append(
|
||||
"min_content_separation must be between 0 and 256, "
|
||||
f"got {self.min_content_separation}")
|
||||
|
||||
if self.intra_plugin_gap < 0:
|
||||
errors.append(
|
||||
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'])
|
||||
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:
|
||||
|
||||
@@ -139,6 +139,59 @@ def trim_to_content(
|
||||
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,
|
||||
@@ -169,16 +222,19 @@ def find_blank_cut(
|
||||
return target
|
||||
|
||||
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.
|
||||
for offset in range(0, search_radius + 1):
|
||||
right = target + offset
|
||||
if right <= hi and not ink[right]:
|
||||
if lo <= right <= hi and not ink[right]:
|
||||
return right
|
||||
left = target - offset
|
||||
if left >= lo and not ink[left]:
|
||||
if lo <= left <= hi and not ink[left]:
|
||||
return left
|
||||
|
||||
return target
|
||||
|
||||
@@ -8,10 +8,15 @@ implement get_vegas_content() and fallback capture of display() output.
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from contextlib import nullcontext
|
||||
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.geometry import find_blank_cut, trim_to_content
|
||||
from src.vegas_mode.geometry import (
|
||||
find_blank_cut,
|
||||
separation_gap,
|
||||
trim_to_content,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
@@ -165,8 +170,13 @@ class PluginAdapter:
|
||||
Trimmed image list, or None if nothing worth showing remains
|
||||
"""
|
||||
if not self.config.auto_trim:
|
||||
self._cache_content(plugin_id, images)
|
||||
return images
|
||||
# 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] = []
|
||||
@@ -214,6 +224,80 @@ class PluginAdapter:
|
||||
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
|
||||
@@ -243,11 +327,15 @@ class PluginAdapter:
|
||||
"""
|
||||
budget = self._width_budget()
|
||||
|
||||
# Count the gaps the compositor will insert between these rows, not
|
||||
# just the pixels of the rows themselves — otherwise a plugin with many
|
||||
# rows quietly occupies far more of the panel than its budget allows.
|
||||
gap = max(0, self.config.intra_plugin_gap)
|
||||
total = sum(img.width for img in images) + gap * (len(images) - 1)
|
||||
# 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.
|
||||
@@ -266,7 +354,9 @@ class PluginAdapter:
|
||||
# cut never lands in the middle of one.
|
||||
for step in range(len(images)):
|
||||
img = images[(start + step) % len(images)]
|
||||
cost = img.width + (gap if selected else 0)
|
||||
cost = img.width
|
||||
if selected:
|
||||
cost += self._row_gap(selected[-1], img)
|
||||
if selected and used + cost > budget:
|
||||
break
|
||||
selected.append(img)
|
||||
@@ -331,7 +421,27 @@ class PluginAdapter:
|
||||
"""
|
||||
try:
|
||||
logger.info("[%s] Native: calling get_vegas_content()", plugin_id)
|
||||
|
||||
# Tell the plugin how much width the ticker wants it to use, and
|
||||
# narrow the canvas for the duration of the call. A plugin that
|
||||
# sizes its own images from display_manager.matrix.width picks up
|
||||
# the narrower value with no changes of its own; one that wants to
|
||||
# be explicit can read get_vegas_render_width().
|
||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||
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:
|
||||
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
|
||||
# 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()
|
||||
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
|
||||
|
||||
@@ -717,7 +839,8 @@ class PluginAdapter:
|
||||
plugin_id
|
||||
)
|
||||
# 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()
|
||||
plugin.display(force_clear=True)
|
||||
captured = self.display_manager.image.copy()
|
||||
|
||||
@@ -14,6 +14,7 @@ from PIL import Image
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.geometry import separation_gap
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -188,12 +189,14 @@ class RenderPipeline:
|
||||
|
||||
logger.info(
|
||||
"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.display_height,
|
||||
len(blocks),
|
||||
total_rows,
|
||||
self.config.separator_width,
|
||||
self.config.min_content_separation,
|
||||
self.config.intra_plugin_gap,
|
||||
)
|
||||
|
||||
@@ -219,15 +222,27 @@ class RenderPipeline:
|
||||
if len(images) == 1:
|
||||
return images[0]
|
||||
|
||||
gap = max(0, self.config.intra_plugin_gap)
|
||||
width = sum(img.width for img in images) + gap * (len(images) - 1)
|
||||
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 img in images:
|
||||
for i, img in enumerate(images):
|
||||
block.paste(img, (x, 0))
|
||||
x += img.width + gap
|
||||
x += img.width + (gaps[i] if i < len(gaps) else 0)
|
||||
return block
|
||||
|
||||
def render_frame(self) -> bool:
|
||||
@@ -250,21 +265,29 @@ class RenderPipeline:
|
||||
|
||||
# Determine if the cycle is done.
|
||||
#
|
||||
# scroll_helper considers a cycle complete only after
|
||||
# total_distance_scrolled >= total_scroll_width + display_width.
|
||||
# That extra display_width of travel causes a "wrap-around" phase
|
||||
# where scroll_position resets to ~0 and the first plugin's content
|
||||
# re-enters from the right — the user sees this 2-3 s of re-entry
|
||||
# as "a plugin partially displaying before the next one starts."
|
||||
# get_visible_portion wraps: once scroll_position + display_width
|
||||
# passes the end of the strip it fills the right-hand side of the
|
||||
# frame from the *head* of the same strip. So the last
|
||||
# display_width of travel shows the cycle's first plugin re-entering
|
||||
# on the right while its last plugin exits on the left, and the
|
||||
# recompose that follows then replaces both at once. That reads as
|
||||
# the ticker "switching mid-scroll".
|
||||
#
|
||||
# We end the cycle as soon as total_distance_scrolled reaches
|
||||
# total_scroll_width (the wrap-around point), before any second-pass
|
||||
# content becomes visible. The scroll_helper's own is_scroll_complete()
|
||||
# check is kept as a fallback for any edge-cases where that threshold
|
||||
# is never hit.
|
||||
# This used to be hidden because the strip began with a full
|
||||
# display_width of blank, so the wrapped-in region was black.
|
||||
# lead_in_width now defaults to 0 (that blank was 10s of dead panel
|
||||
# at 50px/s), which exposed the wrap — so the cycle has to end
|
||||
# before it, one display width earlier.
|
||||
#
|
||||
# A strip no wider than the display never wraps, and subtracting
|
||||
# would make the cycle complete instantly, so clamp in that case.
|
||||
wrap_point = self.scroll_helper.total_scroll_width
|
||||
if wrap_point > self.display_width:
|
||||
wrap_point -= self.display_width
|
||||
|
||||
at_wrap_point = (
|
||||
not self._cycle_complete and
|
||||
self.scroll_helper.total_distance_scrolled >= self.scroll_helper.total_scroll_width
|
||||
self.scroll_helper.total_distance_scrolled >= wrap_point
|
||||
)
|
||||
|
||||
if at_wrap_point or self.scroll_helper.is_scroll_complete():
|
||||
|
||||
+387
-19
@@ -277,54 +277,64 @@ class TestPluginBoundaryGaps:
|
||||
return RenderPipeline(VegasModeConfig(**cfg), DM(), FakeStream())
|
||||
|
||||
def test_separator_only_at_plugin_boundaries(self):
|
||||
# Two plugins, two rows each. Expect: row row [sep] row row, with the
|
||||
# small intra gap inside each pair.
|
||||
# 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, lead_in_width=0,
|
||||
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:108].any() # intra gap inside plugin a
|
||||
assert ink[108:208].all()
|
||||
assert not ink[208:240].any() # separator between a and b
|
||||
assert ink[240:340].all()
|
||||
assert not ink[340:348].any() # intra gap inside plugin b
|
||||
assert ink[348:448].all()
|
||||
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, lead_in_width=0,
|
||||
separator_width=32, intra_plugin_gap=8, min_content_separation=24,
|
||||
lead_in_width=0,
|
||||
)
|
||||
pipeline.compose_scroll_content()
|
||||
# 4 rows + 2 intra gaps + 1 separator
|
||||
assert pipeline.scroll_helper.cached_image.width == 400 + 16 + 32
|
||||
# 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_reclaims_the_chasms(self):
|
||||
# 12 rows from one plugin: previously 11 separators at 32px = 352px of
|
||||
# gap; now 11 intra gaps at 8px = 88px.
|
||||
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, lead_in_width=0,
|
||||
separator_width=32, intra_plugin_gap=8, min_content_separation=24,
|
||||
lead_in_width=0,
|
||||
)
|
||||
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):
|
||||
row = Image.new('RGB', (100, DISPLAY_H), (255, 255, 255))
|
||||
pipeline = self._pipeline([('solo', [row])], lead_in_width=0)
|
||||
assert pipeline._join_plugin_rows([row]) is row
|
||||
|
||||
def test_zero_intra_gap_butts_rows_together(self):
|
||||
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, 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()
|
||||
assert pipeline.scroll_helper.cached_image.width == 150
|
||||
assert column_has_ink(pipeline.scroll_helper.cached_image).all()
|
||||
@@ -601,3 +611,361 @@ class TestConfigSurface:
|
||||
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')
|
||||
|
||||
@@ -9,6 +9,9 @@ from src.vegas_mode.geometry import (
|
||||
column_has_ink,
|
||||
content_bounds,
|
||||
dead_window_stats,
|
||||
edge_blank,
|
||||
find_blank_cut,
|
||||
separation_gap,
|
||||
trim_to_content,
|
||||
window_coverage_stats,
|
||||
)
|
||||
@@ -271,3 +274,97 @@ class TestLongestRunHelper:
|
||||
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
|
||||
|
||||
@@ -923,7 +923,8 @@ def save_main_config():
|
||||
'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_intra_plugin_gap', 'vegas_render_width_pct',
|
||||
'vegas_min_content_separation']
|
||||
|
||||
if any(k in data for k in vegas_fields):
|
||||
if 'display' not in current_config:
|
||||
@@ -974,6 +975,8 @@ def save_main_config():
|
||||
'vegas_scroll_speed': ('scroll_speed', 1, 200),
|
||||
'vegas_separator_width': ('separator_width', 0, 128),
|
||||
'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
|
||||
'vegas_render_width_pct': ('render_width_pct', 10, 100),
|
||||
'vegas_min_content_separation': ('min_content_separation', 0, 256),
|
||||
'vegas_target_fps': ('target_fps', 30, 200),
|
||||
'vegas_buffer_ahead': ('buffer_ahead', 1, 5),
|
||||
'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="form-group" id="setting-display-vegas_intra_plugin_gap" data-setting-key="display.vegas_scroll.intra_plugin_gap">
|
||||
<label for="vegas_intra_plugin_gap" class="block text-sm font-medium text-gray-700">Row Gap (pixels){{ ui.help_tip('Gap between rows contributed by the same plugin (0–128 px).\nDefault: 8. Multi-row plugins such as sports scoreboards, news feeds and the F1 standings return one image per row; this keeps those rows close together while Separator Width still marks the jump to the next plugin. Set 0 to butt rows directly together.', 'Row Gap') }}</label>
|
||||
<label for="vegas_intra_plugin_gap" class="block text-sm font-medium text-gray-700">Row Gap (pixels){{ ui.help_tip('Extra gap always added between rows contributed by the same plugin (0–128 px).\nDefault: 8. This is a floor on top of Row Separation below, which does most of the work. Set both to 0 to butt rows directly together.', 'Row Gap') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_intra_plugin_gap"
|
||||
name="vegas_intra_plugin_gap"
|
||||
@@ -447,6 +447,31 @@
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_min_content_separation" data-setting-key="display.vegas_scroll.min_content_separation">
|
||||
<label for="vegas_min_content_separation" class="block text-sm font-medium text-gray-700">Row Separation (pixels){{ ui.help_tip('Blank space guaranteed between rows of the same plugin, measured from the actual content rather than added blindly (0–256 px).\nDefault: 24. Rows already carrying wide margins get nothing added; rows drawn right up to their own edges — sports score cards, for instance — get the full amount, so they no longer look like they are touching. Raise it if items still feel cramped.', 'Row Separation') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_content_separation"
|
||||
name="vegas_min_content_separation"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_content_separation', 24) }}"
|
||||
min="0"
|
||||
max="256"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_render_width_pct" data-setting-key="display.vegas_scroll.render_width_pct">
|
||||
<label for="vegas_render_width_pct" class="block text-sm font-medium text-gray-700">Plugin Render Width (%){{ ui.help_tip('How much of the screen width each plugin is told it has while drawing for the ticker (10–100%).\nDefault: 100 (unchanged). Lowering it makes plugins choose a tighter layout rather than being cropped — a weather forecast becomes narrow cards instead of five columns spread across the panel. Useful on wide displays. Override per plugin with the vegas_width_pct setting in that plugin\'s own configuration.', 'Plugin Render Width') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_render_width_pct"
|
||||
name="vegas_render_width_pct"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('render_width_pct', 100) }}"
|
||||
min="10"
|
||||
max="100"
|
||||
step="5"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
Reference in New Issue
Block a user