mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Vegas mode: end cycles before the wrap, keep the width budget honest
Three fixes, the first a regression from lead_in_width defaulting to 0. get_visible_portion wraps: once scroll_position + display_width passes the end of the strip it fills the right of the frame from the *head* of the same strip. So the final display_width of travel showed the cycle's first plugin re-entering on the right while its last plugin exited on the left, and the recompose that followed replaced both at once. On a 512px panel at 50px/s that was 10.2s of two plugins on screen at once, ending in a hard cut — reported as the ticker "switching mid-scroll" from F1 to news. That used to be invisible because the strip began with a full display_width of blank, so the wrapped-in region was black. Removing that blank (it was 10s of dead panel per cycle) exposed the wrap. Cycles now end one display width earlier, before any wrapped content appears, clamped for strips no wider than the display so they don't complete instantly and spin the recompose loop. Verified on hardware: a 3936px strip now completes at 68.5s, exactly (3936 - 512) / 50. Second, auto_trim=False also skipped the width budget, which is an unrelated concern — turning off margin cropping should not let one plugin hold the panel for minutes. Seen in the field: the F1 scoreboard contributed 116 images and 14,848px untouched, giving a 33,821px cycle (11 minutes of content). The budget now applies regardless of trimming; with it restored that cycle is 6,362px. Third, the budget accounted for row gaps using the flat intra_plugin_gap while the compositor had moved to measured separation, so it under-counted by up to (min_content_separation - intra_plugin_gap) per row and a many-row plugin overran its cap. Both now use the same separation_gap() rule, and a test asserts the composed block fits the budget end to end rather than trusting the two paths to agree. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user