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 @@
- +
+ +
+ + +
+
+ +
+
+ + +