From 9813e478370fe279093f7b26385977c422c5f669 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 20:17:35 +0000 Subject: [PATCH 1/3] Strip plugin-baked scroll padding when capturing content for Vegas mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins that build their own ticker image via ScrollHelper.create_scrolling_image() (or that manually pad both ends for a clean standalone loop) carry a solid-black margin up to display_width wide on one or both edges. Vegas mode already adds its own configurable gap around every item, so leaving that margin in place stacked an extra, uncontrolled blank stretch on top of separator_width for whichever plugin took the ScrollHelper-capture path — producing inconsistent transition gaps between modules compared to plugins that provide content natively via get_vegas_content(). _get_scroll_helper_content() now detects and crops any such margin before handing the image to the Vegas render pipeline, so every plugin's gap is governed solely by vegas_scroll.separator_width regardless of which capture path produced its content. --- src/vegas_mode/plugin_adapter.py | 69 ++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index ae68cd4bd..95e07828c 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -279,6 +279,19 @@ def _get_scroll_helper_content( # Copy the image to prevent modification img = cached_image.copy() + # Plugins that build their own ticker image via this shared + # ScrollHelper's create_scrolling_image() get a solid-black + # leading margin exactly `display_width` columns wide baked in + # (scroll_helper.py's "initial gap before first item"). Vegas mode + # adds its own leading gap/separator around every item already, + # so leaving this in stacks a second, uncontrolled blank margin on + # top of vegas_scroll.separator_width — making this plugin's + # transitions look inconsistent with plugins that provide content + # via get_vegas_content() (which carries no such margin). Strip it + # here so every plugin contributes only its real content and the + # gap between items is governed solely by separator_width. + img = self._strip_scroll_padding(img, scroll_helper, plugin_id) + # Ensure correct height if img.height != self.display_height: logger.info( @@ -306,6 +319,62 @@ def _get_scroll_helper_content( logger.exception("[%s] Error getting scroll_helper content", plugin_id) return None + def _strip_scroll_padding( + self, img: Image.Image, scroll_helper: Any, plugin_id: str + ) -> Image.Image: + """ + Crop off a plugin's own leading/trailing blank margins, if present. + + create_scrolling_image() always pads the *start* of its cached image + with exactly `scroll_helper.display_width` columns of solid black + (0, 0, 0) ("initial gap before first item"). Some ticker-style plugins + also pad the *end* of their own cached image (e.g. so their standalone + display exits cleanly before looping). Vegas mode already adds its own + gap/separator around every item, so either margin left in place stacks + an extra, uncontrolled blank stretch on top of `separator_width` — + only when running inside Vegas mode does this matter, since the + plugin's own standalone display still wants that margin. Detect solid + black margins up to `scroll_helper.display_width` wide on each edge and + crop them here. Images built via set_scrolling_image() (no such + margins) are left untouched. + + Args: + img: Captured scroll_helper.cached_image (already copied) + scroll_helper: The plugin's ScrollHelper instance + plugin_id: Plugin identifier for logging + + Returns: + img, cropped on whichever edge(s) had a matching blank margin + """ + pad_width = getattr(scroll_helper, 'display_width', None) + if not isinstance(pad_width, int) or pad_width <= 0 or pad_width >= img.width: + return img + + def is_solid_black(strip: Image.Image) -> bool: + return strip.convert('RGB').getextrema() == ((0, 0), (0, 0), (0, 0)) + + left = pad_width if is_solid_black(img.crop((0, 0, pad_width, img.height))) else 0 + right = ( + pad_width + if is_solid_black(img.crop((img.width - pad_width, 0, img.width, img.height))) + else 0 + ) + + if not left and not right: + return img + + # Degenerate case (e.g. an all-black cached image): don't crop past + # zero width, just leave the image as-is. + if left + right >= img.width: + return img + + cropped = img.crop((left, 0, img.width - right, img.height)) + logger.info( + "[%s] Stripping scroll_helper padding (left=%dpx, right=%dpx): %dpx -> %dpx", + plugin_id, left, right, img.width, cropped.width + ) + return cropped + def _trigger_scroll_content_generation( self, plugin: 'BasePlugin', plugin_id: str, scroll_helper: Any ) -> Optional[Image.Image]: From 43faf10feab9d06c801ca99d85821e817af8f37d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 12:31:07 +0000 Subject: [PATCH 2/3] Address CodeRabbit nitpick: warn on double-edge padding crop, add unit tests Logging a double-edge match at warning level (vs. info for a single edge) makes it easy to spot an unexpected crop in the field, since two edges matching at once is a much stronger signal of genuine baked-in padding than one edge coinciding with real all-black content. Also adds test/test_vegas_plugin_adapter.py covering _strip_scroll_padding's branch logic: leading-only, trailing-only, both-edges, no-match, degenerate all-black, missing/undersized display_width, and the info-vs-warning log level. --- src/vegas_mode/plugin_adapter.py | 9 +- test/test_vegas_plugin_adapter.py | 137 ++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 test/test_vegas_plugin_adapter.py diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index 95e07828c..b50d09466 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -369,7 +369,14 @@ def is_solid_black(strip: Image.Image) -> bool: return img cropped = img.crop((left, 0, img.width - right, img.height)) - logger.info( + + # Both edges matching at once is a much stronger signal of genuine + # baked-in padding than a single edge (which has a small chance of + # coinciding with real all-black content, e.g. a dark logo touching + # one boundary). Log that case at warning level so an unexpected + # double-edge crop is easy to spot in the field. + log = logger.warning if (left and right) else logger.info + log( "[%s] Stripping scroll_helper padding (left=%dpx, right=%dpx): %dpx -> %dpx", plugin_id, left, right, img.width, cropped.width ) diff --git a/test/test_vegas_plugin_adapter.py b/test/test_vegas_plugin_adapter.py new file mode 100644 index 000000000..9c9491874 --- /dev/null +++ b/test/test_vegas_plugin_adapter.py @@ -0,0 +1,137 @@ +""" +Tests for src/vegas_mode/plugin_adapter.py + +Covers PluginAdapter._strip_scroll_padding(): the heuristic that crops a +plugin's own baked-in leading/trailing blank margins before Vegas mode +composites the content, so vegas_scroll.separator_width is the only gap +applied between items. +""" + +import logging + +import pytest +from PIL import Image + +from src.common.scroll_helper import ScrollHelper +from src.vegas_mode.plugin_adapter import PluginAdapter + + +class FakeDisplayManager: + width = 64 + height = 32 + + +class FakePlugin: + def __init__(self, scroll_helper): + self.scroll_helper = scroll_helper + + +@pytest.fixture +def adapter(): + return PluginAdapter(FakeDisplayManager()) + + +def _solid(width, height, color): + return Image.new('RGB', (width, height), color) + + +class TestStripScrollPadding: + def test_leading_pad_from_create_scrolling_image_is_stripped(self, adapter): + sh = ScrollHelper(64, 32) + item = _solid(40, 32, (200, 50, 50)) + sh.create_scrolling_image([item], item_gap=10, element_gap=0) + + images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker") + assert images[0].width == 40 + assert images[0].getpixel((0, 0)) == (200, 50, 50) + + def test_leading_and_trailing_pad_both_stripped(self, adapter): + sh = ScrollHelper(64, 32) + content_w = 80 + full = _solid(64 + content_w + 64, 32, (0, 0, 0)) + full.paste(_solid(content_w, 32, (10, 220, 30)), (64, 0)) + sh.set_scrolling_image(full) + + images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker") + assert images[0].width == content_w + assert images[0].getpixel((0, 0)) == (10, 220, 30) + assert images[0].getpixel((content_w - 1, 0)) == (10, 220, 30) + + def test_leading_only_pad_stripped_trailing_content_kept(self, adapter): + sh = ScrollHelper(64, 32) + content_w = 80 + full = _solid(64 + content_w, 32, (0, 0, 0)) + full.paste(_solid(content_w, 32, (5, 5, 250)), (64, 0)) + sh.set_scrolling_image(full) + + images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker") + assert images[0].width == content_w + assert images[0].getpixel((0, 0)) == (5, 5, 250) + + def test_trailing_only_pad_stripped_leading_content_kept(self, adapter): + sh = ScrollHelper(64, 32) + content_w = 80 + full = _solid(content_w + 64, 32, (0, 0, 0)) + full.paste(_solid(content_w, 32, (5, 5, 250)), (0, 0)) + sh.set_scrolling_image(full) + + images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker") + assert images[0].width == content_w + assert images[0].getpixel((0, 0)) == (5, 5, 250) + + def test_no_margin_image_left_untouched(self, adapter): + sh = ScrollHelper(64, 32) + raw = _solid(150, 32, (5, 5, 5)) + raw.paste(_solid(50, 32, (123, 45, 67)), (0, 0)) + sh.set_scrolling_image(raw) + + images = adapter._get_scroll_helper_content(FakePlugin(sh), "no_margin") + assert images[0].width == 150 + + def test_degenerate_all_black_image_left_untouched(self, adapter): + sh = ScrollHelper(64, 32) + sh.set_scrolling_image(_solid(50, 32, (0, 0, 0))) + + images = adapter._get_scroll_helper_content(FakePlugin(sh), "all_black") + assert images[0].width == 50 + + def test_missing_display_width_attribute_left_untouched(self, adapter): + sh = ScrollHelper(64, 32) + item = _solid(40, 32, (200, 50, 50)) + sh.create_scrolling_image([item], item_gap=10, element_gap=0) + original_width = sh.cached_image.width + del sh.display_width + + images = adapter._get_scroll_helper_content(FakePlugin(sh), "ticker") + assert images[0].width == original_width + + def test_pad_width_not_smaller_than_image_left_untouched(self, adapter): + sh = ScrollHelper(64, 32) + sh.set_scrolling_image(_solid(64, 32, (0, 0, 0))) + + images = adapter._get_scroll_helper_content(FakePlugin(sh), "narrow") + assert images[0].width == 64 + + def test_both_edges_matching_logs_warning(self, adapter, caplog): + sh = ScrollHelper(64, 32) + content_w = 80 + full = _solid(64 + content_w + 64, 32, (0, 0, 0)) + full.paste(_solid(content_w, 32, (10, 220, 30)), (64, 0)) + sh.set_scrolling_image(full) + + with caplog.at_level(logging.WARNING, logger="src.vegas_mode.plugin_adapter"): + adapter._get_scroll_helper_content(FakePlugin(sh), "ticker") + + assert any("Stripping scroll_helper padding" in r.message for r in caplog.records) + + def test_single_edge_match_logs_info_not_warning(self, adapter, caplog): + sh = ScrollHelper(64, 32) + item = _solid(40, 32, (200, 50, 50)) + sh.create_scrolling_image([item], item_gap=10, element_gap=0) + + with caplog.at_level(logging.INFO, logger="src.vegas_mode.plugin_adapter"): + adapter._get_scroll_helper_content(FakePlugin(sh), "ticker") + + strip_records = [r for r in caplog.records if "Stripping scroll_helper padding" in r.message] + assert len(strip_records) == 1 + assert strip_records[0].levelno == logging.INFO From 8d96ea5dd417dcd574fc2837d9e096e5bf0d51a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 12:35:35 +0000 Subject: [PATCH 3/3] Add type hints and docstring to test _solid helper (CodeRabbit nitpick) --- test/test_vegas_plugin_adapter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_vegas_plugin_adapter.py b/test/test_vegas_plugin_adapter.py index 9c9491874..323410de9 100644 --- a/test/test_vegas_plugin_adapter.py +++ b/test/test_vegas_plugin_adapter.py @@ -31,7 +31,8 @@ def adapter(): return PluginAdapter(FakeDisplayManager()) -def _solid(width, height, color): +def _solid(width: int, height: int, color: tuple) -> Image.Image: + """Create a solid-color RGB image of the given dimensions.""" return Image.new('RGB', (width, height), color)