From a413849891406f1ca10371e67880ac5cf1082a28 Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 29 Jul 2026 15:40:37 -0400 Subject: [PATCH] Add overflow handling: keep ordered content whole instead of rotating a window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The width budget split any oversized plugin by advancing a window each cycle. That is right for interchangeable items — news headlines, odds, stock prices — but wrong for ordered content: a league table showed ranks 1-6, then resumed at 7 two rotations later, which reads as out of order and out of context. Nobody needs rank 23 in a ticker; they need the top of the table, every time. overflow_mode chooses between them: rotate — advance a window each cycle so everything is seen eventually (unchanged default) truncate — always show the start and drop the rest, keeping ordered content coherent. Records no window state, so every pass starts at the top. Per-plugin vegas_overflow overrides the global setting, since one install has both kinds of plugin. Also adds per-plugin vegas_max_width_screens, so content that must stay whole can be given more room — or uncapped with 0 — without lifting the cap on every ticker. Applied on the test rig: f1-scoreboard and ledmatrix-leaderboard set to truncate, and baseball given 4.5 screens because it was showing 8 of 9 games when the whole slate needed only a little more room. Verified: F1 now reports "the first 10 of 116 ... the rest are not shown", baseball has dropped out of the budget log entirely, and stocks, odds-ticker and stock-news still rotate. Also corrects the crop log, which claimed "window advances next cycle" unconditionally and so misreported truncated crops. A test now pins the behaviour behind the message: truncate must leave no offset recorded. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- config/config.template.json | 1 + src/vegas_mode/config.py | 21 +++ src/vegas_mode/plugin_adapter.py | 143 ++++++++++++++---- test/test_vegas_density.py | 128 ++++++++++++++++ web_interface/blueprints/api_v3.py | 12 +- .../templates/v3/partials/display.html | 8 + 6 files changed, 283 insertions(+), 30 deletions(-) diff --git a/config/config.template.json b/config/config.template.json index 35d4c7ad..6f84e048 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -145,6 +145,7 @@ "lead_in_width": 0, "plugins_per_cycle": 6, "max_plugin_width_ratio": 3.0, + "overflow_mode": "rotate", "dynamic_duration_enabled": true, "min_cycle_duration": 60, "max_cycle_duration": 240 diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index 97e7ae9b..14786481 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -91,6 +91,18 @@ class VegasModeConfig: # letter floating between two unrelated plugins. min_cut_gap: int = 6 + # What to do when a plugin's content exceeds its width budget. + # + # "rotate" — advance a window each cycle so everything is seen eventually. + # Right for interchangeable items: news headlines, odds, stocks. + # "truncate" — always show the start. Right for ordered content, where a + # window into the middle is meaningless: a league table that + # shows ranks 1-6 then resumes at 7 two rotations later reads + # as out of order and out of context. + # + # Override per plugin with vegas_overflow. + overflow_mode: str = "rotate" + # Cap on one plugin's share of a cycle, as a multiple of display width. # A single ticker returning 7,000px would otherwise hold the panel for over # two minutes. Overflow is deferred to later cycles rather than discarded. @@ -148,6 +160,7 @@ class VegasModeConfig: plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)), max_plugin_width_ratio=float( vegas_config.get('max_plugin_width_ratio', 3.0)), + overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')), plugin_order=list(vegas_config.get('plugin_order', [])), excluded_plugins=set(vegas_config.get('excluded_plugins', [])), target_fps=int(vegas_config.get('target_fps', 125)), @@ -179,6 +192,7 @@ class VegasModeConfig: 'lead_in_width': self.lead_in_width, 'plugins_per_cycle': self.plugins_per_cycle, 'max_plugin_width_ratio': self.max_plugin_width_ratio, + 'overflow_mode': self.overflow_mode, 'plugin_order': self.plugin_order, 'excluded_plugins': list(self.excluded_plugins), 'target_fps': self.target_fps, @@ -322,6 +336,11 @@ class VegasModeConfig: errors.append( f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}") + if self.overflow_mode not in ('rotate', 'truncate'): + errors.append( + "overflow_mode must be 'rotate' or 'truncate', " + f"got {self.overflow_mode!r}") + if self.max_plugin_width_ratio < 0: errors.append( "max_plugin_width_ratio must be >= 0 " @@ -375,6 +394,8 @@ class VegasModeConfig: if 'max_plugin_width_ratio' in vegas_config: self.max_plugin_width_ratio = float( vegas_config['max_plugin_width_ratio']) + if 'overflow_mode' in vegas_config: + self.overflow_mode = str(vegas_config['overflow_mode']) if 'plugin_order' in vegas_config: self.plugin_order = list(vegas_config['plugin_order']) if 'excluded_plugins' in vegas_config: diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index d02fdc00..c722edf0 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -120,7 +120,7 @@ class PluginAdapter: "[%s] Native content SUCCESS: %d images, %dpx total", plugin_id, len(content), total_width ) - return self._finalize(content, plugin_id, 'native') + return self._finalize(content, plugin_id, 'native', plugin) logger.info("[%s] Native content returned None", plugin_id) # Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds) @@ -133,7 +133,7 @@ class PluginAdapter: "[%s] ScrollHelper content SUCCESS: %d images, %dpx total", plugin_id, len(content), total_width ) - return self._finalize(content, plugin_id, 'scroll_helper') + return self._finalize(content, plugin_id, 'scroll_helper', plugin) if has_scroll_helper: logger.info("[%s] ScrollHelper content returned None", plugin_id) @@ -154,7 +154,7 @@ class PluginAdapter: "[%s] Fallback capture SUCCESS: %d images, %dpx total", plugin_id, len(content), total_width ) - return self._finalize(content, plugin_id, 'fallback') + return self._finalize(content, plugin_id, 'fallback', plugin) logger.warning( "[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)", @@ -163,7 +163,8 @@ class PluginAdapter: return None def _finalize( - self, images: List[Image.Image], plugin_id: str, source: str + self, images: List[Image.Image], plugin_id: str, source: str, + plugin: Optional['BasePlugin'] = None ) -> Optional[List[Image.Image]]: """ Trim dead space off a segment, then cache it. @@ -190,7 +191,7 @@ class PluginAdapter: # 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) + kept = self._apply_width_budget(list(images), plugin_id, plugin) self._cache_content(plugin_id, kept) return kept @@ -235,7 +236,7 @@ class PluginAdapter: len(kept), dropped_blank ) - kept = self._apply_width_budget(kept, plugin_id) + kept = self._apply_width_budget(kept, plugin_id, plugin) self._cache_content(plugin_id, kept) return kept @@ -332,15 +333,74 @@ class PluginAdapter: threshold=self.config.trim_threshold, ) - def _width_budget(self) -> int: - """Maximum columns one plugin may occupy in a cycle. 0 means unlimited.""" + def _plugin_setting(self, plugin: 'BasePlugin', key: str): + """Read a per-plugin config override, or None if absent.""" + plugin_cfg = getattr(plugin, 'config', None) + if not isinstance(plugin_cfg, dict): + return None + value = plugin_cfg.get(key) + return None if value in (None, '') else value + + def resolve_overflow_mode(self, plugin: 'BasePlugin', plugin_id: str) -> str: + """ + How to handle content that exceeds this plugin's width budget. + + 'rotate' advances a window each cycle so everything is seen eventually, + which suits interchangeable items. 'truncate' always shows the start, + which suits ordered content — a league table that shows ranks 1-6 and + then resumes at 7 two rotations later reads as out of order, and nobody + needs rank 23 in a ticker anyway. + + Per-plugin ``vegas_overflow`` wins over the global ``overflow_mode``. + """ + raw = self._plugin_setting(plugin, 'vegas_overflow') + if raw is not None: + candidate = str(raw).strip().lower() + if candidate in ('rotate', 'truncate'): + return candidate + logger.warning( + "[%s] Invalid vegas_overflow %r, expected 'rotate' or 'truncate'", + plugin_id, raw + ) + return self.config.overflow_mode + + def _width_budget(self, plugin: Optional['BasePlugin'] = None, + plugin_id: str = '') -> int: + """ + Maximum columns one plugin may occupy in a cycle. 0 means unlimited. + + A per-plugin ``vegas_max_width_screens`` overrides the global ratio, so + content that has to stay whole can be given room (or uncapped with 0) + without lifting the cap on every ticker. + """ ratio = self.config.max_plugin_width_ratio + + if plugin is not None: + raw = self._plugin_setting(plugin, 'vegas_max_width_screens') + if raw is not None: + try: + candidate = float(raw) + except (TypeError, ValueError): + logger.warning( + "[%s] Invalid vegas_max_width_screens %r, ignoring", + plugin_id, raw + ) + else: + if candidate >= 0: + ratio = candidate + else: + logger.warning( + "[%s] vegas_max_width_screens must be >= 0, got %s", + plugin_id, candidate + ) + if ratio <= 0: return 0 return int(self.display_width * ratio) def _apply_width_budget( - self, images: List[Image.Image], plugin_id: str + self, images: List[Image.Image], plugin_id: str, + plugin: Optional['BasePlugin'] = None ) -> List[Image.Image]: """ Hold one plugin to its share of a cycle. @@ -359,7 +419,9 @@ class PluginAdapter: Images that fit the budget, starting from the plugin's current rotation offset. """ - budget = self._width_budget() + budget = self._width_budget(plugin, plugin_id) + mode = (self.resolve_overflow_mode(plugin, plugin_id) + if plugin is not None else self.config.overflow_mode) # Count the gaps the compositor will actually insert, not just the # pixels of the rows — otherwise a plugin with many rows quietly @@ -377,9 +439,15 @@ class PluginAdapter: return images if len(images) == 1: - return [self._crop_to_budget(images[0], budget, plugin_id)] + return [self._crop_to_budget(images[0], budget, plugin_id, mode)] - start = self._item_offsets.get(plugin_id, 0) % len(images) + if mode == 'truncate': + # Ordered content: always show from the top. Deliberately does not + # advance the offset, so the same opening items appear every time + # rather than the viewer being shown the middle of a ranked list. + start = 0 + else: + start = self._item_offsets.get(plugin_id, 0) % len(images) selected: List[Image.Image] = [] used = 0 consumed = 0 @@ -397,17 +465,24 @@ class PluginAdapter: used += cost consumed += 1 - self._item_offsets[plugin_id] = (start + consumed) % len(images) - - logger.info( - "[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) " - "from offset %d; remainder deferred to a later cycle", - plugin_id, budget, len(selected), len(images), used, start - ) + if mode == 'truncate': + logger.info( + "[%s] Width budget %dpx: showing the first %d of %d row(s) " + "(%dpx incl. gaps); the rest are not shown (overflow=truncate)", + plugin_id, budget, len(selected), len(images), used + ) + else: + self._item_offsets[plugin_id] = (start + consumed) % len(images) + logger.info( + "[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) " + "from offset %d; remainder deferred to a later cycle", + plugin_id, budget, len(selected), len(images), used, start + ) return selected def _crop_to_budget( - self, img: Image.Image, budget: int, plugin_id: str + self, img: Image.Image, budget: int, plugin_id: str, + mode: str = 'rotate' ) -> Image.Image: """ Narrow a single oversized image to the budget, advancing a window @@ -416,9 +491,14 @@ class PluginAdapter: The cut is snapped to the nearest blank column so it does not slice through a glyph or logo and leave half a character at the panel edge. """ - offset = self._item_offsets.get(plugin_id, 0) - if offset >= img.width: + if mode == 'truncate': + # Always the start of the strip, so a ranked table is never entered + # from the middle. offset = 0 + else: + offset = self._item_offsets.get(plugin_id, 0) + if offset >= img.width: + offset = 0 # Cut only where the plugin left a real gap between items. Snapping to # any blank column used to pick the single-column gaps between @@ -435,11 +515,13 @@ class PluginAdapter: # (words, ticker entries); it would be wrong to let a solid image # escape the cap in its name. end = min(offset + budget, img.width) - self._item_offsets[plugin_id] = 0 if end >= img.width else end + if mode != 'truncate': + self._item_offsets[plugin_id] = 0 if end >= img.width else end logger.info( "[%s] Width budget %dpx: cropped continuous %dpx image to " - "[%d:%d] (no item gaps of %dpx+ to align to)", - plugin_id, budget, img.width, offset, end, min_run + "[%d:%d] (no item gaps of %dpx+ to align to)%s", + plugin_id, budget, img.width, offset, end, min_run, + "" if mode != 'truncate' else "; showing the start only" ) return img.crop((offset, 0, end, img.height)) @@ -456,13 +538,16 @@ class PluginAdapter: # because the alternative is cutting through an item. end = max(within) if within else min(later) - # Next cycle resumes where this one stopped; wrap when the strip ends. - self._item_offsets[plugin_id] = 0 if end >= img.width else end + if mode != 'truncate': + # Next cycle resumes where this one stopped; wrap when the strip ends. + self._item_offsets[plugin_id] = 0 if end >= img.width else end logger.info( "[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] " - "(%dpx) at item boundaries, window advances next cycle", - plugin_id, budget, img.width, start, end, end - start + "(%dpx) at item boundaries, %s", + plugin_id, budget, img.width, start, end, end - start, + "showing the start only (overflow=truncate)" + if mode == 'truncate' else "window advances next cycle" ) return img.crop((start, 0, end, img.height)) diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py index 210833c6..1e236e04 100644 --- a/test/test_vegas_density.py +++ b/test/test_vegas_density.py @@ -1509,3 +1509,131 @@ class TestDeferredDraining: p.extend_scroll_content() assert p.drain_deferred() is True assert p.drain_deferred() is True + + +class TestOverflowMode: + """ + Rotating a window through content only makes sense when the items are + interchangeable. For ordered content it shows the middle of a ranked list — + ranks 1-6, then 7 onwards two rotations later, which reads as out of order. + """ + + class Cfg: + def __init__(self, cfg=None): + self.config = cfg or {} + + def get_vegas_content(self): + return None + + def test_default_is_rotate(self): + adapter = adapter_with() + assert adapter.resolve_overflow_mode(self.Cfg(), 'p') == 'rotate' + + def test_global_mode_applies(self): + adapter = adapter_with(overflow_mode='truncate') + assert adapter.resolve_overflow_mode(self.Cfg(), 'p') == 'truncate' + + def test_per_plugin_override_wins(self): + adapter = adapter_with(overflow_mode='rotate') + plugin = self.Cfg({'vegas_overflow': 'truncate'}) + assert adapter.resolve_overflow_mode(plugin, 'p') == 'truncate' + + @pytest.mark.parametrize('bad', ['sideways', '', None, 5]) + def test_invalid_override_falls_back(self, bad): + adapter = adapter_with(overflow_mode='rotate') + plugin = self.Cfg({'vegas_overflow': bad}) + assert adapter.resolve_overflow_mode(plugin, 'p') == 'rotate' + + def test_truncate_always_shows_the_same_opening_rows(self): + # The reported problem: a ranked list should not resume from the middle. + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=0, min_content_separation=0, + overflow_mode='truncate') + rows = [canvas([(0, 200)], width=200) for _ in range(8)] + plugin = NativePlugin(rows) + + first = adapter.get_content(plugin, 'ranks') + adapter.invalidate_cache('ranks') + second = adapter.get_content(plugin, 'ranks') + assert len(first) == len(second) + assert 'ranks' not in adapter._item_offsets, "must not advance a window" + + def test_rotate_still_advances(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=0, min_content_separation=0, + overflow_mode='rotate') + rows = [canvas([(0, 200)], width=200) for _ in range(8)] + adapter.get_content(NativePlugin(rows), 'ticker') + assert adapter._item_offsets.get('ticker', 0) > 0 + + def test_truncate_on_a_single_wide_image_starts_at_zero(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + overflow_mode='truncate') + strip = canvas([(0, 5000)], width=5000) + plugin = NativePlugin([strip]) + first = adapter.get_content(plugin, 'table')[0] + adapter.invalidate_cache('table') + second = adapter.get_content(plugin, 'table')[0] + assert first.tobytes() == second.tobytes(), "window must not advance" + + +class TestPerPluginWidthBudget: + class Cfg: + def __init__(self, cfg=None): + self.config = cfg or {} + + def get_vegas_content(self): + return None + + def test_global_ratio_by_default(self): + adapter = adapter_with(max_plugin_width_ratio=3.0) + assert adapter._width_budget(self.Cfg(), 'p') == DISPLAY_W * 3 + + def test_per_plugin_override_widens(self): + adapter = adapter_with(max_plugin_width_ratio=3.0) + plugin = self.Cfg({'vegas_max_width_screens': 8}) + assert adapter._width_budget(plugin, 'p') == DISPLAY_W * 8 + + def test_per_plugin_zero_means_uncapped(self): + adapter = adapter_with(max_plugin_width_ratio=3.0) + plugin = self.Cfg({'vegas_max_width_screens': 0}) + assert adapter._width_budget(plugin, 'p') == 0 + + def test_uncapped_plugin_keeps_all_its_content(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0) + + class Wide(NativePlugin): + def __init__(self, images): + super().__init__(images) + self.config = {'vegas_max_width_screens': 0} + + strip = canvas([(0, 6000)], width=6000) + assert adapter.get_content(Wide([strip]), 'whole')[0].width == 6000 + + @pytest.mark.parametrize('bad', ['wide', -1, None, '']) + def test_invalid_override_falls_back_to_global(self, bad): + adapter = adapter_with(max_plugin_width_ratio=2.0) + plugin = self.Cfg({'vegas_max_width_screens': bad}) + assert adapter._width_budget(plugin, 'p') == DISPLAY_W * 2 + + def test_no_plugin_uses_the_global(self): + adapter = adapter_with(max_plugin_width_ratio=2.0) + assert adapter._width_budget() == DISPLAY_W * 2 + + def test_truncate_single_image_never_records_an_offset(self): + # The behavioural guarantee behind the log message: no window state is + # kept, so every pass starts at the top. + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + overflow_mode='truncate') + strip = canvas([(0, 5000)], width=5000) + for _ in range(4): + adapter.invalidate_cache('table') + adapter.get_content(NativePlugin([strip]), 'table') + assert 'table' not in adapter._item_offsets + + def test_rotate_single_image_does_record_an_offset(self): + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + overflow_mode='rotate') + strip = canvas([(0, 5000)], width=5000) + adapter.get_content(NativePlugin([strip]), 'ticker') + assert adapter._item_offsets.get('ticker', 0) > 0 diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index c197fc30..fef28649 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -926,7 +926,7 @@ def save_main_config(): 'vegas_intra_plugin_gap', 'vegas_render_width_pct', 'vegas_min_content_separation', 'vegas_min_cut_gap', 'vegas_continuous_scroll', 'vegas_extend_threshold_screens', - 'vegas_smooth_scroll'] + 'vegas_smooth_scroll', 'vegas_overflow_mode'] if any(k in data for k in vegas_fields): if 'display' not in current_config: @@ -951,6 +951,16 @@ def save_main_config(): # max_plugin_width_ratio is the one fractional setting, so it is # handled outside the integer loop below. + if data.get('vegas_overflow_mode') not in ('', None): + mode = str(data['vegas_overflow_mode']).strip().lower() + if mode not in ('rotate', 'truncate'): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for vegas_overflow_mode: " + "must be 'rotate' or 'truncate'" + }), 400 + vegas_config['overflow_mode'] = mode + if data.get('vegas_extend_threshold_screens') not in ('', None): try: screens = float(data['vegas_extend_threshold_screens']) diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index 01efd788..2fa31027 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -547,6 +547,14 @@ class="form-control"> +
+ + +
+