feat(vegas): keep live games in the ticker by default (#699)

* perf(timing): say which render-thread work a late frame followed

The soak already says how often a moving frame reached the panel late, but
not what the render thread was doing just before it. Vegas does two kinds of
work there between frames -- building its strip (compose, extend) and, with
live elements, patching changed pixels into it -- and deciding whether either
is affordable needs their own numbers.

- FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame.
  Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind;
  aggregate() still takes frames without ops. The file schema is unchanged.
- Vegas tags compose and every strip extension (with the bytes it copied).
- frame_soak prints an "after work" table: frames, late %, freezes and MB
  moved per kind, only when something tagged its work.
- render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes /
  --patch-every / --patch-where (in-place column writes, as a live element
  update does) and --extend-every-screens / --extend-width (append + trim on
  a fixed cadence that holds the strip's width).

No runtime behaviour changes: this is the measurement gate for live Vegas
elements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(changelog): note the frame-op attribution and bench modes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(scroll): build the strip's PIL image only when something reads it

Every Vegas strip extension rebuilt ScrollHelper.cached_image from
cached_array in full, twice (append, then trim), on the render thread:
Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a
Pi 4 (measured on ledpi), about two thirds of an extension's render-thread
cost. Nothing on the frame path reads the image's pixels; every frame is cut
from the array.

cached_image is now a property. append_content and drop_scrolled_prefix
defer it; the first read builds it from the array it started with and keeps
it only if the strip has not changed meanwhile, so a sync push racing an
extension cannot leave a stale image cached. Assigning cached_image stores
exactly what was assigned, as before. has_strip() says whether there is a
strip without building its image; the helper's frame path, Vegas and the
adapter's scroll-cache invalidation use it. The strip is also no longer held
in memory twice.

In Vegas the image is now built only by a multi-display sync push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(vegas): live elements -- a plugin API for content that changes while it scrolls

Vegas bakes each plugin's pictures into one strip, so a card already on its
way across the panel keeps what it showed when it was drawn. This adds the
API and bookkeeping for content that can be updated in place; the worker
that redraws and swaps it follows separately. No shipped plugin implements
the hook yet, so nothing changes for users.

Plugin API (core 3.8.0), all no-ops by default:
- BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version,
  live, refresh_hz)]: named, fixed-width pieces of Vegas content.
- BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free
  redraw for content that changes with time.
- BasePlugin.notify_vegas_data_changed(): data that lands outside update().
- src/plugin_system/vegas_elements.py (VegasElement, re-exported from
  base_plugin).

Core:
- PluginAdapter asks a plugin that implements the hook for elements on the
  background fetch only (under its lock, on its own canvas); every other
  path keeps get_vegas_content(). Live elements are pinned (padded with
  content_padding, never trimmed), tagged with their key, digest and data
  epoch in Image.info so the existing cache and group plumbing carry them
  unchanged, and untagged if a width budget crops them.
- RenderPipeline records where each live element lands (ElementRecord), in
  absolute strip columns a trim does not move; the block-start arithmetic
  is shared with the STATIC markers.
- PluginManager update listeners (add/remove_update_listener,
  notify_data_changed): told the moment update() completes, not at the
  next ~4s Vegas poll. The coordinator uses one to move each plugin's data
  epoch on.
- vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval,
  live_lead_screens; per-plugin core-owned vegas_live. Live elements are
  off under multi-display sync, in swap mode and with offscreen_prefetch off.
- scripts/check_plugin.py checks the element contract
  (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub
  is a working example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(vegas): live elements update in place while they scroll

One background worker (src/vegas_mode/live_worker.py) redraws a plugin's
live elements when its data epoch moves on (update listener) or on their
refresh_hz, nearest the screen first, and hands changed pixels lock-free to
the render thread, which copies them into the strip between frames
(RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most
four patches or two screens of bytes a frame, no drawing or locks there.
The worker takes over group prefetch once a live element is placed, runs
inside the render gate, and is supervised. Update tick 1s while live
elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md
describes what was built and why SegmentStrip was not needed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(sports): live Vegas cards for the scoreboards (shared layer)

One live element per game, drawn only when what the card shows changes, so
a score changes on a card already crossing the panel. The shared part, so
each scoreboard adopts it in a few lines:

- src/common/sports_vegas.py: game_key, game_fingerprint (the whole game
  dict, frozen: no drawn field can be missed), dedupe_games, VegasCardCache,
  StickyOdds (odds a live poll left out stay drawn), finished_games /
  with_finished_games (a game that just went final keeps its card, after its
  league's live games; one a heuristic only judged over keeps its live
  state, so a tied end of regulation never shows FINAL early).
- SportsScrollDisplay.make_vegas_renderer() is the override point;
  build_vegas_elements() and SportsScrollDisplayManager
  .get_vegas_elements_for() do the rest. A card's version includes its
  teams' ranks, which the renderer draws from the rankings cache.
- SportsLiveSharedMixin._record_finished_game() / finished_games_snapshot():
  held for FINISHED_GAME_TTL after it leaves the live list.

A sport that does not implement make_vegas_renderer keeps its ordinary Vegas
content, so no scoreboard changes until it opts in.

scripts/render_plugin.py --vegas renders a plugin's Vegas block as the
ticker lays it out, and --timeline stacks it at successive moments as
the ticker would update it in place; the join is now
render_pipeline.join_plugin_rows().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(vegas): keep live games in the ticker by default

display.vegas_scroll.live_in_ticker now defaults to true: through a live
game the marquee keeps running and the live scoreboard takes extra turns in
it -- its cards updating in place while they scroll -- instead of the ticker
giving way to the full-screen scoreboard.

The new default would reach nobody on its own: every existing config holds
an explicit false copied from the template (there was no control for it),
and the template merge only adds missing keys. ConfigManager therefore turns
a stored false on once, with a backup, and records live_in_ticker_migrated
so a false chosen afterwards stays. The marker is never in the template.

A "Keep live games in the ticker" checkbox under Vegas mode sets it. Tests
that pin the full-screen takeover now say live_in_ticker=false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(sports): a default _determine_game_type on SportsScrollDisplay

render_vegas_card looked the method up with getattr and a None default, which
static analysis (Codacy) reports as calling something that may not be
callable. The base class now has the default -- the card type from the game's
state -- and the plugins that define their own override it as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: review follow-ups on the shared live-card layer

- The reused Vegas renderer always gets the current rankings, empty
  included, so ranks cleared since are not kept drawn.
- render_plugin.py: --timeline refuses --no-live (a timeline shows live
  elements changing), --timeline/--no-live need --vegas, and the Vegas
  paths create the output's directory like the display path does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 08:27:16 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a12be7c3c5
commit 7f06cc9c3b
16 changed files with 246 additions and 30 deletions
+14
View File
@@ -134,6 +134,20 @@ class TestJsonPartialSaves:
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['vegas_scroll']['live_refresh'] is True
def test_live_games_can_be_kept_in_the_ticker_or_not(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'vegas_live_in_ticker': False})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is False
# An unticked box is absent from a submitted form: that is false too.
resp = _post_json(api_v3_client, {'__form_section': 'display',
'vegas_scroll_speed': '50',
'vegas_live_in_ticker': 'on'})
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is True
resp = _post_json(api_v3_client, {'__form_section': 'display',
'vegas_scroll_speed': '50'})
assert resp.status_code == 200, resp.get_json()
assert saved['config']['display']['vegas_scroll']['live_in_ticker'] is False
def test_double_sided_axis_only_keeps_enabled(self, api_v3_client, saved):
resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'})
assert resp.status_code == 200, resp.get_json()
@@ -0,0 +1,108 @@
"""live_in_ticker's new default reaches existing installs, once (src/config_manager.py).
3.8.0 makes display.vegas_scroll.live_in_ticker true. Every existing config
holds an explicit false copied from the template, which the template merge
never touches (it only adds missing keys), so ConfigManager turns that false
on once and marks the config. A false chosen after that -- the new checkbox,
or by hand -- must stay false.
"""
import json
import os
import sys
from pathlib import Path
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.config_manager import ConfigManager # noqa: E402
REPO = Path(__file__).resolve().parent.parent
MARKER = ConfigManager.LIVE_IN_TICKER_MARKER
@pytest.fixture
def files(tmp_path):
template = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": False, "live_in_ticker": True}}}
paths = {name: tmp_path / f"{name}.json" for name in ("config", "secrets", "template")}
paths["template"].write_text(json.dumps(template))
paths["secrets"].write_text("{}")
return paths
def _load(files, config):
files["config"].write_text(json.dumps(config))
manager = ConfigManager(config_path=str(files["config"]),
secrets_path=str(files["secrets"]))
manager.template_path = str(files["template"])
manager.load_config()
return manager, json.loads(files["config"].read_text())
def _vegas(saved):
return saved["display"]["vegas_scroll"]
def test_the_old_default_is_turned_on_once_and_marked(files):
old = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": False}}}
manager, saved = _load(files, old)
assert _vegas(saved)["live_in_ticker"] is True
assert _vegas(saved)[MARKER] is True
assert _vegas(manager.config)["live_in_ticker"] is True
# The config as it was is kept beside it.
backup = json.loads(Path(f"{files['config']}.backup").read_text())
assert _vegas(backup)["live_in_ticker"] is False
def test_false_chosen_after_the_migration_stays_false(files):
chosen = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": False,
MARKER: True}}}
_manager, saved = _load(files, chosen)
assert _vegas(saved)["live_in_ticker"] is False
def test_a_marked_config_is_not_written_again(files):
marked = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": True,
MARKER: True}}}
files["config"].write_text(json.dumps(marked))
before = files["config"].stat().st_mtime_ns
manager = ConfigManager(config_path=str(files["config"]),
secrets_path=str(files["secrets"]))
manager.template_path = str(files["template"])
manager.load_config()
assert files["config"].stat().st_mtime_ns == before
assert not Path(f"{files['config']}.backup").exists()
def test_a_config_without_vegas_settings_gets_the_new_default(files):
_manager, saved = _load(files, {"display": {"brightness": 90}})
assert _vegas(saved)["live_in_ticker"] is True
assert _vegas(saved)[MARKER] is True
def test_true_already_is_only_marked(files):
on = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": True}}}
_manager, saved = _load(files, on)
assert _vegas(saved)["live_in_ticker"] is True and _vegas(saved)[MARKER] is True
def test_a_second_load_keeps_a_false_set_in_between(files):
old = {"display": {"brightness": 90,
"vegas_scroll": {"enabled": True, "live_in_ticker": False}}}
_manager, saved = _load(files, old)
_vegas(saved)["live_in_ticker"] = False # the checkbox, unticked
_manager, saved = _load(files, saved)
assert _vegas(saved)["live_in_ticker"] is False
def test_the_shipped_template_has_the_new_default_and_never_the_marker():
template = json.loads((REPO / "config" / "config.template.json").read_text(encoding="utf-8"))
vegas = template["display"]["vegas_scroll"]
assert vegas["live_in_ticker"] is True
assert MARKER not in vegas
+4
View File
@@ -67,10 +67,13 @@ class TestConfigWhileStopped:
class TestLivePriorityThrottle:
"""The full-screen takeover's live scan: live_in_ticker off, no longer the default."""
def test_scan_runs_at_most_once_per_interval(self, monkeypatch):
now = [1000.0]
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: now[0])
c = _coordinator()
c.vegas_config.live_in_ticker = False
c._live_priority_check = MagicMock(return_value=None)
for _ in range(10):
@@ -84,6 +87,7 @@ class TestLivePriorityThrottle:
def test_live_content_still_pauses_vegas(self, monkeypatch):
monkeypatch.setattr(coordinator_module.time, 'monotonic', lambda: 1000.0)
c = _coordinator()
c.vegas_config.live_in_ticker = False
c._live_priority_check = MagicMock(return_value='nfl_live')
c.pause = MagicMock()
assert c.run_frame() is False
+7 -2
View File
@@ -58,11 +58,16 @@ def test_an_iteration_does_not_poll_every_plugin_for_its_mode(caplog):
def _live_coordinator(live):
"""A coordinator running the real run_frame(), with a switchable live check."""
"""A coordinator running the real run_frame(), with a switchable live check.
live_in_ticker off: these pin the full-screen takeover, which is no longer
the default.
"""
coord = _coordinator({})
del coord.run_frame # the real one: it is what refuses frames while paused
coord.vegas_config = VegasModeConfig.from_config({'display': {'vegas_scroll': {
'enabled': True, 'max_cycle_duration': 0, 'continuous_scroll': True}}})
'enabled': True, 'max_cycle_duration': 0, 'continuous_scroll': True,
'live_in_ticker': False}}})
coord.render_pipeline.has_deferred.return_value = False
coord.render_pipeline.needs_extension.return_value = False
coord.render_pipeline.render_frame.return_value = True
+6 -6
View File
@@ -5,10 +5,10 @@ live content did not appear in it at all, because the display controller
refused to run the ticker while anything was live. With a dozen plugins
enabled that left a live score either absent or minutes stale.
Two things change, both off by default. `live_in_ticker` keeps the marquee
running instead of yielding to a full-screen takeover, and the rotation is
expanded by Smooth Weighted Round-Robin so a weighted plugin gets several
slots per cycle, spaced through it rather than clumped.
Two things change. `live_in_ticker` keeps the marquee running instead of
yielding to a full-screen takeover (on by default since 3.8.0), and the
rotation is expanded by Smooth Weighted Round-Robin so a weighted plugin gets
several slots per cycle, spaced through it rather than clumped.
Weights are per plugin, not per game: a scoreboard showing four live games
still occupies one slot at a time and rotates its own games within it.
@@ -288,9 +288,9 @@ class TestTheSchedule:
class TestConfigParsing:
def test_defaults_preserve_todays_behaviour(self):
def test_live_games_stay_in_the_ticker_by_default(self):
cfg = VegasModeConfig.from_config({})
assert cfg.live_in_ticker is False
assert cfg.live_in_ticker is True
assert cfg.live_weight == 3 and cfg.favorite_live_weight == 5
@pytest.mark.parametrize("given,expected", [(0, 1), (-4, 1), (99, 10), (4, 4)])