diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index 5f6965f8..e89c23d1 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -12,7 +12,11 @@ from contextlib import nullcontext from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING from PIL import Image -from src.vegas_mode.geometry import find_blank_cut, trim_to_content +from src.vegas_mode.geometry import ( + find_blank_cut, + separation_gap, + trim_to_content, +) if TYPE_CHECKING: from src.plugin_system.base_plugin import BasePlugin @@ -166,8 +170,13 @@ class PluginAdapter: Trimmed image list, or None if nothing worth showing remains """ if not self.config.auto_trim: - self._cache_content(plugin_id, images) - return images + # Trimming is off, but the width budget is a separate concern — + # 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) + self._cache_content(plugin_id, kept) + return kept original_width = sum(img.width for img in images) kept: List[Image.Image] = [] @@ -275,6 +284,20 @@ class PluginAdapter: return self.display_width return max(1, int(self.display_width * pct / 100)) + def _row_gap(self, left: Image.Image, right: Image.Image) -> int: + """ + Gap the compositor will insert between two of a plugin's rows. + + Mirrors RenderPipeline._join_plugin_rows so the width budget measures + what will actually be rendered. + """ + return separation_gap( + left, right, + target=max(0, self.config.min_content_separation), + minimum=max(0, self.config.intra_plugin_gap), + threshold=self.config.trim_threshold, + ) + def _width_budget(self) -> int: """Maximum columns one plugin may occupy in a cycle. 0 means unlimited.""" ratio = self.config.max_plugin_width_ratio @@ -304,11 +327,15 @@ class PluginAdapter: """ budget = self._width_budget() - # Count the gaps the compositor will insert between these rows, not - # just the pixels of the rows themselves — otherwise a plugin with many - # rows quietly occupies far more of the panel than its budget allows. - gap = max(0, self.config.intra_plugin_gap) - total = sum(img.width for img in images) + gap * (len(images) - 1) + # Count the gaps the compositor will actually insert, not just the + # pixels of the rows — otherwise a plugin with many rows quietly + # occupies far more of the panel than its budget allows. These must use + # the same measured rule as RenderPipeline._join_plugin_rows; assuming + # the flat intra_plugin_gap here under-counted by up to + # (min_content_separation - intra_plugin_gap) per row. + total = sum(img.width for img in images) + sum( + self._row_gap(images[i], images[i + 1]) for i in range(len(images) - 1) + ) if not budget or total <= budget: # Fits, so reset rotation — the whole segment is being shown. @@ -327,7 +354,9 @@ class PluginAdapter: # cut never lands in the middle of one. for step in range(len(images)): img = images[(start + step) % len(images)] - cost = img.width + (gap if selected else 0) + cost = img.width + if selected: + cost += self._row_gap(selected[-1], img) if selected and used + cost > budget: break selected.append(img) diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index f9739b95..4c063518 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -265,21 +265,29 @@ class RenderPipeline: # Determine if the cycle is done. # - # scroll_helper considers a cycle complete only after - # total_distance_scrolled >= total_scroll_width + display_width. - # That extra display_width of travel causes a "wrap-around" phase - # where scroll_position resets to ~0 and the first plugin's content - # re-enters from the right — the user sees this 2-3 s of re-entry - # as "a plugin partially displaying before the next one starts." + # get_visible_portion wraps: once scroll_position + display_width + # passes the end of the strip it fills the right-hand side of the + # frame from the *head* of the same strip. So the last + # display_width of travel shows the cycle's first plugin re-entering + # on the right while its last plugin exits on the left, and the + # recompose that follows then replaces both at once. That reads as + # the ticker "switching mid-scroll". # - # We end the cycle as soon as total_distance_scrolled reaches - # total_scroll_width (the wrap-around point), before any second-pass - # content becomes visible. The scroll_helper's own is_scroll_complete() - # check is kept as a fallback for any edge-cases where that threshold - # is never hit. + # This used to be hidden because the strip began with a full + # display_width of blank, so the wrapped-in region was black. + # lead_in_width now defaults to 0 (that blank was 10s of dead panel + # at 50px/s), which exposed the wrap — so the cycle has to end + # before it, one display width earlier. + # + # A strip no wider than the display never wraps, and subtracting + # would make the cycle complete instantly, so clamp in that case. + wrap_point = self.scroll_helper.total_scroll_width + if wrap_point > self.display_width: + wrap_point -= self.display_width + at_wrap_point = ( not self._cycle_complete and - self.scroll_helper.total_distance_scrolled >= self.scroll_helper.total_scroll_width + self.scroll_helper.total_distance_scrolled >= wrap_point ) if at_wrap_point or self.scroll_helper.is_scroll_complete(): diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py index ac8b6e15..a17724e4 100644 --- a/test/test_vegas_density.py +++ b/test/test_vegas_density.py @@ -756,3 +756,175 @@ class TestNewConfigKeys: 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 + + +class TestCycleEndsBeforeWrap: + """ + get_visible_portion wraps the head of the strip into the right side of the + frame once scroll_position + display_width passes the end. With a leading + blank that was invisible; with lead_in_width=0 it showed the cycle's first + plugin re-entering while the last one exited, then a recompose replaced + both — the reported "switched mid-scroll". + """ + + def _pipeline(self, strip_width, **cfg): + from src.vegas_mode.render_pipeline import RenderPipeline + + class FakeStream: + def get_grouped_content_for_composition(self): + return [('a', [Image.new('RGB', (strip_width, DISPLAY_H), (255, 255, 255))])] + + def get_active_plugin_ids(self): + return ['a'] + + class DM: + width = DISPLAY_W + height = DISPLAY_H + + def __init__(self): + self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H)) + + def set_scrolling_state(self, *a): + pass + + def update_display(self): + pass + + p = RenderPipeline(VegasModeConfig(lead_in_width=0, **cfg), DM(), FakeStream()) + assert p.compose_scroll_content() + return p + + def _advance_to(self, pipeline, distance): + pipeline.scroll_helper.total_distance_scrolled = distance + pipeline.scroll_helper.scroll_position = float(distance) + + def test_cycle_is_not_complete_before_the_wrap_point(self): + p = self._pipeline(2000) + self._advance_to(p, 2000 - DISPLAY_W - 1) + p.render_frame() + assert not p.is_cycle_complete() + + def test_cycle_completes_exactly_at_the_wrap_point(self): + p = self._pipeline(2000) + self._advance_to(p, 2000 - DISPLAY_W) + p.render_frame() + assert p.is_cycle_complete() + + def test_completes_a_full_display_width_earlier_than_the_strip_end(self): + # The whole point: it must not run to total_scroll_width, which is + # where the wrapped content has already been on screen for 10s at + # 50px/s on a 512px panel. + p = self._pipeline(3000) + self._advance_to(p, 3000 - DISPLAY_W - 1) + p.render_frame() + assert not p.is_cycle_complete() + self._advance_to(p, 3000 - DISPLAY_W) + p.render_frame() + assert p.is_cycle_complete() + + def test_strip_narrower_than_the_display_does_not_complete_instantly(self): + # Subtracting the display width would go negative and end the cycle on + # the very first frame, spinning the recompose loop. + p = self._pipeline(200) + self._advance_to(p, 0) + p.render_frame() + assert not p.is_cycle_complete() + + def test_strip_narrower_than_the_display_still_completes(self): + p = self._pipeline(200) + self._advance_to(p, 200) + p.render_frame() + assert p.is_cycle_complete() + + def test_strip_exactly_the_display_width(self): + p = self._pipeline(DISPLAY_W) + self._advance_to(p, 0) + p.render_frame() + assert not p.is_cycle_complete() + self._advance_to(p, DISPLAY_W) + p.render_frame() + assert p.is_cycle_complete() + + +class TestBudgetIndependentOfTrim: + """ + Turning off margin trimming must not disable the per-plugin width cap — + they are unrelated concerns. Found in the field: with auto_trim off, the F1 + scoreboard contributed 116 images / 14,848px untouched, producing a 33,821px + cycle. + """ + + def test_budget_still_applies_with_trim_off(self): + adapter = adapter_with(auto_trim=False, max_plugin_width_ratio=1.0, + intra_plugin_gap=0, min_content_separation=0) + items = [canvas([(0, 400)], width=400) for _ in range(10)] + images = adapter.get_content(NativePlugin(items), 'f1-scoreboard') + assert sum(i.width for i in images) <= DISPLAY_W + + def test_trim_off_still_leaves_content_untrimmed(self): + # The margins must survive; only the cap should act. + adapter = adapter_with(auto_trim=False, max_plugin_width_ratio=0) + images = adapter.get_content(NativePlugin([canvas([(4, 39)])]), 'x') + assert images[0].width == DISPLAY_W + + def test_single_oversized_image_capped_with_trim_off(self): + adapter = adapter_with(auto_trim=False, max_plugin_width_ratio=1.0) + images = adapter.get_content( + NativePlugin([canvas([(0, 6898)], width=6898)]), 'leaderboard') + assert images[0].width <= DISPLAY_W + DISPLAY_W // 16 + + +class TestBudgetUsesMeasuredGaps: + """ + The budget must count the gaps the compositor actually inserts. Assuming the + flat intra_plugin_gap under-counted by up to + (min_content_separation - intra_plugin_gap) per row, so a many-row plugin + overran its cap. + """ + + def test_flush_rows_are_budgeted_with_the_measured_gap(self): + # 6 flush rows of 100px against a 512px budget. With 24px measured gaps + # only 4 fit (400 + 3*24 = 472; a 5th would be 596). + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=8, min_content_separation=24) + rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(6)] + images = adapter.get_content(NativePlugin(rows), 'rows') + n = len(images) + assert 100 * n + 24 * (n - 1) <= DISPLAY_W + assert n == 4 + + def test_larger_separation_fits_fewer_rows(self): + rows = [Image.new('RGB', (100, DISPLAY_H), (255, 255, 255)) for _ in range(8)] + tight = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=0, min_content_separation=0) + loose = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=0, min_content_separation=48) + assert len(loose.get_content(NativePlugin(rows), 'r')) < \ + len(tight.get_content(NativePlugin(rows), 'r')) + + def test_composed_block_respects_the_budget_end_to_end(self): + # The real invariant: what the compositor produces must fit the cap. + from src.vegas_mode.render_pipeline import RenderPipeline + + cfg = dict(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=8, min_content_separation=24) + adapter = adapter_with(**cfg) + rows = [Image.new('RGB', (90, DISPLAY_H), (255, 255, 255)) for _ in range(9)] + selected = adapter.get_content(NativePlugin(rows), 'rows') + + class FakeStream: + def get_grouped_content_for_composition(self): + return [('rows', selected)] + + def get_active_plugin_ids(self): + return ['rows'] + + class DM: + width = DISPLAY_W + height = DISPLAY_H + + def set_scrolling_state(self, *a): + pass + + p = RenderPipeline(VegasModeConfig(lead_in_width=0, **cfg), DM(), FakeStream()) + assert p._join_plugin_rows(selected).width <= DISPLAY_W