Compare commits

...
19 Commits
Author SHA1 Message Date
Chuck 53f4f1fdbf Merge branch 'vegas-live/pr7-dev-server-vegas' into vegas-live/strip-in-place 2026-09-30 21:25:34 -04:00
Chuck 1a1a6f5c65 Merge branch 'vegas-live/pr6-live-in-ticker' into vegas-live/pr7-dev-server-vegas 2026-09-30 21:25:32 -04:00
Chuck 0b3fa712d0 Merge branch 'vegas-live/pr4-sports-cards' into vegas-live/pr6-live-in-ticker 2026-09-30 21:25:30 -04:00
ChuckandClaude Opus 5.5 fb9820b78e 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>
2026-09-30 21:25:29 -04:00
Chuck ad47f89066 Merge remote-tracking branch 'origin/main' into vegas-live/pr4-sports-cards
# Conflicts:
#	CHANGELOG.md
#	src/plugin_system/testing/vegas.py
#	src/vegas_mode/render_pipeline.py
#	test/test_harness_vegas_elements.py
2026-09-30 21:07:37 -04:00
ChuckandClaude Opus 5.5 3973f0c3c5 refactor(scroll): no assert in _extended_strip
An assert vanishes under python -O (Codacy); a real check says the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:26:12 -04:00
Chuck 04cad60259 Merge branch 'vegas-live/pr7-dev-server-vegas' into vegas-live/strip-in-place 2026-09-30 20:25:58 -04:00
Chuck 22b88c4677 Merge branch 'vegas-live/pr6-live-in-ticker' into vegas-live/pr7-dev-server-vegas 2026-09-30 20:25:56 -04:00
Chuck a51305bb4f Merge branch 'vegas-live/pr4-sports-cards' into vegas-live/pr6-live-in-ticker 2026-09-30 20:25:54 -04:00
ChuckandClaude Opus 5.5 d4f828d189 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>
2026-09-30 20:25:52 -04:00
ChuckandClaude Opus 5.5 ec1aa34f40 perf(scroll): extend and trim the Vegas strip in place
Every strip extension rebuilt the whole strip (np.concatenate: 2-2.6 ms for
a 10-14k px strip at 512x64 on a Pi 4) and every trim copied what was left
(1.2-1.8 ms), on the render thread. With ~4 ms of slack per refresh, every
extension frame on hdpi missed its refresh (5/5 in each soak run).

The strip now lives in a buffer with spare room; cached_array is a view of
its live columns. An append writes only the new columns (~0.2 ms), a trim
only moves the view's start, and the one full copy happens when the buffer
is reallocated (STRIP_SPARE_FACTOR 3: about once every two strip-lengths
scrolled). A cached_array set from outside -- the multi-display follower's
read-only one, create_scrolling_image's -- is never written through, and a
new strip lets the old buffer go. last_copy_bytes says what was copied, and
the Vegas frame-timing attribution reports that instead of the whole strip.

test_scroll_helper_in_place.py: the buffer is reused and only new columns
copied, trims copy nothing, reallocation when the room runs out, outside
arrays untouched, and random appends/trims/patches/scrolling checked frame
by frame against the old copying strip (mutation-checked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:17:31 -04:00
ChuckandClaude Opus 5.5 f96c815e0e feat(dev): preview a plugin's Vegas strip in the dev server
The dev server's View selector gains "Vegas strip (live elements)" and
"Vegas strip (plain Vegas content)": the plugin's block of the Vegas ticker,
laid out by the ticker's own code (render_vegas_strip, as render_plugin.py
--vegas uses), with its live elements listed. /api/render takes
"vegas": "live" | "plain"; the display view is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:14:01 -04:00
ChuckandClaude Opus 5.5 559ef14a5c 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>
2026-09-30 18:36:39 -04:00
ChuckandClaude Opus 5.5 8cce532ea9 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>
2026-09-30 18:36:39 -04:00
ChuckandClaude Opus 5.5 876130e93f 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>
2026-09-30 18:36:39 -04:00
ChuckandClaude Opus 5.5 701c220ad0 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>
2026-09-30 18:36:39 -04:00
ChuckandClaude Opus 5.5 1160eb5efe 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>
2026-09-30 18:36:39 -04:00
ChuckandClaude Opus 5.5 6efea8c19a docs(changelog): note the frame-op attribution and bench modes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:36:38 -04:00
ChuckandClaude Opus 5.5 1252df9bad 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>
2026-09-30 18:36:38 -04:00
31 changed files with 1755 additions and 85 deletions
+40
View File
@@ -384,9 +384,49 @@ read any of them:
the Vegas update tick runs every second instead of every four.
- Web UI: "Update live content while it scrolls" under Vegas mode's Cycle
Pacing (`display.vegas_scroll.live_refresh`).
- **Live cards for the scoreboards (shared code).** New module
`src/common/sports_vegas.py`: `game_key()`, `dedupe_games()`,
`VegasCardCache` (draws a card only when its fingerprint changes) and
`StickyOdds` (keeps a card's odds through a live poll that left them out),
`finished_games()` and `with_finished_games()` (a game that just went final
keeps its card, showing FINAL, where its live card was).
`SportsScrollDisplay` gains `make_vegas_renderer()` (the override point; a
sport that does not implement it keeps its ordinary Vegas content),
`render_vegas_card()`, `vegas_separator()` and `build_vegas_elements()`,
and `SportsScrollDisplayManager` gains `get_vegas_elements_for()`.
`SportsLiveSharedMixin` gains `_record_finished_game()` /
`finished_games_snapshot()`, so a game that goes final keeps a card to show
FINAL on until the hourly recent list takes it over.
- `scripts/render_plugin.py --vegas` renders a plugin's block of the Vegas
strip as the ticker lays it out (live elements, or with `--no-live` its
ordinary content) and writes the live elements' keys and columns beside
it. `--timeline ROWS` stacks the block at successive moments as the
ticker would update it in place (`--timeline-step`, and
`--timeline-update` to run `update()` between rows).
`render_vegas_strip()` and `render_vegas_timeline()` in
`src/plugin_system/testing/vegas.py`; the join is now
`render_pipeline.join_plugin_rows()`.
- **Behaviour change: live games stay in the Vegas ticker by default.**
`display.vegas_scroll.live_in_ticker` now defaults to `true`: the marquee
keeps running through a live game, which takes extra turns in it, instead
of giving way to the full-screen scoreboard. Existing configs all held the
old `false`, copied from the template, so the first start turns it on once
(`ConfigManager._migrate_live_in_ticker_default`; the previous config is
kept as `config.json.backup` and `live_in_ticker_migrated` records that it
ran). To keep the full-screen scoreboard, untick the new **Keep live games
in the ticker** under Vegas mode; a `false` set after the migration stays.
### Scrolling
- A Vegas strip extension no longer costs a late frame. Appending the next
group rebuilt the whole strip (`np.concatenate`, 2-2.6ms for a 10-14k px
strip at 512x64 on a Pi 4) and trimming copied what was left (1.2-1.8ms),
so on hdpi every extension frame missed its refresh. The strip now lives in
a buffer with spare room (`ScrollHelper.STRIP_SPARE_FACTOR`): an append
writes only the new columns (~0.2ms), a trim only moves the start, and the
one full copy happens when the buffer is reallocated, about once every two
strip-lengths scrolled. A strip set from outside (the multi-display
follower's) is never written through.
- A Vegas strip extension costs the render thread about a third of what it
did. Appending the next group and trimming what has scrolled past each
rebuilt the strip's PIL image from its numpy array in full
+1 -1
View File
@@ -134,7 +134,7 @@
"plugin_rotation_order": [],
"use_short_date_format": true,
"vegas_scroll": {
"live_in_ticker": false,
"live_in_ticker": true,
"live_weight": 3,
"favorite_live_weight": 5,
"enabled": false,
+21 -7
View File
@@ -75,17 +75,31 @@ total. See the full list in
### Live Content in the Ticker
By default, live content **preempts** Vegas mode: while any plugin reports
live priority, the display controller refuses to run the ticker and shows
that plugin's full-screen display instead. You get a big readable scoreboard,
but the marquee stops entirely for the duration of the game.
By default (since 3.8.0) live content **stays in the ticker** and takes
**extra turns inside it**, and a scoreboard that supports live cards updates
the score on a card already crossing the screen (`live_refresh`, "Update live
content while it scrolls").
Set `live_in_ticker` to keep the ticker running and let live content take
**extra turns inside it** instead:
To get the old behaviour back -- live content **preempts** Vegas mode: while
any plugin reports live priority the ticker stops and that plugin's
full-screen display is shown instead -- untick **Keep live games in the
ticker** under Vegas mode, or set `live_in_ticker` to `false`:
```json
"vegas_scroll": {
"live_in_ticker": false
}
```
Until 3.8.0 `false` was the default and every config held it, copied from
the template. The first start on 3.8.0 turns it on once (a backup of the
config is kept as `config.json.backup`, and `live_in_ticker_migrated` records
that it ran); a `false` set after that is left alone.
The weights below apply while live content is in the ticker:
```json
"vegas_scroll": {
"live_in_ticker": true,
"live_weight": 3,
"favorite_live_weight": 5
}
+1 -1
View File
@@ -152,7 +152,7 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
| `max_cycle_duration` | int, `240` |
| `frame_based_scrolling` | bool, `true` — does not step or set a frame rate; motion is by elapsed time either way. When `true`, `scroll_speed` passes through a clamp of 0.1–5 px per `scroll_delay` (see next row) |
| `scroll_delay` | float, `0.02` — not a frame period. Only used with `frame_based_scrolling`: the applied speed is `clamp(scroll_speed × scroll_delay, 0.1, 5) / scroll_delay` px/s, so at `0.02` speeds under 5 px/s run at 5, and at `0.001` nothing runs slower than 100 px/s |
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
| `live_in_ticker` | bool, `true` — keep scrolling during live games instead of handing the display to a full-screen scoreboard. `false` was the default before 3.8.0; the first start on 3.8.0 turns a stored `false` on once and sets `live_in_ticker_migrated` |
| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content |
| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live |
+3
View File
@@ -336,6 +336,9 @@ to rebuild it twice, 1.7-3.8 ms each on a Pi 4). The `extend` row of
work" table), and extensions no longer rebuilding the strip's PIL copy.
3. **Live elements:** the plugin API, the records, the worker and in-place
patches, with the sports scoreboards and the flight map adopting it.
4. **Live games in the ticker by default:** `live_in_ticker` true, so a live
game's cards update in the marquee instead of the full-screen scoreboard
replacing it; existing configs are switched once (`ConfigManager`).
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores the
deferred path when `false`, and `display.vegas_scroll.live_refresh` (default
+3 -3
View File
@@ -308,9 +308,9 @@ the core then falls back to its own live-content check — so a plugin whose
weight calculation is broken still gets `live_weight` for a game that really
is live, rather than being demoted to 1.
Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the
default (`false`) live content preempts Vegas entirely and there is no ticker
to be weighted within. See
Only consulted while `vegas_scroll.live_in_ticker` is on (the default since
3.8.0). With it off live content preempts Vegas entirely and there is no
ticker to be weighted within. See
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
### Vegas scroll hooks
+1
View File
@@ -37,6 +37,7 @@ src/common/sports_celebration.py
src/common/sports_fetch.py
src/common/sports_scroll.py
src/common/sports_timezone.py
src/common/sports_vegas.py
src/config_service.py
src/core_config_keys.py
src/deprecation.py
+54 -3
View File
@@ -200,12 +200,21 @@ def api_plugin_defaults(plugin_id):
return jsonify({'defaults': defaults})
#: /api/render "vegas" values: the plugin's block of the Vegas strip, built
#: from its live elements (falling back to its Vegas content, as the ticker
#: does) or from its ordinary Vegas content only.
VEGAS_VIEWS = ('live', 'plain')
def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height,
skip_update):
skip_update, vegas=None):
"""Render one plugin at one size. Returns the /api/render response dict.
A fresh plugin instance per call, mirroring the safety harness, so sizes
never share state.
never share state. With ``vegas`` set ('live' or 'plain') the image is the
plugin's block of the Vegas strip instead of its display(), laid out by
the ticker's own code (src/plugin_system/testing/vegas.py), and the
response lists where each live element sits in it.
"""
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
from src.plugin_system.plugin_loader import PluginLoader
@@ -243,6 +252,10 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
logger.warning("update() raised for plugin %s", plugin_id, exc_info=True)
warnings.append(f"update() raised: {type(e).__name__} — see server log")
if vegas:
return _vegas_response(plugin_id, plugin_instance, display_manager, vegas,
start_time, errors, warnings)
# Run display()
try:
plugin_instance.display(force_clear=True)
@@ -262,6 +275,40 @@ def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, heig
}
def _vegas_response(plugin_id, plugin_instance, display_manager, vegas, start_time,
errors, warnings):
"""The /api/render response for the Vegas strip view."""
import base64
import io
from src.plugin_system.testing.vegas import render_vegas_strip
block, layout = None, []
try:
block, layout = render_vegas_strip(plugin_instance, plugin_id, display_manager,
live=(vegas == 'live'))
except Exception as e:
logger.warning("Vegas render raised for plugin %s", plugin_id, exc_info=True)
errors.append(f"Vegas render raised: {type(e).__name__} — see server log")
if block is None:
if not errors:
errors.append("The plugin has no Vegas content")
block = display_manager.image
elif vegas == 'live' and not layout:
warnings.append("No live elements: this is the plugin's ordinary Vegas content")
buffer = io.BytesIO()
block.convert('RGB').save(buffer, format='PNG')
return {
'image': 'data:image/png;base64,' + base64.b64encode(buffer.getvalue()).decode('ascii'),
'width': block.width,
'height': block.height,
'render_time_ms': round((time.time() - start_time) * 1000, 1),
'errors': errors,
'warnings': warnings,
'live_elements': [{'key': key, 'x': x, 'width': width} for x, key, width in layout],
}
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
"""Re-derive a plugin directory from the search dirs' own listings.
@@ -333,6 +380,10 @@ def api_render():
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
vegas = data.get('vegas') or None
if vegas is not None and vegas not in VEGAS_VIEWS:
return jsonify({'error': f'vegas must be one of {", ".join(VEGAS_VIEWS)}'}), 400
try:
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
except LookupError:
@@ -345,7 +396,7 @@ def api_render():
try:
result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
mock_data, width, height, skip_update)
mock_data, width, height, skip_update, vegas=vegas)
except Exception:
app.logger.exception('plugin load failed during render')
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
+57
View File
@@ -56,9 +56,33 @@ def main() -> int:
help='Display mode to render, for plugins that declare '
'more than one in their manifest (e.g. nrl_live). '
'Omitted, the plugin picks its own default.')
parser.add_argument('--vegas', action='store_true',
help="Render the plugin's block of the Vegas ticker strip "
"instead of display(): its live elements if it has "
"them, else its Vegas content, laid out as the "
"ticker lays them out. Also writes the live "
"elements' keys and columns to <output>.json")
parser.add_argument('--no-live', action='store_true',
help="With --vegas: ignore live elements and render the "
"plugin's ordinary Vegas content (for before/after)")
parser.add_argument('--timeline', type=int, default=0, metavar='ROWS',
help="With --vegas: render ROWS rows, each the block a "
"--timeline-step later as the ticker would update it "
"in place (animated elements redrawn for that moment)")
parser.add_argument('--timeline-step', type=float, default=0.25, metavar='SECONDS',
help="Seconds between --timeline rows (default 0.25)")
parser.add_argument('--timeline-update', action='store_true',
help="With --timeline: run update() before each row and "
"redraw every live element from the new data")
args = parser.parse_args()
if args.timeline > 1 and args.no_live:
# A timeline shows live elements changing; plain content never does.
parser.error("--timeline shows live elements; it cannot be combined with --no-live")
if (args.timeline or args.no_live) and not args.vegas:
parser.error("--timeline and --no-live need --vegas")
if not (MIN_DIMENSION <= args.width <= MAX_DIMENSION):
print(f"Error: --width must be between {MIN_DIMENSION} and {MAX_DIMENSION} (got {args.width})")
raise SystemExit(1)
@@ -145,6 +169,39 @@ def main() -> int:
except Exception as e:
logger.warning("update() raised: %s — continuing to display()", e)
if args.vegas:
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
if args.vegas and args.timeline > 1:
from src.plugin_system.testing.vegas import render_vegas_timeline
image, rows = render_vegas_timeline(
plugin_instance, args.plugin, display_manager, steps=args.timeline,
step_seconds=args.timeline_step, run_update=args.timeline_update)
if image is None:
logger.error("Plugin '%s' has no Vegas content", args.plugin)
return 1
image.save(args.output)
logger.info("Saved a %d-row Vegas timeline (%dx%d) to %s",
rows, image.width, image.height, args.output)
return 0
if args.vegas:
from src.plugin_system.testing.vegas import render_vegas_strip
block, layout = render_vegas_strip(
plugin_instance, args.plugin, display_manager, live=not args.no_live)
if block is None:
logger.error("Plugin '%s' has no Vegas content", args.plugin)
return 1
block.save(args.output)
sidecar = Path(args.output).with_suffix('.json')
sidecar.write_text(json.dumps(
{"width": block.width, "height": block.height,
"live_elements": [{"key": k, "x": x, "width": w} for x, k, w in layout]},
indent=2) + "\n", encoding="utf-8")
logger.info("Saved Vegas strip %dx%d (%d live element(s)) to %s and %s",
block.width, block.height, len(layout), args.output, sidecar)
return 0
# A plugin that declares several display modes usually renders nothing
# useful without being told which one to draw: the scoreboards keep their
# state on per-mode sub-managers and their no-argument path returns False.
+18 -1
View File
@@ -247,6 +247,19 @@
</div>
</details>
<!-- What to render: the plugin's screen, or its block of the Vegas strip -->
<div class="flex items-center gap-2">
<label for="viewSelect" class="text-xs whitespace-nowrap" style="color: var(--text-secondary);">View</label>
<select id="viewSelect" onchange="onConfigChange()"
class="flex-1 px-2 py-1.5 rounded-lg text-xs"
style="background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color);"
title="Vegas strip: the plugin's block of the Vegas ticker, laid out as the ticker lays it out">
<option value="">Display</option>
<option value="live">Vegas strip (live elements)</option>
<option value="plain">Vegas strip (plain Vegas content)</option>
</select>
</div>
<!-- Render buttons -->
<div class="flex gap-2">
<button onclick="renderPlugin()" id="renderBtn"
@@ -489,6 +502,7 @@
width: width,
height: height,
mock_data: mockData,
vegas: document.getElementById('viewSelect').value || null,
}),
});
@@ -510,8 +524,11 @@
updateZoom();
// Show render time
const live = data.live_elements;
document.getElementById('renderTimeText').textContent =
`${data.render_time_ms}ms`;
`${data.render_time_ms}ms` + (live ? ` · ${data.width}px strip, ` +
`${live.length} live element(s)` +
(live.length ? `: ${live.map(e => e.key).join(', ')}` : '') : '');
// Show warnings/errors
showMessages(data.errors || [], data.warnings || []);
+12
View File
@@ -45,6 +45,7 @@ Rules for the package:
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
| [`sports_timezone`](#sports_timezone) | Which timezone a scoreboard draws start times in | Yes (scoreboards) | 3.6.0 |
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
@@ -280,6 +281,17 @@ fonts, colours, dates, the switch-mode upcoming card). The docstring lists
the attributes the host class must have and the three methods deliberately
left out.
### sports_vegas
[`sports_vegas.py`](sports_vegas.py). What a scoreboard needs for live Vegas
cards (one element per game, swapped in place while it scrolls):
`game_key()`, `game_fingerprint()`, `dedupe_games()`, `VegasCardCache` (draws
a card only when its fingerprint changes), `StickyOdds` (keeps a card's odds
through a live poll that left them out), and `finished_games()` /
`with_finished_games()` (a game that just went final keeps its card, showing
FINAL). `SportsScrollDisplay.build_vegas_elements()` in `sports_scroll` puts
them together; a scoreboard not built on it (UFC) uses them directly.
### sports_timezone
[`sports_timezone.py`](sports_timezone.py).
+72 -9
View File
@@ -114,6 +114,18 @@ class ScrollHelper:
self.cached_image = None # see the property below
self.cached_array: Optional[np.ndarray] = None # Numpy array cache for fast operations
self.total_scroll_width = 0
# An extended strip lives in a buffer with spare room after it, and
# cached_array is a view of the buffer's live columns: an append writes
# only the new columns, and a trim only moves the view's start. See
# append_content. _strip_view is the view this helper last made; a
# cached_array that is anything else was set from outside and is not
# written through.
self._strip_buffer: Optional[np.ndarray] = None
self._strip_view: Optional[np.ndarray] = None
self._strip_start = 0
#: Bytes the last append_content / drop_scrolled_prefix copied, for
#: frame-timing attribution (src/common/frame_timing.py note_op).
self.last_copy_bytes = 0
# Pre-allocated buffer for output frame (reused to avoid allocations)
self._frame_buffer: Optional[np.ndarray] = None
@@ -249,6 +261,7 @@ class ScrollHelper:
self.total_scroll_width = 0
self.cached_image = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0))
self.cached_array = np.array(self.cached_image)
self._forget_strip_buffer()
self.scroll_position = 0.0
self.total_distance_scrolled = 0.0
self.scroll_complete = False
@@ -284,6 +297,7 @@ class ScrollHelper:
self.cached_image = full_image
# Convert to numpy array for fast operations
self.cached_array = np.array(full_image)
self._forget_strip_buffer()
actual_image_width = full_image.width
self.total_scroll_width = actual_image_width
@@ -712,12 +726,12 @@ class ScrollHelper:
addition.paste(img, (x, 0))
x += img.width + element_gap
# numpy concatenate, and no conversion back: the strip can be tens of
# thousands of columns wide and this runs on the render path. The PIL
# image is built from the array only if something reads it (see the
# cached_image property).
self.cached_array = np.concatenate(
(self.cached_array, np.array(addition)), axis=1)
# Written into the spare room after the strip, when there is some:
# the strip can be tens of thousands of columns wide and this runs on
# the render thread, where copying all of it (2-3 ms at 512x64 on a
# Pi 4) cost the frame after every extension. The PIL image is built
# from the array only if something reads it (see cached_image).
self.cached_array = self._extended_strip(np.asarray(addition))
self._defer_image()
self.total_scroll_width = self.cached_array.shape[1]
self.scroll_complete = False
@@ -729,6 +743,44 @@ class ScrollHelper:
)
return True
#: Room an extended strip's buffer is given, as a multiple of what it
#: holds when (re)allocated. Trims free columns at the front and appends
#: use them at the back, so with 3x the buffer is reallocated -- the one
#: full copy -- about once every two strip-lengths scrolled.
STRIP_SPARE_FACTOR = 3.0
def _extended_strip(self, addition: np.ndarray) -> np.ndarray:
"""The strip with ``addition`` after it, written in place when it fits."""
live = self.cached_array
if live is None:
# append_content builds a first strip itself and never comes here.
raise RuntimeError("no strip to extend")
width = live.shape[1]
added = addition.shape[1]
buffer = self._strip_buffer
if (live is self._strip_view and buffer is not None
and self._strip_start + width + added <= buffer.shape[1]):
end = self._strip_start + width
buffer[:, end:end + added] = addition
self.last_copy_bytes = addition.nbytes
else:
total = width + added
buffer = np.empty((live.shape[0], max(total + 1, int(total * self.STRIP_SPARE_FACTOR)))
+ live.shape[2:], dtype=live.dtype)
buffer[:, :width] = live
buffer[:, width:total] = addition
self._strip_buffer = buffer
self._strip_start = 0
self.last_copy_bytes = live.nbytes + addition.nbytes
self._strip_view = buffer[:, self._strip_start:self._strip_start + width + added]
return self._strip_view
def _forget_strip_buffer(self) -> None:
"""A new strip replaces the extended one: let its buffer go."""
self._strip_buffer = None
self._strip_view = None
self._strip_start = 0
def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
"""
Discard columns that have already scrolled past, to bound memory.
@@ -766,9 +818,18 @@ class ScrollHelper:
if cut <= 0:
return 0
# .copy() so the original buffer is released rather than kept alive by
# a numpy view. The PIL image is deferred, as in append_content.
self.cached_array = self.cached_array[:, cut:].copy()
if self.cached_array is self._strip_view:
# Only the view's start moves; the columns behind it are reused
# when the buffer is next reallocated (append_content).
self._strip_view = self.cached_array[:, cut:]
self._strip_start += cut
self.cached_array = self._strip_view
self.last_copy_bytes = 0
else:
# Not a strip this helper extended: .copy() so the original
# buffer is released rather than kept alive by a view.
self.cached_array = self.cached_array[:, cut:].copy()
self.last_copy_bytes = self.cached_array.nbytes
self._defer_image()
self.total_scroll_width = self.cached_array.shape[1]
self.scroll_position -= cut
@@ -881,6 +942,7 @@ class ScrollHelper:
# Convert to numpy array for fast operations (required for get_visible_portion)
self.cached_array = np.array(image)
self._forget_strip_buffer()
# Update scroll width
self.total_scroll_width = image.width
@@ -1138,6 +1200,7 @@ class ScrollHelper:
"""
self.cached_image = None
self.cached_array = None
self._forget_strip_buffer()
self.total_scroll_width = 0
self.scroll_position = 0.0
self.total_distance_scrolled = 0.0
+184 -2
View File
@@ -42,17 +42,19 @@ Usage::
from __future__ import annotations
import functools
import logging
import time
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional, Tuple
from PIL import Image
from src.common import scroll_config
from src.common import scroll_config, sports_vegas
from src.common.scroll_helper import ScrollHelper
logger = logging.getLogger(__name__)
#: Defaults every copy agreed on. A subclass overrides
#: :meth:`SportsScrollDisplay.scroll_settings_defaults` to change them —
#: the soccer lineage uses a 24px gap and min/max duration keys instead.
@@ -413,6 +415,160 @@ class SportsScrollDisplay:
"""Whether content is prepared and ready to scroll."""
return bool(self.scroll_helper.cached_image)
# ------------------------------------------------------------------
# Live Vegas cards
# ------------------------------------------------------------------
#
# One live element per game (src/plugin_system/vegas_elements.py): the
# ticker swaps a card in place when its game changes. A sport opts in by
# implementing make_vegas_renderer(); everything else is here.
def make_vegas_renderer(self, card_width: int,
rankings_cache: Optional[Dict[str, int]] = None) -> Any:
"""The renderer this sport draws one game card with, at ``card_width``.
**Override point.** Return the object whose ``render_game_card(game,
game_type)`` draws one card exactly ``card_width`` wide at the display's
height -- the one prepare_scroll_content already builds -- without the
black padding prepare_scroll_content adds around each card (the ticker
adds its own). Raising NotImplementedError, the default, keeps the
plugin on its ordinary Vegas content.
"""
raise NotImplementedError(
f"{type(self).__name__} has no live Vegas cards (make_vegas_renderer)")
def _determine_game_type(self, game: Dict[str, Any]) -> str:
"""The card a game is drawn as: 'live', 'recent' or 'upcoming'.
From the game's state; a sport whose scroll display decides it
differently (most define their own) overrides this.
"""
return {'in': 'live', 'post': 'recent'}.get(sports_vegas._state(game), 'upcoming')
def render_vegas_card(self, renderer: Any, game: Dict[str, Any]) -> Image.Image:
"""Draw one game's card. Override only if the renderer is called differently."""
card: Image.Image = renderer.render_game_card(game, self._determine_game_type(game))
return card
def vegas_separator(self, league: str) -> Optional[Image.Image]:
"""The league separator shown before a league's cards, if there is an icon."""
icon = self._separator_icons.get(league)
if icon is None:
return None
gap = self._vegas_settings(league).get("gap_between_games", 48)
pad = max(4, int(gap) // 2)
image = Image.new('RGB', (icon.width + pad * 2, self.display_height), (0, 0, 0))
mask = icon if icon.mode == 'RGBA' else None
image.paste(icon, (pad, (self.display_height - icon.height) // 2), mask)
return image
def _vegas_memo(self) -> Dict[Any, Any]:
"""Per-size, per-config memo for the live path; emptied when either changes."""
stamp = (self.display_width, self.display_height, id(self.config))
memo: Optional[Tuple[Any, Dict[Any, Any]]] = getattr(self, '_vegas_memo_store', None)
if memo is None or memo[0] != stamp:
memo = (stamp, {})
self._vegas_memo_store = memo
store: Dict[Any, Any] = memo[1]
return store
def _vegas_settings(self, league: Optional[str]) -> Dict[str, Any]:
"""A league's scroll settings, looked up once per size and config.
The live path asks after every update; a sport's settings lookup can
be expensive (sizing the default card width builds probe renderers).
"""
memo = self._vegas_memo()
key = ('settings', league)
if key not in memo:
memo[key] = dict(self._get_scroll_settings(league))
settings: Dict[str, Any] = memo[key]
return settings
def _vegas_renderer(self, card_width: int,
rankings_cache: Optional[Dict[str, int]]) -> Any:
"""The sport's renderer for one card width, built once rather than per slate.
Building one loads fonts and, for the default card width, probes the
layout; the scroll path pays that on every prepare, which the live
path would repeat on every update.
"""
memo = self._vegas_memo()
key = ('renderer', card_width)
if key not in memo:
memo[key] = self.make_vegas_renderer(card_width, rankings_cache)
renderer = memo[key]
if hasattr(renderer, 'set_rankings_cache'):
# Every time, empty included: the renderer is reused across
# slates, and ranks cleared since must not stay drawn.
renderer.set_rankings_cache(rankings_cache or {})
return renderer
def build_vegas_elements(
self,
games: List[Dict[str, Any]],
leagues: List[str],
rankings_cache: Optional[Dict[str, int]] = None,
fingerprint: Optional[Callable[[Dict[str, Any]], Any]] = None,
now: Optional[float] = None,
) -> Optional[List[Any]]:
"""The slate as live Vegas elements: one card per game, separators between leagues.
Only cards whose fingerprint changed are drawn; the rest come from the
cache. ``fingerprint(game)`` should return what the card draws (the
plugin's own signature fields, the clock included for live games); by
default the whole game dict is used, which redraws on any change. The
teams' ranks from ``rankings_cache`` count too: the renderer draws
them from there, not from the game.
Raises NotImplementedError when the sport has no make_vegas_renderer.
"""
from src.plugin_system.vegas_elements import VegasElement
games = sports_vegas.dedupe_games(games)
if not games:
return None
# Settings follow each game's own league, not the slate's first one:
# a card's width must not change because another league has no games
# today (the ticker refuses a redraw of another width).
first = self._vegas_settings(leagues[0] if leagues else None)
cards = getattr(self, '_vegas_cards', None)
if cards is None:
cards = self._vegas_cards = sports_vegas.VegasCardCache()
odds = getattr(self, '_vegas_odds', None)
if odds is None:
odds = self._vegas_odds = sports_vegas.StickyOdds()
fingerprint = fingerprint or sports_vegas.game_fingerprint
elements: List[Any] = []
keys: List[str] = []
current_league = None
separators = 0
for game in games:
league = game.get("league")
settings = self._vegas_settings(league) if league else first
card_width = int(settings.get("game_card_width", self.display_width))
if settings.get("show_league_separators", True) and league != current_league:
separator = self.vegas_separator(league) if league else None
if separator is not None:
elements.append(VegasElement(
key=f"sep:{separators}:{league}", image=separator, live=False))
separators += 1
current_league = league
key = sports_vegas.game_key(game)
drawn = odds.apply(key, game, now)
ranks = (rankings_cache.get(str(drawn.get("home_abbr"))),
rankings_cache.get(str(drawn.get("away_abbr")))) if rankings_cache else None
renderer = self._vegas_renderer(card_width, rankings_cache)
elements.append(cards.element(
key, (fingerprint(drawn), ranks, card_width, self.display_height),
functools.partial(self.render_vegas_card, renderer, drawn)))
keys.append(key)
cards.retain(keys)
odds.retain(keys)
return elements
def get_current_game_count(self) -> int:
return len(self._current_games)
@@ -525,6 +681,32 @@ class SportsScrollDisplayManager:
scroll_display.clear()
self._current_game_type = ""
def get_vegas_elements_for(
self,
game_type: str,
games: List[Dict[str, Any]],
leagues: List[str],
rankings_cache: Optional[Dict[str, int]] = None,
fingerprint: Optional[Callable[[Dict[str, Any]], Any]] = None,
) -> Optional[List[Any]]:
"""Live Vegas cards for a slate, built on the ``game_type`` display.
None when the sport has no live cards (it does not implement
make_vegas_renderer) or building them failed, so the plugin's
get_vegas_elements() can return it and the ticker falls back to the
plugin's ordinary Vegas content.
"""
scroll_display = self.get_scroll_display(game_type)
try:
return scroll_display.build_vegas_elements(
games, leagues, rankings_cache, fingerprint)
except NotImplementedError:
return None
except Exception:
# Built straight from feed data, like prepare_scroll_content.
self.logger.exception("Error building live Vegas cards")
return None
def get_all_vegas_content_items(self) -> List[Image.Image]:
"""Every display's Vegas items, for splicing into the marquee."""
items: List[Image.Image] = []
+49
View File
@@ -1348,6 +1348,55 @@ class SportsLiveSharedMixin:
or candidate < current):
self._next_scheduled_start_ts = candidate
#: How long a game that finished live is still reported by
#: finished_games_snapshot(): long enough for the recent-games list, which
#: refreshes about hourly, to take it over well before most slates would.
FINISHED_GAME_TTL = 900.0
def _record_finished_game(self, details: Dict) -> None:
"""Remember a game that was live and has just gone final (or looks over).
A finished game leaves ``live_games`` at the next poll, and the recent
list that will show it refreshes about hourly, so in between nothing
holds the game's final score -- and a live Vegas card for it would keep
its last live score. Call this wherever a poll drops a game as final
or over. Only a game this manager had as live is taken; one already
held takes the newer details (a game dropped by an "is it over"
heuristic, then marked final by the feed) but keeps its expiry, so a
feed that lists finals all day cannot keep one here all day.
"""
game_id = details.get("id") if isinstance(details, dict) else None
if not game_id:
return
finished = self.__dict__.setdefault("_finished_games", {})
held = finished.get(game_id)
if held is not None:
finished[game_id] = (held[0], dict(details))
return
if not any(g.get("id") == game_id for g in getattr(self, "live_games", ()) or ()):
return
finished[game_id] = (time.monotonic(), dict(details))
def finished_games_snapshot(self) -> List[Dict]:
"""Games that went final here within FINISHED_GAME_TTL, newest data first.
Copies, safe to decorate. The caller dedupes them against its other
lists (src/common/sports_vegas.dedupe_games keeps the liveliest copy,
and a final beats nothing but a live one).
"""
finished = self.__dict__.get("_finished_games")
if not finished:
return []
now = time.monotonic()
# A copy first: a manager finishing its update in the background (off
# the plugin's lock) may record a game while the ticker reads these.
held = list(finished.items())
for game_id, (seen, _game) in held:
if now - seen > self.FINISHED_GAME_TTL:
finished.pop(game_id, None)
return [dict(game) for _id, (seen, game) in held
if now - seen <= self.FINISHED_GAME_TTL]
def _note_live_fetch(self, found_live: bool) -> None:
"""Record whether a look for live games found any."""
if found_live:
+250
View File
@@ -0,0 +1,250 @@
"""Live Vegas cards for the sports scoreboards.
A scoreboard hands the Vegas ticker one card per game. As live elements
(src/plugin_system/vegas_elements.py) those cards change on the panel while
they scroll: a goal redraws its game's card and the ticker swaps it in place.
This module is what every scoreboard needs for that and would otherwise write
nine times:
- :func:`game_key` -- a stable key per game, so the ticker can tell which card
a redraw belongs to however the slate is re-sorted.
- :class:`VegasCardCache` -- draws a card only when what it shows changed
(its fingerprint), so an unchanged slate costs a dictionary lookup per game
and a changed one only the cards that changed.
- :class:`StickyOdds` -- live odds are fetched only for games near the front
of the rotation, so a card's odds come and go between polls; this keeps the
last odds for a while instead of redrawing the card without them.
- :func:`dedupe_games` -- a game present in two managers' lists (live and
recent, around the final whistle) appears once, its liveliest copy.
- :func:`finished_games` / :func:`with_finished_games` -- a game that has just
gone final keeps its card, now showing FINAL, where its live card was,
until the recent list (refreshed about hourly) takes it over.
- :func:`game_fingerprint` -- what a card is redrawn on by default: the whole
game dict, frozen hashable.
SportsScrollDisplay.build_vegas_elements (src/common/sports_scroll.py) puts
them together; a plugin adopts it by implementing make_vegas_renderer().
"""
from __future__ import annotations
import time
from collections import OrderedDict
from typing import Any, Callable, Dict, Hashable, Iterable, List, Optional, Tuple
from PIL import Image
#: Which copy of a duplicated game wins: the liveliest.
_STATE_PRIORITY = {'in': 3, 'post': 2, 'pre': 1}
def _state(game: Dict[str, Any]) -> str:
status = game.get('status')
state = status.get('state') if isinstance(status, dict) else status
if isinstance(state, str):
return state
if game.get('is_live'):
return 'in'
if game.get('is_final'):
return 'post'
return 'pre'
def _freeze(value: Any) -> Any:
"""A hashable, order-stable copy of feed data."""
if isinstance(value, dict):
return tuple(sorted((str(k), _freeze(v)) for k, v in value.items()))
if isinstance(value, (list, tuple)):
return tuple(_freeze(v) for v in value)
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return repr(value)
def game_fingerprint(game: Dict[str, Any]) -> Hashable:
"""Everything in a game dict, hashable: a card drawn from it changes only if this does.
The default card version. Nothing a card could draw is left out, so no
field is ever frozen on the panel; the cost is a redraw when a field the
card does not draw changes too, which feed data rarely does between polls.
"""
frozen: Hashable = _freeze(game)
return frozen
def game_key(game: Dict[str, Any]) -> str:
"""A key that names this game and nothing else, across polls.
``game:<league>:<id>`` from the feed's own id. A game without one falls
back to its teams and start time, which is stable for the life of a game.
"""
league = game.get('league') or 'game'
game_id = game.get('id') or game.get('game_id')
if game_id not in (None, ''):
return f"game:{league}:{game_id}"
away = game.get('away_abbr') or game.get('away_team') or '?'
home = game.get('home_abbr') or game.get('home_team') or '?'
start = game.get('start_time_utc') or game.get('start_time') or ''
return f"game:{league}:{away}@{home}:{start}"
def dedupe_games(games: Iterable[Dict[str, Any]],
key_fn: Callable[[Dict[str, Any]], str] = game_key) -> List[Dict[str, Any]]:
"""Each game once, in first-seen order, keeping its liveliest copy.
Around a final whistle a game can be in the live list (last poll) and the
recent list (next poll) at once; two cards with one key would be refused
by the ticker, and showing the game twice is wrong anyway.
"""
chosen: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
for game in games:
key = key_fn(game)
current = chosen.get(key)
if current is None or _STATE_PRIORITY.get(_state(game), 0) > \
_STATE_PRIORITY.get(_state(current), 0):
chosen[key] = game
return list(chosen.values())
def finished_games(
live_managers: Iterable[Tuple[str, Any]]) -> List[Dict[str, Any]]:
"""Games that just left these live managers' lists, final ones as recent games.
``live_managers`` pairs each league with its live manager (None is
skipped). Each manager reports what SportsLiveSharedMixin recorded
(finished_games_snapshot, copies), with its league. A final game is
drawn as a recent card. One a poll only judged over -- a tied end of
regulation looks like that too -- keeps its last live state, so its card
never says FINAL early; if play resumes the live list has it again, and
dedupe_games keeps that copy.
"""
finished: List[Dict[str, Any]] = []
for league, manager in live_managers:
snapshot = getattr(manager, 'finished_games_snapshot', None)
if not callable(snapshot):
continue
for game in snapshot():
game['league'] = league
if game.get('is_final'):
status = game.get('status')
status = dict(status) if isinstance(status, dict) else {}
status['state'] = 'post'
game.update(status=status, is_live=False)
finished.append(game)
return finished
def with_finished_games(
games: List[Dict[str, Any]], leagues: List[str],
finished: List[Dict[str, Any]],
) -> Tuple[List[Dict[str, Any]], List[str]]:
"""The slate with games that just went final where their live cards were.
A slate lists each league's games together, live ones first. Each
finished game goes after its league's live games, ahead of the rest; a
league with no games left in the slate is added at the end. A finished
game the slate also has (the recent list caught up) is left for
dedupe_games, which keeps one copy.
"""
if not finished:
return list(games), list(leagues)
pending: "OrderedDict[Any, List[Dict[str, Any]]]" = OrderedDict()
for game in finished:
pending.setdefault(game.get('league'), []).append(game)
merged: List[Dict[str, Any]] = []
for index, game in enumerate(games):
league = game.get('league')
if league in pending and _state(game) != 'in':
merged.extend(pending.pop(league))
merged.append(game)
following = games[index + 1] if index + 1 < len(games) else None
if league in pending and (following is None or following.get('league') != league):
merged.extend(pending.pop(league)) # the league's games were all live
leagues = list(leagues)
for league, rest in pending.items():
merged.extend(rest)
if league not in leagues:
leagues.append(league)
return merged, leagues
class VegasCardCache:
"""Cards drawn once per fingerprint, kept for as long as their game is.
``element(key, fingerprint, render)`` returns a VegasElement whose image is
``render()``'s -- called only when the fingerprint differs from the one the
cached card was drawn for. The fingerprint is also the element's version,
so the ticker skips unchanged cards without comparing pixels.
Bounded: keys not passed to :meth:`retain` after a slate are dropped, and
at most ``max_entries`` are ever held (oldest first).
"""
def __init__(self, max_entries: int = 96) -> None:
self.max_entries = max(1, int(max_entries))
self._cards: "OrderedDict[str, Tuple[Hashable, Image.Image]]" = OrderedDict()
self.renders = 0
def element(self, key: str, fingerprint: Hashable,
render: Callable[[], Image.Image], live: bool = True) -> Any:
from src.plugin_system.vegas_elements import VegasElement
cached = self._cards.get(key)
if cached is not None and cached[0] == fingerprint:
self._cards.move_to_end(key)
image = cached[1]
else:
image = render()
self.renders += 1
self._cards[key] = (fingerprint, image)
self._cards.move_to_end(key)
while len(self._cards) > self.max_entries:
self._cards.popitem(last=False)
return VegasElement(key=key, image=image, version=fingerprint, live=live)
def retain(self, keys: Iterable[str]) -> None:
"""Forget every card whose key is not in ``keys``."""
keep = set(keys)
for key in [k for k in self._cards if k not in keep]:
self._cards.pop(key, None)
def clear(self) -> None:
self._cards.clear()
def __len__(self) -> int:
return len(self._cards)
class StickyOdds:
"""Keep a game's last odds on its card while a live poll leaves them out.
Live odds are fetched only for games near the front of the rotation
(src/common/sports_fetch.py), so the same game's dict has odds on one poll
and none on the next. Drawn as-is that redraws the card every poll with
the odds flickering in and out. ``apply`` returns the game with its last
non-empty odds put back, for up to ``ttl_s`` seconds after they were seen.
"""
def __init__(self, ttl_s: float = 600.0) -> None:
self.ttl_s = float(ttl_s)
self._seen: Dict[str, Tuple[float, Any]] = {}
def apply(self, key: str, game: Dict[str, Any],
now: Optional[float] = None) -> Dict[str, Any]:
now = time.monotonic() if now is None else now
odds = game.get('odds')
if odds:
self._seen[key] = (now, odds)
return game
seen = self._seen.get(key)
if seen is None or now - seen[0] > self.ttl_s:
self._seen.pop(key, None)
return game
refilled = dict(game)
refilled['odds'] = seen[1]
return refilled
def retain(self, keys: Iterable[str]) -> None:
keep = set(keys)
for key in [k for k in self._seen if k not in keep]:
self._seen.pop(key, None)
+45 -3
View File
@@ -451,8 +451,10 @@ class ConfigManager:
template_config = json.load(f)
# Check if migration is needed
if self._config_needs_migration(self.config, template_config):
self.logger.info("Config migration needed - adding new configuration items with defaults")
needs_merge = self._config_needs_migration(self.config, template_config)
if needs_merge or self._live_in_ticker_needs_migration():
if needs_merge:
self.logger.info("Config migration needed - adding new configuration items with defaults")
# Create backup of current config
backup_path = f"{self.config_path}.backup"
@@ -461,7 +463,9 @@ class ConfigManager:
self.logger.info(f"Created backup of current config at {os.path.abspath(backup_path)}")
# Merge template defaults into current config
self._merge_template_defaults(self.config, template_config)
if needs_merge:
self._merge_template_defaults(self.config, template_config)
self._migrate_live_in_ticker_default()
# save_config_atomic strips the merged secrets back out and
# keeps the file's owner and mode.
@@ -482,6 +486,44 @@ class ConfigManager:
self.logger.error(f"Error during config migration: {e}")
# Don't raise - continue with current config
#: Set in display.vegas_scroll once _migrate_live_in_ticker_default() has
#: run. Never in the template: the template merge would add it first, and
#: the flip would then never run.
LIVE_IN_TICKER_MARKER = 'live_in_ticker_migrated'
def _vegas_scroll_section(self) -> Optional[Dict[str, Any]]:
display = self.config.get('display')
vegas = display.get('vegas_scroll') if isinstance(display, dict) else None
return vegas if isinstance(vegas, dict) else None
def _live_in_ticker_needs_migration(self) -> bool:
vegas = self._vegas_scroll_section()
return vegas is not None and not vegas.get(self.LIVE_IN_TICKER_MARKER)
def _migrate_live_in_ticker_default(self) -> None:
"""Turn on live_in_ticker for a config that only ever had the old default. Once.
LEDMatrix 3.8.0 makes ``display.vegas_scroll.live_in_ticker`` true:
live games stay in the Vegas ticker, their cards updating while they
scroll, instead of the ticker giving way to the full-screen
scoreboard. 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, so the new default would reach nobody.
This rewrites that ``false`` once and marks the config, so a
``false`` chosen afterwards (the Vegas checkbox, or by hand) stays.
"""
vegas = self._vegas_scroll_section()
if vegas is None or vegas.get(self.LIVE_IN_TICKER_MARKER):
return
vegas[self.LIVE_IN_TICKER_MARKER] = True
if vegas.get('live_in_ticker') is False:
vegas['live_in_ticker'] = True
self.logger.info(
"Vegas mode now keeps live games in the ticker (the new default): "
"display.vegas_scroll.live_in_ticker turned on, once. Untick "
"\"Keep live games in the ticker\" under Vegas mode for the "
"full-screen scoreboard.")
def _config_needs_migration(self, current_config: Dict[str, Any], template_config: Dict[str, Any]) -> bool:
"""Check if config needs migration by comparing with template."""
return self._has_new_keys(current_config, template_config)
+1 -1
View File
@@ -2206,7 +2206,7 @@ class DisplayController:
"""Whether live content should stay in the ticker instead of preempting it."""
coordinator = self.vegas_coordinator
config = getattr(coordinator, 'vegas_config', None)
return bool(getattr(config, 'live_in_ticker', False))
return bool(getattr(config, 'live_in_ticker', True))
def _check_live_priority(self, advance=False):
"""Return the live-priority mode to display, or None if nothing is live.
+85
View File
@@ -266,6 +266,91 @@ def _base_redraw():
return BasePlugin.redraw_vegas_element
def render_vegas_strip(plugin: Any, plugin_id: str, display_manager: Any,
live: bool = True) -> Any:
"""The plugin's block of the Vegas strip, laid out exactly as the ticker would.
Fetched through the ticker's own adapter (trimming, pinning, width
budget) and joined with its own spacing, so what this draws is what
scrolls. ``live=False`` shows the ordinary get_vegas_content() instead.
Returns ``(block, layout)`` -- layout a list of ``(x, key, width)`` for the
live elements in the block -- or ``(None, [])`` when there is nothing.
"""
_adapter, block, layout = _vegas_block(plugin, plugin_id, display_manager, live)
return block, [(x, meta.key, width) for x, meta, width in layout]
def _vegas_block(plugin: Any, plugin_id: str, display_manager: Any, live: bool) -> Any:
"""(adapter, block, layout) for a plugin's Vegas block, through the ticker's own code."""
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.elements import LiveEpochs
from src.vegas_mode.plugin_adapter import PluginAdapter
from src.vegas_mode.render_pipeline import join_plugin_rows
config = VegasModeConfig()
adapter = PluginAdapter(display_manager, config)
adapter.live_elements_enabled = live
adapter.live_epochs = LiveEpochs()
images = adapter.get_content(plugin, plugin_id, offscreen_only=True)
if not images:
return adapter, None, []
block, layout = join_plugin_rows(images, config)
return adapter, block, layout
def render_vegas_timeline(plugin: Any, plugin_id: str, display_manager: Any,
steps: int = 8, step_seconds: float = 0.25,
run_update: bool = False) -> Any:
"""The plugin's Vegas block at successive moments, one row per step.
Row 0 is the block as placed (render_vegas_strip). Each later row is the
same block ``step_seconds`` later, changed the way the ticker would change
it in place: every live element with ``refresh_hz`` redrawn for that
moment through redraw_vegas_element(), and -- with ``run_update`` -- the
plugin's update() run first and every live element redrawn from the new
data. A redraw of another width is left out, as the ticker refuses it.
Rows are separated by a grey line.
Returns ``(image, rows)``, or ``(None, 0)`` when there is nothing.
"""
import time
import numpy as np
adapter, block, layout = _vegas_block(plugin, plugin_id, display_manager, True)
if block is None:
return None, 0
base = np.array(block.convert('RGB'))
rows = [base.copy()]
height = base.shape[0]
start = time.monotonic()
for step in range(1, max(1, int(steps))):
frame = rows[-1].copy()
if run_update:
plugin.update()
adapter.live_epochs.bump(plugin_id)
batch = adapter.render_live_elements(plugin, plugin_id, lock_timeout=5.0)
rendered = batch[1] if batch is not None else {}
for x, meta, width in layout:
element = rendered.get(meta.key)
if element is not None and element.width == width:
frame[:, x:x + width] = element.pixels
at = start + step * float(step_seconds)
for x, meta, width in layout:
if meta.refresh_hz > 0:
element = adapter.redraw_live_element(plugin, plugin_id, meta.key,
width, height, at)
if element is not None and element.width == width:
frame[:, x:x + width] = element.pixels
rows.append(frame)
divider = np.full((1, base.shape[1], 3), 60, dtype=np.uint8)
stacked = [rows[0]]
for row in rows[1:]:
stacked.extend([divider, row])
return Image.fromarray(np.concatenate(stacked, axis=0)), len(rows)
def check_plugin_vegas_elements(plugin_id: str, plugin_dir: Any, config: dict,
mock_data: dict, width: int, height: int,
run_update: bool = True) -> VegasElementReport:
+8 -5
View File
@@ -180,10 +180,13 @@ class VegasModeConfig:
# --- Live content in the ticker -------------------------------------
#
# By default a live game preempts Vegas entirely: the display controller
# refuses to run the ticker while any plugin reports live priority, and you
# get the full-screen scoreboard instead. Set live_in_ticker to keep the
# marquee running and let live content take extra turns within it.
# By default live content stays in the marquee and takes extra turns
# within it, its cards updating while they scroll (live elements, below).
# With live_in_ticker false a live game preempts Vegas entirely: the
# display controller refuses to run the ticker while any plugin reports
# live priority, and you get the full-screen scoreboard instead. (False
# was the default until 3.8.0; ConfigManager turns it on once for configs
# that still hold the old default.)
#
# The rotation is otherwise a strict round robin -- every plugin appears
# exactly once per cycle -- so with a dozen plugins enabled a live score
@@ -193,7 +196,7 @@ class VegasModeConfig:
# 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
# that slot using its own favorite_live_boost.
live_in_ticker: bool = False
live_in_ticker: bool = True
# Slots per cycle for a plugin reporting live content. 1 disables the boost
# and restores the plain round robin.
+58 -40
View File
@@ -32,6 +32,51 @@ logger = logging.getLogger(__name__)
SYNC_SEND_INTERVAL = 1.0 / 90
def join_plugin_rows(
images: List[Image.Image], config: VegasModeConfig
) -> Tuple[Image.Image, List[Tuple[int, ElementMeta, int]]]:
"""Join one plugin's images into the block the strip will hold.
Returns ``(block, layout)``, layout being ``(x, meta, width)`` for every
image tagged as a live element (src/vegas_mode/elements.py), x measured
from the block's left edge. A single image is returned as it is.
A module function so tooling (scripts/render_plugin.py --vegas) lays a
plugin out exactly as the ticker does.
"""
if len(images) == 1:
meta = meta_of(images[0])
layout = [(0, meta, images[0].width)] if meta is not None else []
return images[0], layout
floor = max(0, config.intra_plugin_gap)
target = max(0, config.min_content_separation)
threshold = config.trim_threshold
# Space by measured separation, not a flat gap. Rows drawn flush to their
# own edges (sports score cards) would otherwise end up nearly touching,
# while rows that already carry wide margins would be pushed needlessly
# further apart.
gaps = [
separation_gap(images[i], images[i + 1], target, floor, threshold)
for i in range(len(images) - 1)
]
width = sum(img.width for img in images) + sum(gaps)
height = max(img.height for img in images)
block = Image.new('RGB', (width, height), (0, 0, 0))
layout: List[Tuple[int, ElementMeta, int]] = []
x = 0
for i, img in enumerate(images):
block.paste(img, (x, 0))
meta = meta_of(img)
if meta is not None:
layout.append((x, meta, img.width))
x += img.width + (gaps[i] if i < len(gaps) else 0)
return block, layout
class RenderPipeline:
"""
High-performance render pipeline for Vegas scroll mode.
@@ -421,6 +466,11 @@ class RenderPipeline:
if note is not None:
note(kind, nbytes)
def _copied_bytes(self) -> int:
"""Bytes the scroll helper's last append or trim copied (the strip, if it cannot say)."""
copied = getattr(self.scroll_helper, 'last_copy_bytes', None)
return int(copied) if isinstance(copied, int) else self._strip_nbytes()
def _strip_nbytes(self) -> int:
array = self.scroll_helper.cached_array
return int(array.nbytes) if array is not None else 0
@@ -615,7 +665,7 @@ class RenderPipeline:
element_gap=0,
)
if appended:
self._note_op('extend', self._strip_nbytes())
self._note_op('extend', self._copied_bytes())
logger.info(
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
plugin_id, self.scroll_helper.total_scroll_width,
@@ -725,7 +775,7 @@ class RenderPipeline:
)
if not appended:
return False
moved = self._strip_nbytes()
moved = self._copied_bytes()
# Where each block starts, laid out as append_content does: a
# separator before every block, or -- when there was no strip to
@@ -744,9 +794,10 @@ class RenderPipeline:
self._static_markers = tuple(
(max(0, x - cut), pid) for x, pid in self._static_markers)
self._forget_trimmed_records(cut)
# The append built the whole strip anew, and a trim copies what is
# left of it again: both land in the frame after this one.
self._note_op('extend', moved + (self._strip_nbytes() if cut else 0))
# Both land in the frame after this one: the append's new columns
# (the whole strip when its buffer had to be reallocated), and a
# trim's copy, if it made one.
self._note_op('extend', moved + (self._copied_bytes() if cut else 0))
self._segments_in_scroll = [pid for pid, _ in grouped]
self.stats['composition_count'] += 1
@@ -787,42 +838,9 @@ class RenderPipeline:
) -> Tuple[Image.Image, List[Tuple[int, ElementMeta, int]]]:
"""_join_plugin_rows, plus where each live element landed in the block.
Returns ``(block, layout)``, layout being ``(x, meta, width)`` for
every image tagged as a live element (src/vegas_mode/elements.py), x
measured from the block's left edge. The offsets were always computed
here; they used to be thrown away.
See join_plugin_rows.
"""
if len(images) == 1:
meta = meta_of(images[0])
layout = [(0, meta, images[0].width)] if meta is not None else []
return images[0], layout
floor = max(0, self.config.intra_plugin_gap)
target = max(0, self.config.min_content_separation)
threshold = self.config.trim_threshold
# Space by measured separation, not a flat gap. Rows drawn flush to
# their own edges (sports score cards) would otherwise end up nearly
# touching, while rows that already carry wide margins would be pushed
# needlessly further apart.
gaps = [
separation_gap(images[i], images[i + 1], target, floor, threshold)
for i in range(len(images) - 1)
]
width = sum(img.width for img in images) + sum(gaps)
height = max(img.height for img in images)
block = Image.new('RGB', (width, height), (0, 0, 0))
layout: List[Tuple[int, ElementMeta, int]] = []
x = 0
for i, img in enumerate(images):
block.paste(img, (x, 0))
meta = meta_of(img)
if meta is not None:
layout.append((x, meta, img.width))
x += img.width + (gaps[i] if i < len(gaps) else 0)
return block, layout
return join_plugin_rows(images, self.config)
# -- live element records ---------------------------------------------
#
+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
+49
View File
@@ -0,0 +1,49 @@
"""scripts/dev_server.py's Vegas strip view, on the stub fixture plugin."""
import importlib.util
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parent.parent
STUB = REPO / "test" / "fixtures" / "plugins" / "vegas-live-stub"
@pytest.fixture
def client(monkeypatch):
spec = importlib.util.spec_from_file_location("dev_server_vegas_under_test",
REPO / "scripts" / "dev_server.py")
dev_server = importlib.util.module_from_spec(spec)
spec.loader.exec_module(dev_server)
monkeypatch.setattr(dev_server, "find_plugin_dir", lambda pid: STUB)
monkeypatch.setattr(dev_server, "_trusted_plugin_dir", lambda d: STUB)
return dev_server.app.test_client()
def _render(client, **extra):
return client.post("/api/render", json={"plugin_id": "vegas-live-stub", "width": 192,
"height": 48, "config": {"enabled": True},
**extra})
def test_the_live_strip_lists_its_elements(client):
data = _render(client, vegas="live").get_json()
assert data["image"].startswith("data:image/png;base64,")
assert data["height"] == 48 and data["width"] > 192
keys = [e["key"] for e in data["live_elements"]]
assert keys[:2] == ["card:0", "card:1"] and "map" in keys
assert not data["errors"]
def test_the_plain_strip_has_no_live_elements(client):
data = _render(client, vegas="plain").get_json()
assert data["live_elements"] == [] and not data["errors"]
def test_the_display_view_is_unchanged(client):
data = _render(client).get_json()
assert (data["width"], data["height"]) == (192, 48)
assert "live_elements" not in data
def test_an_unknown_view_is_refused(client):
assert _render(client, vegas="sideways").status_code == 400
+28
View File
@@ -15,6 +15,7 @@ from src.plugin_system.testing.harness import _instantiate # noqa: E402
from src.plugin_system.testing.loading import build_full_config, load_harness_spec, load_manifest # noqa: E402
from src.plugin_system.testing.vegas import ( # noqa: E402
check_vegas_elements, implements_vegas_elements, render_vegas_elements,
render_vegas_strip, render_vegas_timeline,
)
from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager # noqa: E402
from src.plugin_system.vegas_elements import VegasElement # noqa: E402
@@ -178,3 +179,30 @@ def test_check_plugin_reports_a_failing_element_check_and_carries_on(monkeypatch
False, None, None)
vegas = [r for r in results if r.mode == "vegas elements"]
assert len(vegas) == 1 and "boom" in vegas[0].error
def test_a_timeline_moves_what_animates_and_nothing_else():
import numpy as np
plugin, dm = _stub(dot_speed=200)
_block, layout = render_vegas_strip(plugin, "vegas-live-stub", dm)
image, rows = render_vegas_timeline(plugin, "vegas-live-stub", dm, steps=3,
step_seconds=0.5)
assert rows == 3 and image.height == 3 * H + 2
pixels = np.asarray(image)
first, last = pixels[:H], pixels[2 * (H + 1):]
columns = {key: (x, width) for x, key, width in layout}
x, width = columns["map"]
assert (first[:, x:x + width] != last[:, x:x + width]).any()
x, width = columns["card:0"]
assert (first[:, x:x + width] == last[:, x:x + width]).all()
def test_a_timeline_with_updates_redraws_the_cards_in_place():
import numpy as np
plugin, dm = _stub(map_hz=0)
_block, layout = render_vegas_strip(plugin, "vegas-live-stub", dm)
image, rows = render_vegas_timeline(plugin, "vegas-live-stub", dm, steps=2,
run_update=True)
pixels = np.asarray(image)
x, width = {key: (x, width) for x, key, width in layout}["card:0"]
assert (pixels[:H, x:x + width] != pixels[H + 1:, x:x + width]).any()
+146
View File
@@ -0,0 +1,146 @@
"""ScrollHelper extends and trims a strip in place (src/common/scroll_helper.py).
Every extension of the Vegas strip used to rebuild it whole (np.concatenate)
and every trim copied what was left: 3.5-4.5 ms on the render thread at 512x64
on a Pi 4, so the frame after each extension was late. The strip now lives in
a buffer with spare room: an append writes only the new columns, a trim only
moves the view's start, and a full copy happens only when the buffer is
reallocated. What a frame shows must not change at all.
"""
import random
import sys
from pathlib import Path
import numpy as np
import pytest
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common.scroll_helper import ScrollHelper # noqa: E402
W, H = 64, 16
def _items(rng, n):
out = []
for _ in range(n):
width = rng.randint(5, 60)
out.append(Image.frombytes("RGB", (width, H),
bytes(rng.randrange(256) for _ in range(width * H * 3))))
return out
def _helper():
helper = ScrollHelper(W, H)
helper.create_scrolling_image(_items(random.Random(1), 4), item_gap=3, lead_gap=0)
return helper
def _reference_append(strip, items, gap):
"""What append_content used to do."""
width = sum(i.width for i in items) + gap * len(items)
addition = Image.new("RGB", (width, H))
x = 0
for item in items:
x += gap
addition.paste(item, (x, 0))
x += item.width
return np.concatenate((strip, np.array(addition)), axis=1)
def test_an_append_writes_into_the_buffer_and_copies_only_the_new_columns():
helper = _helper()
helper.append_content(_items(random.Random(2), 2), item_gap=3) # allocates
buffer = helper._strip_buffer
before = helper.cached_array.shape[1]
items = _items(random.Random(3), 2)
helper.append_content(items, item_gap=3)
assert helper._strip_buffer is buffer
assert np.shares_memory(helper.cached_array, buffer)
added = helper.cached_array.shape[1] - before
assert helper.last_copy_bytes == added * H * 3
def test_a_trim_copies_nothing():
helper = _helper()
helper.append_content(_items(random.Random(2), 3), item_gap=3)
helper.scroll_position = 120.0
cut = helper.drop_scrolled_prefix()
assert cut == 120 and helper.last_copy_bytes == 0
assert np.shares_memory(helper.cached_array, helper._strip_buffer)
def test_the_buffer_is_reallocated_when_the_room_runs_out():
helper = _helper()
helper.append_content(_items(random.Random(2), 1), item_gap=3)
first = helper._strip_buffer
rng = random.Random(4)
while helper._strip_buffer is first:
helper.append_content(_items(rng, 3), item_gap=3)
assert helper.last_copy_bytes == helper.cached_array.nbytes
assert helper._strip_start == 0
def test_a_strip_set_from_outside_is_never_written_through():
# The multi-display follower adopts a read-only array straight from an image.
helper = _helper()
helper.append_content(_items(random.Random(2), 1), item_gap=3)
adopted = np.asarray(Image.new("RGB", (300, H), (9, 9, 9)))
helper.cached_array = adopted
helper.total_scroll_width = 300
helper.append_content(_items(random.Random(3), 1), item_gap=3)
assert not np.shares_memory(helper.cached_array, adopted)
assert (adopted == 9).all()
helper.cached_array = adopted
helper.scroll_position = 100.0
helper.drop_scrolled_prefix()
assert not np.shares_memory(helper.cached_array, adopted)
def test_a_new_strip_lets_the_old_buffer_go():
helper = _helper()
helper.append_content(_items(random.Random(2), 1), item_gap=3)
helper.create_scrolling_image(_items(random.Random(5), 2), item_gap=3, lead_gap=0)
assert helper._strip_buffer is None
helper.append_content(_items(random.Random(2), 1), item_gap=3)
helper.clear_cache()
assert helper._strip_buffer is None and helper._strip_view is None
@pytest.mark.parametrize("seed", range(12))
def test_every_frame_matches_the_old_copying_strip(seed):
"""Random appends, trims, scrolling and patches, against a strip kept the old way."""
rng = random.Random(seed)
helper = _helper()
reference = helper.cached_array.copy()
for _ in range(60):
op = rng.random()
if op < 0.35:
items = _items(rng, rng.randint(1, 3))
gap = rng.randint(0, 6)
helper.append_content(items, item_gap=gap)
reference = _reference_append(reference, items, gap)
elif op < 0.55:
keep = rng.randint(0, W)
before = helper.scroll_position
cut = helper.drop_scrolled_prefix(keep_before=keep)
reference = reference[:, cut:].copy()
assert helper.scroll_position == before - cut
elif op < 0.7 and helper.cached_array.shape[1] > 8:
x = rng.randrange(helper.cached_array.shape[1] - 4)
pixels = np.full((H, 4, 3), rng.randrange(256), dtype=np.uint8)
helper.patch_columns(x, pixels)
reference[:, x:x + 4] = pixels
else:
limit = max(0, helper.cached_array.shape[1] - W - 1)
helper.scroll_position = float(rng.randint(0, limit)) if limit else 0.0
assert helper.cached_array.shape == reference.shape
assert (helper.cached_array == reference).all()
assert helper.total_scroll_width == reference.shape[1]
frame = np.asarray(helper.get_visible_portion())
x = int(helper.scroll_position)
if x + W <= reference.shape[1]:
assert (frame == reference[:, x:x + W]).all()
# The lazily built image is the strip as it stands.
assert (np.asarray(helper.cached_image) == reference).all()
+415
View File
@@ -0,0 +1,415 @@
"""Live Vegas cards for the scoreboards (src/common/sports_vegas.py, sports_scroll).
A scoreboard adopts live cards by implementing make_vegas_renderer(); the
shared code then draws one card per game, only when what the card shows
changed, keyed by the game so the ticker can swap it in place.
"""
import sys
import time
from pathlib import Path
from types import SimpleNamespace
import pytest
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.common import sports_vegas # noqa: E402
from src.common.sports_scroll import SportsScrollDisplay, SportsScrollDisplayManager # noqa: E402
from src.common.sports_shared import SportsLiveSharedMixin # noqa: E402
from src.plugin_system.vegas_elements import VegasElement # noqa: E402
W, H = 128, 32
def _game(gid, league="nfl", home=0, away=0, state="in", clock="10:00", odds=None):
return {"id": gid, "league": league, "home_abbr": "HOM", "away_abbr": "AWY",
"home_score": home, "away_score": away,
"status": {"state": state}, "clock": clock, "odds": odds}
class _Renderer:
def __init__(self, width):
self.width = width
self.calls = []
self.rankings = None
def render_game_card(self, game, game_type):
self.calls.append((game["id"], game_type, game.get("odds")))
return Image.new("RGB", (self.width, H), (game["home_score"] * 10 % 255, 0, 0))
def set_rankings_cache(self, rankings):
self.rankings = rankings
class _Display(SportsScrollDisplay):
made = 0
def make_vegas_renderer(self, card_width, rankings_cache=None):
type(self).made += 1
self.renderer = _Renderer(card_width)
return self.renderer
def _load_separator_icons(self):
self._separator_icons = {"nfl": Image.new("RGBA", (10, 10), (255, 255, 255, 255)),
"ncaa_fb": Image.new("RGBA", (10, 10), (0, 255, 0, 255))}
class _Plain(SportsScrollDisplay):
pass
def _dm():
return SimpleNamespace(width=W, height=H, matrix=None, refresh_hz=100.0,
set_scrolling_state=lambda *a, **k: None)
def _display(cls=_Display, **config):
return cls(_dm(), config)
def _fp(game):
return (game["home_score"], game["away_score"], game["clock"],
bool(game.get("odds")))
# -- building blocks ------------------------------------------------------------
def test_game_keys_name_the_game():
assert sports_vegas.game_key(_game("401")) == "game:nfl:401"
assert sports_vegas.game_key({"league": "mlb", "away_abbr": "A", "home_abbr": "B",
"start_time": "2026-09-30T19:00Z"}) \
== "game:mlb:A@B:2026-09-30T19:00Z"
def test_dedupe_keeps_the_liveliest_copy_in_first_seen_order():
games = [_game("1", state="post", home=3), _game("2", state="pre"),
_game("1", state="in", home=2)]
kept = sports_vegas.dedupe_games(games)
assert [g["id"] for g in kept] == ["1", "2"]
assert kept[0]["status"]["state"] == "in"
def test_the_card_cache_draws_only_on_a_new_fingerprint():
cache = sports_vegas.VegasCardCache()
drawn = []
def render(value):
drawn.append(value)
return Image.new("RGB", (4, H))
first = cache.element("k", 1, lambda: render(1))
again = cache.element("k", 1, lambda: render(2))
changed = cache.element("k", 2, lambda: render(3))
assert drawn == [1, 3]
assert isinstance(first, VegasElement) and first.version == 1
assert again.image is first.image and changed.version == 2
def test_the_card_cache_forgets_games_not_retained_and_is_bounded():
cache = sports_vegas.VegasCardCache(max_entries=3)
for key in "abcde":
cache.element(key, 0, lambda: Image.new("RGB", (4, H)))
assert len(cache) == 3
cache.retain(["e"])
assert len(cache) == 1
def test_sticky_odds_fill_a_gap_and_then_expire():
odds = sports_vegas.StickyOdds(ttl_s=60)
game = _game("1", odds={"spread": -3})
assert odds.apply("k", game, now=0)["odds"] == {"spread": -3}
later = _game("1", odds=None)
refilled = odds.apply("k", later, now=30)
assert refilled["odds"] == {"spread": -3}
assert later["odds"] is None # the feed's dict is untouched
assert odds.apply("k", later, now=100)["odds"] is None
# -- SportsScrollDisplay.build_vegas_elements ---------------------------------------
def test_a_slate_becomes_keyed_cards_with_league_separators():
display = _display()
games = [_game("1"), _game("2"), _game("3", league="ncaa_fb")]
elements = display.build_vegas_elements(games, ["nfl", "ncaa_fb"], fingerprint=_fp)
keys = [e.key for e in elements]
assert keys == ["sep:0:nfl", "game:nfl:1", "game:nfl:2",
"sep:1:ncaa_fb", "game:ncaa_fb:3"]
assert [e.live for e in elements] == [False, True, True, False, True]
card = elements[1]
assert card.image.size == (display._get_scroll_settings()["game_card_width"], H)
def test_only_changed_games_are_redrawn():
display = _display()
games = [_game("1"), _game("2"), _game("3")]
display.build_vegas_elements(games, ["nfl"], fingerprint=_fp)
renderer = display.renderer
assert len(renderer.calls) == 3
display.build_vegas_elements(games, ["nfl"], fingerprint=_fp)
assert len(renderer.calls) == 3 # nothing changed
games[1] = _game("2", home=7)
display.build_vegas_elements(games, ["nfl"], fingerprint=_fp)
assert [c[0] for c in renderer.calls[3:]] == ["2"]
def test_a_clock_change_redraws_that_card_when_the_fingerprint_has_the_clock():
display = _display()
display.build_vegas_elements([_game("1"), _game("2")], ["nfl"], fingerprint=_fp)
display.build_vegas_elements([_game("1", clock="9:41"), _game("2")], ["nfl"],
fingerprint=_fp)
assert [c[0] for c in display.renderer.calls[2:]] == ["1"]
def test_the_renderer_is_built_once_not_per_slate():
_Display.made = 0
display = _display()
for _ in range(3):
display.build_vegas_elements([_game("1")], ["nfl"], fingerprint=_fp)
assert _Display.made == 1
def test_a_card_keeps_its_odds_through_a_poll_without_them():
display = _display()
display.build_vegas_elements([_game("1", odds={"spread": -3})], ["nfl"], fingerprint=_fp)
display.build_vegas_elements([_game("1", odds=None)], ["nfl"], fingerprint=_fp)
assert len(display.renderer.calls) == 1 # no redraw without the odds
def test_game_dicts_are_never_mutated():
display = _display()
game = _game("1", odds=None)
snapshot = dict(game)
display.build_vegas_elements([game], ["nfl"], fingerprint=_fp)
assert game == snapshot
def test_a_sport_without_a_renderer_stays_on_its_ordinary_content():
manager = SportsScrollDisplayManager(_dm(), {})
manager.display_class = _Plain
assert manager.get_vegas_elements_for("mixed", [_game("1")], ["nfl"]) is None
def test_a_renderer_that_fails_falls_back_instead_of_raising():
class Broken(_Display):
def render_vegas_card(self, renderer, game):
raise KeyError("home_logo")
manager = SportsScrollDisplayManager(_dm(), {})
manager.display_class = Broken
assert manager.get_vegas_elements_for("mixed", [_game("1")], ["nfl"]) is None
def test_the_manager_hands_back_the_elements():
manager = SportsScrollDisplayManager(_dm(), {})
manager.display_class = _Display
elements = manager.get_vegas_elements_for("mixed", [_game("1")], ["nfl"],
fingerprint=_fp)
assert [e.key for e in elements] == ["sep:0:nfl", "game:nfl:1"]
# -- the finished-game capture ------------------------------------------------------
class _Live(SportsLiveSharedMixin):
def __init__(self):
self.live_games = [_game("1"), _game("2")]
def test_a_game_that_was_live_is_remembered_when_it_ends():
live = _Live()
live._record_finished_game(_game("1", state="post", home=24))
live._record_finished_game(_game("99", state="post")) # never live here
finished = live.finished_games_snapshot()
assert [g["id"] for g in finished] == ["1"]
assert finished[0]["home_score"] == 24
finished[0]["league"] = "changed"
assert live.finished_games_snapshot()[0]["league"] == "nfl" # copies
def test_a_finished_game_is_forgotten_after_its_ttl(monkeypatch):
live = _Live()
live._record_finished_game(_game("1", state="post"))
real = time.monotonic
monkeypatch.setattr(time, "monotonic", lambda: real() + live.FINISHED_GAME_TTL + 1)
assert live.finished_games_snapshot() == []
def test_final_replaces_the_live_card_in_place():
# The same key, so the ticker swaps the card rather than adding one.
display = _display()
before = display.build_vegas_elements([_game("1", home=7)], ["nfl"], fingerprint=_fp)
after = display.build_vegas_elements(
sports_vegas.dedupe_games([_game("1", state="post", home=14)]), ["nfl"],
fingerprint=_fp)
assert before[-1].key == after[-1].key
assert before[-1].version != after[-1].version
def test_settings_are_looked_up_once_per_league_not_per_update(monkeypatch):
display = _display()
calls = []
real = display._get_scroll_settings
monkeypatch.setattr(display, "_get_scroll_settings",
lambda league=None: calls.append(league) or real(league))
slate = [_game("1"), _game("2", league="ncaa_fb")]
for _ in range(3):
display.build_vegas_elements(slate, ["nfl", "ncaa_fb"], fingerprint=_fp)
assert sorted(calls) == ["ncaa_fb", "nfl"]
def test_each_card_takes_its_own_leagues_width():
display = _display(nfl={"scroll_settings": {"game_card_width": 100}},
ncaa_fb={"scroll_settings": {"game_card_width": 140}})
display.SCROLL_LEAGUE_KEYS = ("nfl", "ncaa_fb")
slate = [_game("1"), _game("2", league="ncaa_fb")]
widths = {e.key: e.image.width
for e in display.build_vegas_elements(slate, ["nfl", "ncaa_fb"], fingerprint=_fp)
if e.live}
# ...and keeps it when the other league has nothing to show.
alone = display.build_vegas_elements(slate[1:], ["ncaa_fb"], fingerprint=_fp)
assert widths == {"game:nfl:1": 100, "game:ncaa_fb:2": 140}
assert [e.image.width for e in alone if e.live] == [140]
def test_a_rank_change_redraws_the_card():
display = _display()
display.build_vegas_elements([_game("1")], ["nfl"], {"HOM": 5}, fingerprint=_fp)
drawn = display._vegas_cards.renders
display.build_vegas_elements([_game("1")], ["nfl"], {"HOM": 5}, fingerprint=_fp)
assert display._vegas_cards.renders == drawn
display.build_vegas_elements([_game("1")], ["nfl"], {"HOM": 3}, fingerprint=_fp)
assert display._vegas_cards.renders == drawn + 1
def test_finished_games_come_back_as_recent_games_of_their_league():
live = _Live()
live._record_finished_game(dict(_game("1", home=24), is_final=True))
finished = sports_vegas.finished_games([("ncaa_fb", live), ("nfl", None)])
assert [(g["id"], g["league"], g["status"]["state"], g["is_live"])
for g in finished] == [("1", "ncaa_fb", "post", False)]
def test_a_game_only_judged_over_is_not_drawn_final():
# A tied end of regulation looks over to the heuristics; overtime may follow.
live = _Live()
live._record_finished_game(_game("1", home=24, clock="0:00"))
finished = sports_vegas.finished_games([("nfl", live)])
assert finished[0]["status"]["state"] == "in"
assert not finished[0].get("is_final")
# ...and when play resumes, the live list's copy is the one kept.
resumed = _game("1", home=24, clock="15:00")
merged, _ = sports_vegas.with_finished_games([resumed], ["nfl"], finished)
assert sports_vegas.dedupe_games(merged) == [resumed]
def test_the_snapshot_copes_with_games_recorded_while_it_reads():
# A baseball league finishing its update in the background records games
# off the plugin's lock while the ticker takes snapshots.
import sys
import threading
live = _Live()
live.live_games = [_game(str(i)) for i in range(400)]
errors = []
stop = threading.Event()
def record():
for i in range(400):
live._record_finished_game(dict(_game(str(i)), is_final=True))
stop.set()
def read():
while not stop.is_set():
try:
live.finished_games_snapshot()
except RuntimeError as exc:
errors.append(exc)
return
previous = sys.getswitchinterval()
sys.setswitchinterval(1e-6)
try:
readers = [threading.Thread(target=read) for _ in range(2)]
for t in readers:
t.start()
record()
for t in readers:
t.join(10)
finally:
sys.setswitchinterval(previous)
assert not errors
assert len(live.finished_games_snapshot()) == 400
def test_a_held_game_takes_the_final_details_but_keeps_its_expiry(monkeypatch):
live = _Live()
live._record_finished_game(_game("1", home=24, clock="0:00")) # judged over
live.live_games = [] # dropped
now = time.monotonic()
monkeypatch.setattr(time, "monotonic", lambda: now + 60)
live._record_finished_game(dict(_game("1", home=27), is_final=True))
finished = sports_vegas.finished_games([("nfl", live)])
assert finished[0]["home_score"] == 27 and finished[0]["status"]["state"] == "post"
monkeypatch.setattr(time, "monotonic", lambda: now + live.FINISHED_GAME_TTL + 1)
assert live.finished_games_snapshot() == []
def _slate(*games):
return [_game(gid, league=league, state=state) for gid, league, state in games]
@pytest.mark.parametrize("games,leagues,expected_ids,expected_leagues", [
# After the league's live games, ahead of its recent and upcoming ones.
(_slate(("2", "nfl", "in"), ("3", "nfl", "post"), ("4", "nfl", "pre"),
("5", "ncaa_fb", "in")),
["nfl", "ncaa_fb"], ["2", "F", "3", "4", "5"], ["nfl", "ncaa_fb"]),
# A league whose games were all live.
(_slate(("2", "nfl", "in"), ("5", "ncaa_fb", "in")),
["nfl", "ncaa_fb"], ["2", "F", "5"], ["nfl", "ncaa_fb"]),
# No live games: first in its league.
(_slate(("5", "ncaa_fb", "pre"), ("3", "nfl", "post")),
["ncaa_fb", "nfl"], ["5", "F", "3"], ["ncaa_fb", "nfl"]),
# Its league has nothing else to show.
(_slate(("5", "ncaa_fb", "pre")), ["ncaa_fb"], ["5", "F"], ["ncaa_fb", "nfl"]),
])
def test_a_finished_game_goes_where_its_live_card_was(games, leagues, expected_ids,
expected_leagues):
final = _game("F", league="nfl", state="post")
merged, merged_leagues = sports_vegas.with_finished_games(games, leagues, [final])
assert [g["id"] for g in merged] == expected_ids
assert merged_leagues == expected_leagues
def test_the_default_fingerprint_follows_every_field_and_ignores_key_order():
game = _game("1", odds={"spread": -3, "details": ["a", "b"]})
same = dict(reversed(list(game.items())))
assert sports_vegas.game_fingerprint(game) == sports_vegas.game_fingerprint(same)
hash(sports_vegas.game_fingerprint(game))
for field, value in [("clock", "9:59"), ("odds", {"spread": -3, "details": ["a"]}),
("status", {"state": "post"})]:
assert sports_vegas.game_fingerprint(dict(game, **{field: value})) != \
sports_vegas.game_fingerprint(game)
def test_no_finished_games_leaves_the_slate_alone():
games = _slate(("2", "nfl", "in"))
merged, leagues = sports_vegas.with_finished_games(games, ["nfl"], [])
assert merged == games and merged is not games and leagues == ["nfl"]
@pytest.mark.parametrize("state,expected", [("in", "live"), ("post", "recent"),
("pre", "upcoming")])
def test_the_default_card_type_follows_the_game_state(state, expected):
display = _display()
display.build_vegas_elements([_game("1", state=state)], ["nfl"], fingerprint=_fp)
assert display.renderer.calls[0][1] == expected
def test_ranks_cleared_since_are_not_kept_by_the_reused_renderer():
display = _display()
display.build_vegas_elements([_game("1")], ["nfl"], {"HOM": 5}, fingerprint=_fp)
assert display.renderer.rankings == {"HOM": 5}
display.build_vegas_elements([_game("1")], ["nfl"], None, fingerprint=_fp)
assert display.renderer.rankings == {}
+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)])
+3 -1
View File
@@ -802,7 +802,8 @@ def save_main_config():
'vegas_intra_plugin_gap', 'vegas_render_width_pct',
'vegas_min_content_separation', 'vegas_min_cut_gap',
'vegas_continuous_scroll', 'vegas_extend_threshold_screens',
'vegas_smooth_scroll', 'vegas_overflow_mode', 'vegas_live_refresh']
'vegas_smooth_scroll', 'vegas_overflow_mode', 'vegas_live_refresh',
'vegas_live_in_ticker']
if any(k in data for k in vegas_fields):
if 'display' not in current_config:
@@ -823,6 +824,7 @@ def save_main_config():
_set_checkbox(vegas_config, 'continuous_scroll', 'vegas_continuous_scroll')
_set_checkbox(vegas_config, 'smooth_scroll', 'vegas_smooth_scroll')
_set_checkbox(vegas_config, 'live_refresh', 'vegas_live_refresh')
_set_checkbox(vegas_config, 'live_in_ticker', 'vegas_live_in_ticker')
# max_plugin_width_ratio is the one fractional setting, so it is
# handled outside the integer loop below.
@@ -579,6 +579,18 @@
</label>
</div>
<div class="form-group mb-4" id="setting-display-vegas_live_in_ticker" data-setting-key="display.vegas_scroll.live_in_ticker">
<label class="flex items-center">
<input type="checkbox"
id="vegas_live_in_ticker"
name="vegas_live_in_ticker"
{% if main_config.display.get('vegas_scroll', {}).get('live_in_ticker', True) %}checked{% endif %}
class="form-checkbox">
<span class="ml-2 text-sm text-gray-700">Keep live games in the ticker{{ ui.help_tip('While a game is live, keep the ticker running and give the live scoreboard extra turns in it (Live Weight). Default: on.
With this off a live game takes over the whole display with the full-screen scoreboard until it ends, and the ticker stops.', 'Live Games') }}</span>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div class="form-group" id="setting-display-vegas_extend_threshold_screens" data-setting-key="display.vegas_scroll.extend_threshold_screens">
<label for="vegas_extend_threshold_screens" class="block text-sm font-medium text-gray-700">Extend When (screens left){{ ui.help_tip('How much unscrolled content triggers loading the next group, measured in screen widths (1.0–10.0).\nDefault: 2. Higher loads earlier and leaves more slack, at the cost of holding more content in memory. Only applies when Continuous Scroll is on.', 'Extend Threshold') }}</label>