Vegas mode: reclaim dead space and pace the rotation

On a wide panel Vegas mode spent much of its time showing black. At 50px/s
on a 512px display, one display width of blank is 10.2 seconds, which makes
several long-standing behaviours expensive:

- ScrollHelper prepended a full display width of black as an "initial gap",
  charged once per cycle — 10.2s of black at the start of every rotation.
- Plugins without get_vegas_content() are captured off a full-display canvas,
  so their blank margins entered the ticker too. Measured: of-the-day drew
  35px of "No Data" on a 512px canvas (92% blank), youtube-stats 142px of
  content with 185px of black either side. Only the scroll_helper path had
  any trimming.
- Cycle transitions deliberately pushed a blank frame and then recomposed
  synchronously: 84ms at best, 4.8s at worst, every millisecond of it black.
- buffer_ahead doubled as the cycle size, so a 21-plugin install showed 3
  plugins per cycle and took ~7 cycles to come around.
- separator_width was applied between every image rather than at plugin
  boundaries, so a per-row ticker like the F1 scoreboard (116 images, which
  it renders 4px apart internally) got a 32px chasm between each row — and
  the width budget didn't count those gaps, so the plugin quietly occupied
  far more of the panel than intended.

Changes:

- src/vegas_mode/geometry.py: numpy column-ink primitives shared by the
  trimmer and the audit tool, so the number reported is the number acted on.
  A Python per-column loop over a 17,000px strip is far too slow for the
  render path.
- PluginAdapter trims every content path, not just scroll_helper. Only outer
  edges are cropped: interior blank columns are the plugin's own layout
  (logo left, score right) and closing them would corrupt the design. A
  plugin on a non-black background is inherently unaffected.
- ScrollHelper.create_scrolling_image takes an explicit lead_gap, still
  defaulting to display_width so the many standalone-ticker callers are
  unchanged. Vegas passes lead_in_width (default 0).
- Cycle end holds the last rendered frame instead of blanking, turning the
  recompose into a brief freeze rather than the panel switching off.
- plugins_per_cycle (default 6) is split from buffer_ahead, which goes back
  to being only a prefetch low-water mark.
- max_plugin_width_ratio (default 3x display width) caps one plugin's share
  of a cycle. Overflow is deferred, not discarded: a rotation offset advances
  each fetch so later rows appear on subsequent cycles. Single oversized
  images are cropped at a blank column so the cut misses glyphs.
- Composition groups images by plugin: rows are joined by intra_plugin_gap
  (default 8) and separator_width applies only between plugins. The width
  budget now counts those gaps.
- Plugin data updates no longer run on the Vegas render path.

All new settings are user-configurable in Display -> Vegas Scroll, including
min/max cycle duration and dynamic duration, which previously existed in code
but were reachable only by hand-editing config.json.

Measured with scripts/dev/vegas_audit.py on a 512x64 panel:

  mean ink coverage    42.7% -> 69.4%
  fully blank           5.9% -> 0%
  reads as empty        13.6% -> 0%
  worst blank stretch    4.8s -> 0s
  full rotation          414s -> 123s
  plugins per cycle         3 -> 6

Note the metric choice: a "fully blank" scan (>=95% black viewport) reported
only 0.4% and badly understated the problem, because two full-width segments
with mid-canvas content never fully blank the viewport — they hold it at ~28%.
window_coverage_stats grades every viewport position by how much ink it
carries, which is what tracks perceived dead time.

Known remaining: cycle transitions still freeze ~3.5s while the next cycle is
fetched. Fixing that needs background prefetch, which is deferred because the
fallback-capture path mutates the shared display_manager.image and racing it
against the render loop risks torn frames.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
ChuckBuilds
2026-07-28 20:18:51 -04:00
co-authored by Claude
parent e2acbfb566
commit 8d57a748a7
14 changed files with 2177 additions and 72 deletions
+36 -1
View File
@@ -918,7 +918,12 @@ def save_main_config():
# Handle Vegas scroll mode settings
vegas_fields = ['vegas_scroll_enabled', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins']
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order', 'vegas_excluded_plugins',
'vegas_auto_trim', 'vegas_trim_threshold', 'vegas_content_padding',
'vegas_min_plugin_width', 'vegas_lead_in_width', 'vegas_plugins_per_cycle',
'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled',
'vegas_min_cycle_duration', 'vegas_max_cycle_duration',
'vegas_intra_plugin_gap']
if any(k in data for k in vegas_fields):
if 'display' not in current_config:
@@ -933,13 +938,43 @@ def save_main_config():
# was submitted (any vegas field present) but enabled key is missing,
# the checkbox was unchecked and we should set enabled=False
vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled'))
vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim'))
vegas_config['dynamic_duration_enabled'] = _coerce_to_bool(
data.get('vegas_dynamic_duration_enabled'))
# max_plugin_width_ratio is the one fractional setting, so it is
# handled outside the integer loop below.
if data.get('vegas_max_plugin_width_ratio') not in ('', None):
try:
ratio = float(data['vegas_max_plugin_width_ratio'])
except (ValueError, TypeError):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_max_plugin_width_ratio: "
"must be a number"
}), 400
if not (0 <= ratio <= 20):
return jsonify({
'status': 'error',
'message': "Invalid value for vegas_max_plugin_width_ratio: "
"must be between 0 and 20 (0 disables the cap)"
}), 400
vegas_config['max_plugin_width_ratio'] = ratio
# Handle numeric settings with validation
numeric_fields = {
'vegas_scroll_speed': ('scroll_speed', 1, 100),
'vegas_separator_width': ('separator_width', 0, 500),
'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
'vegas_target_fps': ('target_fps', 1, 200),
'vegas_buffer_ahead': ('buffer_ahead', 1, 20),
'vegas_trim_threshold': ('trim_threshold', 0, 254),
'vegas_content_padding': ('content_padding', 0, 128),
'vegas_min_plugin_width': ('min_plugin_width', 0, 512),
'vegas_lead_in_width': ('lead_in_width', 0, 2048),
'vegas_plugins_per_cycle': ('plugins_per_cycle', 1, 50),
'vegas_min_cycle_duration': ('min_cycle_duration', 5, 3600),
'vegas_max_cycle_duration': ('max_cycle_duration', 10, 3600),
}
for field_name, (config_key, min_val, max_val) in numeric_fields.items():
if field_name in data: