From 616d21c6d37708fed0989be5848853e490593862 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 29 Jul 2026 09:01:28 -0400 Subject: [PATCH] Vegas mode: render plugins narrower, space rows by measured separation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- config/config.template.json | 2 + src/display_manager.py | 53 +++++ src/plugin_system/base_plugin.py | 34 +++ .../testing/visual_display_manager.py | 29 +++ src/vegas_mode/config.py | 34 +++ src/vegas_mode/geometry.py | 53 +++++ src/vegas_mode/plugin_adapter.py | 100 ++++++++- src/vegas_mode/render_pipeline.py | 25 ++- test/test_vegas_density.py | 193 ++++++++++++++++-- test/test_vegas_geometry.py | 48 +++++ web_interface/blueprints/api_v3.py | 5 +- .../templates/v3/partials/display.html | 27 ++- 12 files changed, 574 insertions(+), 29 deletions(-) diff --git a/config/config.template.json b/config/config.template.json index ece166c2..2ced4828 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -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, diff --git a/src/display_manager.py b/src/display_manager.py index 9de558a1..5db9e521 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -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. diff --git a/src/plugin_system/base_plugin.py b/src/plugin_system/base_plugin.py index 54759855..87056b0f 100644 --- a/src/plugin_system/base_plugin.py +++ b/src/plugin_system/base_plugin.py @@ -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. diff --git a/src/plugin_system/testing/visual_display_manager.py b/src/plugin_system/testing/visual_display_manager.py index 54f14f81..e3dc2ec6 100644 --- a/src/plugin_system/testing/visual_display_manager.py +++ b/src/plugin_system/testing/visual_display_manager.py @@ -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): """ diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index 38a56bc9..efe1fc71 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -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: diff --git a/src/vegas_mode/geometry.py b/src/vegas_mode/geometry.py index 03d040da..bec65c01 100644 --- a/src/vegas_mode/geometry.py +++ b/src/vegas_mode/geometry.py @@ -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, diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index f790c984..5f6965f8 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -8,6 +8,7 @@ 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 @@ -214,6 +215,66 @@ 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 _width_budget(self) -> int: """Maximum columns one plugin may occupy in a cycle. 0 means unlimited.""" ratio = self.config.max_plugin_width_ratio @@ -331,7 +392,27 @@ class PluginAdapter: """ try: 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: logger.info("[%s] Native: get_vegas_content() returned None", plugin_id) @@ -683,7 +764,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 +810,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() diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index aa624ebf..f9739b95 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -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: diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py index 708136bb..ac8b6e15 100644 --- a/test/test_vegas_density.py +++ b/test/test_vegas_density.py @@ -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,148 @@ 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 diff --git a/test/test_vegas_geometry.py b/test/test_vegas_geometry.py index b2353857..2573fc16 100644 --- a/test/test_vegas_geometry.py +++ b/test/test_vegas_geometry.py @@ -9,6 +9,8 @@ from src.vegas_mode.geometry import ( column_has_ink, content_bounds, dead_window_stats, + edge_blank, + separation_gap, trim_to_content, window_coverage_stats, ) @@ -271,3 +273,49 @@ 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 diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 47cc86d3..2f7a1f8a 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -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), diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index 92663f9d..2b8cde7d 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -438,7 +438,7 @@
- +
+ +
+ + +
+
+ +
+
+ + +