From acf82ed76c79e9e20581c1cc408795b57790566f Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Mon, 10 Aug 2026 09:10:07 -0400 Subject: [PATCH] fix(vegas): apply the runt floor to the multi-row rotation too The floor only guarded the single-image path. I had reasoned the row path could not produce a runt because it wraps, which is wrong: wrapping only helps when the row wrapped to actually fits. Rows of 450, 450 and 100 against a 512px budget give the 100 a pass of its own -- two seconds against nine, which is the symptom this branch exists to remove. Reproduced before changing anything: pass 1: 450px pass 2: 450px pass 3: 100px A window may now overrun the budget while it is still shorter than the floor, bounded at the same 1.5 budgets the single-image path allows, so the short row is carried with its neighbour instead of standing alone. pass 1: 450px pass 2: 450px pass 3: 550px A next row too wide to absorb within that cap still leaves a short window standing -- rows of 900 and 100 keep alternating. Merging them would mean a window of nearly two budgets, and the rule that always shows an oversized first row already makes the same trade. Three regression tests: the reported shape, that the overrun stays bounded when a row cannot be absorbed, and that absorbing never drops a row from the rotation. The single-image path is untouched -- the four plugins that actually hit the cap on a live panel replay identically. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- src/vegas_mode/plugin_adapter.py | 17 ++++++++++- test/test_vegas_density.py | 50 ++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index a86cf8fc..0ea71571 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -552,13 +552,28 @@ class PluginAdapter: # Walk forward from the rotation offset, taking whole items only, so a # cut never lands in the middle of one. + # + # A window may overrun the budget while it is still shorter than the + # runt floor, for the same reason _merge_trailing_runt exists on the + # single-image path: a pass far shorter than its neighbours reads as + # the display failing rather than as a rotation. Rows of 450, 450 and + # 100 against a 512px budget used to give the 100 a pass of its own -- + # two seconds against nine. Wrapping does not prevent that, because it + # only helps when the row wrapped to actually fits. + floor = budget // 2 for step in range(len(images)): img = images[(start + step) % len(images)] cost = img.width if selected: cost += self._row_gap(selected[-1], img) if selected and used + cost > budget: - break + # Keep the overrun bounded at the same 1.5 budgets the + # single-image path allows. A next row too wide to absorb + # leaves a short window standing -- better than a window of + # 1.9 budgets, and the same trade the always-take-the-first + # rule below already makes. + if used >= floor or used + cost > budget + floor: + break selected.append(img) used += cost consumed += 1 diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py index b41b1570..da6cb44a 100644 --- a/test/test_vegas_density.py +++ b/test/test_vegas_density.py @@ -1731,6 +1731,56 @@ class TestTrailingRuntWindow: "a strip this close to the budget should be shown whole every " "time, not split into a big pass and a sliver; got %r" % widths) + def test_a_short_final_row_window_is_not_left_alone(self): + # The multi-row path has the same fault as the single-image one, and + # wrapping does not save it: rows of 450/450/100 against a 512px budget + # gave the 100 a pass of its own, two seconds against nine, because the + # row it wrapped to did not fit either. + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=0, min_content_separation=0) + rows = [canvas([(0, 450)], width=450), + canvas([(0, 450)], width=450), + canvas([(0, 100)], width=100)] + + widths = [] + for _ in range(6): + adapter.invalidate_cache('rows') + shown = adapter.get_content(NativePlugin(list(rows)), 'rows') + widths.append(sum(img.width for img in shown)) + + assert min(widths) >= DISPLAY_W // 2, ( + "a row window should not be a sliver, got %r" % widths) + assert max(widths) <= DISPLAY_W * 1.5, ( + "absorbing a short row must stay bounded, got %r" % widths) + + def test_row_rotation_still_covers_every_row(self): + # Absorbing a short tail must not drop rows from the rotation. + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=0, min_content_separation=0) + rows = [canvas([(0, 450)], width=450), + canvas([(0, 450)], width=450), + canvas([(0, 100)], width=100)] + + seen = set() + for _ in range(8): + adapter.invalidate_cache('rows') + for img in adapter.get_content(NativePlugin(list(rows)), 'rows'): + seen.add(img.width) + assert seen == {450, 100}, "rotation never showed every row: %r" % seen + + def test_a_row_too_wide_to_absorb_still_bounds_the_overrun(self): + # When the next row cannot be taken without blowing past 1.5 budgets, + # a short window is the lesser evil — the same trade the always-show- + # the-first-row rule already makes. + adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0, + intra_plugin_gap=0, min_content_separation=0) + rows = [canvas([(0, 900)], width=900), canvas([(0, 100)], width=100)] + for _ in range(4): + adapter.invalidate_cache('wide') + shown = adapter.get_content(NativePlugin(list(rows)), 'wide') + assert sum(i.width for i in shown) <= 900, ( + "must not merge a row that overruns the cap") + def test_a_genuinely_long_strip_still_gets_capped(self): # Absorbing runts must not become "never cap anything". adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)