mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Fix inconsistent Vegas scroll transition gaps caused by plugin-baked padding (#384)
* Strip plugin-baked scroll padding when capturing content for Vegas mode 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. * 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. * Add type hints and docstring to test _solid helper (CodeRabbit nitpick) --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -279,6 +279,19 @@ class PluginAdapter:
|
||||
# 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 @@ class PluginAdapter:
|
||||
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]:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user