diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index ae68cd4bd..b50d09466 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,69 @@ 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)) + + # 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 + ) + return cropped + def _trigger_scroll_content_generation( self, plugin: 'BasePlugin', plugin_id: str, scroll_helper: Any ) -> Optional[Image.Image]: diff --git a/test/test_vegas_plugin_adapter.py b/test/test_vegas_plugin_adapter.py new file mode 100644 index 000000000..323410de9 --- /dev/null +++ b/test/test_vegas_plugin_adapter.py @@ -0,0 +1,138 @@ +""" +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: int, height: int, color: tuple) -> Image.Image: + """Create a solid-color RGB image of the given dimensions.""" + 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