mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 23:35:08 +00:00
Compare commits
5
Commits
7804ea8f69
...
a12be7c3c5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a12be7c3c5 | ||
|
|
f4bda50710 | ||
|
|
56947298d6 | ||
|
|
596809acc3 | ||
|
|
77862b631b |
+101
@@ -331,9 +331,110 @@ read any of them:
|
||||
lock stays busy past the same 5s bound the change is handed to the update
|
||||
worker, which applies the latest one as soon as the lock frees, and before
|
||||
the plugin's next update() at the latest. The plugin API is unchanged.
|
||||
- With a Vegas width budget set (`max_plugin_width_ratio` or a plugin's
|
||||
`vegas_max_width_screens`), a single image over the budget with no gaps
|
||||
between items -- a map, one long headline -- no longer takes a pass of its
|
||||
own showing four blank columns. The cut landed in the middle of the blank
|
||||
margin trimming leaves at the image's edge; margins are no longer cut
|
||||
points, so such an image is cropped to the budget as intended.
|
||||
|
||||
### Live Vegas elements (plugin API)
|
||||
|
||||
- New plugin hooks for content that can change while it scrolls:
|
||||
`BasePlugin.get_vegas_elements()` returns `VegasElement`s -- named,
|
||||
fixed-width pieces of Vegas content -- instead of pictures;
|
||||
`redraw_vegas_element(key, width, height, at)` redraws one without the
|
||||
plugin lock for content that changes with time; and
|
||||
`notify_vegas_data_changed()` reports data that arrived outside
|
||||
`update()`. New module `src/plugin_system/vegas_elements.py`
|
||||
(`VegasElement`, also re-exported from `base_plugin`). See "Live Vegas
|
||||
elements" in `docs/PLUGIN_API_REFERENCE.md`.
|
||||
- The ticker asks a plugin that implements the hook for elements on its
|
||||
background fetch (under the plugin's lock, on a canvas of its own) and
|
||||
records where each one lands in the strip, in absolute columns a trim does
|
||||
not move (`src/vegas_mode/elements.py`). Live elements are never trimmed to
|
||||
their ink: each is padded with `content_padding` black columns either side.
|
||||
Every other path -- the first strip, the render-thread fallback, plugins
|
||||
without the hook -- is unchanged. Swapping redraws into the strip builds
|
||||
on this.
|
||||
- `PluginManager.add_update_listener()` / `remove_update_listener()` /
|
||||
`notify_data_changed()`: a listener hears a plugin id the moment its
|
||||
`update()` completes, rather than at the next ~4s Vegas poll.
|
||||
- New `display.vegas_scroll` settings: `live_refresh` (default `true`; the
|
||||
kill switch), `live_max_hz`, `live_min_interval`, `live_lead_screens`, and
|
||||
a per-plugin core-owned `vegas_live`. Live elements are off whatever these
|
||||
say under multi-display sync, in swap mode and with `offscreen_prefetch`
|
||||
off.
|
||||
- `scripts/check_plugin.py` checks the element contract for any plugin that
|
||||
implements it (`src/plugin_system/testing/vegas.py`), and
|
||||
`test/fixtures/plugins/vegas-live-stub` is a working example.
|
||||
- **Live elements update in place.** When a plugin's `update()` completes,
|
||||
one background worker (`src/vegas_mode/live_worker.py`) redraws its live
|
||||
elements that are on or ahead of the screen, nearest first, and hands the
|
||||
ones whose pixels changed to the render thread, which copies them into the
|
||||
strip between two frames (`RenderPipeline.apply_live_patches`,
|
||||
`ScrollHelper.patch_columns`): at most four patches or two screens of bytes
|
||||
a frame, no drawing and no locks on the render thread. Elements with
|
||||
`refresh_hz` are redrawn that often while near the screen, through the
|
||||
plugin's lock-free `redraw_vegas_element()`. The worker also takes over
|
||||
group prefetching once the strip holds a live element, so one thread
|
||||
still does all the drawing; it runs inside the render gate, starts only
|
||||
when a live element is placed, and is restarted if it dies (three times in
|
||||
ten minutes turns live updates off for the run). While live elements exist,
|
||||
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()`.
|
||||
|
||||
### Scrolling
|
||||
|
||||
- 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
|
||||
(`Image.fromarray`: 1.7ms for an 8,000px strip, 3.8ms for 20,000px, on a
|
||||
Pi 4 -- twice per extension), though every frame is cut from the array and
|
||||
nothing on the frame path reads the image's pixels. `ScrollHelper` now
|
||||
builds `cached_image` only when something reads it, which in Vegas means
|
||||
only a multi-display sync push, and the strip is no longer held in memory
|
||||
twice. Assigning `cached_image` still stores exactly what was assigned.
|
||||
New `ScrollHelper.has_strip()` says whether there is a strip without
|
||||
building its image; the frame path and Vegas use it.
|
||||
|
||||
### Tooling
|
||||
|
||||
- The frame-timing recorder says which render-thread work a late frame
|
||||
followed. Work done between two frames calls
|
||||
`FrameTimingRecorder.note_op(kind, nbytes)` and the next presented frame
|
||||
carries the tag; the stats gain `op_frames`, `late_op_frames`, `op_freezes`
|
||||
and `op_bytes` per kind (additive; the file's schema version is unchanged).
|
||||
Vegas tags every strip `compose` and `extend`, and `frame_soak.py` prints an
|
||||
"after work" table with each kind's own late rate.
|
||||
`scripts/render_bench.py` can drive the same work on a panel with nothing
|
||||
else running: `--strip-screens` for a Vegas-sized strip, `--patch-bytes /
|
||||
--patch-every / --patch-where` for in-place column writes, and
|
||||
`--extend-every-screens` for appending and trimming on a fixed cadence.
|
||||
See "Soaking a rig" in `docs/SCROLL_PERFORMANCE.md`.
|
||||
- `scripts/sports_drift_report.py`: for a ledmatrix-plugins checkout, counts
|
||||
how many different bodies each method family has across the nine
|
||||
scoreboards' `sports.py`, `manager.py` and `game_renderer.py`, lists the
|
||||
|
||||
@@ -132,6 +132,10 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `offscreen_prefetch` | bool, `true` — render every plugin's ticker content on the background thread, each on its own canvas. `false` restores handing canvas-bound plugins to the render thread, one pause at a time. Temporary; see [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
||||
| `prefetch_gate` | bool, `true` — let that background thread run Python only while the render thread is waiting for the panel, so the render thread never waits for the GIL when a refresh comes round. Only takes effect with the rebuilt rgbmatrix binding (`scripts/build_rgbmatrix_nogil.sh`). See [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
||||
| `switch_interval_ms` | float, `0` — experimental: shorten Python's GIL switch interval to this many ms while Vegas runs. `0` leaves the default (5 ms) alone |
|
||||
| `live_refresh` | bool, `true` — live elements: a plugin that supports them (scores, the flight map) has what is already scrolling updated when its data changes, instead of freezing each card as it was drawn. Always off under multi-display sync, in swap mode and with `offscreen_prefetch` off. `false` restores the frozen behaviour exactly. Per plugin: `vegas_live` in the plugin's section |
|
||||
| `live_max_hz` | float, `5` (0–10) — ceiling on how often an animated live element (a moving aircraft) is redrawn; `0` keeps data updates and turns animation off. Capped at 1 Hz without the rebuilt rgbmatrix binding |
|
||||
| `live_min_interval` | float, `2` (0.5–60) — shortest time between two data redraws of one plugin; a faster plugin is redrawn at this rate, never skipped |
|
||||
| `live_lead_screens` | float, `1` (0–5) — how far ahead of the screen, in screen widths, an animated element starts being redrawn |
|
||||
| `smooth_scroll` | bool, `true` — move a whole number of pixels per panel refresh, locked to vsync. `scroll_speed` is snapped to the nearest speed the panel can show that way (at 95Hz: 95, 47.5, 31.7 px/s…), measured against the panel's real refresh rate once scrolling starts |
|
||||
| `sub_pixel_blend` | bool, `false` — the older smoothing: advance by elapsed time and blend neighbouring pixel columns. Looks anti-aliased in the web preview but shimmers on the panel and is not locked to the refresh. Overrides `smooth_scroll` when on |
|
||||
| `extend_threshold_screens` | float, `2.0` |
|
||||
|
||||
+113
-150
@@ -1,9 +1,10 @@
|
||||
# Offscreen Rendering
|
||||
|
||||
**Status (2026-09-24):** step 1, offscreen rendering, is implemented
|
||||
**Status (2026-09-30):** offscreen rendering is implemented
|
||||
(`DisplayManager.offscreen()`, the adapter on the prefetch thread, the plugin
|
||||
lock). Steps 2 and 3 are proposed. When all three land, this file becomes the
|
||||
reference for how plugin content is rendered off the render thread.
|
||||
lock), and so are live elements, which grew out of steps 2 and 3 below: see
|
||||
*Live elements*. The segment strip proposed as step 2 was not needed; *Why not
|
||||
a SegmentStrip* says why.
|
||||
|
||||
First soak of step 1 on hdpi (50 px/s, `pwm_bits` 7, preview open, 8-minute
|
||||
runs, A/B/B/A):
|
||||
@@ -153,136 +154,101 @@ particular keeps presenting while a plugin draws elsewhere.
|
||||
fetch left is the inline fallback when no prepared group is ready, which in
|
||||
practice is the first extension. Prefetching at start removes that too.
|
||||
|
||||
## Keeping live content fresh
|
||||
## Live elements: content that changes while it scrolls
|
||||
|
||||
Offscreen rendering is also what makes fresh sports scores possible. Today a
|
||||
plugin's segment is drawn when its group is prefetched, and the strip carries
|
||||
7,000–10,000 px of content ahead of the viewport (hdpi logs: "7153px still
|
||||
ahead", "9842px ahead"). At ~100 px/s, a score drawn now reaches the screen
|
||||
70–100 seconds later. When a plugin reports new data, Vegas only drops its
|
||||
cache (`invalidate_pending_updates`), so the change is drawn on the plugin's
|
||||
*next* turn, several minutes later. A segment already in the strip scrolls by
|
||||
with the data it was drawn with.
|
||||
Offscreen rendering is also what makes fresh content possible. A plugin's
|
||||
segment is drawn when its group is prefetched, and the strip carries
|
||||
7,000-10,000 px of content ahead of the viewport, so at ~100 px/s a score drawn
|
||||
then reaches the screen 70-100 seconds later -- and once in the strip it never
|
||||
changed: when a plugin reported new data, Vegas only dropped its caches, so the
|
||||
change appeared on the plugin's *next* turn, minutes later.
|
||||
|
||||
That was the right trade while every redraw of a canvas-bound plugin stalled
|
||||
the scroll. Off the render thread a redraw costs the scroll nothing, so the
|
||||
strip can afford three things.
|
||||
A plugin can now hand Vegas **live elements** instead of pictures
|
||||
(`BasePlugin.get_vegas_elements()`, see "Live Vegas elements" in
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#live-vegas-elements)):
|
||||
named, fixed-width pieces of content -- one per game card, one for a map. Vegas
|
||||
records where each lands in the strip and, when the plugin's data changes,
|
||||
redraws just the changed ones off the render thread and copies their pixels
|
||||
over the old ones between two frames. A card already crossing the panel
|
||||
changes; nothing next to it moves.
|
||||
|
||||
### 1. Refresh at the gate
|
||||
### Why not redraw every frame
|
||||
|
||||
Before a segment enters the viewport, check whether its plugin has updated
|
||||
since the segment was drawn. If it has, redraw it offscreen and replace it
|
||||
while it is still out of sight. Width changes are fine here, because
|
||||
everything from that segment onward is still invisible.
|
||||
On a Pi the render thread has about 4 ms of slack per refresh at 512x64 after
|
||||
the ~6 ms blit, and a scoreboard card is ~29 ms of Pillow work that holds the
|
||||
GIL. Drawing on the render thread is out of the question at any rate, so the
|
||||
render thread only ever *copies* pixels that are already drawn. Measured on a
|
||||
Pi 4 (ledpi): writing a 35 KB card into a 20,000 px strip takes 8.5 µs, a
|
||||
101 KB map 17 µs, four cards (the per-frame cap) 34 µs -- against 124 µs for
|
||||
the viewport slice every frame already does.
|
||||
|
||||
The gate sits `lead` pixels ahead of the viewport's right edge:
|
||||
`lead = max(one screen, speed × (render time + margin))`. The render time is
|
||||
the plugin's own, measured on each render (sports cards take the longest,
|
||||
hundreds of ms up to seconds per the prefetch notes). A plugin whose render
|
||||
does not finish before its segment reaches the viewport keeps the old segment.
|
||||
The scroll never waits for it.
|
||||
### How an update reaches the screen
|
||||
|
||||
Content is then at most `lead / speed` seconds old when it appears, a few
|
||||
seconds instead of minutes, without changing how far ahead the rotation
|
||||
fetches.
|
||||
1. A plugin's `update()` completes. The update worker calls
|
||||
`PluginManager._note_update_completed`, which calls the update listeners
|
||||
(`add_update_listener`) there and then, with the plugin's lock still held.
|
||||
Vegas's listener moves the plugin's **epoch** on
|
||||
(`src/vegas_mode/elements.py`, `LiveEpochs`) and wakes the live worker.
|
||||
2. The **live worker** (`src/vegas_mode/live_worker.py`), the one background
|
||||
thread that draws for the strip once it holds a live element, finds the
|
||||
plugin's elements whose recorded epoch is older than its current one,
|
||||
nearest the screen first, and calls `get_vegas_elements()` under the
|
||||
plugin's lock (0.25 s wait, then a 1 s backoff). Elements whose `version`
|
||||
is unchanged cost nothing; the rest are pinned and checksummed, and each
|
||||
whose pixels changed becomes a patch in a one-per-element slot (the latest
|
||||
wins).
|
||||
3. Between two frames the render thread
|
||||
(`RenderPipeline.apply_live_patches`, from `coordinator.run_frame`) pops at
|
||||
most four patches or two screens of bytes and copies each into the strip
|
||||
with `ScrollHelper.patch_columns`. It takes no lock and draws nothing. A
|
||||
patch made for an older strip, for an element trimmed away or already
|
||||
behind the screen, or from older data than the strip shows, is dropped.
|
||||
|
||||
### 2. Replace ahead of the screen
|
||||
End to end, a new score reaches a card already on screen within one poll of
|
||||
the data source (30 s for live games) plus about a second: the listener is
|
||||
immediate, and while live elements exist the update tick that schedules
|
||||
plugins runs every second instead of every four.
|
||||
|
||||
When a plugin reports new data (the Vegas update tick already names them), any
|
||||
of its segments that are **anywhere ahead of the viewport** are redrawn and
|
||||
replaced straight away, not only at the gate. That covers the long stretch of
|
||||
strip between prefetch and the gate.
|
||||
Elements that change with **time** rather than data (an aircraft moving
|
||||
between position reports) ask for `refresh_hz`; the worker calls
|
||||
`redraw_vegas_element()` -- without the plugin's lock, from state the plugin
|
||||
publishes in one assignment -- that often while the element is on or within
|
||||
`live_lead_screens` of the screen, capped by `live_max_hz` (5), at 1 Hz
|
||||
without the render gate, and halved for an element whose redraws average over
|
||||
50 ms.
|
||||
|
||||
### 3. Update on screen
|
||||
### Geometry
|
||||
|
||||
A segment that is already **visible** is patched in place when the redrawn
|
||||
version has the same geometry: the same total width, and the same width for
|
||||
each card (a sports plugin returns one image per game, joined with
|
||||
`intra_plugin_gap`). Scoreboard cards keep a fixed layout, so a score change
|
||||
patches in and the digits update as the card scrolls past. The patch is a
|
||||
pixel copy of one card (a 150×64 card is ~29 KB) applied by the render thread
|
||||
between frames, so a frame never shows half of a patch.
|
||||
A live element is never trimmed to its ink: the adapter pads it with
|
||||
`content_padding` black columns either side and pins its width, and a redraw
|
||||
at any other width is refused (it shows the next time the plugin comes round).
|
||||
Records keep **absolute** strip columns -- the strip column plus everything
|
||||
trimmed off the front since the strip was composed -- so a trim moves one
|
||||
origin rather than every record. Nothing on screen is ever moved, inserted or
|
||||
resized; a game added to a slate appears on the plugin's next turn.
|
||||
|
||||
When the geometry differs (a game added or dropped, a card that grew), the
|
||||
visible part cannot change without a jump. Only the cards not yet on screen
|
||||
are replaced, and only if the geometry up to that point is unchanged. Otherwise
|
||||
the segment keeps its snapshot until it has scrolled off.
|
||||
### Why not a SegmentStrip
|
||||
|
||||
### Avoiding wasted work
|
||||
The proposal here was to replace the single strip with a list of segments.
|
||||
In-place patching of the single strip meets every goal without that: the
|
||||
patch is O(element) and the strip layout never changes. What a segment list
|
||||
would still buy is cheaper extensions, and most of that came from making the
|
||||
strip's PIL copy lazy instead (`ScrollHelper.cached_image`: an extension used
|
||||
to rebuild it twice, 1.7-3.8 ms each on a Pi 4). The `extend` row of
|
||||
`frame_soak.py`'s "after work" table says whether the rest is worth it.
|
||||
|
||||
- **Change detection.** `run_scheduled_updates_with_changes()` names a plugin
|
||||
whenever its `update()` ran, not when its data changed. On hdpi
|
||||
`clock-simple` and `ledmatrix-music` are named on every 4-second tick. A
|
||||
redraw whose pixels hash the same as the segment's is discarded without a
|
||||
swap.
|
||||
- **Redraw on real updates only.** Vegas makes no API calls. Each plugin
|
||||
fetches on its own schedule, and a redraw is triggered only when the
|
||||
plugin's `update()` has run since its segment was drawn. On hdpi live
|
||||
football, baseball and hockey poll every 30 s (live odds every 60 s,
|
||||
everything else hourly), so a live sports card is redrawn once per poll.
|
||||
- **Floor.** A plugin is redrawn at most once per
|
||||
`vegas_scroll.refresh_min_interval` (proposed 10 s), and never while its
|
||||
previous redraw is still running. The floor never holds back a sports card
|
||||
polling every 30 s. It exists for chatty plugins: `clock-simple` updates
|
||||
every second and `ledmatrix-music` polls every 2 s.
|
||||
- **One worker.** Redraws go through the same background worker as prefetch,
|
||||
one plugin at a time at `nice 10`, under the plugin's lock.
|
||||
### When it is off
|
||||
|
||||
Data freshness is still bounded by each plugin's own fetch interval (how often
|
||||
it polls live scores). Drawing faster cannot beat the data source.
|
||||
|
||||
### The strip becomes a list of segments
|
||||
|
||||
All three need the strip to be replaceable by segment. Today it is one
|
||||
image (`ScrollHelper.cached_array`, 8,000–20,000 px wide, 1.5–3.8 MB), and
|
||||
`append_content()` rebuilds the whole thing on the render thread for every
|
||||
appended block. That is also a pause source.
|
||||
|
||||
Proposed `SegmentStrip`, used by Vegas in place of the single image:
|
||||
|
||||
- an ordered list of segments: plugin id, card boundaries, a pixel array, the
|
||||
render time, and the plugin data version it was drawn from, plus its
|
||||
x-offset in the strip;
|
||||
- `visible(x, width)` assembles the viewport by slicing across at most a few
|
||||
segments: the same ~100 KB copy per frame that slicing the single image
|
||||
costs today;
|
||||
- append and trim become O(block) list operations, not a copy of the strip;
|
||||
- replace swaps one list entry and shifts the offsets of the segments after it
|
||||
(dozens at most). A same-geometry patch copies pixels into the existing array.
|
||||
|
||||
Every mutation is prepared off the render thread and applied by the render
|
||||
thread at a frame boundary, so the strip the render loop reads is never
|
||||
half-changed.
|
||||
|
||||
### Multi-display sync
|
||||
|
||||
The follower renders from its own copy of the strip, offset from the leader's
|
||||
scroll position. Today the leader sends that copy whole, and only in
|
||||
`start_new_cycle()` (`send_scroll_image`), plus the scroll position every
|
||||
frame. Continuous scroll, the default, extends and trims the strip without
|
||||
starting a new cycle, and nothing sends those changes. From reading the code,
|
||||
the follower therefore probably falls out of step after the first extension
|
||||
already, before any of this design. That is untested; it needs a two-Pi rig.
|
||||
|
||||
With a segment strip, keeping the follower identical becomes **replaying the
|
||||
leader's operations**:
|
||||
|
||||
- Every strip mutation (append, trim, replace, patch) is one operation in
|
||||
strip coordinates. The leader applies it and sends the same operation to the
|
||||
follower over the existing TCP channel. Segments are small: a card is ~29 KB
|
||||
raw and compresses well.
|
||||
- Operations on off-screen segments apply on arrival. A patch to a segment
|
||||
that is on either panel carries an *apply at scroll position X* stamp a
|
||||
couple of hundred milliseconds ahead. Both sides apply it when their scroll
|
||||
position passes X, so both panels change on the same frame, within the
|
||||
existing position-sync jitter.
|
||||
- Each operation carries a sequence number. A follower that sees a gap (a
|
||||
reconnect, a dropped message) asks for a full snapshot, which is today's
|
||||
`send_scroll_image` path.
|
||||
|
||||
That also fixes the probable continuous-mode gap as a side effect, since
|
||||
appends and trims become operations too. Until it is in place, fresh-content
|
||||
updates are disabled while sync is active.
|
||||
- `display.vegas_scroll.live_refresh: false` (the kill switch; also in the
|
||||
web UI), or `vegas_live: false` in one plugin's section.
|
||||
- Always under multi-display sync: the follower mirrors whole strips only, so
|
||||
a patch would never reach it. (Continuous-mode sync has a separate problem:
|
||||
the follower is not sent extensions or trims at all.)
|
||||
- In swap mode (`continuous_scroll: false`) and with `offscreen_prefetch:
|
||||
false`.
|
||||
- For plugins without the hook, which are drawn and placed exactly as before,
|
||||
and on the paths that fetch without the plugin's lock (the first strip of a
|
||||
run, the render thread's fallback fetch), which use `get_vegas_content()`.
|
||||
|
||||
## Risks, and what was checked
|
||||
|
||||
@@ -328,11 +294,10 @@ updates are disabled while sync is active.
|
||||
source of the single-refresh late frames. Holding frames for two refreshes
|
||||
(≈50 px/s) doubles the budget. Cutting the blit itself is the native-presenter
|
||||
step.
|
||||
- **Live refreshes pushed from `update()`.** Some sports plugins call
|
||||
`display()` and `update_display()` from inside `update()`, which runs on the
|
||||
update worker and can push to the panel mid-Vegas. That is a separate
|
||||
hazard. `offscreen()` gives a tool for it (run the update worker offscreen
|
||||
while Vegas owns the panel), but it is out of scope here.
|
||||
- **Multi-display sync in continuous mode.** The follower is sent the whole
|
||||
strip only at a new cycle and on connect, never the extensions and trims of
|
||||
continuous mode, so it drifts from the leader after the first extension.
|
||||
Live elements stay off under sync for that reason.
|
||||
|
||||
## Test plan
|
||||
|
||||
@@ -348,14 +313,15 @@ updates are disabled while sync is active.
|
||||
- **Emulator integration:** a stub canvas-bound plugin whose `display()` sleeps
|
||||
300 ms. The Vegas render loop never goes a frame without presenting (frame
|
||||
timing recorder: zero freezes).
|
||||
- **Unit, `SegmentStrip`:** the viewport assembled across segment boundaries
|
||||
matches slicing one concatenated image, pixel for pixel. Append, trim,
|
||||
replace-ahead and same-geometry patch each leave every other column
|
||||
unchanged. A geometry-changing patch of a visible segment is refused.
|
||||
- **Freshness:** a stub sports plugin whose score changes every second. The
|
||||
score on screen is never older than `lead / speed` plus the plugin's fetch
|
||||
interval. A visible card's digits change without the frame-timing recorder
|
||||
seeing a late frame. An unchanged redraw is discarded.
|
||||
- **Live elements** (`test/test_vegas_live_*.py`,
|
||||
`test/test_vegas_elements_*.py`, `test/test_scroll_helper_patch.py`): every
|
||||
record points at exactly its element's pixels through any sequence of
|
||||
compose, extend and trim; a patch changes only its element's columns (a
|
||||
property test against a twin strip that is never patched); the render
|
||||
thread's apply takes no lock and draws nothing; the worker's priorities,
|
||||
floors, backoff and hand-over; and, end to end on the emulator with the stub
|
||||
plugin (`test/fixtures/plugins/vegas-live-stub`), an update changes a card
|
||||
already in the strip and an animated element moves with no update at all.
|
||||
- **Hardware:** an hdpi soak, A/B against the #628 build, alternating order.
|
||||
Targets: no freezes, an empty 6+ bucket, the 3–5 bucket near zero, and the late
|
||||
rate below 0.66%. Plus, for freshness: log each segment's age when it enters
|
||||
@@ -363,26 +329,23 @@ updates are disabled while sync is active.
|
||||
|
||||
## Rollout
|
||||
|
||||
Three changes, each soaked on hdpi before the next:
|
||||
1. **Offscreen rendering** (shipped): `offscreen()`, the adapter on the
|
||||
prefetch thread, and the plugin lock. Removed the render-thread pauses.
|
||||
2. **Measurement and the lazy strip image:** late frames attributed to the
|
||||
render-thread work before them (`FrameTimingRecorder.note_op`, the "after
|
||||
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.
|
||||
|
||||
1. **Offscreen rendering:** `offscreen()`, the adapter on the prefetch thread,
|
||||
and the plugin lock. Removes the render-thread pauses.
|
||||
2. **`SegmentStrip`:** Vegas's strip becomes a list of segments. Removes the
|
||||
whole-strip copy on append. No visible behaviour change.
|
||||
3. **Fresh content:** refresh at the gate, replace ahead, patch on screen,
|
||||
with change detection and the rate limit.
|
||||
|
||||
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores today's
|
||||
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores the
|
||||
deferred path when `false`, and `display.vegas_scroll.live_refresh` (default
|
||||
`true`) turns off step 3. Keep both for one release, then delete the old paths.
|
||||
`true`) turns live elements off. Keep both for one release, then delete the
|
||||
old paths.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Keep the kill switch, or ship without one?
|
||||
2. Plugin lock timeout: skip the plugin and keep its cached segment (proposed),
|
||||
or wait longer?
|
||||
3. `refresh_min_interval`: 10 s proposed. It only limits chatty plugins;
|
||||
live sports are redrawn once per 30 s poll regardless.
|
||||
4. Multi-display sync: is there a two-Pi rig to test on? Operation replay is
|
||||
proposed as part of the segment strip (step 2), with fresh content
|
||||
disabled under sync until it has been verified on real hardware.
|
||||
1. Multi-display sync: is there a two-Pi rig to test on? Replaying strip
|
||||
operations to the follower (append, trim, patch, in absolute columns) would
|
||||
fix continuous-mode sync and let live elements run under it.
|
||||
2. Is the `extend` cost worth a segment list after the lazy image? The soak's
|
||||
"after work" table answers it per rig.
|
||||
|
||||
@@ -387,6 +387,79 @@ The width Vegas wants this plugin's content to occupy, from the plugin's
|
||||
`display_manager` while it asks for content, so a plugin that sizes itself
|
||||
from `display_manager.width` does not need to read this.
|
||||
|
||||
#### Live Vegas elements
|
||||
|
||||
*New in core 3.8.0.* Content from `get_vegas_content()` is baked into the
|
||||
ticker's strip when the plugin's turn is prefetched, so a score drawn then
|
||||
scrolls past with that score however many goals are scored while it crosses
|
||||
the panel. A plugin that returns **live elements** instead gets them updated
|
||||
in place: after its `update()` the ticker asks again, compares each element
|
||||
with what the strip holds, and swaps the changed ones in between two frames
|
||||
-- on screen included -- without anything next to them moving.
|
||||
|
||||
```python
|
||||
try:
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
except ImportError: # core older than 3.8.0: the hook is never called
|
||||
VegasElement = None
|
||||
|
||||
class MyScoreboard(BasePlugin):
|
||||
def get_vegas_elements(self):
|
||||
if VegasElement is None:
|
||||
return None
|
||||
return [VegasElement(key=f"game:{g['id']}",
|
||||
image=self._card(g), # cache by fingerprint
|
||||
version=self._fingerprint(g)) # changes iff pixels would
|
||||
for g in self.games]
|
||||
```
|
||||
|
||||
`VegasElement(key, image, version=None, live=True, refresh_hz=0.0)`:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `key` | Names the element across redraws; unique in the list, stable for the same logical item (`"game:nfl:401547417"`, `"map"`). |
|
||||
| `image` | The element now, at the display's height. A live element's **width must not depend on its data**: a redraw at another width is never swapped in (it appears the next time the plugin comes round), because nothing on screen may move. |
|
||||
| `version` | Anything hashable that changes exactly when the pixels would. Handed back with the **same image object** as last time, it lets the ticker skip converting the element; a new image is always converted and compared by its pixels, so a redraw for new settings is never missed. `None` means "compare pixels". |
|
||||
| `live` | `False` places it as plain content (trimmed, never refreshed): separators, decoration. |
|
||||
| `refresh_hz` | For content that changes with **time** rather than data (an aircraft moving between position reports): the ticker calls `redraw_vegas_element()` about this often while the element is on or near the screen, capped by `vegas_scroll.live_max_hz` and at 1 Hz without the rebuilt rgbmatrix binding. |
|
||||
|
||||
**`get_vegas_elements() -> Optional[List[VegasElement]]`** — called on the
|
||||
ticker's background thread under the plugin's lock (never while `update()`
|
||||
runs), on a canvas of its own and told its render width, exactly like
|
||||
`get_vegas_content()`. It is called after every `update()` while any of the
|
||||
plugin's elements is on or ahead of the screen, so it must be cheap when
|
||||
nothing changed (cache images by version), idempotent, and must not fetch.
|
||||
Return `None` to use `get_vegas_content()`, which a plugin must keep working
|
||||
for older cores and for the paths that do not ask for elements (the ticker's
|
||||
first strip, multi-display sync, the `live_refresh` switch).
|
||||
|
||||
**`redraw_vegas_element(key, width, height, at) -> Optional[PIL.Image]`** —
|
||||
only for elements with `refresh_hz`. Called **without** the plugin's lock,
|
||||
possibly while `update()` runs, so read only state `update()` replaces in one
|
||||
assignment (an immutable snapshot), never state it mutates in place. `at` is
|
||||
the `time.monotonic()` the pixels are expected on the panel: draw the element
|
||||
as it should look then. Return exactly `width` x `height`, or `None` to skip
|
||||
the tick.
|
||||
|
||||
**`notify_vegas_data_changed()`** — data that arrives outside `update()` (a
|
||||
background thread, a push callback) calls this so the ticker redraws without
|
||||
waiting for the next `update()`. Safe from any thread.
|
||||
|
||||
Live elements are never trimmed to their ink: the ticker pads each with
|
||||
`content_padding` black columns either side, the margin trimming would have
|
||||
left. A single element wider than the plugin's width budget
|
||||
(`vegas_max_width_screens`, not counting that padding) is cropped like any
|
||||
other content and scrolls by as plain, no longer live. The user can turn them off per plugin with `vegas_live: false` (a
|
||||
core-owned property) or for the whole ticker with
|
||||
`display.vegas_scroll.live_refresh: false`; they are always off under
|
||||
multi-display sync.
|
||||
|
||||
`scripts/check_plugin.py` checks the contract for any plugin that implements
|
||||
the hook (unique keys, height, width stable with no new data, redraw size,
|
||||
slow calls) and prints a `vegas elements` row; the checks are in
|
||||
`src/plugin_system/testing/vegas.py`. `test/fixtures/plugins/vegas-live-stub`
|
||||
is a small working example.
|
||||
|
||||
#### Legacy: `get_vegas_content_type()` and `get_vegas_display_mode()`
|
||||
|
||||
Superseded by participation, and still read to derive it when neither the
|
||||
|
||||
@@ -47,6 +47,17 @@ is `CORE_PLUGIN_PROPERTIES` in `src/plugin_system/schema_manager.py`):
|
||||
`src/plugin_system/base_plugin.py`; see
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#vegas-participation)
|
||||
|
||||
6. **`vegas_live`** (boolean; no default, unset means on)
|
||||
- Description: for a plugin with live Vegas elements (it implements
|
||||
`get_vegas_elements()`), whether the ticker changes what is already
|
||||
scrolling when the plugin's data changes. `false` shows each card as it
|
||||
was when drawn, as before live elements existed
|
||||
- Ignored by plugins without live elements, and whenever live elements
|
||||
are off for the whole ticker (`display.vegas_scroll.live_refresh`)
|
||||
- Read by `PluginAdapter.is_live_capable()` in
|
||||
`src/vegas_mode/plugin_adapter.py`; see
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md#live-vegas-elements)
|
||||
|
||||
`skin` and `skin_options` were core properties until the skin system was
|
||||
removed. A plugin config saved with them still loads and saves; the keys are
|
||||
dropped on the next save (see `RETIRED_PLUGIN_KEYS` in `schema_manager.py`).
|
||||
|
||||
@@ -342,6 +342,7 @@ service's user.
|
||||
| **wait** | Time blocked in `SwapOnVSync`, i.e. the slack left in each refresh. A p50 near zero means the rig has no headroom and anything extra lands a frame late. |
|
||||
| **work** | Everything else between two frames: drawing, scrolling, and waiting for the GIL. A wide gap between its p50 and p99 is another thread getting in the way. |
|
||||
| **Binding** | `STOCK` means the rgbmatrix binding holds the GIL through the vsync wait, which starves every other thread. See *Rebuilding the binding*. |
|
||||
| **after work** | Frames presented straight after tagged render-thread work, with their own late rate: `extend` and `compose` (Vegas building its strip), `patch` (live elements, once they land). A kind whose late rate sits well above the overall one is the work making frames late. Shown only when something tagged its work. |
|
||||
|
||||
The refresh rate is estimated from the frames themselves (swaps that block on
|
||||
vsync can only land on refresh boundaries). Cross-check it with
|
||||
@@ -406,6 +407,10 @@ sudo python3 scripts/render_bench.py --speed 50 # a held (frame_hold 2) spe
|
||||
sudo python3 scripts/render_bench.py --busy 2 # with threads imitating plugin updates
|
||||
sudo python3 scripts/render_bench.py --json /tmp/pi4-512x64.json
|
||||
|
||||
# render-thread strip work, each tagged so the report gives it a late rate:
|
||||
sudo python3 scripts/render_bench.py --patch-bytes 101376 --patch-every 25 # a live map patch
|
||||
sudo python3 scripts/render_bench.py --strip-screens 30 --extend-every-screens 6 # Vegas extensions
|
||||
|
||||
sudo systemctl start ledmatrix
|
||||
```
|
||||
|
||||
@@ -468,6 +473,8 @@ refreshes" comes from.
|
||||
| `duplicate` | frames that advanced no pixels. A crisp fixed-step scroll should show none; any at all means the loop is presenting faster than the strip is moving. |
|
||||
| `blank` | frames with no visible slice to draw: the helper had no content. Should be zero. |
|
||||
| `restarts` | how many times the strip was scrolled through end to end. Informational: the bench restarts the strip where a plugin would hand over to the next one. |
|
||||
| `patches` | `--patch-bytes N --patch-every K`: N bytes of columns written into the strip in place every K frames, on screen or (`--patch-where ahead`) just past it -- what a live element update costs the render thread. Their frames are the `patch` row under *after work*. |
|
||||
| `extensions` | `--extend-every-screens N`: a block appended and the scrolled-past columns trimmed every N screens, as continuous Vegas does. The cost is a copy of the whole strip, so size it like Vegas's with `--strip-screens` (8,000-20,000px). Their frames are the `extend` row. |
|
||||
|
||||
`--json` writes the full report plus the panel geometry, the solved speed and
|
||||
these counters, so two rigs (or one rig before and after a change) can be
|
||||
|
||||
@@ -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
|
||||
@@ -72,13 +73,17 @@ src/plugin_system/testing/loading.py
|
||||
src/plugin_system/testing/mocks.py
|
||||
src/plugin_system/testing/plugin_test_base.py
|
||||
src/plugin_system/testing/sizes.py
|
||||
src/plugin_system/testing/vegas.py
|
||||
src/plugin_system/vegas_elements.py
|
||||
src/redaction.py
|
||||
src/scan_order.py
|
||||
src/startup_validator.py
|
||||
src/vegas_mode/__init__.py
|
||||
src/vegas_mode/config.py
|
||||
src/vegas_mode/coordinator.py
|
||||
src/vegas_mode/elements.py
|
||||
src/vegas_mode/geometry.py
|
||||
src/vegas_mode/live_worker.py
|
||||
src/vegas_mode/stream_manager.py
|
||||
src/web_interface/api_helpers.py
|
||||
src/web_interface/config_arrays.py
|
||||
|
||||
@@ -72,6 +72,7 @@ from src.plugin_system.testing.harness import ( # noqa: E402
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
|
||||
)
|
||||
from src.plugin_system.testing.vegas import check_plugin_vegas_elements # noqa: E402
|
||||
|
||||
logger = get_logger("[Check Plugin]")
|
||||
|
||||
@@ -193,6 +194,24 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
|
||||
all_run_results.extend(results)
|
||||
|
||||
# Live Vegas elements, for a plugin that has them: checked once, at the
|
||||
# first size, with the base config.
|
||||
width, height = effective_sizes[0]
|
||||
try:
|
||||
vegas = check_plugin_vegas_elements(
|
||||
plugin_id, plugin_dir, full_config, effective_mock_data, width, height,
|
||||
run_update=effective_run_update)
|
||||
except Exception as exc: # noqa: BLE001 - one plugin must not end an --all run
|
||||
all_run_results.append(RenderResult(
|
||||
plugin_id, width, height, "vegas elements",
|
||||
error=f"the element check itself failed: {exc!r}"))
|
||||
return all_run_results
|
||||
if vegas.implemented:
|
||||
all_run_results.append(RenderResult(
|
||||
plugin_id, width, height, "vegas elements",
|
||||
error="; ".join(vegas.errors) or None,
|
||||
notes=[f"{vegas.elements} element(s), {vegas.live} live"] + vegas.warnings))
|
||||
|
||||
return all_run_results
|
||||
|
||||
|
||||
@@ -236,6 +255,8 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
f" controller skips the mode")
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
if r.notes:
|
||||
detail += f" ({'; '.join(r.notes)})"
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
print()
|
||||
return everything_ok
|
||||
|
||||
@@ -35,6 +35,9 @@ blit copying the frame into the matrix canvas (rgbmatrix SetImage).
|
||||
wait blocked in SwapOnVSync, i.e. slack before the refresh.
|
||||
work everything else between two frames: drawing, scrolling, and
|
||||
waiting for the GIL.
|
||||
after work frames presented straight after tagged render-thread work
|
||||
(Vegas strip extensions, live-element patches), with their own
|
||||
late rate. Shown only when something tagged its work.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -126,6 +129,33 @@ def _edge(index: int, bucket_ms: float):
|
||||
return round((index + 1) * bucket_ms, 2)
|
||||
|
||||
|
||||
def op_rows(totals: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
"""Per kind of noted render-thread work: how often its frame was late.
|
||||
|
||||
A kind's frames are the ones presented straight after that work ran (see
|
||||
"Operations" in src/common/frame_timing.py). Stats from a recorder that
|
||||
predates the counters have none, and give an empty table.
|
||||
"""
|
||||
frames = totals.get("op_frames") or {}
|
||||
late = totals.get("late_op_frames") or {}
|
||||
freezes = totals.get("op_freezes") or {}
|
||||
moved = totals.get("op_bytes") or {}
|
||||
rows = {}
|
||||
for kind in sorted(set(frames) | set(freezes)):
|
||||
count = frames.get(kind, 0)
|
||||
if not count and not freezes.get(kind, 0):
|
||||
continue
|
||||
rows[kind] = {
|
||||
"frames": count,
|
||||
"late": late.get(kind, 0),
|
||||
"late_pct": (round(100.0 * late.get(kind, 0) / count, 3)
|
||||
if count else None),
|
||||
"freezes": freezes.get(kind, 0),
|
||||
"bytes": moved.get(kind, 0),
|
||||
}
|
||||
return rows
|
||||
|
||||
|
||||
def build_report(before, after, preview: bool) -> Dict[str, Any]:
|
||||
delta = diff(before, after)
|
||||
totals = delta["totals"]
|
||||
@@ -159,6 +189,7 @@ def build_report(before, after, preview: bool) -> Dict[str, Any]:
|
||||
if totals["worst_interval_ms"] else None),
|
||||
"timing_ms": {name: percentiles(h, bucket_ms)
|
||||
for name, h in delta["histograms"].items()},
|
||||
"ops": op_rows(totals),
|
||||
}
|
||||
# The rate the panel held while rendering: the typical frame's interval
|
||||
# per refresh held. A few percent under the idle rate is normal (the Pi is
|
||||
@@ -216,6 +247,17 @@ def print_report(report: Dict[str, Any], limit: float) -> None:
|
||||
print(f"{name:<18}" + "".join(f"{str(row.get(k, '-')):>8}"
|
||||
for k in ("p50", "p95", "p99", "max")))
|
||||
print()
|
||||
ops = report.get("ops") or {}
|
||||
if ops:
|
||||
# Frames presented straight after render-thread work of each kind. A
|
||||
# late rate well above the overall one points at that work.
|
||||
print(f"{'after work':<18}{'frames':>8}{'late':>8}{'late %':>8}"
|
||||
f"{'freezes':>9}{'MB moved':>10}")
|
||||
for kind, row in ops.items():
|
||||
pct = "-" if row["late_pct"] is None else f"{row['late_pct']:g}"
|
||||
print(f"{kind:<18}{row['frames']:>8}{row['late']:>8}{pct:>8}"
|
||||
f"{row['freezes']:>9}{row['bytes'] / 1e6:>10.2f}")
|
||||
print()
|
||||
if report["late_pct"] is None:
|
||||
print("RESULT nothing scrolled - no verdict")
|
||||
elif not locked(report, limit):
|
||||
|
||||
+192
-7
@@ -25,6 +25,14 @@ against another) and for A/B testing a change to the render path.
|
||||
sudo python3 scripts/render_bench.py --busy 2 # with background load
|
||||
sudo python3 scripts/render_bench.py --json /tmp/pi4.json
|
||||
|
||||
# the cost of changing pixels under a moving strip (Vegas live elements):
|
||||
# a 101KB write into the visible columns every 25 frames
|
||||
sudo python3 scripts/render_bench.py --patch-bytes 101376 --patch-every 25
|
||||
|
||||
# the cost of extending a Vegas-sized strip on the render thread: a 30-screen
|
||||
# strip, extended by 6 screens (and trimmed) every 6 screens scrolled
|
||||
sudo python3 scripts/render_bench.py --strip-screens 30 --extend-every-screens 6
|
||||
|
||||
sudo systemctl start ledmatrix
|
||||
|
||||
Like scripts/scroll_speeds.py, this never starts or stops the service itself,
|
||||
@@ -92,13 +100,17 @@ def load_config() -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def build_strip(width: int, height: int, label: str):
|
||||
"""A marquee strip a few screens wide, with text and colour.
|
||||
def build_strip(width: int, height: int, label: str, screens: float = 4.0):
|
||||
"""A marquee strip about ``screens`` screens wide, with text and colour.
|
||||
|
||||
Deliberately not plain white text on black: how long ``SetImage`` takes
|
||||
depends on how many subpixels are lit, so a strip that is mostly dark
|
||||
flatters the panel and hides exactly the regression this benchmark exists
|
||||
to catch.
|
||||
|
||||
The width matters to the extension mode, whose cost is a copy of the whole
|
||||
strip: Vegas carries 8,000-20,000 columns, so measure extension against a
|
||||
strip that wide (``--strip-screens``), not the four-screen default.
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
@@ -123,7 +135,7 @@ def build_strip(width: int, height: int, label: str):
|
||||
text_width = max(1, box[2] - box[0])
|
||||
text_height = box[3] - box[1]
|
||||
|
||||
reps = max(2, (width * 4) // text_width + 1)
|
||||
reps = max(2, int(width * screens) // text_width + 1)
|
||||
strip = Image.new("RGB", (text_width * reps, height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(strip)
|
||||
draw.fontmode = "1" # the panel has no partial brightness; see DisplayManager
|
||||
@@ -179,6 +191,119 @@ class BackgroundLoad:
|
||||
zlib.compress(image.tobytes(), 1)
|
||||
|
||||
|
||||
class StripWork:
|
||||
"""Render-thread work a Vegas strip does between frames, on a schedule.
|
||||
|
||||
Patching writes a block of columns into the strip in place, as a live
|
||||
element update does. Extending appends a block and trims what has scrolled
|
||||
past, as continuous Vegas does (render_pipeline.extend_scroll_content).
|
||||
Both run where Vegas runs them -- on the frame loop, before the next frame
|
||||
is drawn -- and are tagged with ``FrameTimingRecorder.note_op``, so the
|
||||
report shows how often the frame straight after each one was late.
|
||||
|
||||
The content comes from the benchmark's own strip, prepared before the run:
|
||||
in Vegas it is drawn off the render thread, so drawing it here would time
|
||||
work the render thread never does.
|
||||
"""
|
||||
|
||||
def __init__(self, helper, recorder, source, *, patch_bytes: int = 0,
|
||||
patch_every: int = 25, patch_where: str = "visible",
|
||||
extend_every_screens: float = 0.0, extend_width: int = 0,
|
||||
separator: int = 32) -> None:
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
self.helper = helper
|
||||
self.recorder = recorder
|
||||
self.width = helper.display_width
|
||||
self.height = helper.display_height
|
||||
self.patch_every = max(1, int(patch_every))
|
||||
self.patch_where = patch_where
|
||||
self.separator = max(0, int(separator))
|
||||
self.patches = 0
|
||||
self.patched_bytes = 0
|
||||
self.extensions = 0
|
||||
self._frames = 0
|
||||
self._position_at_extend = 0.0
|
||||
|
||||
pixels = np.asarray(source.convert("RGB"))
|
||||
source_width = pixels.shape[1]
|
||||
|
||||
def columns(count: int, offset: int):
|
||||
# Wraps around the source, so any width can be cut from it.
|
||||
return np.ascontiguousarray(
|
||||
pixels[:, (np.arange(count) + offset) % source_width])
|
||||
|
||||
# Two versions to alternate between, so every patch changes pixels.
|
||||
self._patches = []
|
||||
if patch_bytes > 0:
|
||||
count = max(1, int(patch_bytes) // (self.height * 3))
|
||||
self._patches = [columns(count, 0), columns(count, count)]
|
||||
|
||||
self.extend_every = (int(extend_every_screens * self.width)
|
||||
if extend_every_screens > 0 else 0)
|
||||
self._blocks = []
|
||||
if self.extend_every:
|
||||
# By default each append (block plus its separator) replaces
|
||||
# exactly what scrolled past since the last one, so the strip
|
||||
# holds its width, as Vegas's does in the steady state.
|
||||
count = int(extend_width) or max(1, self.extend_every - self.separator)
|
||||
self._blocks = [Image.fromarray(columns(count, 0)),
|
||||
Image.fromarray(columns(count, count))]
|
||||
|
||||
def reset(self) -> None:
|
||||
"""The strip was restarted from the beginning."""
|
||||
self._position_at_extend = self.helper.scroll_position
|
||||
|
||||
def before_frame(self) -> None:
|
||||
"""Do whatever work is due before the next frame is drawn."""
|
||||
if self._blocks:
|
||||
self._extend_if_due()
|
||||
if self._patches:
|
||||
self._frames += 1
|
||||
if self._frames % self.patch_every == 0:
|
||||
self._patch()
|
||||
|
||||
def _extend_if_due(self) -> None:
|
||||
helper = self.helper
|
||||
if helper.scroll_position < self._position_at_extend:
|
||||
self._position_at_extend = helper.scroll_position
|
||||
# Due on a fixed cadence rather than N screens after the last one ran,
|
||||
# which would drift by the overshoot of a multi-pixel step each time.
|
||||
due_at = self._position_at_extend + self.extend_every
|
||||
if helper.scroll_position < due_at:
|
||||
return
|
||||
block = self._blocks[self.extensions % 2]
|
||||
helper.append_content([block], item_gap=self.separator, element_gap=0)
|
||||
moved = helper.cached_array.nbytes
|
||||
# One screen kept behind the viewport, as Vegas does. The trim shifts
|
||||
# every strip coordinate, the cadence's included.
|
||||
cut = helper.drop_scrolled_prefix(keep_before=self.width)
|
||||
if cut:
|
||||
moved += helper.cached_array.nbytes
|
||||
self._position_at_extend = due_at - cut
|
||||
self.extensions += 1
|
||||
self.recorder.note_op("extend", moved)
|
||||
|
||||
def _patch(self) -> None:
|
||||
patch = self._patches[self.patches % 2]
|
||||
strip = self.helper.cached_array
|
||||
count = patch.shape[1]
|
||||
if strip is None or count > strip.shape[1]:
|
||||
return
|
||||
start = int(self.helper.scroll_position)
|
||||
if self.patch_where == "visible":
|
||||
x = start + max(0, (self.width - count) // 2)
|
||||
else:
|
||||
# Just past the right edge: a change to content not yet on screen.
|
||||
x = start + self.width + 16
|
||||
x = max(0, min(x, strip.shape[1] - count))
|
||||
strip[:, x:x + count] = patch
|
||||
self.patches += 1
|
||||
self.patched_bytes += patch.nbytes
|
||||
self.recorder.note_op("patch", patch.nbytes)
|
||||
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -202,8 +327,36 @@ def main(argv=None) -> int:
|
||||
help="also write the report as JSON, for comparing rigs")
|
||||
parser.add_argument("--label", default=None,
|
||||
help="name for this run in the JSON report (default: hostname)")
|
||||
parser.add_argument("--strip-screens", type=float, default=4.0, metavar="S",
|
||||
help="strip width in screens (default 4). Vegas strips are "
|
||||
"8,000-20,000px; the extension cost scales with it")
|
||||
parser.add_argument("--patch-bytes", type=int, default=0, metavar="N",
|
||||
help="write N bytes of columns into the strip in place "
|
||||
"every --patch-every frames, as a Vegas live element "
|
||||
"update does (a 150x64 card is ~29KB, a 512x64 map "
|
||||
"~100KB)")
|
||||
parser.add_argument("--patch-every", type=int, default=25, metavar="K",
|
||||
help="frames between patches (default 25; 1 = every frame)")
|
||||
parser.add_argument("--patch-where", choices=("visible", "ahead"),
|
||||
default="visible",
|
||||
help="patch the columns on screen, or just past its right "
|
||||
"edge (default visible)")
|
||||
parser.add_argument("--extend-every-screens", type=float, default=0.0,
|
||||
metavar="N",
|
||||
help="append a block and trim the strip every N screens "
|
||||
"scrolled, as continuous Vegas does")
|
||||
parser.add_argument("--extend-width", type=int, default=0, metavar="W",
|
||||
help="width of each appended block in px (default: N "
|
||||
"screens less the separator, so the strip holds its "
|
||||
"width)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.extend_every_screens > 0 and args.strip_screens < args.extend_every_screens + 3:
|
||||
# The strip must stay ahead of the viewport between extensions.
|
||||
args.strip_screens = args.extend_every_screens + 3
|
||||
print(f"strip widened to {args.strip_screens:g} screens so extensions "
|
||||
"keep ahead of the viewport")
|
||||
|
||||
# Everything the display service logs would otherwise land in the middle of
|
||||
# the report; the benchmark's own output is the point. The stall watchdog
|
||||
# is the exception: a stack dump naming what held a frame up belongs here.
|
||||
@@ -272,8 +425,9 @@ def main(argv=None) -> int:
|
||||
print(f"asked for {requested:.1f} px/s -> {choice.describe()}")
|
||||
|
||||
helper.set_sub_pixel_scrolling(False)
|
||||
helper.set_scrolling_image(
|
||||
build_strip(width, height, f"{choice.pixels_per_second:.0f} px/s"))
|
||||
strip = build_strip(width, height, f"{choice.pixels_per_second:.0f} px/s",
|
||||
screens=args.strip_screens)
|
||||
helper.set_scrolling_image(strip)
|
||||
|
||||
# The display service's own recorder, owned outright here: never flushed to
|
||||
# the service's stats file, drained exactly at the start and end of the
|
||||
@@ -288,8 +442,23 @@ def main(argv=None) -> int:
|
||||
recorder.scrolling_now = display._scrolling_now # pylint: disable=protected-access
|
||||
display.frame_timing = recorder
|
||||
|
||||
print(f"scrolling {width}x{height} for {args.seconds:.0f}s"
|
||||
+ (f" with {args.busy} background worker(s)" if args.busy else "")
|
||||
work = StripWork(helper, recorder, strip,
|
||||
patch_bytes=args.patch_bytes, patch_every=args.patch_every,
|
||||
patch_where=args.patch_where,
|
||||
extend_every_screens=args.extend_every_screens,
|
||||
extend_width=args.extend_width)
|
||||
|
||||
doing = []
|
||||
if args.busy:
|
||||
doing.append(f"{args.busy} background worker(s)")
|
||||
if args.patch_bytes > 0:
|
||||
doing.append(f"a {args.patch_bytes}B {args.patch_where} patch every "
|
||||
f"{args.patch_every} frame(s)")
|
||||
if work.extend_every:
|
||||
doing.append(f"an extension every {args.extend_every_screens:g} screens")
|
||||
print(f"scrolling {width}x{height} ({helper.cached_array.shape[1]}px strip) "
|
||||
f"for {args.seconds:.0f}s"
|
||||
+ (" with " + ", ".join(doing) if doing else "")
|
||||
+ " ...", flush=True)
|
||||
|
||||
frames = 0
|
||||
@@ -309,8 +478,11 @@ def main(argv=None) -> int:
|
||||
before = recorder.snapshot()
|
||||
run_started = now
|
||||
frames = duplicates = blanks = restarts = 0
|
||||
work.patches = work.patched_bytes = work.extensions = 0
|
||||
if run_started is not None and now - run_started >= args.seconds:
|
||||
break
|
||||
# Where Vegas does its strip work: before the frame is drawn.
|
||||
work.before_frame()
|
||||
helper.update_scroll_position()
|
||||
if helper.is_scroll_complete():
|
||||
# The helper parks at the end of the strip and stops
|
||||
@@ -320,6 +492,7 @@ def main(argv=None) -> int:
|
||||
# the benchmark measures a still image for the rest of the
|
||||
# run and reports a smoothness it never demonstrated.
|
||||
helper.reset_scroll()
|
||||
work.reset()
|
||||
restarts += 1
|
||||
visible = helper.get_visible_portion()
|
||||
column = int(helper.scroll_position)
|
||||
@@ -375,6 +548,11 @@ def main(argv=None) -> int:
|
||||
if restarts:
|
||||
print(f"restarts {restarts} (the strip was scrolled through "
|
||||
f"{restarts} time{'s' if restarts != 1 else ''})")
|
||||
if work.patches:
|
||||
print(f"patches {work.patches} ({work.patched_bytes / 1e6:.1f} MB "
|
||||
"written into the strip)")
|
||||
if work.extensions:
|
||||
print(f"extensions {work.extensions}")
|
||||
|
||||
if args.json_path:
|
||||
report.update({
|
||||
@@ -388,6 +566,13 @@ def main(argv=None) -> int:
|
||||
"duplicate_frames": duplicates,
|
||||
"blank_frames": blanks,
|
||||
"strip_restarts": restarts,
|
||||
"strip_screens": args.strip_screens,
|
||||
"patch_bytes": args.patch_bytes,
|
||||
"patch_every": args.patch_every,
|
||||
"patch_where": args.patch_where,
|
||||
"patches": work.patches,
|
||||
"extend_every_screens": args.extend_every_screens,
|
||||
"extensions": work.extensions,
|
||||
"max_late_pct": args.max_late_pct,
|
||||
"passed": frame_soak.passed(report, args.max_late_pct),
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
+69
-12
@@ -66,6 +66,17 @@ faster than the panel (every frame early) or sits at half its rate (every
|
||||
frame late), both of which look self-consistent to an estimate taken from
|
||||
their own intervals.
|
||||
|
||||
Operations
|
||||
----------
|
||||
The late count says how often, not which work did it. Render-thread work that
|
||||
happens between two frames -- extending the Vegas strip, patching a live
|
||||
element into it -- calls :meth:`FrameTimingRecorder.note_op` first, and the
|
||||
next presented frame carries the tag: the interval that frame ends is the one
|
||||
the work landed in. ``op_frames`` counts timed frames per kind,
|
||||
``late_op_frames`` the late ones among them, ``op_freezes`` those that were a
|
||||
freeze instead, and ``op_bytes`` what the work moved. A kind whose late rate
|
||||
sits well above the overall one is the work to look at.
|
||||
|
||||
Stall watchdog
|
||||
--------------
|
||||
Counting a freeze says that it happened, not why. ``StallWatchdog`` watches the
|
||||
@@ -153,11 +164,20 @@ def default_stats_path() -> str:
|
||||
return os.path.join(base, STATS_FILENAME)
|
||||
|
||||
|
||||
#: One presented frame's interval: (interval, blit, wait, hold, ops), where
|
||||
#: ops is the work noted before it (kind -> bytes) or None.
|
||||
_Frame = Tuple[float, float, float, int, Optional[Dict[str, int]]]
|
||||
|
||||
|
||||
def _bucket(seconds: float) -> int:
|
||||
index = int(seconds * 1000.0 / BUCKET_MS)
|
||||
return min(max(index, 0), BUCKET_COUNT - 1)
|
||||
|
||||
|
||||
def _bump(counter: Dict[str, int], key: str, by: int = 1) -> None:
|
||||
counter[key] = counter.get(key, 0) + by
|
||||
|
||||
|
||||
def binding_releases_gil() -> Optional[bool]:
|
||||
"""Whether the loaded rgbmatrix binding releases the GIL, or None.
|
||||
|
||||
@@ -250,12 +270,14 @@ class FrameTimingRecorder:
|
||||
self.info = dict(info or {})
|
||||
|
||||
# Render-thread state.
|
||||
self._pending: List[Tuple[float, float, float, int]] = []
|
||||
self._pending: List[_Frame] = []
|
||||
self._static_frames = 0
|
||||
self._previous: Optional[Tuple[float, bool, int]] = None
|
||||
# The interval ended by a static frame that followed a scrolling one,
|
||||
# until the next frame shows whether the scroll went on.
|
||||
self._unsure: Optional[Tuple[float, float, float, int]] = None
|
||||
self._unsure: Optional[_Frame] = None
|
||||
# Work noted since the last frame (kind -> bytes), for the next one.
|
||||
self._ops: Optional[Dict[str, int]] = None
|
||||
self._last_flush: Optional[float] = None
|
||||
self._queue: "queue.SimpleQueue" = queue.SimpleQueue()
|
||||
self._worker: Optional[threading.Thread] = None
|
||||
@@ -281,6 +303,11 @@ class FrameTimingRecorder:
|
||||
"freeze_seconds": 0.0,
|
||||
"freeze_by": {label: 0 for _, label in FREEZE_BUCKETS},
|
||||
"worst_interval_ms": 0.0,
|
||||
# Per kind of noted render-thread work; see "Operations".
|
||||
"op_frames": {},
|
||||
"late_op_frames": {},
|
||||
"op_freezes": {},
|
||||
"op_bytes": {},
|
||||
}
|
||||
self.histograms: Dict[str, Dict[int, int]] = {
|
||||
"blit": {}, "wait": {}, "work": {}, "interval_per_hold": {},
|
||||
@@ -305,6 +332,21 @@ class FrameTimingRecorder:
|
||||
|
||||
# -- render thread ------------------------------------------------------
|
||||
|
||||
def note_op(self, kind: str, nbytes: int = 0) -> None:
|
||||
"""Tag the next presented frame with work done before it.
|
||||
|
||||
Render thread only, like :meth:`record`, which consumes the tag: the
|
||||
interval the next frame ends is the one this work landed in. Several
|
||||
notes before one frame accumulate, per kind. See "Operations".
|
||||
|
||||
:param kind: a short name for the work, e.g. ``"extend"``, ``"patch"``.
|
||||
:param nbytes: how much the work moved, summed into ``op_bytes``.
|
||||
"""
|
||||
ops = self._ops
|
||||
if ops is None:
|
||||
ops = self._ops = {}
|
||||
ops[kind] = ops.get(kind, 0) + int(nbytes)
|
||||
|
||||
def record(self, blit: float, wait: float, hold: int, scrolling: bool,
|
||||
presented_at: float) -> None:
|
||||
"""One frame reached the panel.
|
||||
@@ -318,6 +360,7 @@ class FrameTimingRecorder:
|
||||
previous = self._previous
|
||||
self._previous = (presented_at, scrolling, hold)
|
||||
self.last_frame = (presented_at, scrolling, threading.get_ident())
|
||||
ops, self._ops = self._ops, None
|
||||
if not scrolling:
|
||||
self._static_frames += 1
|
||||
# The scroll ended, or its state went missing for this frame: the
|
||||
@@ -325,7 +368,8 @@ class FrameTimingRecorder:
|
||||
# state, so the interval is due at the scroll's own.
|
||||
self._unsure = None
|
||||
if previous is not None and previous[1]:
|
||||
self._unsure = (presented_at - previous[0], blit, wait, previous[2])
|
||||
self._unsure = (presented_at - previous[0], blit, wait,
|
||||
previous[2], ops)
|
||||
elif self.watchdog is None and self.scrolling_now is not None \
|
||||
and os.environ.get("LEDMATRIX_STALL_WATCHDOG", "1") != "0":
|
||||
self.watchdog = StallWatchdog(self, **watchdog_settings())
|
||||
@@ -335,14 +379,14 @@ class FrameTimingRecorder:
|
||||
unsure, self._unsure = self._unsure, None
|
||||
if previous[1]:
|
||||
if interval < GAP_SECONDS:
|
||||
self._pending.append((interval, blit, wait, hold))
|
||||
self._pending.append((interval, blit, wait, hold, ops))
|
||||
elif unsure is not None and interval < RESUME_SECONDS:
|
||||
# One static frame between two scrolling ones: the scroll never
|
||||
# stopped, only its state did. Both intervals were motion.
|
||||
self._static_frames -= 1
|
||||
if unsure[0] < GAP_SECONDS:
|
||||
self._pending.append(unsure)
|
||||
self._pending.append((interval, blit, wait, hold))
|
||||
self._pending.append((interval, blit, wait, hold, ops))
|
||||
|
||||
if self._last_flush is None:
|
||||
self._last_flush = presented_at
|
||||
@@ -382,15 +426,17 @@ class FrameTimingRecorder:
|
||||
except Exception: # never let telemetry take anything down
|
||||
logger.debug("Frame timing flush failed", exc_info=True)
|
||||
|
||||
def aggregate(self, batch: List[Tuple[float, float, float, int]],
|
||||
static: int) -> None:
|
||||
"""Fold one window of frames into the running totals."""
|
||||
def aggregate(self, batch: List[_Frame], static: int) -> None:
|
||||
"""Fold one window of frames into the running totals.
|
||||
|
||||
Each frame is ``(interval, blit, wait, hold, ops)``; ``ops`` (the work
|
||||
noted before it, or None) may be left off.
|
||||
"""
|
||||
totals = self.totals
|
||||
totals["static_frames"] += static
|
||||
|
||||
per_hold = sorted(interval / max(1, hold)
|
||||
for interval, _, _, hold in batch
|
||||
if interval < FREEZE_SECONDS)
|
||||
per_hold = sorted(frame[0] / max(1, frame[3]) for frame in batch
|
||||
if frame[0] < FREEZE_SECONDS)
|
||||
if len(per_hold) >= MIN_FRAMES_FOR_REFRESH:
|
||||
estimate = per_hold[len(per_hold) // 10]
|
||||
current = self.refresh_period
|
||||
@@ -412,15 +458,22 @@ class FrameTimingRecorder:
|
||||
period = self.refresh_period
|
||||
|
||||
histograms = self.histograms
|
||||
for interval, blit, wait, hold in batch:
|
||||
for frame in batch:
|
||||
interval, blit, wait, hold = frame[:4]
|
||||
ops = frame[4] if len(frame) > 4 else None
|
||||
totals["worst_interval_ms"] = max(totals["worst_interval_ms"],
|
||||
interval * 1000.0)
|
||||
if ops:
|
||||
for kind, nbytes in ops.items():
|
||||
_bump(totals["op_bytes"], kind, nbytes)
|
||||
if interval >= FREEZE_SECONDS:
|
||||
totals["freezes"] += 1
|
||||
totals["freeze_seconds"] += interval
|
||||
label = next(name for limit, name in FREEZE_BUCKETS
|
||||
if interval < limit)
|
||||
totals["freeze_by"][label] += 1
|
||||
for kind in ops or ():
|
||||
_bump(totals["op_freezes"], kind)
|
||||
continue
|
||||
totals["scroll_frames"] += 1
|
||||
for name, value in (("blit", blit), ("wait", wait),
|
||||
@@ -432,6 +485,10 @@ class FrameTimingRecorder:
|
||||
if period:
|
||||
totals["timed_frames"] += 1
|
||||
missed = round(interval / period) - hold
|
||||
for kind in ops or ():
|
||||
_bump(totals["op_frames"], kind)
|
||||
if missed >= 1:
|
||||
_bump(totals["late_op_frames"], kind)
|
||||
if missed >= 1:
|
||||
totals["late_frames"] += 1
|
||||
totals["missed_refreshes"] += missed
|
||||
|
||||
+107
-19
@@ -111,7 +111,7 @@ class ScrollHelper:
|
||||
self.total_distance_scrolled = 0.0 # Track total distance including wrap-arounds
|
||||
self.scroll_speed = 1.0
|
||||
self.scroll_delay = 0.001 # Minimal delay for high FPS (1ms)
|
||||
self.cached_image: Optional[Image.Image] = None
|
||||
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
|
||||
|
||||
@@ -172,7 +172,53 @@ class ScrollHelper:
|
||||
# Scrolling state management
|
||||
self.is_scrolling = False
|
||||
self.scroll_complete = False
|
||||
|
||||
|
||||
# -- the strip as a PIL image ---------------------------------------------
|
||||
#
|
||||
# Every frame is cut from cached_array; nothing on the frame path reads the
|
||||
# PIL image's pixels. Extending and trimming a strip (append_content,
|
||||
# drop_scrolled_prefix) used to rebuild that image in full each time
|
||||
# anyway: Image.fromarray of a Vegas-sized strip is 1.7-3.8ms on a Pi 4,
|
||||
# twice per extension, on the render thread. Those two now leave it to be
|
||||
# built from the array on first read, which in Vegas means only by a
|
||||
# multi-display sync push -- and the strip is not held twice in memory.
|
||||
#
|
||||
# Assigning cached_image still stores exactly what was assigned; a lazy
|
||||
# image is only ever one the helper derived from its own array.
|
||||
|
||||
@property
|
||||
def cached_image(self) -> Optional[Image.Image]:
|
||||
"""The strip as a PIL image, built from ``cached_array`` if deferred."""
|
||||
image = self.__dict__.get('_cached_image')
|
||||
if image is not None:
|
||||
return image
|
||||
source = self.__dict__.get('_image_source')
|
||||
if source is None:
|
||||
return None
|
||||
# Built from the array this read started with. Another thread (the
|
||||
# sync push) may read while the render thread extends the strip; it
|
||||
# then gets the strip as it was, as it did when the image was built
|
||||
# eagerly, and the stale build is not kept.
|
||||
image = Image.fromarray(source)
|
||||
if self.__dict__.get('_image_source') is source:
|
||||
self._cached_image = image
|
||||
return image
|
||||
|
||||
@cached_image.setter
|
||||
def cached_image(self, image: Optional[Image.Image]) -> None:
|
||||
self._cached_image = image
|
||||
self._image_source = None
|
||||
|
||||
def _defer_image(self) -> None:
|
||||
"""The array just changed under the image: rebuild it only if read."""
|
||||
self._cached_image = None
|
||||
self._image_source = self.cached_array
|
||||
|
||||
def has_strip(self) -> bool:
|
||||
"""Whether there is a strip (an image, or one deferred), not reading it."""
|
||||
return (self.__dict__.get('_cached_image') is not None
|
||||
or self.__dict__.get('_image_source') is not None)
|
||||
|
||||
def create_scrolling_image(self, content_items: list,
|
||||
item_gap: int = 32,
|
||||
element_gap: int = 16,
|
||||
@@ -283,7 +329,7 @@ class ScrollHelper:
|
||||
Otherwise the position advances by elapsed time at the configured
|
||||
speed.
|
||||
"""
|
||||
if not self.cached_image:
|
||||
if not self.has_strip():
|
||||
return
|
||||
|
||||
# Calculate frame time for consistent scroll speed regardless of FPS
|
||||
@@ -427,7 +473,7 @@ class ScrollHelper:
|
||||
Returns:
|
||||
PIL Image showing the visible portion, or None if no cached image
|
||||
"""
|
||||
if not self.cached_image or self.cached_array is None:
|
||||
if self.cached_array is None or not self.has_strip():
|
||||
return None
|
||||
|
||||
start_x_int = int(self.scroll_position)
|
||||
@@ -501,7 +547,7 @@ class ScrollHelper:
|
||||
slices (128×32 = 12 KB) used here.
|
||||
"""
|
||||
_size = (self.display_width, self.display_height)
|
||||
img_w = self.cached_image.width
|
||||
img_w = self.cached_array.shape[1]
|
||||
|
||||
if end_x <= img_w:
|
||||
# Normal case: single contiguous slice (fastest path)
|
||||
@@ -646,7 +692,7 @@ class ScrollHelper:
|
||||
if not content_items:
|
||||
return False
|
||||
|
||||
if self.cached_image is None or self.cached_array is None:
|
||||
if self.cached_array is None or not self.has_strip():
|
||||
# Nothing to extend yet — this is just the first build.
|
||||
self.create_scrolling_image(
|
||||
content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0)
|
||||
@@ -666,13 +712,14 @@ class ScrollHelper:
|
||||
addition.paste(img, (x, 0))
|
||||
x += img.width + element_gap
|
||||
|
||||
# numpy concatenate then one conversion back, rather than allocating a
|
||||
# full-width PIL image and pasting twice: the strip can be tens of
|
||||
# thousands of columns wide and this runs on the render path.
|
||||
# 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)
|
||||
self.cached_image = Image.fromarray(self.cached_array)
|
||||
self.total_scroll_width = self.cached_image.width
|
||||
self._defer_image()
|
||||
self.total_scroll_width = self.cached_array.shape[1]
|
||||
self.scroll_complete = False
|
||||
|
||||
self.logger.info(
|
||||
@@ -699,14 +746,15 @@ class ScrollHelper:
|
||||
Returns:
|
||||
Number of columns actually removed
|
||||
"""
|
||||
if self.cached_image is None or self.cached_array is None:
|
||||
if self.cached_array is None or not self.has_strip():
|
||||
return 0
|
||||
strip_width = self.cached_array.shape[1]
|
||||
|
||||
# While the viewport wraps, get_visible_portion fills its right-hand side
|
||||
# from the *head* of the strip, so trimming the head would change what
|
||||
# is on screen. Continuous mode extends before ever reaching that state;
|
||||
# refusing here keeps "trimming is invisible" true unconditionally.
|
||||
if self.scroll_position + self.display_width > self.cached_image.width:
|
||||
if self.scroll_position + self.display_width > strip_width:
|
||||
return 0
|
||||
|
||||
cut = int(self.scroll_position) - max(0, keep_before)
|
||||
@@ -714,15 +762,15 @@ class ScrollHelper:
|
||||
return 0
|
||||
# Never trim so far that the remaining strip is narrower than the
|
||||
# viewport, or get_visible_portion has nothing to slice.
|
||||
cut = min(cut, max(0, self.cached_image.width - self.display_width))
|
||||
cut = min(cut, max(0, strip_width - self.display_width))
|
||||
if cut <= 0:
|
||||
return 0
|
||||
|
||||
# .copy() so the original buffer is released rather than kept alive by
|
||||
# a numpy view.
|
||||
# a numpy view. The PIL image is deferred, as in append_content.
|
||||
self.cached_array = self.cached_array[:, cut:].copy()
|
||||
self.cached_image = Image.fromarray(self.cached_array)
|
||||
self.total_scroll_width = self.cached_image.width
|
||||
self._defer_image()
|
||||
self.total_scroll_width = self.cached_array.shape[1]
|
||||
self.scroll_position -= cut
|
||||
self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut)
|
||||
|
||||
@@ -732,9 +780,46 @@ class ScrollHelper:
|
||||
)
|
||||
return cut
|
||||
|
||||
def patch_columns(self, x: int, pixels: np.ndarray) -> int:
|
||||
"""Overwrite the strip's columns from ``x`` with ``pixels``, in place.
|
||||
|
||||
What a live Vegas element update is (src/vegas_mode/elements.py): the
|
||||
strip keeps its width, the scroll keeps its position, and only these
|
||||
columns change. Call it between frames on the thread that draws them;
|
||||
every frame copies its slice out of the strip (get_visible_portion),
|
||||
so no frame already handed on can see half a patch.
|
||||
|
||||
Clipped to the strip at both ends. Refused (0) for an array this
|
||||
helper may not write -- the multi-display follower adopts a read-only
|
||||
one -- or for pixels of another height. The PIL image is deferred, so
|
||||
a later read of cached_image shows the patch.
|
||||
|
||||
Args:
|
||||
x: Strip column of the first column of ``pixels``
|
||||
pixels: uint8 array (height, width, 3)
|
||||
|
||||
Returns:
|
||||
Bytes written.
|
||||
"""
|
||||
strip = self.cached_array
|
||||
if strip is None or not strip.flags.writeable:
|
||||
return 0
|
||||
if pixels.ndim != 3 or pixels.shape[0] != strip.shape[0] \
|
||||
or pixels.shape[2] != strip.shape[2]:
|
||||
return 0
|
||||
width = pixels.shape[1]
|
||||
lo, hi = max(0, int(x)), min(strip.shape[1], int(x) + width)
|
||||
if hi <= lo:
|
||||
return 0
|
||||
strip[:, lo:hi] = pixels[:, lo - int(x):hi - int(x)]
|
||||
if self.__dict__.get('_cached_image') is not None \
|
||||
or self.__dict__.get('_image_source') is not None:
|
||||
self._defer_image()
|
||||
return (hi - lo) * strip.shape[0] * strip.shape[2]
|
||||
|
||||
def remaining_unscrolled(self) -> int:
|
||||
"""Columns of strip still to the right of the viewport."""
|
||||
if self.cached_image is None:
|
||||
if not self.has_strip():
|
||||
return 0
|
||||
return max(0, self.total_scroll_width - int(self.scroll_position)
|
||||
- self.display_width)
|
||||
@@ -1082,5 +1167,8 @@ class ScrollHelper:
|
||||
'elapsed_time': (time.time() - self.scroll_start_time)
|
||||
if self.scroll_start_time
|
||||
else None,
|
||||
'cached_image_size': (self.cached_image.width, self.cached_image.height) if self.cached_image else None
|
||||
# From the array: reading cached_image would build a deferred one.
|
||||
'cached_image_size': ((self.cached_array.shape[1], self.cached_array.shape[0])
|
||||
if self.cached_array is not None and self.has_strip()
|
||||
else None)
|
||||
}
|
||||
|
||||
+184
-2
@@ -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] = []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -915,8 +915,7 @@ class DisplayManager:
|
||||
``display.dirty_tracking: false`` if a redraw issue is ever suspected.
|
||||
|
||||
Serialized via ``_update_lock``: plugins can call this directly from
|
||||
background threads (e.g. sports base classes push an immediate
|
||||
"live" refresh from inside update()), so without a lock two callers
|
||||
background threads of their own, so without a lock two callers
|
||||
could both pass the digest check before either writes it back,
|
||||
double-pushing a frame, or interleave the offscreen/current canvas
|
||||
swap below. The lock is scoped to this method, so callers never
|
||||
|
||||
@@ -15,6 +15,8 @@ import os
|
||||
import sys
|
||||
from src.deprecation import deprecated, warn_deprecated
|
||||
from src.logging_config import get_logger
|
||||
# Re-exported: a plugin may import it from here beside VegasDisplayMode.
|
||||
from src.plugin_system.vegas_elements import VegasElement # noqa: F401
|
||||
|
||||
|
||||
_shared_fallback_font_manager: Optional[Any] = None
|
||||
@@ -986,6 +988,88 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_vegas_elements(self) -> Optional[List[Any]]:
|
||||
"""
|
||||
Vegas content as live elements: named, fixed-width pieces the ticker
|
||||
can swap in place while they are on screen.
|
||||
|
||||
get_vegas_content() hands the ticker pictures, and a picture already
|
||||
in the scrolling strip keeps what it showed when it was drawn. Return
|
||||
a list of ``VegasElement`` (src/plugin_system/vegas_elements.py)
|
||||
instead and the ticker records where each one is; after your update()
|
||||
it calls this again, compares each element's ``version`` (or pixels)
|
||||
with what the strip holds, and swaps the changed ones in between two
|
||||
frames -- a score changes on a card already crossing the panel, and
|
||||
nothing next to it moves.
|
||||
|
||||
The contract:
|
||||
|
||||
- Called only on the ticker's background thread, under this plugin's
|
||||
lock (never while update() runs), on a canvas of its own and told
|
||||
its width (get_vegas_render_width()), like get_vegas_content().
|
||||
- Called often -- after every update() while any of your elements is
|
||||
on or ahead of the screen -- so it must be cheap when nothing
|
||||
changed (cache images by version), idempotent, and must not fetch.
|
||||
- A live element's width must not depend on its data: a redraw at a
|
||||
different width is not swapped in (it shows the next time the
|
||||
plugin comes round).
|
||||
- Keys must be unique in the list and stable for the same logical
|
||||
item.
|
||||
|
||||
Return None (the default) to use get_vegas_content(). A core older
|
||||
than 3.8.0 never calls this, so keep get_vegas_content() working and
|
||||
floor ``ledmatrix_min_version`` at 3.8.0 only if you rely on it.
|
||||
|
||||
Example (scoreboard)::
|
||||
|
||||
def get_vegas_elements(self):
|
||||
return [VegasElement(key=f"game:{g['id']}",
|
||||
image=self._card_for(g), # cached by fingerprint
|
||||
version=self._fingerprint(g))
|
||||
for g in self.games]
|
||||
|
||||
Returns:
|
||||
A list of VegasElement, or None.
|
||||
"""
|
||||
return None
|
||||
|
||||
def redraw_vegas_element(self, key: str, width: int, height: int,
|
||||
at: float) -> Optional[Any]:
|
||||
"""
|
||||
Redraw one live element for a moment in time, without the plugin lock.
|
||||
|
||||
Only for elements returned with ``refresh_hz > 0``: content that
|
||||
changes with time rather than with data, such as an aircraft moving
|
||||
between position reports. The ticker calls it up to that often while
|
||||
the element is on or near the screen.
|
||||
|
||||
- Called WITHOUT this plugin's lock, possibly while update() runs, so
|
||||
read only state that update() replaces in a single assignment (an
|
||||
immutable snapshot), never state it mutates in place.
|
||||
- ``at`` is the time.monotonic() at which the pixels are expected to
|
||||
reach the panel; draw the element as it should look then.
|
||||
- Return an image of exactly ``width`` x ``height``, or None to skip
|
||||
this tick.
|
||||
|
||||
Returns:
|
||||
PIL Image of exactly (width, height), or None.
|
||||
"""
|
||||
return None
|
||||
|
||||
def notify_vegas_data_changed(self) -> None:
|
||||
"""
|
||||
Tell the Vegas ticker this plugin's data changed outside update().
|
||||
|
||||
The ticker redraws a plugin's live elements when its update()
|
||||
completes. Data that lands some other way -- a background thread, a
|
||||
push callback -- calls this so the change reaches the screen without
|
||||
waiting for the next update(). Cheap and safe from any thread.
|
||||
"""
|
||||
notify = getattr(getattr(self, 'plugin_manager', None),
|
||||
'notify_data_changed', None)
|
||||
if callable(notify):
|
||||
notify(self.plugin_id)
|
||||
|
||||
def get_vegas_participation(self) -> str:
|
||||
"""
|
||||
How this plugin takes part in Vegas mode: ``'scroll'``, ``'pause'`` or
|
||||
|
||||
@@ -16,7 +16,7 @@ import time
|
||||
import threading
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, NamedTuple, Optional, Any, Tuple, Union
|
||||
from typing import Callable, Dict, List, NamedTuple, Optional, Any, Tuple, Union
|
||||
import logging
|
||||
from src import display_watchdog
|
||||
from src.exceptions import PluginError, ConfigError
|
||||
@@ -197,6 +197,11 @@ class PluginManager:
|
||||
# run_scheduled_updates_with_changes().
|
||||
self._completed_updates: set = set()
|
||||
self._completed_updates_lock = threading.Lock()
|
||||
# Called with a plugin id the moment its data may have changed: its
|
||||
# update() completed, or it called notify_vegas_data_changed(). See
|
||||
# add_update_listener(). A tuple, replaced rather than mutated, so the
|
||||
# worker can iterate it without a lock.
|
||||
self._update_listeners: Tuple[Callable[[str], None], ...] = ()
|
||||
# Config changes that found the plugin's lock busy, latest per plugin,
|
||||
# with the instance they were meant for. See apply_config_change().
|
||||
self._deferred_config_changes: Dict[str, Tuple[Any, Dict[str, Any]]] = {}
|
||||
@@ -589,8 +594,8 @@ class PluginManager:
|
||||
#: prefix rule would silently stop validating it.
|
||||
#:
|
||||
#: Read by: ``vegas_mode/plugin_adapter.py`` (``vegas_width_pct``,
|
||||
#: ``vegas_overflow``) and ``base_plugin.py`` (``vegas_max_width_screens``,
|
||||
#: ``vegas_participation``).
|
||||
#: ``vegas_overflow``, ``vegas_live``) and ``base_plugin.py``
|
||||
#: (``vegas_max_width_screens``, ``vegas_participation``).
|
||||
#:
|
||||
#: The list itself lives with the other core-owned per-plugin properties in
|
||||
#: ``schema_manager.CORE_PLUGIN_PROPERTIES``, which the web save path also
|
||||
@@ -1723,9 +1728,53 @@ class PluginManager:
|
||||
return self.drain_completed_updates()
|
||||
|
||||
def _note_update_completed(self, plugin_id: str) -> None:
|
||||
"""Record that a plugin's update() finished, for the next poll."""
|
||||
"""Record that a plugin's update() finished, for the next poll.
|
||||
|
||||
Also tells the update listeners at once, so Vegas live elements are
|
||||
redrawn the moment new data lands instead of at the next ~4s poll.
|
||||
This runs while the plugin's lock is still held (see _finish), which
|
||||
is what makes the listeners' contract strict.
|
||||
"""
|
||||
with self._completed_updates_lock:
|
||||
self._completed_updates.add(plugin_id)
|
||||
self._fire_update_listeners(plugin_id)
|
||||
|
||||
def add_update_listener(self, listener: Callable[[str], None]) -> None:
|
||||
"""Call ``listener(plugin_id)`` whenever a plugin's data may have changed.
|
||||
|
||||
That is: its update() completed successfully, or it called
|
||||
notify_vegas_data_changed(). The listener runs on the thread that
|
||||
noticed -- the update worker, with the plugin's lock still held, or
|
||||
the plugin's own thread -- so it must return at once and take no lock
|
||||
a plugin could hold: record the id and hand off (a dict store, a
|
||||
queue put). An exception from it is logged and does not reach the
|
||||
plugin. Adding the same listener twice has no effect.
|
||||
"""
|
||||
# __dict__.get: tests build bare managers with PluginManager.__new__.
|
||||
listeners = self.__dict__.get('_update_listeners', ())
|
||||
if listener not in listeners:
|
||||
self._update_listeners = listeners + (listener,)
|
||||
|
||||
def remove_update_listener(self, listener: Callable[[str], None]) -> None:
|
||||
"""Stop calling a listener added with add_update_listener()."""
|
||||
self._update_listeners = tuple(
|
||||
fn for fn in self.__dict__.get('_update_listeners', ()) if fn != listener)
|
||||
|
||||
def notify_data_changed(self, plugin_id: str) -> None:
|
||||
"""A plugin's data changed outside update(); tell the update listeners.
|
||||
|
||||
BasePlugin.notify_vegas_data_changed() lands here.
|
||||
"""
|
||||
self._fire_update_listeners(plugin_id)
|
||||
|
||||
def _fire_update_listeners(self, plugin_id: str) -> None:
|
||||
for listener in self.__dict__.get('_update_listeners', ()):
|
||||
try:
|
||||
listener(plugin_id)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self._warn_rate_limited(
|
||||
"update-listener",
|
||||
"An update listener failed for plugin %s: %r", plugin_id, exc)
|
||||
|
||||
def drain_completed_updates(self) -> List[str]:
|
||||
"""Return and clear the plugin ids whose update() has since finished."""
|
||||
|
||||
@@ -155,6 +155,18 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
||||
"Leave unset to use the plugin's own default."
|
||||
),
|
||||
},
|
||||
# Read by vegas_mode/plugin_adapter.py (PluginAdapter.is_live_capable).
|
||||
# No default, for the same reason: unset means on.
|
||||
"vegas_live": {
|
||||
"type": "boolean",
|
||||
"title": "Update in the Vegas ticker",
|
||||
"description": (
|
||||
"Vegas mode: for a plugin with live elements (scores, the flight "
|
||||
"map), change what is already scrolling when its data changes. "
|
||||
"Off shows each card as it was when it was drawn, as before. "
|
||||
"Leave unset for on."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
#: The keys of CORE_PLUGIN_PROPERTIES that are Vegas tuning rather than plugin
|
||||
@@ -162,7 +174,7 @@ CORE_PLUGIN_PROPERTIES: Dict[str, Dict[str, Any]] = {
|
||||
#: PluginManager.CORE_OWNED_CONFIG_KEYS).
|
||||
CORE_VEGAS_TUNING_KEYS = frozenset({
|
||||
'vegas_width_pct', 'vegas_overflow', 'vegas_max_width_screens',
|
||||
'vegas_participation',
|
||||
'vegas_participation', 'vegas_live',
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from datetime import timedelta
|
||||
import socket
|
||||
import ssl
|
||||
import urllib.error
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
@@ -84,6 +84,8 @@ class RenderResult:
|
||||
fill_checked: bool = False
|
||||
fill_ok: Optional[bool] = None # False only in strict mode
|
||||
fill_extent: Optional[Tuple[float, float]] = None # (extent_x, extent_y)
|
||||
# warnings worth printing that do not fail the result
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def size_label(self) -> str:
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
"""Offline checks for a plugin's live Vegas elements.
|
||||
|
||||
A plugin that implements ``get_vegas_elements()`` promises the ticker a few
|
||||
things it cannot check for itself until they go wrong on a panel: unique,
|
||||
stable keys; images at the display's height; the same width for the same key
|
||||
until the data changes; the same result when nothing changed; and, for an
|
||||
element with ``refresh_hz``, a ``redraw_vegas_element()`` that returns exactly
|
||||
the size asked for, quickly. :func:`check_vegas_elements` exercises each of
|
||||
those the way the Vegas ticker calls the hooks -- on a canvas of the plugin's
|
||||
own, told its render width -- and says what failed.
|
||||
|
||||
``scripts/check_plugin.py`` runs it for every plugin that implements the hook.
|
||||
See "Live Vegas elements" in docs/PLUGIN_API_REFERENCE.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterator, List, Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
#: A warm get_vegas_elements() slower than this holds the ticker's single
|
||||
#: background worker, and the plugin's lock, for longer than it should.
|
||||
SLOW_ELEMENTS_SECONDS = 0.2
|
||||
#: A redraw_vegas_element() slower than this cannot keep up with a few Hz.
|
||||
SLOW_REDRAW_SECONDS = 0.02
|
||||
#: The narrowed render width the check also tries, as a share of the panel.
|
||||
NARROW_PCT = 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class VegasElementReport:
|
||||
"""What :func:`check_vegas_elements` found."""
|
||||
implemented: bool
|
||||
elements: int = 0
|
||||
live: int = 0
|
||||
errors: List[str] = field(default_factory=list)
|
||||
warnings: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.errors
|
||||
|
||||
|
||||
def implements_vegas_elements(plugin: Any) -> bool:
|
||||
"""Whether the plugin's class overrides BasePlugin.get_vegas_elements."""
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
method = getattr(type(plugin), 'get_vegas_elements', None)
|
||||
return method is not None and method is not getattr(
|
||||
BasePlugin, 'get_vegas_elements', None)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _as_vegas_canvas(plugin: Any, display_manager: Any, width: int) -> Iterator[None]:
|
||||
"""Run a hook the way Vegas does: told its width, on a canvas of its own."""
|
||||
plugin._vegas_render_width = width
|
||||
try:
|
||||
offscreen = getattr(display_manager, 'offscreen', None)
|
||||
with offscreen(width) if offscreen is not None else nullcontext():
|
||||
yield
|
||||
finally:
|
||||
plugin._vegas_render_width = None
|
||||
|
||||
|
||||
def render_vegas_elements(plugin: Any, display_manager: Any,
|
||||
width: Optional[int] = None) -> Any:
|
||||
"""Call ``plugin.get_vegas_elements()`` as the Vegas ticker does."""
|
||||
render_width = int(width or display_manager.width)
|
||||
with _as_vegas_canvas(plugin, display_manager, render_width):
|
||||
return plugin.get_vegas_elements()
|
||||
|
||||
|
||||
def redraw_vegas_element(plugin: Any, display_manager: Any, key: str,
|
||||
width: int, height: int, at: Optional[float] = None,
|
||||
render_width: Optional[int] = None) -> Any:
|
||||
"""Call ``plugin.redraw_vegas_element()`` as the Vegas ticker does."""
|
||||
with _as_vegas_canvas(plugin, display_manager,
|
||||
int(render_width or display_manager.width)):
|
||||
return plugin.redraw_vegas_element(
|
||||
key, width, height, time.monotonic() if at is None else at)
|
||||
|
||||
|
||||
def _refresh_hz(element: Any) -> Optional[float]:
|
||||
"""An element's refresh_hz as a number (None counts as 0), or None if it is not one."""
|
||||
try:
|
||||
return float(element.refresh_hz or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _usable(element: Any) -> bool:
|
||||
"""A VegasElement the checks can read: a str key and an image."""
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
return (isinstance(element, VegasElement) and isinstance(element.key, str)
|
||||
and bool(element.key) and isinstance(element.image, Image.Image))
|
||||
|
||||
|
||||
def check_vegas_elements(plugin: Any, display_manager: Any) -> VegasElementReport:
|
||||
"""Exercise a plugin's live-element hooks and report what breaks the contract.
|
||||
|
||||
Errors are what the ticker would refuse or show wrongly; warnings are what
|
||||
it would cope with but should not have to (slow calls, an element wider
|
||||
than the width the plugin was asked to render at).
|
||||
"""
|
||||
report = VegasElementReport(implemented=implements_vegas_elements(plugin))
|
||||
if not report.implemented:
|
||||
return report
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
|
||||
height = int(display_manager.height)
|
||||
full = int(display_manager.width)
|
||||
|
||||
def fetch(width: int, label: str):
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
result = render_vegas_elements(plugin, display_manager, width)
|
||||
except Exception as exc: # noqa: BLE001 - a plugin hook can raise anything
|
||||
report.errors.append(f"get_vegas_elements() raised {exc!r} ({label})")
|
||||
return None, 0.0
|
||||
return result, time.perf_counter() - started
|
||||
|
||||
first, _ = fetch(full, "full width")
|
||||
if first is None:
|
||||
if not report.errors:
|
||||
report.warnings.append(
|
||||
"get_vegas_elements() returned None: the ticker will use "
|
||||
"get_vegas_content() instead")
|
||||
return report
|
||||
if not isinstance(first, (list, tuple)):
|
||||
report.errors.append(
|
||||
f"get_vegas_elements() returned {type(first).__name__}, expected a list")
|
||||
return report
|
||||
|
||||
seen = set()
|
||||
widths = {}
|
||||
for index, element in enumerate(first):
|
||||
where = f"element[{index}]"
|
||||
if not isinstance(element, VegasElement):
|
||||
report.errors.append(f"{where} is a {type(element).__name__}, not a VegasElement")
|
||||
continue
|
||||
key = element.key
|
||||
if not isinstance(key, str) or not key:
|
||||
report.errors.append(f"{where} has no key (a non-empty str is required)")
|
||||
continue
|
||||
where = f"element {key!r}"
|
||||
if key in seen:
|
||||
report.errors.append(f"{where} appears twice; keys must be unique")
|
||||
continue
|
||||
seen.add(key)
|
||||
image: Any = element.image # typed Image, but a plugin may pass anything
|
||||
if not isinstance(image, Image.Image):
|
||||
report.errors.append(f"{where} image is a {type(image).__name__}")
|
||||
continue
|
||||
if element.image.height != height:
|
||||
report.errors.append(
|
||||
f"{where} is {element.image.height}px tall; the display is {height}px")
|
||||
if element.image.width <= 0 or element.image.height <= 0:
|
||||
report.errors.append(f"{where} image is empty ({element.image.width}x"
|
||||
f"{element.image.height})")
|
||||
continue
|
||||
if element.image.width > full:
|
||||
report.warnings.append(
|
||||
f"{where} is {element.image.width}px wide, wider than the "
|
||||
f"{full}px it was asked to render at")
|
||||
hz = _refresh_hz(element)
|
||||
if hz is None:
|
||||
report.errors.append(
|
||||
f"{where} refresh_hz {element.refresh_hz!r} is not a number")
|
||||
elif hz < 0:
|
||||
report.errors.append(f"{where} has a negative refresh_hz")
|
||||
report.elements += 1
|
||||
if element.live:
|
||||
report.live += 1
|
||||
widths[key] = element.image.width
|
||||
|
||||
if report.errors:
|
||||
return report
|
||||
|
||||
# The same data twice must give the same keys, widths and versions: the
|
||||
# ticker redraws on every update and swaps in only what changed.
|
||||
second, warm = fetch(full, "second call")
|
||||
if second is not None and not isinstance(second, (list, tuple)):
|
||||
report.errors.append(
|
||||
f"get_vegas_elements() returned {type(second).__name__} on a second "
|
||||
"call, expected a list")
|
||||
elif isinstance(second, (list, tuple)):
|
||||
again = {e.key: e for e in second if _usable(e)}
|
||||
for element in first:
|
||||
other = again.get(element.key)
|
||||
if other is None:
|
||||
report.errors.append(
|
||||
f"element {element.key!r} disappeared on a second call with "
|
||||
"no new data")
|
||||
continue
|
||||
if element.live and other.image.width != element.image.width:
|
||||
report.errors.append(
|
||||
f"element {element.key!r} changed width with no new data "
|
||||
f"({element.image.width} -> {other.image.width}px); a live "
|
||||
"element's width must not depend on when it is drawn")
|
||||
if element.version is not None and other.version != element.version \
|
||||
and not _refresh_hz(element):
|
||||
report.warnings.append(
|
||||
f"element {element.key!r} changed version with no new data; "
|
||||
"every update will redraw it")
|
||||
if warm > SLOW_ELEMENTS_SECONDS:
|
||||
report.warnings.append(
|
||||
f"get_vegas_elements() took {warm * 1000:.0f}ms with nothing new "
|
||||
f"(over {SLOW_ELEMENTS_SECONDS * 1000:.0f}ms); cache what has not "
|
||||
"changed")
|
||||
|
||||
narrow = max(1, full * NARROW_PCT // 100)
|
||||
if narrow < full:
|
||||
narrowed, _ = fetch(narrow, f"{NARROW_PCT}% width")
|
||||
if isinstance(narrowed, (list, tuple)):
|
||||
for element in narrowed:
|
||||
if isinstance(element, VegasElement) and isinstance(element.image, Image.Image) \
|
||||
and element.image.width > narrow:
|
||||
report.warnings.append(
|
||||
f"element {element.key!r} is {element.image.width}px wide at "
|
||||
f"a {narrow}px render width; read get_vegas_render_width() "
|
||||
"or display_manager.width when sizing it")
|
||||
break
|
||||
|
||||
has_redraw = type(plugin).redraw_vegas_element is not _base_redraw()
|
||||
for element in first:
|
||||
hz = _refresh_hz(element) or 0.0
|
||||
if not (element.live and hz > 0):
|
||||
continue
|
||||
if not has_redraw:
|
||||
report.warnings.append(
|
||||
f"element {element.key!r} asks for {hz:g}Hz but "
|
||||
"redraw_vegas_element() is not implemented; the ticker re-runs "
|
||||
"get_vegas_elements() under the plugin's lock instead")
|
||||
continue
|
||||
w, h = element.image.width, element.image.height
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
redrawn = redraw_vegas_element(plugin, display_manager, element.key, w, h)
|
||||
except Exception as exc: # noqa: BLE001 - a plugin hook can raise anything
|
||||
report.errors.append(f"redraw_vegas_element({element.key!r}) raised {exc!r}")
|
||||
continue
|
||||
took = time.perf_counter() - started
|
||||
if redrawn is not None:
|
||||
if not isinstance(redrawn, Image.Image):
|
||||
report.errors.append(
|
||||
f"redraw_vegas_element({element.key!r}) returned "
|
||||
f"{type(redrawn).__name__}, expected an Image or None")
|
||||
elif redrawn.size != (w, h):
|
||||
report.errors.append(
|
||||
f"redraw_vegas_element({element.key!r}) returned "
|
||||
f"{redrawn.width}x{redrawn.height}, asked for {w}x{h}")
|
||||
if took > SLOW_REDRAW_SECONDS:
|
||||
report.warnings.append(
|
||||
f"redraw_vegas_element({element.key!r}) took {took * 1000:.1f}ms "
|
||||
f"(over {SLOW_REDRAW_SECONDS * 1000:.0f}ms); the ticker will "
|
||||
"slow its refresh")
|
||||
return report
|
||||
|
||||
|
||||
def _base_redraw():
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
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:
|
||||
"""Load a plugin from its directory at one panel size and check its elements.
|
||||
|
||||
What ``scripts/check_plugin.py`` runs: the plugin gets the same mocked
|
||||
managers as the rendering harness, and its update() is run first (a
|
||||
network error there is tolerated, as in the harness) so the elements are
|
||||
drawn from data rather than from an empty start.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from src.plugin_system.testing.harness import _TOLERATED_UPDATE_ERRORS, _instantiate
|
||||
from src.plugin_system.testing.loading import load_manifest
|
||||
from src.plugin_system.testing.visual_display_manager import VisualTestDisplayManager
|
||||
|
||||
plugin_dir = Path(plugin_dir)
|
||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||
try:
|
||||
plugin = _instantiate(plugin_id, load_manifest(plugin_dir), plugin_dir,
|
||||
config, mock_data, display_manager)
|
||||
except Exception as exc: # noqa: BLE001 - the matrix run reports load errors
|
||||
report = VegasElementReport(implemented=False)
|
||||
report.warnings.append(f"not checked: the plugin did not load ({exc!r})")
|
||||
return report
|
||||
report = VegasElementReport(implemented=implements_vegas_elements(plugin))
|
||||
if not report.implemented:
|
||||
return report
|
||||
if run_update:
|
||||
try:
|
||||
plugin.update()
|
||||
except Exception as exc: # noqa: BLE001 - a plugin's update can raise anything
|
||||
if not isinstance(exc, _TOLERATED_UPDATE_ERRORS):
|
||||
report.errors.append(f"update() raised {exc!r}")
|
||||
return report
|
||||
report.warnings.append(f"update() had no network ({exc!r}); checked "
|
||||
"with whatever data the plugin starts with")
|
||||
checked = check_vegas_elements(plugin, display_manager)
|
||||
checked.warnings[:0] = report.warnings
|
||||
return checked
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Live elements: Vegas content that can change while it is on screen.
|
||||
|
||||
A plugin's ``get_vegas_content()`` hands the Vegas ticker pictures, and the
|
||||
ticker bakes them into its strip: a score drawn when the plugin's turn was
|
||||
prefetched scrolls past with that score, however many goals are scored while
|
||||
it crosses the panel. A plugin that returns **elements** instead gives each
|
||||
picture a name and a fixed width. The ticker then keeps track of where each
|
||||
one is in the strip, and when the plugin's data changes it asks for just the
|
||||
changed elements and swaps their pixels in place -- on screen included,
|
||||
between two frames, without anything next to them moving.
|
||||
|
||||
A plugin opts in by implementing ``BasePlugin.get_vegas_elements()``, and, for
|
||||
content that changes with time rather than with data (an aircraft moving
|
||||
between position reports), ``BasePlugin.redraw_vegas_element()``. See "Live
|
||||
Vegas elements" in docs/PLUGIN_API_REFERENCE.md.
|
||||
|
||||
Added in LEDMatrix 3.8.0. Import it guarded, so the plugin still loads on an
|
||||
older core (which never calls the hooks)::
|
||||
|
||||
try:
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
except ImportError: # core older than 3.8.0
|
||||
VegasElement = None
|
||||
|
||||
def get_vegas_elements(self):
|
||||
if VegasElement is None:
|
||||
return None
|
||||
return [VegasElement(key=f"game:{g['id']}", image=self._card(g),
|
||||
version=self._fingerprint(g))
|
||||
for g in self._games]
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Hashable, Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@dataclass(frozen=True, eq=False)
|
||||
class VegasElement:
|
||||
"""One named, fixed-width piece of a plugin's Vegas content.
|
||||
|
||||
Attributes:
|
||||
key: Names the element across redraws, unique within one list the
|
||||
plugin returns: ``"game:nfl:401547417"``, ``"sep:0:nfl"``,
|
||||
``"map"``. The ticker matches a redraw to the pixels already in
|
||||
its strip by this key, so it must stay the same for the same
|
||||
logical thing and must not be reused for a different one.
|
||||
image: The element as drawn now, at the display's height. For a
|
||||
``live`` element its width is fixed for as long as the key is on
|
||||
the strip: a redraw at a different width is never swapped in (it
|
||||
appears the next time the plugin comes round instead), because
|
||||
nothing on screen may move. Draw live elements at a width that
|
||||
does not depend on the data -- a fixed card width, not the
|
||||
width of the text.
|
||||
version: Anything hashable that changes exactly when the pixels
|
||||
would, such as the tuple of fields the element draws. The ticker
|
||||
skips work for an unchanged version. ``None`` means "compare the
|
||||
pixels", which is always correct and costs a checksum.
|
||||
live: False places the element exactly as plain content is placed
|
||||
(trimmed to its ink, never refreshed): separators, decoration.
|
||||
refresh_hz: More than 0 asks for ``redraw_vegas_element()`` about
|
||||
this often while the element is on or near the screen, for
|
||||
content that changes with time rather than with data. The ticker
|
||||
caps the rate (``vegas_scroll.live_max_hz``, 1 Hz on a display
|
||||
without the rebuilt rgbmatrix binding) and slows it for an
|
||||
element that is slow to draw.
|
||||
"""
|
||||
key: str
|
||||
image: Image.Image
|
||||
version: Optional[Hashable] = None
|
||||
live: bool = True
|
||||
refresh_hz: float = 0.0
|
||||
@@ -99,6 +99,25 @@ class VegasModeConfig:
|
||||
# overall from 0.90% to 0.60%. See src/common/render_gate.py.
|
||||
prefetch_gate: bool = True
|
||||
|
||||
# Live elements (src/plugin_system/vegas_elements.py): a plugin that hands
|
||||
# the ticker named, fixed-width elements has them redrawn when its data
|
||||
# changes, and the changed pixels are swapped into the strip in place --
|
||||
# on screen included -- instead of waiting for the plugin's next turn.
|
||||
# False restores the frozen-segment behaviour exactly. Also off, whatever
|
||||
# this says, under multi-display sync, in swap mode (continuous_scroll
|
||||
# false) and with offscreen_prefetch false.
|
||||
live_refresh: bool = True
|
||||
# Ceiling on how often an element that animates (refresh_hz) is redrawn,
|
||||
# in Hz. 0 turns animation off and keeps data-driven updates.
|
||||
live_max_hz: float = 5.0
|
||||
# Shortest time between two data redraws of one plugin, in seconds. A
|
||||
# plugin updating faster is redrawn at this rate, never skipped: the
|
||||
# latest data is always drawn eventually.
|
||||
live_min_interval: float = 2.0
|
||||
# How far ahead of the right edge, in screens, an animated element starts
|
||||
# being redrawn, so it is already moving when it scrolls in.
|
||||
live_lead_screens: float = 1.0
|
||||
|
||||
# Keep one continuous strip, extending it with the next group of plugins as
|
||||
# the scroll approaches the end, instead of composing a fresh strip and
|
||||
# swapping it in. A swap stops the motion, substitutes every pixel at once
|
||||
@@ -235,6 +254,10 @@ class VegasModeConfig:
|
||||
offscreen_prefetch=bool(get('offscreen_prefetch', d.offscreen_prefetch)),
|
||||
switch_interval_ms=float(get('switch_interval_ms', d.switch_interval_ms) or 0.0),
|
||||
prefetch_gate=bool(get('prefetch_gate', d.prefetch_gate)),
|
||||
live_refresh=bool(get('live_refresh', d.live_refresh)),
|
||||
live_max_hz=float(get('live_max_hz', d.live_max_hz)),
|
||||
live_min_interval=float(get('live_min_interval', d.live_min_interval)),
|
||||
live_lead_screens=float(get('live_lead_screens', d.live_lead_screens)),
|
||||
extend_threshold_screens=float(
|
||||
get('extend_threshold_screens', d.extend_threshold_screens)),
|
||||
auto_trim=get('auto_trim', d.auto_trim),
|
||||
@@ -281,6 +304,10 @@ class VegasModeConfig:
|
||||
'offscreen_prefetch': self.offscreen_prefetch,
|
||||
'switch_interval_ms': self.switch_interval_ms,
|
||||
'prefetch_gate': self.prefetch_gate,
|
||||
'live_refresh': self.live_refresh,
|
||||
'live_max_hz': self.live_max_hz,
|
||||
'live_min_interval': self.live_min_interval,
|
||||
'live_lead_screens': self.live_lead_screens,
|
||||
'extend_threshold_screens': self.extend_threshold_screens,
|
||||
'auto_trim': self.auto_trim,
|
||||
'trim_threshold': self.trim_threshold,
|
||||
@@ -377,6 +404,18 @@ class VegasModeConfig:
|
||||
"extend_threshold_screens must be between 1.0 and 10.0, "
|
||||
f"got {self.extend_threshold_screens}")
|
||||
|
||||
if not 0.0 <= self.live_max_hz <= 10.0:
|
||||
errors.append(
|
||||
f"live_max_hz must be between 0 and 10, got {self.live_max_hz}")
|
||||
if not 0.5 <= self.live_min_interval <= 60.0:
|
||||
errors.append(
|
||||
"live_min_interval must be between 0.5 and 60, "
|
||||
f"got {self.live_min_interval}")
|
||||
if not 0.0 <= self.live_lead_screens <= 5.0:
|
||||
errors.append(
|
||||
"live_lead_screens must be between 0 and 5, "
|
||||
f"got {self.live_lead_screens}")
|
||||
|
||||
if not 1 <= self.min_cut_gap <= 128:
|
||||
errors.append(
|
||||
"min_cut_gap must be between 1 and 128, "
|
||||
|
||||
@@ -23,6 +23,7 @@ from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
|
||||
from src import display_watchdog
|
||||
from src.common import render_gate
|
||||
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.stream_manager import StreamManager
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline
|
||||
@@ -86,6 +87,9 @@ class VegasModeCoordinator:
|
||||
|
||||
# Class-level so coordinators built without __init__ (tests) have it.
|
||||
_last_live_check: float = float('-inf')
|
||||
#: Whether live elements are on for this run (see _apply_live_state).
|
||||
live_active: bool = False
|
||||
_live_reason: Optional[str] = None
|
||||
# Set only while Vegas has changed the GIL switch interval; read with getattr.
|
||||
_saved_switch_interval: Optional[float]
|
||||
|
||||
@@ -124,6 +128,12 @@ class VegasModeCoordinator:
|
||||
self.stream_manager
|
||||
)
|
||||
|
||||
# Live elements: one data epoch per plugin, shared with the adapter,
|
||||
# which stamps every element it draws with it. Moved on by the plugin
|
||||
# manager's update listener while Vegas runs. See _apply_live_state.
|
||||
self.live_epochs = LiveEpochs()
|
||||
self.plugin_adapter.live_epochs = self.live_epochs
|
||||
|
||||
# State management
|
||||
self._is_active = False
|
||||
self._is_paused = False
|
||||
@@ -293,6 +303,9 @@ class VegasModeCoordinator:
|
||||
self._fps_was_degraded = False
|
||||
self._apply_switch_interval()
|
||||
self._install_render_gate()
|
||||
# Before the first background fetch below, which is the first
|
||||
# that may ask a plugin for live elements.
|
||||
self._apply_live_state()
|
||||
|
||||
# Line up the next group immediately, so the first extension is already
|
||||
# warm rather than stalling the scroll to fetch it.
|
||||
@@ -319,6 +332,7 @@ class VegasModeCoordinator:
|
||||
|
||||
self._restore_switch_interval()
|
||||
self._remove_render_gate()
|
||||
self._set_live(False, None)
|
||||
|
||||
# Cleanup components
|
||||
self.render_pipeline.reset()
|
||||
@@ -344,6 +358,62 @@ class VegasModeCoordinator:
|
||||
sys.setswitchinterval(saved)
|
||||
self._saved_switch_interval = None
|
||||
|
||||
# -- live elements ------------------------------------------------------
|
||||
|
||||
def _live_blocker(self) -> Optional[str]:
|
||||
"""Why live elements must stay off for this run, or None if they may run."""
|
||||
cfg = self.vegas_config
|
||||
if not getattr(cfg, 'live_refresh', False):
|
||||
return "switched off (vegas_scroll.live_refresh)"
|
||||
if getattr(self.render_pipeline, 'sync_manager', None) is not None:
|
||||
# The follower mirrors whole strips only; a patch would not reach it.
|
||||
return "multi-display sync is configured"
|
||||
if not cfg.continuous_scroll:
|
||||
return "swap mode (vegas_scroll.continuous_scroll is off)"
|
||||
if not cfg.offscreen_prefetch:
|
||||
return "vegas_scroll.offscreen_prefetch is off"
|
||||
if not hasattr(self.display_manager, 'offscreen'):
|
||||
return "the display manager has no off-screen canvas"
|
||||
return None
|
||||
|
||||
def _apply_live_state(self) -> None:
|
||||
"""Switch live elements on or off for this run, as the config allows."""
|
||||
blocker = self._live_blocker()
|
||||
self._set_live(blocker is None, blocker)
|
||||
|
||||
def _set_live(self, active: bool, reason: Optional[str]) -> None:
|
||||
was, self.live_active = self.live_active, active
|
||||
# getattr: tests build coordinators without every component.
|
||||
adapter = getattr(self, 'plugin_adapter', None)
|
||||
if adapter is not None:
|
||||
adapter.live_elements_enabled = active
|
||||
pipeline = getattr(self, 'render_pipeline', None)
|
||||
if pipeline is not None and hasattr(pipeline, 'set_live'):
|
||||
pipeline.set_live(active)
|
||||
plugin_manager = getattr(self, 'plugin_manager', None)
|
||||
add = getattr(plugin_manager, 'add_update_listener', None)
|
||||
remove = getattr(plugin_manager, 'remove_update_listener', None)
|
||||
if active and callable(add):
|
||||
add(self._on_plugin_data_changed)
|
||||
elif not active and callable(remove):
|
||||
remove(self._on_plugin_data_changed)
|
||||
if active != was or (reason is not None and reason != self._live_reason):
|
||||
if active:
|
||||
logger.info("Vegas live elements on")
|
||||
elif reason is not None:
|
||||
logger.info("Vegas live elements off: %s", reason)
|
||||
self._live_reason = reason
|
||||
|
||||
def _on_plugin_data_changed(self, plugin_id: str) -> None:
|
||||
"""Update listener: a plugin's data may have changed.
|
||||
|
||||
Runs on the update worker with the plugin's lock held, so it only
|
||||
moves the plugin's epoch on and wakes the live-element worker, which
|
||||
redraws once the lock is free.
|
||||
"""
|
||||
self.live_epochs.bump(plugin_id)
|
||||
self.render_pipeline.notify_live_data(plugin_id)
|
||||
|
||||
def _install_render_gate(self) -> None:
|
||||
"""Gate the prefetch thread on the render thread's swaps; see VegasModeConfig."""
|
||||
if not self.vegas_config.prefetch_gate:
|
||||
@@ -439,6 +509,12 @@ class VegasModeCoordinator:
|
||||
# game still shown as live the next morning.
|
||||
self.render_pipeline.refresh_updated_plugins()
|
||||
|
||||
# Copy any live-element redraws the worker has finished into the
|
||||
# strip, between this frame and the last. A deque check when there
|
||||
# are none.
|
||||
if self.live_active:
|
||||
self.render_pipeline.apply_live_patches()
|
||||
|
||||
# Extend the strip before the scroll can reach its end, so the next
|
||||
# group arrives from the right and motion never stops. No cycle
|
||||
# boundary, so no freeze, no substitution and no restart with the
|
||||
@@ -650,7 +726,12 @@ class VegasModeCoordinator:
|
||||
# main loop's _tick_plugin_updates() finds all intervals already
|
||||
# satisfied on return, so the inter-iteration gap is <1 ms and the
|
||||
# display never shows a frozen frame between iterations.
|
||||
_UPDATE_TICK_FRAMES = max(1, int(self.render_pipeline.target_fps * 4)) # every 4 s regardless of FPS
|
||||
# Every 4 s, or every 1 s while the strip holds live elements:
|
||||
# plugins are only scheduled on this tick, so its period is added
|
||||
# to how late a live update can be.
|
||||
tick_seconds = (1.0 if self.live_active
|
||||
and self.render_pipeline.has_live_records() else 4.0)
|
||||
_UPDATE_TICK_FRAMES = max(1, int(self.render_pipeline.target_fps * tick_seconds))
|
||||
if (self._update_callback and
|
||||
frame_count % _UPDATE_TICK_FRAMES == 0 and
|
||||
not self._update_tick_running):
|
||||
@@ -774,6 +855,8 @@ class VegasModeCoordinator:
|
||||
# Cached segments were trimmed under the old settings, so drop them
|
||||
# or a changed trim/padding value would not visibly take effect.
|
||||
self.plugin_adapter.invalidate_cache()
|
||||
if self._is_active:
|
||||
self._apply_live_state()
|
||||
|
||||
# Force refresh of stream manager to pick up plugin_order/buffer changes
|
||||
self.stream_manager._last_refresh = 0
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Bookkeeping for live Vegas elements (see src/plugin_system/vegas_elements.py).
|
||||
|
||||
A live element travels through the same plumbing as any other Vegas content --
|
||||
the adapter's cache, a prefetched group, the pipeline's join -- as a PIL image.
|
||||
What makes it live rides along in the image's ``info`` dict (:data:`INFO_KEY`),
|
||||
which Pillow copies through ``copy()``, ``crop()``, ``convert()`` and
|
||||
``resize()``, so none of that plumbing has to change shape. The pipeline reads
|
||||
the tag back when it places the image in the strip and keeps an
|
||||
:class:`ElementRecord` of where it went.
|
||||
|
||||
Geometry is pinned: a live element is never trimmed to its ink. It is padded
|
||||
with ``content_padding`` black columns each side, the margin trimming would
|
||||
have left, so its width in the strip is its image width plus twice that, for
|
||||
as long as its key is there. That is what lets a redraw be swapped in place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import threading
|
||||
import zlib
|
||||
from typing import Dict, NamedTuple, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
#: Where a live element's :class:`ElementMeta` rides in ``Image.info``.
|
||||
INFO_KEY = "ledmatrix.vegas_element"
|
||||
|
||||
|
||||
class ElementMeta(NamedTuple):
|
||||
"""What the pipeline needs to know about one live element's pixels."""
|
||||
plugin_id: str
|
||||
key: str
|
||||
#: The plugin's data epoch (LiveEpochs) the pixels were drawn from.
|
||||
epoch: int
|
||||
#: pixel_digest() of the pinned pixels.
|
||||
digest: Tuple[Tuple[int, ...], int]
|
||||
#: time.monotonic() when drawn.
|
||||
rendered_at: float
|
||||
refresh_hz: float
|
||||
#: The plugin's own version for the pixels, or None.
|
||||
version: object = None
|
||||
|
||||
|
||||
class ElementRecord(NamedTuple):
|
||||
"""Where one live element sits in the strip.
|
||||
|
||||
``abs_x`` is in absolute strip columns: the strip's own column plus every
|
||||
column trimmed off its front since it was composed (the pipeline's
|
||||
``_strip_origin``). Trimming therefore never moves a record.
|
||||
"""
|
||||
seq: int
|
||||
plugin_id: str
|
||||
key: str
|
||||
abs_x: int
|
||||
width: int
|
||||
epoch: int
|
||||
digest: Tuple[Tuple[int, ...], int]
|
||||
refresh_hz: float
|
||||
|
||||
|
||||
class RenderedElement(NamedTuple):
|
||||
"""One live element freshly redrawn by the worker, ready to compare and swap."""
|
||||
key: str
|
||||
#: The plugin's data epoch it was drawn from.
|
||||
epoch: int
|
||||
version: object
|
||||
#: Pinned pixels (see pin_element), read-only.
|
||||
pixels: np.ndarray
|
||||
digest: Tuple[Tuple[int, ...], int]
|
||||
#: Pinned width, the width it would occupy in the strip.
|
||||
width: int
|
||||
|
||||
|
||||
class LivePatch(NamedTuple):
|
||||
"""A redraw handed from the worker to the render thread for one record."""
|
||||
seq: int
|
||||
#: The strip generation it was made against; a patch for an older strip
|
||||
#: is dropped.
|
||||
strip_gen: int
|
||||
epoch: int
|
||||
pixels: np.ndarray
|
||||
digest: Tuple[Tuple[int, ...], int]
|
||||
made_at: float
|
||||
|
||||
|
||||
class LiveView(NamedTuple):
|
||||
"""Where the viewport is, in absolute strip columns, published every frame."""
|
||||
abs_left: int
|
||||
abs_right: int
|
||||
#: The end of the strip: how far ahead content exists.
|
||||
abs_end: int
|
||||
#: time.monotonic() when published. An old one means frames have stopped.
|
||||
t_mono: float
|
||||
|
||||
|
||||
def tag(image: Image.Image, meta: ElementMeta) -> Image.Image:
|
||||
"""Mark ``image`` as the live element ``meta`` describes. Returns it."""
|
||||
image.info[INFO_KEY] = meta
|
||||
return image
|
||||
|
||||
|
||||
def meta_of(image: object) -> Optional[ElementMeta]:
|
||||
"""The live-element tag on ``image``, or None for plain content."""
|
||||
info = getattr(image, 'info', None)
|
||||
if not isinstance(info, dict):
|
||||
return None
|
||||
meta = info.get(INFO_KEY)
|
||||
return meta if isinstance(meta, ElementMeta) else None
|
||||
|
||||
|
||||
def untag(image: Image.Image) -> Image.Image:
|
||||
"""Make ``image`` plain content again (e.g. after cropping it). Returns it."""
|
||||
image.info.pop(INFO_KEY, None)
|
||||
return image
|
||||
|
||||
|
||||
def pin_element(image: Image.Image, padding: int) -> Tuple[Image.Image, np.ndarray]:
|
||||
"""An element's pixels as they will sit in the strip, as image and array.
|
||||
|
||||
RGB, with ``padding`` black columns each side. The array is what a live
|
||||
patch writes into the strip; it is read-only, so a patch in flight cannot
|
||||
be changed under the render thread.
|
||||
"""
|
||||
if image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
pad = max(0, int(padding))
|
||||
if pad:
|
||||
pinned = Image.new('RGB', (image.width + 2 * pad, image.height), (0, 0, 0))
|
||||
pinned.paste(image, (pad, 0))
|
||||
else:
|
||||
pinned = image.copy()
|
||||
array = np.ascontiguousarray(np.asarray(pinned))
|
||||
array.setflags(write=False)
|
||||
return pinned, array
|
||||
|
||||
|
||||
def pixel_digest(array: np.ndarray) -> Tuple[Tuple[int, ...], int]:
|
||||
"""A cheap fingerprint of an element's pixels: its shape and a CRC.
|
||||
|
||||
Two redraws with the same digest are treated as the same pixels and the
|
||||
second is not swapped in. CRC-32 rather than Adler-32: a changed digit is
|
||||
a small, local change, which is exactly where Adler-32 is weakest.
|
||||
"""
|
||||
data = np.ascontiguousarray(array)
|
||||
return tuple(data.shape), zlib.crc32(memoryview(data).cast('B'))
|
||||
|
||||
|
||||
class LiveEpochs:
|
||||
"""A counter per plugin that moves on whenever its data may have changed.
|
||||
|
||||
Bumped when a plugin's update() completes (PluginManager's update
|
||||
listener) or when it calls notify_vegas_data_changed(). Every live element
|
||||
is tagged with the epoch it was drawn from; one drawn from an older epoch
|
||||
than the plugin's current one is due a redraw. Epochs are the truth and
|
||||
wake-ups only hints, so a missed wake-up delays a redraw but never loses
|
||||
one.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = itertools.count(1)
|
||||
self._epochs: Dict[str, int] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def bump(self, plugin_id: str) -> int:
|
||||
with self._lock:
|
||||
epoch = next(self._counter)
|
||||
self._epochs[plugin_id] = epoch
|
||||
return epoch
|
||||
|
||||
def get(self, plugin_id: str) -> int:
|
||||
return self._epochs.get(plugin_id, 0)
|
||||
@@ -0,0 +1,503 @@
|
||||
"""The one background worker behind live Vegas elements.
|
||||
|
||||
Vegas draws everything the strip shows off the render thread. Until live
|
||||
elements that was one short-lived prefetch thread per group; now, once the
|
||||
strip holds a live element, it is this worker, which does three kinds of job
|
||||
one at a time, most urgent first:
|
||||
|
||||
- **group steps**: fetching the next group of plugins for the strip, one
|
||||
plugin per step (what the prefetch thread did in one go);
|
||||
- **data refreshes**: when a plugin's data has moved on (its epoch, see
|
||||
elements.LiveEpochs) past what its elements in the strip were drawn from,
|
||||
redraw them and hand over the ones whose pixels changed;
|
||||
- **ticks**: redraw an element that animates (``refresh_hz``) while it is on
|
||||
or near the screen.
|
||||
|
||||
Nothing here touches the strip. A finished redraw becomes a
|
||||
:class:`~src.vegas_mode.elements.LivePatch` in the pipeline's slot for that
|
||||
element (one per element, the latest wins) and the render thread copies it
|
||||
into the strip between two frames (RenderPipeline.apply_live_patches). The
|
||||
hand-over is lock-free: a dict store and a deque append here, a deque popleft
|
||||
and a dict pop there, so the render thread never waits on this thread.
|
||||
|
||||
Every job runs inside the render gate when there is one (src/common/
|
||||
render_gate.py), so Python runs here only while the render thread is waiting
|
||||
for the panel. Without it (the stock rgbmatrix binding) animation is capped at
|
||||
:data:`UNGATED_MAX_HZ`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import logging
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from contextlib import nullcontext
|
||||
from typing import Any, Callable, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from src.vegas_mode.elements import ElementRecord, LivePatch, LiveView
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: A view older than this means frames have stopped (a paused scroll, an
|
||||
#: interrupt): only group work runs, since nothing redrawn would be seen.
|
||||
VIEW_STALE_S = 0.5
|
||||
#: How long a data refresh waits for the plugin's lock before trying later.
|
||||
DATA_LOCK_TIMEOUT = 0.25
|
||||
#: ...and how much later.
|
||||
LOCK_BACKOFF_S = 1.0
|
||||
#: Animation ceiling without the render gate, where every redraw competes
|
||||
#: with the render thread for the GIL.
|
||||
UNGATED_MAX_HZ = 1.0
|
||||
#: An element whose redraws take longer than this on average is animated at
|
||||
#: half its rate, down to MIN_THROTTLED_HZ.
|
||||
SLOW_RENDER_S = 0.05
|
||||
MIN_THROTTLED_HZ = 0.5
|
||||
#: Longest the worker sleeps with nothing due, so a floor or a backoff that
|
||||
#: expires is noticed.
|
||||
IDLE_WAIT_S = 0.5
|
||||
#: How often the worker logs what it did, when it did anything.
|
||||
SUMMARY_INTERVAL_S = 300.0
|
||||
#: Weight of the newest sample in the per-element render time average.
|
||||
EWMA_ALPHA = 0.2
|
||||
#: How long the worker waits for a one-shot prefetch thread it takes over from.
|
||||
LEGACY_PREFETCH_JOIN_S = 15.0
|
||||
|
||||
|
||||
def _visible(record: ElementRecord, view: LiveView) -> bool:
|
||||
return record.abs_x < view.abs_right and record.abs_x + record.width > view.abs_left
|
||||
|
||||
|
||||
def _behind(record: ElementRecord, view: LiveView) -> bool:
|
||||
return record.abs_x + record.width <= view.abs_left
|
||||
|
||||
|
||||
class _GroupJob:
|
||||
"""A group fetch in progress, one member per step."""
|
||||
|
||||
def __init__(self, generation: int, plugin_ids: List[str]) -> None:
|
||||
self.generation = generation
|
||||
self.pending = list(plugin_ids)
|
||||
self.group: List[Tuple[str, Any]] = []
|
||||
|
||||
|
||||
class VegasWorker(threading.Thread):
|
||||
"""See the module docstring. Owned by the RenderPipeline that starts it."""
|
||||
|
||||
def __init__(self, pipeline: Any, clock: Callable[[], float] = time.monotonic) -> None:
|
||||
super().__init__(daemon=True, name="vegas-live-worker")
|
||||
self.pipeline = pipeline
|
||||
#: time.monotonic, or a fake one in tests; the pipeline's view is
|
||||
#: stamped with time.monotonic too.
|
||||
self._clock = clock
|
||||
self.inbox: "queue.SimpleQueue[Tuple[str, Any]]" = queue.SimpleQueue()
|
||||
self._stopping = False
|
||||
self._group_wanted = False
|
||||
self._group_job: Optional[_GroupJob] = None
|
||||
self._strip_gen = pipeline._strip_gen
|
||||
# Per element (record seq): the epoch this worker last handed over,
|
||||
# or found needed nothing; the digest of its latest hand-over; when
|
||||
# its next animation tick is due.
|
||||
self._handled_epoch: Dict[int, int] = {}
|
||||
self._handed_digest: Dict[int, Any] = {}
|
||||
self._next_tick: Dict[int, float] = {}
|
||||
# Per plugin: when its last data refresh ran, and a lock backoff.
|
||||
self._last_data_job: Dict[str, float] = {}
|
||||
self._backoff_until: Dict[str, float] = {}
|
||||
# Per (plugin, key): average redraw time, for throttling.
|
||||
self._render_ewma: Dict[Tuple[str, str], float] = {}
|
||||
self._refused: Set[Tuple[str, str, int]] = set()
|
||||
self.stats: collections.Counter = collections.Counter()
|
||||
self.busy_seconds = 0.0
|
||||
self._began = clock()
|
||||
self._last_summary = self._began
|
||||
self._jobs_since_prune = 0
|
||||
# The slowest redraw since the last summary: (seconds, (plugin, key)).
|
||||
self._slowest_redraw: Optional[Tuple[float, Tuple[str, str]]] = None
|
||||
|
||||
# -- control, from other threads ------------------------------------------
|
||||
|
||||
def request_group(self) -> None:
|
||||
"""Fetch the next group for the strip when nothing more urgent is due."""
|
||||
self._group_wanted = True
|
||||
self.inbox.put(("group", None))
|
||||
|
||||
def notify_data(self, plugin_id: str) -> None:
|
||||
"""A plugin's data moved on. Only a wake-up: its epoch is the truth."""
|
||||
self.inbox.put(("data", plugin_id))
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop after the current job. Does not wait for it."""
|
||||
self._stopping = True
|
||||
self.inbox.put(("stop", None))
|
||||
|
||||
# -- the loop -------------------------------------------------------------
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
# Linux applies nice per thread: deprioritise against the render
|
||||
# loop, as the one-shot prefetch thread always did.
|
||||
os.nice(10)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
self._join_legacy_prefetch()
|
||||
while not self._should_stop():
|
||||
self._wait(self._next_wait(self._clock()))
|
||||
if self._should_stop():
|
||||
break
|
||||
job = self._pick(self._clock())
|
||||
if job is not None:
|
||||
self._run(job)
|
||||
self._maybe_summarise()
|
||||
self._hand_over_partial_group()
|
||||
logger.debug("Vegas live worker stopped")
|
||||
|
||||
def _should_stop(self) -> bool:
|
||||
# A method, not a bare attribute read: stop() sets it from another
|
||||
# thread between two reads in run().
|
||||
return self._stopping
|
||||
|
||||
def _join_legacy_prefetch(self) -> None:
|
||||
"""Let a one-shot prefetch thread, or a worker stopped earlier, finish first.
|
||||
|
||||
So that only one thread ever draws for the strip: measured on hdpi,
|
||||
each extra thread competing for the GIL made the render thread late
|
||||
more often, not less (src/common/render_gate.py).
|
||||
"""
|
||||
for name in ('_prefetch_thread', '_retired_worker'):
|
||||
thread = getattr(self.pipeline, name, None)
|
||||
if thread is not None and thread is not self \
|
||||
and thread is not threading.current_thread() and thread.is_alive():
|
||||
thread.join(LEGACY_PREFETCH_JOIN_S)
|
||||
|
||||
def _wait(self, timeout: float) -> None:
|
||||
try:
|
||||
message = self.inbox.get(timeout=max(0.0, timeout))
|
||||
except queue.Empty:
|
||||
return
|
||||
while True:
|
||||
if message[0] == "stop":
|
||||
self._stopping = True
|
||||
elif message[0] == "group":
|
||||
self._group_wanted = True
|
||||
try:
|
||||
message = self.inbox.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
|
||||
def _next_wait(self, now: float) -> float:
|
||||
"""Seconds until something may be due: the next tick, else IDLE_WAIT_S."""
|
||||
p = self.pipeline
|
||||
if self._group_job is not None or (
|
||||
self._group_wanted and p._prepared_group is None):
|
||||
return 0.0
|
||||
# Ticks only count while frames are flowing: during a pause _pick runs
|
||||
# none, and a past-due tick would otherwise make this 0 and spin.
|
||||
view = p._view
|
||||
if view is None or now - view.t_mono > VIEW_STALE_S:
|
||||
return IDLE_WAIT_S
|
||||
# Only elements _due_tick would run. A tick left behind by an element
|
||||
# trimmed away, or one no longer animated, is never run, and counting
|
||||
# it held this at its floor: a spin at 100 wake-ups a second.
|
||||
soonest = now + IDLE_WAIT_S
|
||||
lead = self._tick_lead()
|
||||
for record in p._elements:
|
||||
if self._tickable(record, view, lead):
|
||||
soonest = min(soonest, self._next_tick.get(record.seq, now))
|
||||
return max(0.01, soonest - now)
|
||||
|
||||
# -- choosing ---------------------------------------------------------------
|
||||
|
||||
def _pick(self, now: float) -> Optional[Tuple[str, Any]]:
|
||||
"""The most urgent job, or None. See the module docstring for the order."""
|
||||
p = self.pipeline
|
||||
if p._strip_gen != self._strip_gen:
|
||||
self._forget_everything(p._strip_gen)
|
||||
view = p._view
|
||||
fresh = view is not None and now - view.t_mono <= VIEW_STALE_S
|
||||
group_ready = self._group_job is not None or (
|
||||
self._group_wanted and p._prepared_group is None)
|
||||
records = p._elements
|
||||
|
||||
if group_ready and fresh and self._group_urgent(view):
|
||||
return ("group", None)
|
||||
if fresh and records:
|
||||
plugin_id = self._due_data(records, view, now, visible_only=True)
|
||||
if plugin_id is not None:
|
||||
return ("data", plugin_id)
|
||||
record = self._due_tick(records, view, now)
|
||||
if record is not None:
|
||||
return ("tick", record)
|
||||
if group_ready:
|
||||
return ("group", None)
|
||||
if fresh and records:
|
||||
plugin_id = self._due_data(records, view, now, visible_only=False)
|
||||
if plugin_id is not None:
|
||||
return ("data", plugin_id)
|
||||
return None
|
||||
|
||||
def _group_urgent(self, view: LiveView) -> bool:
|
||||
width = self.pipeline.display_width
|
||||
threshold = (self.pipeline.config.extend_threshold_screens + 1.0) * width
|
||||
return bool(view.abs_end - view.abs_right <= threshold)
|
||||
|
||||
def _epoch(self, plugin_id: str) -> int:
|
||||
epochs = getattr(self.pipeline.stream_manager.plugin_adapter, 'live_epochs', None)
|
||||
return int(epochs.get(plugin_id)) if epochs is not None else 0
|
||||
|
||||
def _done_epoch(self, record: ElementRecord) -> int:
|
||||
applied = self.pipeline._applied.get(record.seq)
|
||||
return max(applied[0] if applied is not None else record.epoch,
|
||||
self._handled_epoch.get(record.seq, -1))
|
||||
|
||||
def _due_data(self, records: Tuple[ElementRecord, ...], view: LiveView,
|
||||
now: float, visible_only: bool) -> Optional[str]:
|
||||
"""The plugin whose stale elements are nearest the screen, if any may redraw."""
|
||||
floor = self.pipeline.config.live_min_interval
|
||||
best: Optional[Tuple[int, str]] = None
|
||||
for record in records:
|
||||
if _behind(record, view):
|
||||
continue
|
||||
if visible_only and not _visible(record, view):
|
||||
continue
|
||||
plugin_id = record.plugin_id
|
||||
if self._epoch(plugin_id) <= self._done_epoch(record):
|
||||
continue
|
||||
if now < self._backoff_until.get(plugin_id, 0.0):
|
||||
continue
|
||||
if now - self._last_data_job.get(plugin_id, float('-inf')) < floor:
|
||||
continue
|
||||
distance = max(0, record.abs_x - view.abs_right)
|
||||
if best is None or distance < best[0]:
|
||||
best = (distance, plugin_id)
|
||||
return best[1] if best is not None else None
|
||||
|
||||
def _tick_hz(self, record: ElementRecord) -> float:
|
||||
cfg = self.pipeline.config
|
||||
hz = min(float(record.refresh_hz), float(cfg.live_max_hz))
|
||||
if getattr(self.pipeline.display_manager, 'render_gate', None) is None:
|
||||
hz = min(hz, UNGATED_MAX_HZ)
|
||||
ewma = self._render_ewma.get((record.plugin_id, record.key), 0.0)
|
||||
if hz > 0 and ewma > SLOW_RENDER_S:
|
||||
# Halved, but never below the floor -- nor raised to it, for an
|
||||
# element already asking for less.
|
||||
hz = min(hz, max(MIN_THROTTLED_HZ, hz / 2.0))
|
||||
return hz
|
||||
|
||||
def _tick_lead(self) -> float:
|
||||
return float(self.pipeline.config.live_lead_screens * self.pipeline.display_width)
|
||||
|
||||
def _tickable(self, record: ElementRecord, view: LiveView, lead: float) -> bool:
|
||||
"""Whether an element animates now: it has a rate, and is on or near the screen."""
|
||||
return (record.refresh_hz > 0 and self._tick_hz(record) > 0
|
||||
and not _behind(record, view) and record.abs_x < view.abs_right + lead)
|
||||
|
||||
def _due_tick(self, records: Tuple[ElementRecord, ...], view: LiveView,
|
||||
now: float) -> Optional[ElementRecord]:
|
||||
lead = self._tick_lead()
|
||||
best: Optional[ElementRecord] = None
|
||||
best_due = 0.0
|
||||
for record in records:
|
||||
if not self._tickable(record, view, lead):
|
||||
self._next_tick.pop(record.seq, None)
|
||||
continue
|
||||
due = self._next_tick.get(record.seq, now)
|
||||
if due <= now and (best is None or due < best_due):
|
||||
best, best_due = record, due
|
||||
return best
|
||||
|
||||
# -- running ----------------------------------------------------------------
|
||||
|
||||
def _run(self, job: Tuple[str, Any]) -> None:
|
||||
kind, arg = job
|
||||
gate = getattr(self.pipeline.display_manager, 'render_gate', None)
|
||||
started = self._clock()
|
||||
try:
|
||||
with gate.yielding() if gate is not None else nullcontext():
|
||||
if kind == "group":
|
||||
self._group_step()
|
||||
elif kind == "data":
|
||||
self._data_job(arg, started)
|
||||
else:
|
||||
self._tick_job(arg, started)
|
||||
self.stats[kind] += 1
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# Plugin code runs in here; one bad job must not end the worker,
|
||||
# which also fetches every group for the strip.
|
||||
self.stats["errors"] += 1
|
||||
if self.stats["errors"] <= 3 or self.stats["errors"] % 100 == 0:
|
||||
logger.exception("Vegas live worker: %s job failed (%s)", kind, exc)
|
||||
finally:
|
||||
self.busy_seconds += self._clock() - started
|
||||
self._jobs_since_prune += 1
|
||||
if self._jobs_since_prune >= 64:
|
||||
self._prune()
|
||||
|
||||
def _hand_over_partial_group(self) -> None:
|
||||
"""On stopping: publish the members of a group already fetched.
|
||||
|
||||
Its plugins were taken from the rotation when it was planned, so a
|
||||
group dropped here would skip them until the next cycle. The rest of
|
||||
it is not fetched. Nothing is published over a group already waiting,
|
||||
or into a Vegas reset since.
|
||||
"""
|
||||
job, self._group_job = self._group_job, None
|
||||
if job is None or not job.group:
|
||||
return
|
||||
p = self.pipeline
|
||||
with p._prefetch_lock:
|
||||
if job.generation == p._prefetch_generation and p._prepared_group is None:
|
||||
p._prepared_group = job.group
|
||||
|
||||
def _group_step(self) -> None:
|
||||
p = self.pipeline
|
||||
job = self._group_job
|
||||
if job is None:
|
||||
with p._prefetch_lock:
|
||||
if p._prepared_group is not None:
|
||||
self._group_wanted = False
|
||||
return
|
||||
generation = p._prefetch_generation
|
||||
self._group_wanted = False
|
||||
job = self._group_job = _GroupJob(generation, p.stream_manager.plan_next_group())
|
||||
if job.generation != p._prefetch_generation:
|
||||
self._group_job = None # Vegas was reset meanwhile
|
||||
return
|
||||
if job.pending:
|
||||
member = p.stream_manager.fetch_group_member(
|
||||
job.pending.pop(0), offscreen_only=True)
|
||||
if member is not None:
|
||||
job.group.append(member)
|
||||
if not job.pending:
|
||||
self._group_job = None
|
||||
with p._prefetch_lock:
|
||||
if job.generation == p._prefetch_generation:
|
||||
p._prepared_group = job.group
|
||||
|
||||
def _data_job(self, plugin_id: str, now: float) -> None:
|
||||
p = self.pipeline
|
||||
self._last_data_job[plugin_id] = now
|
||||
plugin = getattr(p.stream_manager.plugin_manager, 'plugins', {}).get(plugin_id)
|
||||
if plugin is None:
|
||||
return
|
||||
gen = p._strip_gen
|
||||
batch = p.stream_manager.plugin_adapter.render_live_elements(
|
||||
plugin, plugin_id, lock_timeout=DATA_LOCK_TIMEOUT)
|
||||
if batch is None:
|
||||
self.stats["lock_busy"] += 1
|
||||
self._backoff_until[plugin_id] = now + LOCK_BACKOFF_S
|
||||
return
|
||||
epoch, rendered = batch
|
||||
view = p._view
|
||||
for record in p._elements:
|
||||
if record.plugin_id != plugin_id:
|
||||
continue
|
||||
if view is not None and _behind(record, view):
|
||||
continue
|
||||
element = rendered.get(record.key)
|
||||
if element is not None:
|
||||
self._hand_over(record, element, epoch, gen)
|
||||
# A key the plugin no longer has keeps its last pixels until it
|
||||
# scrolls off; either way this epoch is dealt with.
|
||||
self._handled_epoch[record.seq] = max(
|
||||
epoch, self._handled_epoch.get(record.seq, -1))
|
||||
|
||||
def _tick_job(self, record: ElementRecord, now: float) -> None:
|
||||
p = self.pipeline
|
||||
hz = self._tick_hz(record)
|
||||
self._next_tick[record.seq] = now + (1.0 / hz if hz > 0 else IDLE_WAIT_S)
|
||||
plugin = getattr(p.stream_manager.plugin_manager, 'plugins', {}).get(record.plugin_id)
|
||||
if plugin is None:
|
||||
return
|
||||
adapter = p.stream_manager.plugin_adapter
|
||||
key = (record.plugin_id, record.key)
|
||||
at = now + self._render_ewma.get(key, 0.0) + p.frame_interval
|
||||
started = time.perf_counter()
|
||||
if adapter.has_lock_free_redraw(plugin):
|
||||
# None from the plugin means nothing to redraw this time.
|
||||
element = adapter.redraw_live_element(
|
||||
plugin, record.plugin_id, record.key, record.width, p.display_height, at)
|
||||
else:
|
||||
# No lock-free redraw: redraw everything, but never wait for the
|
||||
# plugin's lock (update() may be doing network I/O under it).
|
||||
batch = adapter.render_live_elements(plugin, record.plugin_id, lock_timeout=0.0)
|
||||
element = batch[1].get(record.key) if batch is not None else None
|
||||
took = time.perf_counter() - started
|
||||
if self._slowest_redraw is None or took > self._slowest_redraw[0]:
|
||||
self._slowest_redraw = (took, key)
|
||||
previous = self._render_ewma.get(key)
|
||||
self._render_ewma[key] = took if previous is None else (
|
||||
EWMA_ALPHA * took + (1.0 - EWMA_ALPHA) * previous)
|
||||
if element is not None:
|
||||
self._hand_over(record, element, element.epoch, p._strip_gen)
|
||||
|
||||
def _hand_over(self, record: ElementRecord, element: Any, epoch: int, gen: int) -> None:
|
||||
"""Queue a redraw for the render thread, unless nothing would change."""
|
||||
p = self.pipeline
|
||||
if element.width != record.width:
|
||||
marker = (record.plugin_id, record.key, element.width)
|
||||
if marker not in self._refused:
|
||||
self._refused.add(marker)
|
||||
logger.info(
|
||||
"[%s] Live element %r redrawn %dpx wide, placed at %dpx; "
|
||||
"kept as it was (a live element's width must not change)",
|
||||
record.plugin_id, record.key, element.width, record.width)
|
||||
self.stats["refused"] += 1
|
||||
return
|
||||
last = self._handed_digest.get(record.seq)
|
||||
if last is None:
|
||||
applied = p._applied.get(record.seq)
|
||||
last = applied[1] if applied is not None else record.digest
|
||||
if element.digest == last:
|
||||
self.stats["unchanged"] += 1
|
||||
return
|
||||
p._live_slots[record.seq] = LivePatch(
|
||||
seq=record.seq, strip_gen=gen, epoch=epoch, pixels=element.pixels,
|
||||
digest=element.digest, made_at=self._clock())
|
||||
p._live_ready.append(record.seq)
|
||||
self._handed_digest[record.seq] = element.digest
|
||||
self.stats["patches"] += 1
|
||||
|
||||
# -- housekeeping -----------------------------------------------------------
|
||||
|
||||
def _forget_everything(self, gen: int) -> None:
|
||||
self._strip_gen = gen
|
||||
self._handled_epoch.clear()
|
||||
self._handed_digest.clear()
|
||||
self._next_tick.clear()
|
||||
|
||||
def _prune(self) -> None:
|
||||
self._jobs_since_prune = 0
|
||||
live = {record.seq for record in self.pipeline._elements}
|
||||
for table in (self._handled_epoch, self._handed_digest, self._next_tick):
|
||||
for seq in [s for s in table if s not in live]:
|
||||
del table[seq]
|
||||
|
||||
def _maybe_summarise(self) -> None:
|
||||
now = self._clock()
|
||||
if now - self._last_summary < SUMMARY_INTERVAL_S:
|
||||
return
|
||||
elapsed = now - self._last_summary
|
||||
self._last_summary = now
|
||||
stats, self.stats = self.stats, collections.Counter()
|
||||
busy, self.busy_seconds = self.busy_seconds, 0.0
|
||||
slowest, self._slowest_redraw = self._slowest_redraw, None
|
||||
if not stats:
|
||||
return
|
||||
redraws = ""
|
||||
if slowest is not None:
|
||||
# What a tick costs is the number that decides whether an
|
||||
# animated element can keep its rate: say it for the worst one.
|
||||
took, (plugin_id, key) = slowest
|
||||
average = self._render_ewma.get((plugin_id, key), took)
|
||||
redraws = "; slowest redraw %.1fms (%s %r, average %.1fms)" % (
|
||||
took * 1000.0, plugin_id, key, average * 1000.0)
|
||||
logger.info(
|
||||
"Vegas live: %d group step(s), %d data refresh(es), %d tick(s); "
|
||||
"%d patch(es) handed over, %d unchanged, %d refused, %d lock-busy, "
|
||||
"%d error(s); worker busy %.1f%%%s",
|
||||
stats["group"], stats["data"], stats["tick"], stats["patches"],
|
||||
stats["unchanged"], stats["refused"], stats["lock_busy"],
|
||||
stats["errors"], 100.0 * busy / elapsed if elapsed else 0.0, redraws)
|
||||
@@ -9,9 +9,22 @@ import logging
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from typing import Dict, Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.plugin_system.base_plugin import BasePlugin as _BasePlugin
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
from src.vegas_mode.elements import (
|
||||
ElementMeta,
|
||||
LiveEpochs,
|
||||
RenderedElement,
|
||||
meta_of,
|
||||
pin_element,
|
||||
pixel_digest,
|
||||
tag,
|
||||
untag,
|
||||
)
|
||||
from src.vegas_mode.geometry import (
|
||||
blank_runs,
|
||||
separation_gap,
|
||||
@@ -87,6 +100,22 @@ class PluginAdapter:
|
||||
# into unrelated headlines once the strip refreshed to 9,505px.
|
||||
self._offset_shapes: dict = {}
|
||||
|
||||
# Live elements (src/vegas_mode/elements.py). Switched on by the
|
||||
# coordinator for a run in which live updates are active; while off,
|
||||
# no plugin is ever asked for elements and every path is as before.
|
||||
self.live_elements_enabled = False
|
||||
# Per-plugin data epochs, stamped on each element drawn. Set by the
|
||||
# coordinator; without it every element is drawn "from epoch 0".
|
||||
self.live_epochs: Optional[LiveEpochs] = None
|
||||
# Element problems already reported, so a plugin with a bad hook logs
|
||||
# once rather than on every fetch.
|
||||
self._element_warnings: set = set()
|
||||
# (plugin_id, key) -> (version, source image, (padding, height),
|
||||
# pinned pixels, digest) of the last conversion, so an element handed
|
||||
# back unchanged is not converted again. Only the live-element worker
|
||||
# reads or writes it; invalidate_cache() swaps in a fresh one.
|
||||
self._element_memo: Dict[Tuple[str, str], Tuple[Any, ...]] = {}
|
||||
|
||||
logger.debug(
|
||||
"PluginAdapter initialized: display=%dx%d",
|
||||
self.display_width, self.display_height
|
||||
@@ -119,9 +148,24 @@ class PluginAdapter:
|
||||
plugin_id, plugin.__class__.__name__
|
||||
)
|
||||
|
||||
# Check cache first
|
||||
# The old contract, kept behind the switch: background callers may
|
||||
# not draw, so anything needing a canvas is left for the render thread.
|
||||
restricted = offscreen_only and not getattr(
|
||||
self.config, 'offscreen_prefetch', True)
|
||||
|
||||
# Live elements are asked for only on the background fetch, which
|
||||
# holds the plugin's lock and draws on a canvas of its own. The render
|
||||
# thread's fetches (the first compose, the inline fallback) take no
|
||||
# lock, so they keep to get_vegas_content().
|
||||
keyed = (offscreen_only and not restricted and self.live_elements_enabled
|
||||
and self.is_live_capable(plugin, plugin_id))
|
||||
|
||||
# Check cache first. A keyed fetch looks past legacy content cached
|
||||
# by a render-thread fetch, or the plugin would not become live until
|
||||
# that entry expired.
|
||||
cached = self._get_cached(plugin_id)
|
||||
if cached is not None:
|
||||
if cached is not None and not (
|
||||
keyed and not any(meta_of(img) for img in cached)):
|
||||
total_width = sum(img.width for img in cached)
|
||||
logger.debug(
|
||||
"[%s] Using cached content: %d images, %dpx total",
|
||||
@@ -129,10 +173,6 @@ class PluginAdapter:
|
||||
)
|
||||
return cached
|
||||
|
||||
# The old contract, kept behind the switch: background callers may
|
||||
# not draw, so anything needing a canvas is left for the render thread.
|
||||
restricted = offscreen_only and not getattr(
|
||||
self.config, 'offscreen_prefetch', True)
|
||||
if not offscreen_only or restricted:
|
||||
return self._fetch_content(plugin, plugin_id, restricted)
|
||||
|
||||
@@ -143,21 +183,41 @@ class PluginAdapter:
|
||||
"round", plugin_id, self.PLUGIN_LOCK_TIMEOUT
|
||||
)
|
||||
return None
|
||||
return self._fetch_content(plugin, plugin_id, restricted=False)
|
||||
return self._fetch_content(plugin, plugin_id, restricted=False,
|
||||
keyed=keyed)
|
||||
|
||||
def is_live_capable(self, plugin: 'BasePlugin', plugin_id: str) -> bool:
|
||||
"""Whether to ask this plugin for live elements rather than pictures.
|
||||
|
||||
It must implement get_vegas_elements() in its own class (a test double
|
||||
or a plugin that only inherits BasePlugin's does not count), and its
|
||||
config must not set ``vegas_live`` off.
|
||||
"""
|
||||
method = getattr(type(plugin), 'get_vegas_elements', None)
|
||||
if method is None or method is _BasePlugin.get_vegas_elements:
|
||||
return False
|
||||
raw = self._plugin_setting(plugin, 'vegas_live')
|
||||
if raw is None:
|
||||
return True
|
||||
if isinstance(raw, str):
|
||||
return raw.strip().lower() not in ('false', '0', 'off', 'no')
|
||||
return bool(raw)
|
||||
|
||||
@contextmanager
|
||||
def _plugin_lock(self, plugin_id: str):
|
||||
def _plugin_lock(self, plugin_id: str, timeout: Optional[float] = None):
|
||||
"""Hold the plugin's update/display lock, waiting a bounded time.
|
||||
|
||||
Yields whether it was acquired. Yields True, holding nothing, when
|
||||
there is no plugin manager to ask -- the behaviour before the lock was
|
||||
taken here at all.
|
||||
taken here at all. ``timeout`` defaults to PLUGIN_LOCK_TIMEOUT; 0
|
||||
does not wait at all.
|
||||
"""
|
||||
if not hasattr(self.plugin_manager, 'get_plugin_lock'):
|
||||
yield True
|
||||
return
|
||||
lock = self.plugin_manager.get_plugin_lock(plugin_id)
|
||||
acquired = lock.acquire(timeout=self.PLUGIN_LOCK_TIMEOUT)
|
||||
wait = self.PLUGIN_LOCK_TIMEOUT if timeout is None else timeout
|
||||
acquired = lock.acquire(timeout=wait) if wait > 0 else lock.acquire(blocking=False)
|
||||
try:
|
||||
yield acquired
|
||||
finally:
|
||||
@@ -188,13 +248,21 @@ class PluginAdapter:
|
||||
self.display_manager.image = original_image
|
||||
|
||||
def _fetch_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool
|
||||
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool,
|
||||
keyed: bool = False
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""Every content path in order: native, scroll helper, display capture.
|
||||
"""Every content path in order: elements, native, scroll helper, capture.
|
||||
|
||||
``restricted`` is the pre-offscreen contract for background callers:
|
||||
skip every path that needs a canvas and return None instead.
|
||||
``keyed`` asks for live elements first (see get_content).
|
||||
"""
|
||||
if keyed:
|
||||
content = self._get_keyed_content(plugin, plugin_id)
|
||||
if content:
|
||||
return self._finalize(content, plugin_id, 'elements', plugin)
|
||||
logger.debug("[%s] No live elements; using its Vegas content", plugin_id)
|
||||
|
||||
# Try native Vegas content method first
|
||||
has_native = hasattr(plugin, 'get_vegas_content')
|
||||
logger.debug("[%s] Has get_vegas_content: %s", plugin_id, has_native)
|
||||
@@ -287,6 +355,12 @@ class PluginAdapter:
|
||||
dropped_blank = 0
|
||||
|
||||
for img in images:
|
||||
if meta_of(img) is not None:
|
||||
# A live element is pinned, not trimmed: it already carries
|
||||
# the margin trimming would leave, and its width must not
|
||||
# follow its ink, or a redraw could never be swapped in place.
|
||||
kept.append(img)
|
||||
continue
|
||||
result = trim_to_content(
|
||||
img,
|
||||
threshold=self.config.trim_threshold,
|
||||
@@ -389,18 +463,21 @@ class PluginAdapter:
|
||||
if isinstance(plugin_cfg, dict):
|
||||
raw = plugin_cfg.get('vegas_width_pct')
|
||||
if raw not in (None, ''):
|
||||
# Reported once per value: the live paths resolve the width on
|
||||
# every redraw, several times a second for an animated element.
|
||||
try:
|
||||
candidate = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"[%s] Invalid vegas_width_pct %r, ignoring", plugin_id, raw)
|
||||
self._warn_element_once(
|
||||
plugin_id, "Invalid vegas_width_pct %r, ignoring", raw,
|
||||
once_key=repr(raw))
|
||||
else:
|
||||
if 10 <= candidate <= 100:
|
||||
pct = candidate
|
||||
else:
|
||||
logger.warning(
|
||||
"[%s] vegas_width_pct %d out of range 10-100, ignoring",
|
||||
plugin_id, candidate)
|
||||
self._warn_element_once(
|
||||
plugin_id, "vegas_width_pct %d out of range 10-100, ignoring",
|
||||
candidate, once_key=repr(raw))
|
||||
|
||||
if pct >= 100:
|
||||
return self.display_width
|
||||
@@ -608,7 +685,16 @@ class PluginAdapter:
|
||||
return images
|
||||
|
||||
if len(images) == 1:
|
||||
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
||||
only = images[0]
|
||||
if meta_of(only) is not None and only.width - 2 * self._padding() <= budget:
|
||||
# A live element's pinned margins are not content. One whose
|
||||
# drawing fits the budget is kept whole, and live, rather than
|
||||
# cut for the sake of its own blank padding.
|
||||
self._clear_offset(plugin_id)
|
||||
return images
|
||||
# A cropped live element is only part of itself, so it can no
|
||||
# longer be swapped whole: it scrolls by as plain content.
|
||||
return [untag(self._crop_to_budget(only, budget, plugin_id, mode))]
|
||||
|
||||
shape = ('rows', len(images))
|
||||
if mode == 'truncate':
|
||||
@@ -692,7 +778,12 @@ class PluginAdapter:
|
||||
# cycle — a lone "y" from "Wednesday" floating between two unrelated
|
||||
# plugins. Overshooting the budget is the lesser evil.
|
||||
min_run = max(2, self.config.min_cut_gap)
|
||||
gaps = blank_runs(img, min_run, self.config.trim_threshold)
|
||||
# A run touching either edge is the image's margin -- the
|
||||
# content_padding trimming leaves, or a live element's pinned padding
|
||||
# -- not a gap between items. Cutting mid-margin gave a window of a few
|
||||
# blank columns, and a solid image with margins no continuous crop.
|
||||
gaps = [(a, b) for a, b in blank_runs(img, min_run, self.config.trim_threshold)
|
||||
if a > 0 and b < img.width]
|
||||
|
||||
if not gaps:
|
||||
# No internal gaps means continuous content — a map, a chart, a
|
||||
@@ -759,6 +850,285 @@ class PluginAdapter:
|
||||
)
|
||||
return img.crop((start, 0, end, img.height))
|
||||
|
||||
def _warn_element_once(self, plugin_id: str, problem: str, *args: Any,
|
||||
once_key: Optional[str] = None) -> None:
|
||||
"""Report a plugin's problem once per process, then quietly.
|
||||
|
||||
Once per ``problem`` (the format string), or per ``once_key`` within it
|
||||
when given, so a different bad value is still reported.
|
||||
"""
|
||||
key = (plugin_id, problem, once_key)
|
||||
if key in self._element_warnings:
|
||||
logger.debug("[%s] " + problem, plugin_id, *args)
|
||||
return
|
||||
self._element_warnings.add(key)
|
||||
logger.warning("[%s] " + problem, plugin_id, *args)
|
||||
|
||||
def _get_keyed_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""The plugin's live elements, as tagged images, or None.
|
||||
|
||||
Called with the plugin's lock held (get_content), so update() is not
|
||||
running and the plugin's data epoch cannot move while it draws. Drawn
|
||||
on a canvas of the plugin's own at its render width, like
|
||||
get_vegas_content(). Any failure returns None, and the caller falls
|
||||
back to the plugin's ordinary Vegas content.
|
||||
"""
|
||||
epochs = self.live_epochs
|
||||
epoch = epochs.get(plugin_id) if epochs is not None else 0
|
||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||
plugin._vegas_render_width = render_width
|
||||
try:
|
||||
with self._isolated_canvas(render_width):
|
||||
result = plugin.get_vegas_elements()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# A plugin hook can raise anything; the legacy content still works.
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() raised %r; using its "
|
||||
"get_vegas_content() instead", exc)
|
||||
return None
|
||||
finally:
|
||||
plugin._vegas_render_width = None
|
||||
try:
|
||||
return self._images_from_elements(result, plugin_id, epoch)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# Converting is per element and guarded; this is the backstop, so
|
||||
# nothing a plugin hands back can cost it its ordinary content.
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() returned elements that could not "
|
||||
"be used (%r); using its get_vegas_content() instead", exc)
|
||||
return None
|
||||
|
||||
def render_live_elements(
|
||||
self, plugin: 'BasePlugin', plugin_id: str, lock_timeout: float
|
||||
) -> Optional[Tuple[int, Dict[str, RenderedElement]]]:
|
||||
"""Redraw a plugin's live elements for the live-element worker.
|
||||
|
||||
Like the keyed fetch, but for a strip that already holds the elements:
|
||||
no cache (the caller knows the plugin's data moved on), and each live
|
||||
element comes back as a RenderedElement to compare with what the strip
|
||||
shows. An element whose ``version`` is the one already redrawn reuses
|
||||
its pinned pixels and digest, so an unchanged scoreboard costs the
|
||||
plugin's own version check and no conversion.
|
||||
|
||||
Returns ``(epoch, {key: element})``, the epoch read under the lock; an
|
||||
empty dict when the plugin had nothing (or failed, logged once). None
|
||||
only when the lock could not be had within ``lock_timeout`` -- the
|
||||
caller tries again later.
|
||||
"""
|
||||
with self._plugin_lock(plugin_id, timeout=lock_timeout) as acquired:
|
||||
if not acquired:
|
||||
return None
|
||||
epochs = self.live_epochs
|
||||
epoch = epochs.get(plugin_id) if epochs is not None else 0
|
||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||
plugin._vegas_render_width = render_width
|
||||
try:
|
||||
with self._isolated_canvas(render_width):
|
||||
result = plugin.get_vegas_elements()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() raised %r while redrawing; "
|
||||
"its elements keep what they show", exc)
|
||||
return epoch, {}
|
||||
finally:
|
||||
plugin._vegas_render_width = None
|
||||
return epoch, self._rendered_from_elements(result, plugin_id, epoch)
|
||||
|
||||
@staticmethod
|
||||
def has_lock_free_redraw(plugin: Any) -> bool:
|
||||
"""Whether the plugin's class overrides BasePlugin.redraw_vegas_element."""
|
||||
method = getattr(type(plugin), 'redraw_vegas_element', None)
|
||||
return method is not None and method is not _BasePlugin.redraw_vegas_element
|
||||
|
||||
def redraw_live_element(
|
||||
self, plugin: 'BasePlugin', plugin_id: str, key: str, width: int,
|
||||
height: int, at: float
|
||||
) -> Optional[RenderedElement]:
|
||||
"""One element redrawn for a moment in time, without the plugin's lock.
|
||||
|
||||
``width`` is the element's width in the strip (pinned); the plugin is
|
||||
asked for that less its padding, exactly, and anything else is
|
||||
refused. None when the plugin has no lock-free redraw, returns None,
|
||||
or fails (logged once).
|
||||
"""
|
||||
if not self.has_lock_free_redraw(plugin):
|
||||
return None
|
||||
padding = self._padding()
|
||||
inner = width - 2 * padding
|
||||
if inner <= 0:
|
||||
return None
|
||||
epochs = self.live_epochs
|
||||
epoch = epochs.get(plugin_id) if epochs is not None else 0
|
||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||
plugin._vegas_render_width = render_width
|
||||
try:
|
||||
with self._isolated_canvas(render_width):
|
||||
image = plugin.redraw_vegas_element(key, inner, height, at)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self._warn_element_once(
|
||||
plugin_id, "redraw_vegas_element(%r) raised %r", key, exc)
|
||||
return None
|
||||
finally:
|
||||
plugin._vegas_render_width = None
|
||||
if image is None:
|
||||
return None
|
||||
if not isinstance(image, Image.Image) or image.size != (inner, height):
|
||||
self._warn_element_once(
|
||||
plugin_id, "redraw_vegas_element(%r) returned %s, expected an "
|
||||
"image of %dx%d; ignoring it", key,
|
||||
f"{image.width}x{image.height}" if isinstance(image, Image.Image)
|
||||
else type(image).__name__, inner, height)
|
||||
return None
|
||||
_pinned, pixels = pin_element(image, padding)
|
||||
return RenderedElement(key=key, epoch=epoch, version=None, pixels=pixels,
|
||||
digest=pixel_digest(pixels), width=pixels.shape[1])
|
||||
|
||||
def _valid_elements(self, result: Any, plugin_id: str) -> Optional[List[VegasElement]]:
|
||||
"""The usable elements in a get_vegas_elements() answer, in order.
|
||||
|
||||
None for no answer (the plugin wants its ordinary content). Anything
|
||||
that is not a VegasElement with a key and a non-empty image is
|
||||
dropped, and a duplicate key keeps its first element, each reported
|
||||
once.
|
||||
"""
|
||||
if result is None:
|
||||
return None
|
||||
if not isinstance(result, (list, tuple)):
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() returned %s, expected a list "
|
||||
"of VegasElement", type(result).__name__)
|
||||
return None
|
||||
seen = set()
|
||||
valid: List[VegasElement] = []
|
||||
for element in result:
|
||||
if not (isinstance(element, VegasElement)
|
||||
and isinstance(element.key, str) and element.key
|
||||
and isinstance(element.image, Image.Image)):
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() returned an item that is "
|
||||
"not a VegasElement with a key and an image (%s); skipping it",
|
||||
type(element).__name__)
|
||||
continue
|
||||
if element.image.width <= 0 or element.image.height <= 0:
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() returned an empty image for "
|
||||
"%r; skipping it", element.key)
|
||||
continue
|
||||
if element.key in seen:
|
||||
self._warn_element_once(
|
||||
plugin_id, "get_vegas_elements() returned key %r twice; "
|
||||
"keeping the first", element.key)
|
||||
continue
|
||||
seen.add(element.key)
|
||||
valid.append(element)
|
||||
return valid
|
||||
|
||||
def _element_image(self, element: VegasElement) -> Image.Image:
|
||||
"""An element's image at the display's height, in RGB."""
|
||||
image = element.image
|
||||
if image.height != self.display_height:
|
||||
image = image.resize((image.width, self.display_height),
|
||||
Image.Resampling.LANCZOS)
|
||||
if image.mode != 'RGB':
|
||||
image = image.convert('RGB')
|
||||
return image
|
||||
|
||||
def _padding(self) -> int:
|
||||
"""Black columns a live element carries each side: what trimming would leave."""
|
||||
return self.config.content_padding if self.config.auto_trim else 0
|
||||
|
||||
@staticmethod
|
||||
def _refresh_hz(element: VegasElement) -> float:
|
||||
try:
|
||||
return max(0.0, float(element.refresh_hz or 0.0))
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
def _images_from_elements(
|
||||
self, result: Any, plugin_id: str, epoch: int
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""Turn get_vegas_elements()'s answer into images for the pipeline.
|
||||
|
||||
Live elements come out pinned (RGB, display height, content_padding
|
||||
black each side, never trimmed afterwards) and tagged with their
|
||||
ElementMeta; plain ones (``live=False``) come out as ordinary content.
|
||||
"""
|
||||
elements = self._valid_elements(result, plugin_id)
|
||||
if elements is None:
|
||||
return None
|
||||
padding = self._padding()
|
||||
now = time.monotonic()
|
||||
images: List[Image.Image] = []
|
||||
for element in elements:
|
||||
try:
|
||||
image = self._element_image(element)
|
||||
if not element.live:
|
||||
# Plain content; a tag copied from a reused image must not
|
||||
# make it live by accident.
|
||||
images.append(untag(image.copy()) if meta_of(image) else image)
|
||||
continue
|
||||
pinned, pixels = pin_element(image, padding)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
# An image Pillow cannot resize or convert (an odd mode, a
|
||||
# closed file) costs that element, not its neighbours.
|
||||
self._warn_element_once(
|
||||
plugin_id, "element %r could not be converted (%r); skipping it",
|
||||
element.key, exc)
|
||||
continue
|
||||
images.append(tag(pinned, ElementMeta(
|
||||
plugin_id=plugin_id, key=element.key, epoch=epoch,
|
||||
digest=pixel_digest(pixels), rendered_at=now,
|
||||
refresh_hz=self._refresh_hz(element), version=element.version)))
|
||||
return images or None
|
||||
|
||||
def _rendered_from_elements(
|
||||
self, result: Any, plugin_id: str, epoch: int
|
||||
) -> Dict[str, RenderedElement]:
|
||||
"""RenderedElements for the live elements in a get_vegas_elements() answer.
|
||||
|
||||
An element handed back as the very image last converted for its key,
|
||||
with the same ``version``, reuses that conversion's pinned pixels and
|
||||
digest, so nothing is converted or checksummed. The image must be the
|
||||
same object: a plugin redrawn for a new config (new colours, a
|
||||
different font) can keep its data version, and must not keep its old
|
||||
pixels with it.
|
||||
"""
|
||||
elements = self._valid_elements(result, plugin_id) or []
|
||||
padding = self._padding()
|
||||
memo = self._element_memo
|
||||
rendered: Dict[str, RenderedElement] = {}
|
||||
for element in elements:
|
||||
if not element.live:
|
||||
continue
|
||||
memo_key = (plugin_id, element.key)
|
||||
cached = memo.get(memo_key)
|
||||
if cached is not None and element.version is not None \
|
||||
and cached[0] == element.version and cached[1] is element.image \
|
||||
and cached[2] == (padding, self.display_height):
|
||||
pixels, digest = cached[3], cached[4]
|
||||
else:
|
||||
try:
|
||||
_pinned, pixels = pin_element(self._element_image(element), padding)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self._warn_element_once(
|
||||
plugin_id, "element %r could not be converted (%r); it "
|
||||
"keeps what it shows", element.key, exc)
|
||||
continue
|
||||
digest = pixel_digest(pixels)
|
||||
memo[memo_key] = (element.version, element.image,
|
||||
(padding, self.display_height), pixels, digest)
|
||||
rendered[element.key] = RenderedElement(
|
||||
key=element.key, epoch=epoch, version=element.version,
|
||||
pixels=pixels, digest=digest, width=pixels.shape[1])
|
||||
# Forget keys the plugin no longer has, so the memo stays its size.
|
||||
stale = [k for k in list(memo)
|
||||
if k[0] == plugin_id and k[1] not in rendered]
|
||||
for memo_key in stale:
|
||||
memo.pop(memo_key, None)
|
||||
return rendered
|
||||
|
||||
def _get_native_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool = False
|
||||
) -> Optional[List[Image.Image]]:
|
||||
@@ -1321,6 +1691,9 @@ class PluginAdapter:
|
||||
self._content_cache.pop(plugin_id, None)
|
||||
else:
|
||||
self._content_cache.clear()
|
||||
# A config change, most often. Swapped rather than cleared:
|
||||
# the live-element worker may be iterating the old one.
|
||||
self._element_memo = {}
|
||||
|
||||
def invalidate_plugin_scroll_cache(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
@@ -1354,7 +1727,13 @@ class PluginAdapter:
|
||||
if helper is None:
|
||||
continue
|
||||
try:
|
||||
if getattr(helper, 'cached_image', None) is not None:
|
||||
# has_strip() rather than reading cached_image, which would
|
||||
# build a deferred image only to throw it away.
|
||||
if isinstance(helper, ScrollHelper):
|
||||
has_image = helper.has_strip()
|
||||
else:
|
||||
has_image = getattr(helper, 'cached_image', None) is not None
|
||||
if has_image:
|
||||
helper.cached_image = None
|
||||
cleared = True
|
||||
if getattr(helper, 'cached_array', None) is not None:
|
||||
|
||||
@@ -5,6 +5,7 @@ Composes plugin content into one wide strip and renders the visible window of
|
||||
it each frame, using ScrollHelper for the numpy-backed scroll.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
@@ -18,6 +19,8 @@ from src.common.scroll_config import solve_crisp
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.matrix_support import DEFAULT_REFRESH_LIMIT_HZ
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.elements import ElementMeta, ElementRecord, LivePatch, LiveView, meta_of
|
||||
from src.vegas_mode.live_worker import LEGACY_PREFETCH_JOIN_S, VegasWorker
|
||||
from src.vegas_mode.geometry import separation_gap
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
|
||||
@@ -29,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.
|
||||
@@ -67,6 +115,35 @@ class RenderPipeline:
|
||||
# without __init__ (tests).
|
||||
_static_markers: Tuple[Tuple[int, str], ...] = ()
|
||||
|
||||
# Live elements in the strip (see "live element records" below). Replaced,
|
||||
# never mutated, like _static_markers, and class-level for the same reason.
|
||||
_elements: Tuple[ElementRecord, ...] = ()
|
||||
# Columns trimmed off the strip's front since it was composed.
|
||||
_strip_origin: int = 0
|
||||
# Bumped whenever a new strip replaces the old one (compose, reset), so
|
||||
# anything computed against the old strip can tell.
|
||||
_strip_gen: int = 0
|
||||
|
||||
# Live updates (see apply_live_patches and src/vegas_mode/live_worker.py).
|
||||
# Where the viewport is, published every frame for the worker.
|
||||
_view: Optional[LiveView] = None
|
||||
# Set by the coordinator for a run in which live elements are on.
|
||||
_live_enabled: bool = False
|
||||
_live_worker: Optional[VegasWorker] = None
|
||||
# The last worker stopped: it finishes its current job, and hands over
|
||||
# what it has of a group, before the one-shot prefetch fetches another.
|
||||
_retired_worker: Optional[VegasWorker] = None
|
||||
#: Patches applied between two frames at most, and the bytes they may
|
||||
#: copy, in screens of pixels (at least one patch is always applied).
|
||||
LIVE_PATCHES_PER_FRAME = 4
|
||||
LIVE_PATCH_BUDGET_SCREENS = 2
|
||||
#: A worker that dies this often in this many seconds is not restarted
|
||||
#: again this run; live updates stop and the one-shot prefetch returns.
|
||||
LIVE_WORKER_MAX_DEATHS = 3
|
||||
LIVE_WORKER_DEATH_WINDOW = 600.0
|
||||
#: Frames between checks that the worker is still alive.
|
||||
LIVE_SUPERVISE_FRAMES = 256
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: VegasModeConfig,
|
||||
@@ -117,6 +194,17 @@ class RenderPipeline:
|
||||
# Render state
|
||||
self._cycle_complete = False
|
||||
self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll
|
||||
self._record_by_seq: Dict[int, ElementRecord] = {}
|
||||
# Live updates. _applied: per record, the (epoch, digest) of the
|
||||
# pixels the strip holds. _live_slots / _live_ready: the worker's
|
||||
# hand-over, one slot per record (latest wins) and the order they
|
||||
# arrived in. Written by the worker, consumed by the render thread;
|
||||
# single-key dict operations and deque append/popleft only.
|
||||
self._applied: Dict[int, Tuple[int, Any]] = {}
|
||||
self._live_slots: Dict[int, LivePatch] = {}
|
||||
self._live_ready: Deque[int] = deque()
|
||||
self._worker_deaths: Deque[float] = deque()
|
||||
self._live_frames = 0
|
||||
|
||||
# The sub-pixel path's pacing; the crisp path solves its own (frame_interval).
|
||||
self._frame_interval = config.get_frame_interval()
|
||||
@@ -292,6 +380,8 @@ class RenderPipeline:
|
||||
# plugin boundaries only.
|
||||
grouped = self.stream_manager.get_grouped_content_for_composition()
|
||||
self._static_markers = ()
|
||||
# A compose replaces the strip, and every record with it.
|
||||
self._reset_records()
|
||||
|
||||
if not grouped:
|
||||
logger.warning("No content available for composition")
|
||||
@@ -304,10 +394,13 @@ class RenderPipeline:
|
||||
# row". Without this, a per-row ticker such as the F1 scoreboard got
|
||||
# the full separator between each of its ~116 rows.
|
||||
blocks = []
|
||||
layouts = []
|
||||
total_rows = 0
|
||||
for plugin_id, images in grouped:
|
||||
for _plugin_id, images in grouped:
|
||||
total_rows += len(images)
|
||||
blocks.append(self._join_plugin_rows(images))
|
||||
block, layout = self._join_plugin_rows_with_layout(images)
|
||||
blocks.append(block)
|
||||
layouts.append(layout)
|
||||
|
||||
# Create scrolling image via ScrollHelper.
|
||||
#
|
||||
@@ -323,11 +416,16 @@ class RenderPipeline:
|
||||
)
|
||||
|
||||
# Verify scroll image was created successfully
|
||||
if not self.scroll_helper.cached_image:
|
||||
if not self.scroll_helper.has_strip():
|
||||
logger.error("ScrollHelper failed to create cached image")
|
||||
return False
|
||||
|
||||
self._static_markers = self._markers_for_composition(blocks)
|
||||
self._register_elements(
|
||||
self._block_starts([b.width for b in blocks], 0, False,
|
||||
lead=self.config.lead_in_width),
|
||||
layouts)
|
||||
self._note_op('compose', self._strip_nbytes())
|
||||
|
||||
# Track which plugins are in this scroll (get safely via buffer status)
|
||||
self._segments_in_scroll = self.stream_manager.get_active_plugin_ids()
|
||||
@@ -340,7 +438,7 @@ class RenderPipeline:
|
||||
"Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
|
||||
"separator=%dpx between plugins, rows spaced to %dpx of ink "
|
||||
"(min added %dpx)",
|
||||
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
|
||||
self.scroll_helper.total_scroll_width if self.scroll_helper.has_strip() else 0,
|
||||
self.display_height,
|
||||
len(blocks),
|
||||
total_rows,
|
||||
@@ -356,6 +454,22 @@ class RenderPipeline:
|
||||
logger.exception("Error composing scroll content")
|
||||
return False
|
||||
|
||||
def _note_op(self, kind: str, nbytes: int = 0) -> None:
|
||||
"""Tag the next presented frame with render-thread work done for it.
|
||||
|
||||
See "Operations" in src/common/frame_timing.py: a soak can then say
|
||||
how often a frame straight after an extension or a patch was late,
|
||||
rather than only how often any frame was.
|
||||
"""
|
||||
timing = getattr(self.display_manager, 'frame_timing', None)
|
||||
note = getattr(timing, 'note_op', None)
|
||||
if note is not None:
|
||||
note(kind, nbytes)
|
||||
|
||||
def _strip_nbytes(self) -> int:
|
||||
array = self.scroll_helper.cached_array
|
||||
return int(array.nbytes) if array is not None else 0
|
||||
|
||||
def _markers_for_composition(self, blocks: List[Image.Image]) -> Tuple[Tuple[int, str], ...]:
|
||||
"""Static markers for a strip just built by create_scrolling_image.
|
||||
|
||||
@@ -412,7 +526,7 @@ class RenderPipeline:
|
||||
|
||||
Cheap enough to call every frame: it is arithmetic over cached state.
|
||||
"""
|
||||
if not self.config.continuous_scroll or not self.scroll_helper.cached_image:
|
||||
if not self.config.continuous_scroll or not self.scroll_helper.has_strip():
|
||||
return False
|
||||
threshold = int(self.display_width * self.config.extend_threshold_screens)
|
||||
return self.scroll_helper.remaining_unscrolled() <= threshold
|
||||
@@ -436,23 +550,43 @@ class RenderPipeline:
|
||||
if not self.config.continuous_scroll:
|
||||
return
|
||||
|
||||
# With live elements in the strip, the live-element worker fetches
|
||||
# groups too, one plugin at a time between its redraws, so that only
|
||||
# one thread ever draws for the strip.
|
||||
worker = self._live_worker
|
||||
if worker is not None and worker.is_alive():
|
||||
with self._prefetch_lock:
|
||||
if self._prepared_group is not None:
|
||||
return
|
||||
worker.request_group()
|
||||
return
|
||||
|
||||
with self._prefetch_lock:
|
||||
if self._prefetch_thread is not None and self._prefetch_thread.is_alive():
|
||||
return
|
||||
if self._prepared_group is not None:
|
||||
return # already have one waiting
|
||||
generation = self._prefetch_generation
|
||||
retired = self._retired_worker
|
||||
|
||||
def _work():
|
||||
# Deprioritise against the render loop. Linux applies nice
|
||||
# per-thread, and the heavy lifting here is PIL and numpy work
|
||||
# that releases the GIL, so the scheduler can actually act on
|
||||
# it — without this the prefetch competes for the same cores and
|
||||
# costs frames.
|
||||
# Deprioritise against the render loop for CPU time (Linux
|
||||
# applies nice per thread). Nice does nothing about the GIL,
|
||||
# which Pillow's drawing holds (docs/OFFSCREEN_RENDERING.md,
|
||||
# risk 5); the render gate below is what keeps this thread off
|
||||
# it while the render thread needs it.
|
||||
try:
|
||||
os.nice(10)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
# A live-element worker just stopped finishes its current job
|
||||
# and hands over what it has of a group. Wait for it, so only
|
||||
# one thread draws and a group it handed over is not replaced.
|
||||
if retired is not None and retired is not threading.current_thread() and retired.is_alive():
|
||||
retired.join(LEGACY_PREFETCH_JOIN_S)
|
||||
with self._prefetch_lock:
|
||||
if generation != self._prefetch_generation or self._prepared_group is not None:
|
||||
return
|
||||
# With vegas_scroll.prefetch_gate on, run only while the render
|
||||
# thread waits on vsync; see src/common/render_gate.py.
|
||||
gate = getattr(self.display_manager, 'render_gate', None)
|
||||
@@ -465,7 +599,8 @@ class RenderPipeline:
|
||||
with self._prefetch_lock:
|
||||
if generation != self._prefetch_generation:
|
||||
return # Vegas was reset while this was fetching
|
||||
self._prepared_group = group
|
||||
if self._prepared_group is None:
|
||||
self._prepared_group = group
|
||||
|
||||
self._prefetch_thread = threading.Thread(
|
||||
target=_work, daemon=True, name="vegas-strip-prefetch")
|
||||
@@ -525,6 +660,7 @@ class RenderPipeline:
|
||||
element_gap=0,
|
||||
)
|
||||
if appended:
|
||||
self._note_op('extend', self._strip_nbytes())
|
||||
logger.info(
|
||||
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
|
||||
plugin_id, self.scroll_helper.total_scroll_width,
|
||||
@@ -581,8 +717,10 @@ class RenderPipeline:
|
||||
else:
|
||||
content.append((pid, images))
|
||||
grouped = content
|
||||
strip_end = (self.scroll_helper.cached_image.width
|
||||
if self.scroll_helper.cached_image is not None else 0)
|
||||
# From the helper's own bookkeeping, never cached_image: reading
|
||||
# that would build the full PIL strip the helper now defers.
|
||||
strip_end = (self.scroll_helper.total_scroll_width
|
||||
if self.scroll_helper.has_strip() else 0)
|
||||
|
||||
# Plugins the background thread had to defer need the shared canvas,
|
||||
# so they can only be fetched here. Queue them rather than doing all
|
||||
@@ -613,12 +751,18 @@ class RenderPipeline:
|
||||
return bool(deferred)
|
||||
|
||||
blocks = []
|
||||
layouts = []
|
||||
total_rows = 0
|
||||
for _plugin_id, images in grouped:
|
||||
total_rows += len(images)
|
||||
blocks.append(self._join_plugin_rows(images))
|
||||
block, layout = self._join_plugin_rows_with_layout(images)
|
||||
blocks.append(block)
|
||||
layouts.append(layout)
|
||||
|
||||
had_strip = self.scroll_helper.cached_image is not None
|
||||
had_strip = self.scroll_helper.has_strip()
|
||||
if not had_strip:
|
||||
# append_content is about to build a strip from scratch.
|
||||
self._reset_records()
|
||||
appended = self.scroll_helper.append_content(
|
||||
content_items=blocks,
|
||||
item_gap=self.config.separator_width,
|
||||
@@ -626,17 +770,15 @@ class RenderPipeline:
|
||||
)
|
||||
if not appended:
|
||||
return False
|
||||
moved = self._strip_nbytes()
|
||||
|
||||
# Where each block starts, laid out as append_content does: a
|
||||
# separator before every block, or -- when there was no strip to
|
||||
# extend -- as create_scrolling_image does with no lead-in.
|
||||
starts = self._block_starts([b.width for b in blocks], strip_end, had_strip)
|
||||
self._register_elements(starts, layouts)
|
||||
if statics:
|
||||
# Where each block ends, laid out as append_content does: a
|
||||
# separator before every block, or -- when there was no strip
|
||||
# to extend -- as create_scrolling_image does with no lead-in.
|
||||
gap = max(0, self.config.separator_width)
|
||||
ends = []
|
||||
x = strip_end if had_strip else -gap
|
||||
for block in blocks:
|
||||
x += gap + block.width
|
||||
ends.append(x)
|
||||
ends = [start + block.width for start, block in zip(starts, blocks)]
|
||||
self._add_static_markers([
|
||||
(ends[n - 1] if n > 0 else strip_end, pid) for n, pid in statics
|
||||
])
|
||||
@@ -646,6 +788,10 @@ class RenderPipeline:
|
||||
if cut and self._static_markers:
|
||||
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))
|
||||
|
||||
self._segments_in_scroll = [pid for pid, _ in grouped]
|
||||
self.stats['composition_count'] += 1
|
||||
@@ -679,31 +825,248 @@ class RenderPipeline:
|
||||
``intra_plugin_gap``. Returned unchanged when there is only one row,
|
||||
which is the common case and avoids a pointless copy.
|
||||
"""
|
||||
if len(images) == 1:
|
||||
return images[0]
|
||||
return self._join_plugin_rows_with_layout(images)[0]
|
||||
|
||||
floor = max(0, self.config.intra_plugin_gap)
|
||||
target = max(0, self.config.min_content_separation)
|
||||
threshold = self.config.trim_threshold
|
||||
def _join_plugin_rows_with_layout(
|
||||
self, images: List[Image.Image]
|
||||
) -> Tuple[Image.Image, List[Tuple[int, ElementMeta, int]]]:
|
||||
"""_join_plugin_rows, plus where each live element landed in the block.
|
||||
|
||||
# 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)
|
||||
]
|
||||
See join_plugin_rows.
|
||||
"""
|
||||
return join_plugin_rows(images, self.config)
|
||||
|
||||
width = sum(img.width for img in images) + sum(gaps)
|
||||
height = max(img.height for img in images)
|
||||
# -- live element records ---------------------------------------------
|
||||
#
|
||||
# Where each live element sits in the strip (ElementRecord), kept so a
|
||||
# redraw can later be swapped into exactly its columns. Coordinates are
|
||||
# absolute: a record's column in the strip is abs_x - _strip_origin, and
|
||||
# a trim moves the origin instead of every record. Only the render thread
|
||||
# changes any of this, at the points where it builds or trims the strip.
|
||||
|
||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
x = 0
|
||||
for i, img in enumerate(images):
|
||||
block.paste(img, (x, 0))
|
||||
x += img.width + (gaps[i] if i < len(gaps) else 0)
|
||||
return block
|
||||
def _block_starts(self, widths: List[int], strip_end: int, had_strip: bool,
|
||||
lead: int = 0) -> List[int]:
|
||||
"""Strip columns where each of these blocks starts once placed.
|
||||
|
||||
Mirrors ScrollHelper exactly: append_content puts a separator before
|
||||
every block after an existing strip; create_scrolling_image (a compose,
|
||||
or an append with nothing to extend) puts ``lead`` columns first and a
|
||||
separator between blocks.
|
||||
"""
|
||||
gap = max(0, self.config.separator_width)
|
||||
starts = []
|
||||
if had_strip:
|
||||
x = strip_end
|
||||
for width in widths:
|
||||
x += gap
|
||||
starts.append(x)
|
||||
x += width
|
||||
else:
|
||||
x = max(0, int(lead))
|
||||
for width in widths:
|
||||
starts.append(x)
|
||||
x += width + gap
|
||||
return starts
|
||||
|
||||
def _next_record_seq(self) -> int:
|
||||
counter = self.__dict__.get('_record_counter')
|
||||
if counter is None:
|
||||
counter = self._record_counter = itertools.count(1)
|
||||
return next(counter)
|
||||
|
||||
def _register_elements(
|
||||
self, starts: List[int], layouts: List[List[Tuple[int, ElementMeta, int]]]
|
||||
) -> int:
|
||||
"""Record every live element in blocks just placed at ``starts``."""
|
||||
new = []
|
||||
for start, layout in zip(starts, layouts):
|
||||
for offset, meta, width in layout:
|
||||
new.append(ElementRecord(
|
||||
seq=self._next_record_seq(), plugin_id=meta.plugin_id,
|
||||
key=meta.key, abs_x=self._strip_origin + start + offset,
|
||||
width=width, epoch=meta.epoch, digest=meta.digest,
|
||||
refresh_hz=meta.refresh_hz))
|
||||
if new:
|
||||
self._elements = self._elements + tuple(new)
|
||||
by_seq = self.__dict__.setdefault('_record_by_seq', {})
|
||||
applied = self.__dict__.setdefault('_applied', {})
|
||||
for record in new:
|
||||
by_seq[record.seq] = record
|
||||
applied[record.seq] = (record.epoch, record.digest)
|
||||
if self._live_enabled:
|
||||
self._ensure_live_worker()
|
||||
return len(new)
|
||||
|
||||
def _forget_trimmed_records(self, cut: int) -> None:
|
||||
"""The strip lost ``cut`` columns off its front: move the origin on."""
|
||||
if cut <= 0:
|
||||
return
|
||||
self._strip_origin += cut
|
||||
origin = self._strip_origin
|
||||
records = self._elements
|
||||
if not records:
|
||||
return
|
||||
kept = tuple(r for r in records if r.abs_x + r.width > origin)
|
||||
if len(kept) != len(records):
|
||||
by_seq = self.__dict__.setdefault('_record_by_seq', {})
|
||||
applied = self.__dict__.setdefault('_applied', {})
|
||||
slots = self.__dict__.setdefault('_live_slots', {})
|
||||
for record in records:
|
||||
if record.abs_x + record.width <= origin:
|
||||
by_seq.pop(record.seq, None)
|
||||
applied.pop(record.seq, None)
|
||||
slots.pop(record.seq, None)
|
||||
self._elements = kept
|
||||
|
||||
def _reset_records(self) -> None:
|
||||
"""A new strip: nothing recorded, coordinates from zero, a new generation."""
|
||||
self._strip_gen += 1
|
||||
self._strip_origin = 0
|
||||
self._elements = ()
|
||||
self._record_by_seq = {}
|
||||
self._applied = {}
|
||||
# Patches still queued belong to the old strip; apply would drop them
|
||||
# on their generation anyway, but there is no reason to keep them.
|
||||
self._live_slots = {}
|
||||
self._live_ready = deque()
|
||||
self._view = None
|
||||
|
||||
def has_live_records(self) -> bool:
|
||||
"""Whether the strip holds any live element."""
|
||||
return bool(self._elements)
|
||||
|
||||
# -- live updates ---------------------------------------------------------
|
||||
|
||||
def set_live(self, enabled: bool) -> None:
|
||||
"""Switch live updates on or off for this run (the coordinator decides)."""
|
||||
self._live_enabled = enabled
|
||||
if enabled:
|
||||
if self._elements:
|
||||
self._ensure_live_worker()
|
||||
elif self._stop_live_worker():
|
||||
# The worker was fetching the strip's groups as well. Hand that
|
||||
# back to the one-shot prefetch now: nothing else asks for a group
|
||||
# until the next extension, which would find none prepared and
|
||||
# fetch inline, stalling the scroll.
|
||||
self.start_prefetch()
|
||||
|
||||
def notify_live_data(self, plugin_id: str) -> None:
|
||||
"""A plugin's data may have changed: wake the worker, if one runs."""
|
||||
worker = self._live_worker
|
||||
if worker is not None:
|
||||
worker.notify_data(plugin_id)
|
||||
|
||||
def _ensure_live_worker(self) -> None:
|
||||
"""Start the live-element worker, or restart one that died.
|
||||
|
||||
Started lazily, by the first live element placed: an install with no
|
||||
plugin that has live elements keeps the one-shot prefetch thread and
|
||||
never runs this worker at all. A worker that keeps dying is given up
|
||||
on for the run; live updates stop and the one-shot prefetch returns.
|
||||
"""
|
||||
if not self._live_enabled:
|
||||
return
|
||||
worker = self._live_worker
|
||||
if worker is not None and worker.is_alive():
|
||||
return
|
||||
if worker is None and not self._elements:
|
||||
# Nothing live in the strip: the one-shot prefetch does the work
|
||||
# until a live element is placed (_register_elements).
|
||||
return
|
||||
deaths = self.__dict__.setdefault('_worker_deaths', deque())
|
||||
now = time.monotonic()
|
||||
if worker is not None:
|
||||
deaths.append(now)
|
||||
while deaths and now - deaths[0] > self.LIVE_WORKER_DEATH_WINDOW:
|
||||
deaths.popleft()
|
||||
if len(deaths) >= self.LIVE_WORKER_MAX_DEATHS:
|
||||
logger.error(
|
||||
"Vegas live worker stopped %d times in %.0fs; live updates "
|
||||
"are off until Vegas restarts", len(deaths),
|
||||
self.LIVE_WORKER_DEATH_WINDOW)
|
||||
self._live_enabled = False
|
||||
self._live_worker = None
|
||||
# Whatever group the dead worker was fetching is lost.
|
||||
self.start_prefetch()
|
||||
return
|
||||
logger.warning("Vegas live worker was not running; restarting it")
|
||||
worker = VegasWorker(self)
|
||||
self._live_worker = worker
|
||||
worker.start()
|
||||
# Whatever the one-shot prefetch was asked for, the worker now does.
|
||||
with self._prefetch_lock:
|
||||
wanted = self._prepared_group is None
|
||||
if wanted and self.config.continuous_scroll:
|
||||
worker.request_group()
|
||||
|
||||
def _stop_live_worker(self) -> bool:
|
||||
"""Ask the worker to stop after its current job. Whether one was running."""
|
||||
worker, self._live_worker = self._live_worker, None
|
||||
if worker is None:
|
||||
return False
|
||||
self._retired_worker = worker
|
||||
worker.stop()
|
||||
return True
|
||||
|
||||
def apply_live_patches(self) -> int:
|
||||
"""Copy the worker's finished redraws into the strip. Render thread only.
|
||||
|
||||
Called between two frames (coordinator.run_frame). The only work here
|
||||
is popping prepared patches and a numpy slice copy per patch -- no
|
||||
drawing, no locks, no allocation -- bounded to LIVE_PATCHES_PER_FRAME
|
||||
patches or LIVE_PATCH_BUDGET_SCREENS screens of bytes, whichever comes
|
||||
first (always at least one). A patch is dropped when it no longer
|
||||
fits: made for an older strip, for an element trimmed away or already
|
||||
behind the screen, or older than what the strip already shows.
|
||||
|
||||
Returns:
|
||||
Patches applied.
|
||||
"""
|
||||
if self._live_enabled:
|
||||
self._live_frames = self.__dict__.get('_live_frames', 0) + 1
|
||||
if self._live_frames % self.LIVE_SUPERVISE_FRAMES == 0:
|
||||
self._ensure_live_worker()
|
||||
ready = self.__dict__.get('_live_ready')
|
||||
if not ready:
|
||||
return 0
|
||||
slots = self._live_slots
|
||||
if getattr(self, 'sync_manager', None) is not None:
|
||||
# Defensive: live elements are never on under sync, and the
|
||||
# follower would not see a patch.
|
||||
ready.clear()
|
||||
slots.clear()
|
||||
return 0
|
||||
budget = (self.LIVE_PATCH_BUDGET_SCREENS * self.display_width
|
||||
* self.display_height * 3)
|
||||
helper = self.scroll_helper
|
||||
left_edge = int(helper.scroll_position)
|
||||
applied = 0
|
||||
moved = 0
|
||||
while ready and applied < self.LIVE_PATCHES_PER_FRAME \
|
||||
and (applied == 0 or moved < budget):
|
||||
seq = ready.popleft()
|
||||
patch = slots.pop(seq, None)
|
||||
if patch is None:
|
||||
continue # a newer patch for this record already went
|
||||
record = self._record_by_seq.get(seq)
|
||||
if record is None or patch.strip_gen != self._strip_gen:
|
||||
continue
|
||||
previous = self._applied.get(seq)
|
||||
if previous is not None and patch.epoch < previous[0]:
|
||||
continue
|
||||
x = record.abs_x - self._strip_origin
|
||||
if x + record.width <= left_edge:
|
||||
continue # scrolled past; nobody will see it
|
||||
moved += helper.patch_columns(x, patch.pixels)
|
||||
self._applied[seq] = (patch.epoch, patch.digest)
|
||||
applied += 1
|
||||
if applied:
|
||||
self._note_op('patch', moved)
|
||||
return applied
|
||||
|
||||
def live_records(self) -> Tuple[ElementRecord, ...]:
|
||||
"""The live elements in the strip, in the order they were placed."""
|
||||
return self._elements
|
||||
|
||||
def render_frame(self) -> bool:
|
||||
"""
|
||||
@@ -718,11 +1081,18 @@ class RenderPipeline:
|
||||
frame_start = time.time()
|
||||
|
||||
try:
|
||||
if not self.scroll_helper.cached_image:
|
||||
if not self.scroll_helper.has_strip():
|
||||
return False
|
||||
|
||||
# Update scroll position
|
||||
self.scroll_helper.update_scroll_position()
|
||||
# Where the viewport is now, for the live-element worker: one
|
||||
# tuple store, read by the worker without a lock.
|
||||
left = self._strip_origin + int(self.scroll_helper.scroll_position)
|
||||
self._view = LiveView(
|
||||
abs_left=left, abs_right=left + self.display_width,
|
||||
abs_end=self._strip_origin + self.scroll_helper.total_scroll_width,
|
||||
t_mono=time.monotonic())
|
||||
|
||||
# Determine if the cycle is done.
|
||||
#
|
||||
@@ -958,10 +1328,11 @@ class RenderPipeline:
|
||||
self.sync_manager.send_new_cycle()
|
||||
# Push the actual scroll image over TCP so follower has identical pixels.
|
||||
# Done in a background thread to not block the render loop (~15ms transfer).
|
||||
if self.scroll_helper.cached_image is not None:
|
||||
image = self.scroll_helper.cached_image
|
||||
if image is not None:
|
||||
threading.Thread(
|
||||
target=self.sync_manager.send_scroll_image,
|
||||
args=(self.scroll_helper.cached_image,),
|
||||
args=(image,),
|
||||
daemon=True, name="sync-image-push"
|
||||
).start()
|
||||
|
||||
@@ -1034,6 +1405,8 @@ class RenderPipeline:
|
||||
self._prepared_group = None
|
||||
self._deferred_queue = []
|
||||
self._static_markers = ()
|
||||
self._stop_live_worker()
|
||||
self._reset_records()
|
||||
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
|
||||
|
||||
@@ -717,6 +717,20 @@ class StreamManager:
|
||||
A STATIC plugin is also returned with an empty list, unfetched: it
|
||||
pauses the scroll instead of adding to it (see is_static_plugin).
|
||||
"""
|
||||
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
|
||||
for plugin_id in self.plan_next_group(count):
|
||||
member = self.fetch_group_member(plugin_id, offscreen_only=offscreen_only)
|
||||
if member is not None:
|
||||
group.append(member)
|
||||
return group
|
||||
|
||||
def plan_next_group(self, count: Optional[int] = None) -> List[str]:
|
||||
"""Which plugins the next group holds, advancing the rotation past them.
|
||||
|
||||
The first half of take_next_group(). The live-element worker fetches
|
||||
a group one plugin at a time (fetch_group_member) so it can fit more
|
||||
urgent redraws between them.
|
||||
"""
|
||||
if count is None:
|
||||
count = self.config.plugins_per_cycle
|
||||
|
||||
@@ -730,37 +744,38 @@ class StreamManager:
|
||||
for _ in range(min(max(1, count), total)):
|
||||
ids.append(self._ordered_plugins[self._prefetch_index])
|
||||
self._prefetch_index = (self._prefetch_index + 1) % total
|
||||
return ids
|
||||
|
||||
plugins = getattr(self.plugin_manager, 'plugins', {})
|
||||
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
|
||||
def fetch_group_member(
|
||||
self, plugin_id: str, offscreen_only: bool = False
|
||||
) -> Optional[Tuple[str, Optional[List[Image.Image]]]]:
|
||||
"""One plugin's entry in a group, as take_next_group() describes it.
|
||||
|
||||
None when the plugin is gone or its fetch raised: it is left out of
|
||||
the group.
|
||||
"""
|
||||
plugin = getattr(self.plugin_manager, 'plugins', {}).get(plugin_id)
|
||||
if not plugin:
|
||||
return None
|
||||
if self.is_static_plugin(plugin_id):
|
||||
# A STATIC plugin pauses the scroll rather than scrolling by, so it
|
||||
# contributes no columns. It keeps its place in the group (empty)
|
||||
# so the pipeline can mark where its turn falls.
|
||||
return (plugin_id, [])
|
||||
try:
|
||||
images = self.plugin_adapter.get_content(
|
||||
plugin, plugin_id, offscreen_only=offscreen_only)
|
||||
except Exception:
|
||||
logger.exception("[%s] ERROR fetching content", plugin_id)
|
||||
self.stats['fetch_errors'] += 1
|
||||
return None
|
||||
if images:
|
||||
self.stats['segments_fetched'] += 1
|
||||
return (plugin_id, images)
|
||||
# Only the old contract hands anything back to the render thread.
|
||||
defer_empty = offscreen_only and not getattr(
|
||||
self.config, 'offscreen_prefetch', True)
|
||||
|
||||
for plugin_id in ids:
|
||||
plugin = plugins.get(plugin_id)
|
||||
if not plugin:
|
||||
continue
|
||||
if self.is_static_plugin(plugin_id):
|
||||
# A STATIC plugin pauses the scroll rather than scrolling by,
|
||||
# so it contributes no columns. It keeps its place in the
|
||||
# group (empty) so the pipeline can mark where its turn falls.
|
||||
group.append((plugin_id, []))
|
||||
continue
|
||||
try:
|
||||
images = self.plugin_adapter.get_content(
|
||||
plugin, plugin_id, offscreen_only=offscreen_only)
|
||||
except Exception:
|
||||
logger.exception("[%s] ERROR fetching content", plugin_id)
|
||||
self.stats['fetch_errors'] += 1
|
||||
continue
|
||||
if images:
|
||||
self.stats['segments_fetched'] += 1
|
||||
group.append((plugin_id, images))
|
||||
else:
|
||||
group.append((plugin_id, None if defer_empty else []))
|
||||
|
||||
return group
|
||||
return (plugin_id, None if defer_empty else [])
|
||||
|
||||
def advance_cycle(self) -> None:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable the stub (test fixture only)."
|
||||
},
|
||||
"cards": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 32,
|
||||
"default": 6,
|
||||
"description": "How many keyed cards to return."
|
||||
},
|
||||
"card_width": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 512,
|
||||
"default": 0,
|
||||
"description": "Card width in px; 0 sizes cards from the render width."
|
||||
},
|
||||
"map_hz": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 30,
|
||||
"default": 4,
|
||||
"description": "refresh_hz of the full-width animated element; 0 leaves it out."
|
||||
},
|
||||
"dot_speed": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 200,
|
||||
"default": 20,
|
||||
"description": "How fast the animated element's dot moves, in px per second."
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Vegas live-element stub.
|
||||
|
||||
A fixture, not a product: it exercises every part of the live-element contract
|
||||
(src/plugin_system/vegas_elements.py) with content whose changes are easy to
|
||||
see and to assert on, and nothing else -- no fonts, no network.
|
||||
|
||||
- ``card:<n>`` -- fixed-width cards. Each draws the bits of ``tick + n`` as
|
||||
lit bars, so every update() changes every card's pixels but never its width.
|
||||
- ``sep`` -- a separator, ``live=False``: placed and trimmed like plain content.
|
||||
- ``map`` -- one full-render-width element with ``refresh_hz``: a dot that
|
||||
moves across it with time, drawn by redraw_vegas_element() from state
|
||||
published in a single attribute store, so it is safe to call without the
|
||||
plugin's lock.
|
||||
|
||||
update() only advances the tick. display() draws the tick's bars full screen
|
||||
so the plugin also passes the ordinary rendering harness.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
try:
|
||||
from src.plugin_system.vegas_elements import VegasElement
|
||||
except ImportError: # core older than 3.8.0: the hooks are never called
|
||||
VegasElement = None
|
||||
|
||||
_COLOURS = [(255, 64, 64), (64, 255, 64), (64, 128, 255), (255, 200, 0),
|
||||
(255, 64, 255), (0, 220, 220)]
|
||||
|
||||
|
||||
class VegasLiveStub(BasePlugin):
|
||||
"""Keyed cards, a separator and an animated element for the Vegas ticker."""
|
||||
|
||||
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
self.tick = 0
|
||||
# Everything the lock-free redraw reads, published in one store.
|
||||
self._snapshot: Tuple[int, float] = (0, time.monotonic())
|
||||
|
||||
# -- data -------------------------------------------------------------
|
||||
|
||||
def update(self) -> None:
|
||||
self.tick += 1
|
||||
self._snapshot = (self.tick, time.monotonic())
|
||||
|
||||
# -- drawing ----------------------------------------------------------
|
||||
|
||||
def _bars(self, image: Image.Image, value: int, colour, box) -> None:
|
||||
x0, y0, x1, y1 = box
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle([x0, y0, x1, y1], outline=colour)
|
||||
bits = 8
|
||||
span = max(1, (x1 - x0 - 2) // bits)
|
||||
for bit in range(bits):
|
||||
if value >> bit & 1:
|
||||
left = x0 + 1 + bit * span
|
||||
draw.rectangle([left, y0 + 2, left + max(0, span - 2), y1 - 2],
|
||||
fill=colour)
|
||||
|
||||
def _card_width(self) -> int:
|
||||
configured = int(self.config.get('card_width', 0) or 0)
|
||||
if configured > 0:
|
||||
return configured
|
||||
return max(24, min(64, self.get_vegas_render_width() // 4))
|
||||
|
||||
def _card(self, index: int, tick: int) -> Image.Image:
|
||||
width, height = self._card_width(), self.display_manager.height
|
||||
image = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
self._bars(image, tick + index, _COLOURS[index % len(_COLOURS)],
|
||||
(0, 0, width - 1, height - 1))
|
||||
return image
|
||||
|
||||
def _map(self, width: int, height: int, at: float) -> Image.Image:
|
||||
tick, _published = self._snapshot
|
||||
image = Image.new('RGB', (width, height), (0, 0, 16))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle([0, 0, width - 1, height - 1], outline=(40, 40, 80))
|
||||
speed = float(self.config.get('dot_speed', 20) or 0)
|
||||
x = int(at * speed) % max(1, width - 4) + 2
|
||||
y = 2 + tick % max(1, height - 4)
|
||||
draw.rectangle([x - 1, y - 1, x + 1, y + 1], fill=(255, 255, 255))
|
||||
return image
|
||||
|
||||
def _dot_column(self, width: int, at: float) -> int:
|
||||
speed = float(self.config.get('dot_speed', 20) or 0)
|
||||
return int(at * speed) % max(1, width - 4) + 2
|
||||
|
||||
def display(self, force_clear: bool = False) -> bool:
|
||||
width, height = self.display_manager.width, self.display_manager.height
|
||||
self.display_manager.clear()
|
||||
self._bars(self.display_manager.image, self.tick, _COLOURS[0],
|
||||
(0, 0, width - 1, height - 1))
|
||||
self.display_manager.update_display()
|
||||
return True
|
||||
|
||||
# -- Vegas ------------------------------------------------------------
|
||||
|
||||
def get_vegas_content(self) -> Optional[List[Image.Image]]:
|
||||
cards = int(self.config.get('cards', 6))
|
||||
return [self._card(i, self.tick) for i in range(cards)] or None
|
||||
|
||||
def get_vegas_elements(self):
|
||||
if VegasElement is None:
|
||||
return None
|
||||
tick = self.tick
|
||||
elements = []
|
||||
for i in range(int(self.config.get('cards', 6))):
|
||||
elements.append(VegasElement(
|
||||
key=f"card:{i}", image=self._card(i, tick),
|
||||
version=(tick, i, self._card_width())))
|
||||
if i == 0:
|
||||
separator = Image.new('RGB', (4, self.display_manager.height), (0, 0, 0))
|
||||
ImageDraw.Draw(separator).rectangle(
|
||||
[1, 0, 2, self.display_manager.height - 1], fill=(90, 90, 90))
|
||||
elements.append(VegasElement(key="sep", image=separator, live=False))
|
||||
hz = float(self.config.get('map_hz', 4) or 0)
|
||||
if hz > 0:
|
||||
width, height = self.get_vegas_render_width(), self.display_manager.height
|
||||
now = time.monotonic()
|
||||
elements.append(VegasElement(
|
||||
key="map", image=self._map(width, height, now),
|
||||
version=(tick, width, self._dot_column(width, now)),
|
||||
refresh_hz=hz))
|
||||
return elements
|
||||
|
||||
def redraw_vegas_element(self, key, width, height, at):
|
||||
if key != "map":
|
||||
return None
|
||||
return self._map(width, height, at)
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "vegas-live-stub",
|
||||
"name": "Vegas Live Stub",
|
||||
"version": "1.0.0",
|
||||
"description": "Test fixture for live Vegas elements: keyed cards whose content changes on every update, a separator, and a full-width element that animates with time. Drives the live-element tests and the hardware soaks. Not installable from the store and never shipped to devices.",
|
||||
"author": "LEDMatrix",
|
||||
"entry_point": "manager.py",
|
||||
"class_name": "VegasLiveStub",
|
||||
"display_modes": ["vegas-live-stub"],
|
||||
"update_interval": 2,
|
||||
"min_ledmatrix_version": "2.0.0",
|
||||
"compatible_versions": [">=2.0.0"]
|
||||
}
|
||||
@@ -119,6 +119,21 @@ class TestJsonPartialSaves:
|
||||
'continuous_scroll', 'smooth_scroll'):
|
||||
assert vegas[key] is True, key
|
||||
|
||||
def test_vegas_speed_only_keeps_live_updates_as_stored(self, api_v3_client, saved):
|
||||
resp = _post_json(api_v3_client, {'vegas_scroll_speed': 70})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert 'live_refresh' not in saved['config']['display']['vegas_scroll']
|
||||
|
||||
def test_live_updates_can_be_switched_off_and_on(self, api_v3_client, saved):
|
||||
resp = _post_json(api_v3_client, {'vegas_live_refresh': False})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['display']['vegas_scroll']['live_refresh'] is False
|
||||
resp = _post_json(api_v3_client, {'__form_section': 'display',
|
||||
'vegas_scroll_speed': '50',
|
||||
'vegas_live_refresh': 'on'})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert saved['config']['display']['vegas_scroll']['live_refresh'] is True
|
||||
|
||||
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,363 @@
|
||||
"""Attributing late frames to render-thread work (FrameTimingRecorder.note_op).
|
||||
|
||||
The recorder already says how often a moving frame was late. These tests pin
|
||||
the part that says which work it followed: Vegas tags a strip extension or a
|
||||
live-element patch before the frame it lands in, and the soak report gives
|
||||
each kind its own late rate. The render bench drives the same work on a
|
||||
schedule so it can be measured on a panel with nothing else running.
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
from src.common.frame_timing import FrameTimingRecorder # noqa: E402
|
||||
from src.common.scroll_helper import ScrollHelper # noqa: E402
|
||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline # noqa: E402
|
||||
|
||||
import frame_soak # noqa: E402
|
||||
import render_bench # noqa: E402
|
||||
|
||||
PERIOD = 0.010 # a 100Hz panel
|
||||
|
||||
|
||||
def _recorder(tmp_path, **kwargs):
|
||||
return FrameTimingRecorder(path=str(tmp_path / "stats.json"),
|
||||
flush_interval=1e9, refresh_hz=100.0, **kwargs)
|
||||
|
||||
|
||||
def _frame(recorder, t, scrolling=True, hold=1):
|
||||
recorder.record(0.002, 0.004, hold, scrolling, t)
|
||||
|
||||
|
||||
def _totals(recorder):
|
||||
recorder.drain()
|
||||
return recorder.totals
|
||||
|
||||
|
||||
# -- the recorder -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_noted_op_tags_the_next_frame_only(tmp_path):
|
||||
r = _recorder(tmp_path)
|
||||
t = 100.0
|
||||
_frame(r, t)
|
||||
for i in range(10):
|
||||
if i == 4:
|
||||
r.note_op("patch", 1000)
|
||||
t += PERIOD
|
||||
_frame(r, t)
|
||||
totals = _totals(r)
|
||||
assert totals["op_frames"] == {"patch": 1}
|
||||
assert totals["late_op_frames"] == {}
|
||||
assert totals["op_bytes"] == {"patch": 1000}
|
||||
|
||||
|
||||
def test_a_late_frame_after_an_op_is_counted_against_it(tmp_path):
|
||||
r = _recorder(tmp_path)
|
||||
t = 100.0
|
||||
_frame(r, t)
|
||||
for i in range(20):
|
||||
late = i in (5, 12)
|
||||
if late or i == 15:
|
||||
r.note_op("extend", 4_000_000)
|
||||
t += 2 * PERIOD if late else PERIOD
|
||||
_frame(r, t)
|
||||
totals = _totals(r)
|
||||
assert totals["late_frames"] == 2
|
||||
assert totals["op_frames"] == {"extend": 3}
|
||||
assert totals["late_op_frames"] == {"extend": 2}
|
||||
assert totals["op_bytes"] == {"extend": 12_000_000}
|
||||
|
||||
|
||||
def test_notes_before_one_frame_accumulate_per_kind(tmp_path):
|
||||
r = _recorder(tmp_path)
|
||||
_frame(r, 100.0)
|
||||
r.note_op("patch", 100)
|
||||
r.note_op("patch", 50)
|
||||
r.note_op("extend", 7)
|
||||
_frame(r, 100.0 + PERIOD)
|
||||
totals = _totals(r)
|
||||
assert totals["op_frames"] == {"patch": 1, "extend": 1}
|
||||
assert totals["op_bytes"] == {"patch": 150, "extend": 7}
|
||||
|
||||
|
||||
def test_an_op_before_a_freeze_is_a_freeze_not_a_late_frame(tmp_path):
|
||||
r = _recorder(tmp_path)
|
||||
_frame(r, 100.0)
|
||||
r.note_op("extend", 10)
|
||||
_frame(r, 100.4)
|
||||
totals = _totals(r)
|
||||
assert totals["freezes"] == 1
|
||||
assert totals["op_freezes"] == {"extend": 1}
|
||||
assert totals["op_frames"] == {}
|
||||
assert totals["op_bytes"] == {"extend": 10}
|
||||
|
||||
|
||||
def test_an_op_survives_the_scroll_state_going_missing_for_a_frame(tmp_path):
|
||||
# The frame the op landed in was presented with the scroll state missing;
|
||||
# the scroll resumed, so the interval counts, and so does its tag.
|
||||
r = _recorder(tmp_path)
|
||||
_frame(r, 100.0)
|
||||
r.note_op("patch", 5)
|
||||
_frame(r, 100.0 + 2 * PERIOD, scrolling=False)
|
||||
_frame(r, 100.0 + 3 * PERIOD)
|
||||
totals = _totals(r)
|
||||
assert totals["late_frames"] == 1
|
||||
assert totals["late_op_frames"] == {"patch": 1}
|
||||
|
||||
|
||||
def test_an_op_noted_before_a_static_frame_is_dropped(tmp_path):
|
||||
r = _recorder(tmp_path)
|
||||
_frame(r, 100.0, scrolling=False)
|
||||
r.note_op("patch", 5)
|
||||
_frame(r, 101.0, scrolling=False)
|
||||
_frame(r, 102.0, scrolling=False)
|
||||
totals = _totals(r)
|
||||
assert totals["op_frames"] == {} and totals["op_bytes"] == {}
|
||||
|
||||
|
||||
def test_frames_before_the_period_is_known_are_not_op_frames(tmp_path):
|
||||
# op_frames is a denominator for late_op_frames, which needs a period.
|
||||
r = FrameTimingRecorder(path=str(tmp_path / "s.json"), flush_interval=1e9)
|
||||
_frame(r, 100.0)
|
||||
r.note_op("patch", 5)
|
||||
_frame(r, 100.0 + PERIOD)
|
||||
totals = _totals(r)
|
||||
assert totals["op_frames"] == {}
|
||||
assert totals["op_bytes"] == {"patch": 5}
|
||||
|
||||
|
||||
def test_aggregate_still_takes_frames_without_ops(tmp_path):
|
||||
r = _recorder(tmp_path)
|
||||
r.aggregate([(PERIOD, 0.002, 0.004, 1)] * 5, 0)
|
||||
assert r.totals["scroll_frames"] == 5
|
||||
assert r.totals["op_frames"] == {}
|
||||
|
||||
|
||||
# -- the soak report ----------------------------------------------------------
|
||||
|
||||
|
||||
def _report(r, before):
|
||||
r.drain()
|
||||
after = json.loads(json.dumps(r.snapshot()))
|
||||
after["updated"] = before["updated"] + 10.0
|
||||
return frame_soak.build_report(before, after, preview=False)
|
||||
|
||||
|
||||
def test_the_soak_report_gives_each_kind_its_late_rate(tmp_path):
|
||||
r = _recorder(tmp_path)
|
||||
_frame(r, 50.0)
|
||||
r.note_op("patch", 10)
|
||||
_frame(r, 50.0 + PERIOD)
|
||||
r.drain()
|
||||
before = json.loads(json.dumps(r.snapshot()))
|
||||
|
||||
t = 100.0
|
||||
_frame(r, t)
|
||||
for i in range(400):
|
||||
late = i % 100 == 0
|
||||
if i % 4 == 0:
|
||||
r.note_op("patch", 1000)
|
||||
t += 2 * PERIOD if late else PERIOD
|
||||
_frame(r, t)
|
||||
report = _report(r, before)
|
||||
row = report["ops"]["patch"]
|
||||
assert row["frames"] == 100 # the frame before the baseline is not in it
|
||||
assert row["late"] == 4
|
||||
assert row["late_pct"] == 4.0
|
||||
assert row["bytes"] == 100_000
|
||||
|
||||
|
||||
def test_the_soak_report_has_no_op_rows_when_nothing_was_tagged(tmp_path, capsys):
|
||||
r = _recorder(tmp_path)
|
||||
before = json.loads(json.dumps(r.snapshot()))
|
||||
t = 100.0
|
||||
_frame(r, t)
|
||||
for _ in range(200):
|
||||
t += PERIOD
|
||||
_frame(r, t)
|
||||
report = _report(r, before)
|
||||
assert report["ops"] == {}
|
||||
frame_soak.print_report(report, 0.1)
|
||||
assert "after work" not in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_the_soak_report_prints_the_op_table(tmp_path, capsys):
|
||||
r = _recorder(tmp_path)
|
||||
before = json.loads(json.dumps(r.snapshot()))
|
||||
t = 100.0
|
||||
_frame(r, t)
|
||||
for i in range(200):
|
||||
if i == 10:
|
||||
r.note_op("extend", 3_000_000)
|
||||
t += PERIOD
|
||||
_frame(r, t)
|
||||
frame_soak.print_report(_report(r, before), 0.1)
|
||||
out = capsys.readouterr().out
|
||||
assert "after work" in out
|
||||
assert "extend" in out
|
||||
|
||||
|
||||
def test_a_report_from_an_older_recorder_has_an_empty_op_table():
|
||||
totals = {"late_frames": 0}
|
||||
assert frame_soak.op_rows(totals) == {}
|
||||
|
||||
|
||||
# -- Vegas tags its own work ---------------------------------------------------
|
||||
|
||||
W, H = 128, 32
|
||||
|
||||
|
||||
class _DM:
|
||||
width = W
|
||||
height = H
|
||||
|
||||
def __init__(self, recorder):
|
||||
self.image = Image.new("RGB", (W, H))
|
||||
self.frame_timing = recorder
|
||||
|
||||
def set_scrolling_state(self, *a):
|
||||
pass
|
||||
|
||||
def update_display(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Stream:
|
||||
def __init__(self, groups):
|
||||
self.groups = groups
|
||||
self.plugin_manager = type("PM", (), {"plugins": {}})()
|
||||
self.plugin_adapter = None
|
||||
self._i = 0
|
||||
|
||||
def get_grouped_content_for_composition(self):
|
||||
return self.groups[0]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return [pid for pid, _ in self.groups[0]]
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
if self._i >= len(self.groups):
|
||||
return []
|
||||
group = self.groups[self._i]
|
||||
self._i += 1
|
||||
return group
|
||||
|
||||
|
||||
class _NoteSpy:
|
||||
def __init__(self):
|
||||
self.notes = []
|
||||
|
||||
def note_op(self, kind, nbytes=0):
|
||||
self.notes.append((kind, nbytes))
|
||||
|
||||
|
||||
def _block(w):
|
||||
return Image.new("RGB", (w, H), (255, 255, 255))
|
||||
|
||||
|
||||
def test_vegas_tags_a_compose_and_every_extension():
|
||||
spy = _NoteSpy()
|
||||
groups = [[("a", [_block(600)])], [("b", [_block(600)])], [("c", [_block(600)])]]
|
||||
p = RenderPipeline(VegasModeConfig(lead_in_width=0, continuous_scroll=True),
|
||||
_DM(spy), _Stream(groups))
|
||||
assert p.compose_scroll_content()
|
||||
assert [kind for kind, _ in spy.notes] == ["compose"]
|
||||
|
||||
p.scroll_helper.scroll_position = 400.0 # far enough to trim on extension
|
||||
assert p.extend_scroll_content()
|
||||
kind, moved = spy.notes[-1]
|
||||
assert kind == "extend"
|
||||
# The append built the whole strip anew and the trim copied it again.
|
||||
assert moved > p.scroll_helper.cached_array.nbytes
|
||||
|
||||
|
||||
def test_vegas_without_a_recorder_does_not_fail():
|
||||
class DM(_DM):
|
||||
def __init__(self):
|
||||
super().__init__(None)
|
||||
|
||||
groups = [[("a", [_block(600)])], [("b", [_block(600)])]]
|
||||
p = RenderPipeline(VegasModeConfig(lead_in_width=0, continuous_scroll=True),
|
||||
DM(), _Stream(groups))
|
||||
assert p.compose_scroll_content()
|
||||
assert p.extend_scroll_content()
|
||||
|
||||
|
||||
# -- the render bench's strip work --------------------------------------------
|
||||
|
||||
|
||||
def _helper(screens=6):
|
||||
helper = ScrollHelper(W, H)
|
||||
helper.set_scrolling_image(render_bench.build_strip(W, H, "t", screens=screens))
|
||||
return helper
|
||||
|
||||
|
||||
def test_the_bench_strip_can_be_made_vegas_wide():
|
||||
assert render_bench.build_strip(W, H, "t", screens=30).width >= W * 30
|
||||
|
||||
|
||||
def test_a_visible_patch_writes_its_columns_on_screen_and_nothing_else():
|
||||
helper = _helper()
|
||||
spy = _NoteSpy()
|
||||
work = render_bench.StripWork(helper, spy, helper.cached_image,
|
||||
patch_bytes=40 * H * 3, patch_every=1)
|
||||
helper.scroll_position = 200.0
|
||||
before = helper.cached_array.copy()
|
||||
work.before_frame()
|
||||
changed = np.flatnonzero((helper.cached_array != before).any(axis=(0, 2)))
|
||||
assert changed.size
|
||||
assert changed.min() >= 200 and changed.max() < 200 + W
|
||||
assert changed.max() - changed.min() < 40
|
||||
assert spy.notes == [("patch", 40 * H * 3)]
|
||||
assert work.patches == 1
|
||||
|
||||
|
||||
def test_an_ahead_patch_lands_past_the_viewport():
|
||||
helper = _helper()
|
||||
work = render_bench.StripWork(helper, _NoteSpy(), helper.cached_image,
|
||||
patch_bytes=20 * H * 3, patch_every=1,
|
||||
patch_where="ahead")
|
||||
helper.scroll_position = 100.0
|
||||
before = helper.cached_array.copy()
|
||||
work.before_frame()
|
||||
changed = np.flatnonzero((helper.cached_array != before).any(axis=(0, 2)))
|
||||
assert changed.min() >= 100 + W
|
||||
|
||||
|
||||
def test_patches_run_every_k_frames():
|
||||
helper = _helper()
|
||||
spy = _NoteSpy()
|
||||
work = render_bench.StripWork(helper, spy, helper.cached_image,
|
||||
patch_bytes=10 * H * 3, patch_every=5)
|
||||
for _ in range(20):
|
||||
work.before_frame()
|
||||
assert work.patches == 4 and len(spy.notes) == 4
|
||||
|
||||
|
||||
def test_extensions_keep_the_strip_bounded_and_the_scroll_going():
|
||||
helper = _helper(screens=8)
|
||||
helper.set_pixels_per_frame(4)
|
||||
spy = _NoteSpy()
|
||||
work = render_bench.StripWork(helper, spy, helper.cached_image,
|
||||
extend_every_screens=2)
|
||||
widths = []
|
||||
for _ in range(3000):
|
||||
work.before_frame()
|
||||
helper.update_scroll_position()
|
||||
assert not helper.is_scroll_complete()
|
||||
widths.append(helper.cached_array.shape[1])
|
||||
assert work.extensions >= 10
|
||||
assert {kind for kind, _ in spy.notes} == {"extend"}
|
||||
# Appends and trims balance: the strip holds its width (within one
|
||||
# extension of it) instead of growing or running out ahead of the viewport.
|
||||
assert max(widths) - min(widths) <= 3 * W
|
||||
assert widths[-1] >= widths[0] - W
|
||||
assert helper.remaining_unscrolled() > 0
|
||||
@@ -0,0 +1,208 @@
|
||||
"""The offline live-element checks (src/plugin_system/testing/vegas.py).
|
||||
|
||||
Run against the stub fixture plugin, which honours the contract, and against
|
||||
small broken plugins, each breaking one clause of it.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin # noqa: E402
|
||||
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
|
||||
|
||||
STUB = Path(__file__).resolve().parent / "fixtures" / "plugins" / "vegas-live-stub"
|
||||
W, H = 192, 48
|
||||
|
||||
|
||||
def _stub(**config):
|
||||
dm = VisualTestDisplayManager(width=W, height=H)
|
||||
full = {**build_full_config(STUB, load_harness_spec(STUB), {}), **config}
|
||||
plugin = _instantiate("vegas-live-stub", load_manifest(STUB), STUB, full, {}, dm)
|
||||
return plugin, dm
|
||||
|
||||
|
||||
class _Broken(BasePlugin):
|
||||
"""A plugin whose get_vegas_elements returns whatever it is given."""
|
||||
|
||||
def __init__(self, dm, result, redraw=None):
|
||||
self.plugin_id = "broken"
|
||||
self.config = {}
|
||||
self.display_manager = dm
|
||||
self.plugin_manager = None
|
||||
self._result = result
|
||||
self._redraw = redraw
|
||||
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
def get_vegas_elements(self):
|
||||
return self._result() if callable(self._result) else self._result
|
||||
|
||||
def redraw_vegas_element(self, key, width, height, at):
|
||||
return self._redraw(width, height) if self._redraw else None
|
||||
|
||||
|
||||
def _img(w, h=H):
|
||||
return Image.new("RGB", (w, h), (255, 0, 0))
|
||||
|
||||
|
||||
def test_the_stub_passes_every_check():
|
||||
plugin, dm = _stub()
|
||||
report = check_vegas_elements(plugin, dm)
|
||||
assert report.implemented and report.ok, report.errors
|
||||
assert report.elements == 8 and report.live == 7
|
||||
assert not report.warnings, report.warnings
|
||||
|
||||
|
||||
def test_the_stub_renders_at_the_width_it_is_given():
|
||||
plugin, dm = _stub()
|
||||
elements = render_vegas_elements(plugin, dm, width=96)
|
||||
by_key = {e.key: e for e in elements}
|
||||
assert by_key["map"].image.size == (96, H)
|
||||
assert by_key["card:0"].image.width == 24
|
||||
assert plugin.get_vegas_render_width() == W # restored afterwards
|
||||
|
||||
|
||||
def test_the_stubs_cards_change_on_update_but_keep_their_width():
|
||||
plugin, dm = _stub()
|
||||
before = {e.key: e for e in render_vegas_elements(plugin, dm)}
|
||||
plugin.update()
|
||||
after = {e.key: e for e in render_vegas_elements(plugin, dm)}
|
||||
for key in ("card:0", "card:3"):
|
||||
assert after[key].version != before[key].version
|
||||
assert after[key].image.size == before[key].image.size
|
||||
assert after[key].image.tobytes() != before[key].image.tobytes()
|
||||
|
||||
|
||||
def test_a_plugin_without_the_hook_is_not_checked():
|
||||
class Plain(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
plugin = Plain.__new__(Plain)
|
||||
dm = VisualTestDisplayManager(width=W, height=H)
|
||||
report = check_vegas_elements(plugin, dm)
|
||||
assert not implements_vegas_elements(plugin)
|
||||
assert not report.implemented and report.ok
|
||||
|
||||
|
||||
def _errors(result, redraw=None):
|
||||
dm = VisualTestDisplayManager(width=W, height=H)
|
||||
return check_vegas_elements(_Broken(dm, result, redraw), dm)
|
||||
|
||||
|
||||
def test_each_broken_clause_is_an_error():
|
||||
assert "expected a list" in _errors("x").errors[0]
|
||||
assert "not a VegasElement" in _errors([object()]).errors[0]
|
||||
assert "twice" in _errors([VegasElement("k", _img(10)),
|
||||
VegasElement("k", _img(10))]).errors[0]
|
||||
assert "tall" in _errors([VegasElement("k", _img(10, H + 1))]).errors[0]
|
||||
assert "no key" in _errors([VegasElement("", _img(10))]).errors[0]
|
||||
|
||||
|
||||
def test_a_width_that_changes_with_nothing_new_is_an_error():
|
||||
widths = iter([10, 12, 10, 10])
|
||||
report = _errors(lambda: [VegasElement("k", _img(next(widths)))])
|
||||
assert any("changed width" in e for e in report.errors)
|
||||
|
||||
|
||||
def test_a_redraw_of_the_wrong_size_is_an_error():
|
||||
report = _errors([VegasElement("m", _img(40), refresh_hz=2)],
|
||||
redraw=lambda w, h: _img(w + 1, h))
|
||||
assert any("asked for 40x48" in e for e in report.errors)
|
||||
|
||||
|
||||
def test_animation_without_a_redraw_is_a_warning():
|
||||
dm = VisualTestDisplayManager(width=W, height=H)
|
||||
|
||||
class NoRedraw(_Broken):
|
||||
redraw_vegas_element = BasePlugin.redraw_vegas_element
|
||||
|
||||
report = check_vegas_elements(
|
||||
NoRedraw(dm, [VegasElement("m", _img(40), refresh_hz=2)]), dm)
|
||||
assert report.ok
|
||||
assert any("not implemented" in w for w in report.warnings)
|
||||
|
||||
|
||||
def test_none_means_legacy_content_and_is_only_a_warning():
|
||||
report = _errors(None)
|
||||
assert report.ok and "get_vegas_content" in report.warnings[0]
|
||||
|
||||
|
||||
def test_a_refresh_rate_that_is_not_a_number_is_an_error_not_a_crash():
|
||||
report = _errors([VegasElement("m", _img(40), refresh_hz="fast")])
|
||||
assert any("not a number" in e for e in report.errors)
|
||||
# None is what a plugin passing the dataclass default through gets.
|
||||
assert _errors([VegasElement("m", _img(40), refresh_hz=None)]).ok
|
||||
|
||||
|
||||
def test_an_empty_image_is_an_error():
|
||||
assert any("empty" in e for e in _errors([VegasElement("k", _img(0))]).errors)
|
||||
|
||||
|
||||
def test_a_second_call_that_breaks_the_contract_is_an_error_not_a_crash():
|
||||
answers = iter([[VegasElement("k", _img(10))], "x", "x", "x"])
|
||||
report = _errors(lambda: next(answers))
|
||||
assert any("second call" in e for e in report.errors)
|
||||
answers = iter([[VegasElement("k", _img(10))], [VegasElement("k", "not an image")],
|
||||
[], []])
|
||||
report = _errors(lambda: next(answers))
|
||||
assert any("disappeared" in e for e in report.errors)
|
||||
|
||||
|
||||
def test_check_plugin_reports_a_failing_element_check_and_carries_on(monkeypatch):
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
import check_plugin
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(check_plugin, "check_plugin_vegas_elements", boom)
|
||||
results = check_plugin.check_one(
|
||||
"vegas-live-stub", [str(STUB.parent)], [(W, H)], {}, {}, False, None,
|
||||
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()
|
||||
@@ -0,0 +1,143 @@
|
||||
"""PluginManager's update listeners: told the moment a plugin's data may have changed.
|
||||
|
||||
Vegas live elements redraw a plugin when its update() completes. Before these
|
||||
listeners the only signal was a set drained by the Vegas tick every ~4s; the
|
||||
listener hears it at once. It is called on the update worker with the
|
||||
plugin's lock still held, which is why a listener may only hand off.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin # noqa: E402
|
||||
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
|
||||
from src.plugin_system.plugin_state import PluginState # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pm(tmp_path):
|
||||
manager = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
|
||||
display_manager=None, cache_manager=None)
|
||||
yield manager
|
||||
manager.stop_update_worker()
|
||||
|
||||
|
||||
class _Plugin:
|
||||
def __init__(self, fail=False):
|
||||
self.enabled = True
|
||||
self.fail = fail
|
||||
self.updates = 0
|
||||
|
||||
def update(self):
|
||||
self.updates += 1
|
||||
if self.fail:
|
||||
raise RuntimeError("no data")
|
||||
|
||||
def display(self, force_clear=False):
|
||||
return True
|
||||
|
||||
|
||||
def _install(pm, plugin, plugin_id="p"):
|
||||
pm.plugins[plugin_id] = plugin
|
||||
pm._update_interval_cache[plugin_id] = 0.01
|
||||
pm.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
return plugin_id
|
||||
|
||||
|
||||
def _wait(predicate, timeout=5.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return False
|
||||
|
||||
|
||||
def test_a_completed_update_calls_the_listener_with_the_lock_held(pm):
|
||||
plugin_id = _install(pm, _Plugin())
|
||||
heard = []
|
||||
|
||||
def listener(pid):
|
||||
heard.append((pid, pm.get_plugin_lock(pid).locked()))
|
||||
|
||||
pm.add_update_listener(listener)
|
||||
pm.run_scheduled_updates()
|
||||
assert _wait(lambda: heard)
|
||||
assert heard[0] == (plugin_id, True)
|
||||
# The poll still sees it too: the set is filled before listeners run.
|
||||
assert plugin_id in pm.drain_completed_updates()
|
||||
|
||||
|
||||
def test_a_failed_update_is_not_reported(pm):
|
||||
_install(pm, _Plugin(fail=True))
|
||||
heard = []
|
||||
pm.add_update_listener(heard.append)
|
||||
pm.run_scheduled_updates()
|
||||
assert _wait(lambda: pm.plugins["p"].updates == 1)
|
||||
time.sleep(0.1)
|
||||
assert heard == []
|
||||
|
||||
|
||||
def test_a_listener_that_raises_does_not_stop_the_others(pm):
|
||||
heard = []
|
||||
|
||||
def broken(_pid):
|
||||
raise ValueError("listener bug")
|
||||
|
||||
pm.add_update_listener(broken)
|
||||
pm.add_update_listener(heard.append)
|
||||
pm._note_update_completed("p") # must not raise
|
||||
assert heard == ["p"]
|
||||
assert "p" in pm.drain_completed_updates()
|
||||
|
||||
|
||||
def test_adding_twice_calls_once_and_removing_stops_it(pm):
|
||||
heard = []
|
||||
pm.add_update_listener(heard.append)
|
||||
pm.add_update_listener(heard.append)
|
||||
pm.notify_data_changed("x")
|
||||
assert heard == ["x"]
|
||||
pm.remove_update_listener(heard.append)
|
||||
pm.notify_data_changed("y")
|
||||
assert heard == ["x"]
|
||||
|
||||
|
||||
def test_notify_data_changed_reaches_listeners_but_not_the_poll(pm):
|
||||
heard = []
|
||||
pm.add_update_listener(heard.append)
|
||||
pm.notify_data_changed("q")
|
||||
assert heard == ["q"]
|
||||
assert pm.drain_completed_updates() == []
|
||||
|
||||
|
||||
def test_a_plugin_can_report_data_that_arrived_on_its_own_thread(pm):
|
||||
class Pushed(BasePlugin):
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
plugin = Pushed.__new__(Pushed)
|
||||
plugin.plugin_id = "pushed"
|
||||
plugin.plugin_manager = pm
|
||||
heard = []
|
||||
pm.add_update_listener(heard.append)
|
||||
thread = threading.Thread(target=plugin.notify_vegas_data_changed)
|
||||
thread.start()
|
||||
thread.join()
|
||||
assert heard == ["pushed"]
|
||||
|
||||
|
||||
def test_a_bare_manager_has_no_listeners_to_call():
|
||||
manager = PluginManager.__new__(PluginManager)
|
||||
manager._completed_updates = set()
|
||||
manager._completed_updates_lock = threading.Lock()
|
||||
manager._note_update_completed("p")
|
||||
manager.add_update_listener(lambda pid: None)
|
||||
manager.remove_update_listener(lambda pid: None)
|
||||
@@ -0,0 +1,218 @@
|
||||
"""ScrollHelper builds the strip's PIL image only when something reads it.
|
||||
|
||||
Vegas extends and trims one long strip on the render thread. Each of those used
|
||||
to rebuild ``cached_image`` from ``cached_array`` in full -- 1.7-3.8ms apiece on
|
||||
a Pi 4 for a Vegas-sized strip, twice per extension -- though nothing on the
|
||||
frame path reads the image's pixels. These tests pin that the frame path never
|
||||
builds it, that a read still gets the right pixels, and that the two threads
|
||||
which do read it (a multi-display sync push, the render thread) cannot leave a
|
||||
stale image behind.
|
||||
"""
|
||||
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 import scroll_helper as scroll_helper_module # noqa: E402
|
||||
from src.common.scroll_helper import ScrollHelper # noqa: E402
|
||||
|
||||
W, H = 64, 16
|
||||
|
||||
|
||||
def _block(width, seed):
|
||||
# frombytes, not fromarray: the no_fromarray fixture refuses the latter
|
||||
# everywhere, and a test block is not the strip.
|
||||
rng = np.random.default_rng(seed)
|
||||
pixels = rng.integers(0, 255, (H, width, 3), dtype=np.uint8)
|
||||
return Image.frombytes("RGB", (width, H), pixels.tobytes())
|
||||
|
||||
|
||||
def _helper(width=400):
|
||||
helper = ScrollHelper(W, H)
|
||||
helper.set_scrolling_image(_block(width, 0))
|
||||
return helper
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_fromarray(monkeypatch):
|
||||
"""Fail if the helper builds a PIL image from its array."""
|
||||
def refuse(*_a, **_k):
|
||||
raise AssertionError("the strip's PIL image was built")
|
||||
monkeypatch.setattr(scroll_helper_module.Image, "fromarray", refuse)
|
||||
|
||||
|
||||
def test_append_and_trim_do_not_build_the_image(no_fromarray):
|
||||
helper = _helper()
|
||||
helper.scroll_position = 300.0
|
||||
assert helper.append_content([_block(200, 1)], item_gap=8)
|
||||
assert helper.drop_scrolled_prefix(keep_before=W) > 0
|
||||
assert helper.__dict__["_cached_image"] is None
|
||||
|
||||
|
||||
def test_the_frame_path_never_builds_the_image(no_fromarray):
|
||||
helper = _helper()
|
||||
helper.set_pixels_per_frame(2)
|
||||
for i in range(400):
|
||||
if helper.remaining_unscrolled() <= 2 * W:
|
||||
helper.append_content([_block(150, i)], item_gap=8)
|
||||
helper.drop_scrolled_prefix(keep_before=W)
|
||||
helper.update_scroll_position()
|
||||
frame = helper.get_visible_portion()
|
||||
assert frame is not None and frame.size == (W, H)
|
||||
helper.get_scroll_info()
|
||||
assert not helper.is_scroll_complete()
|
||||
|
||||
|
||||
def test_reading_the_image_gives_the_strip_as_it_is():
|
||||
helper = _helper()
|
||||
helper.scroll_position = 250.0
|
||||
helper.append_content([_block(120, 7)], item_gap=8)
|
||||
helper.drop_scrolled_prefix(keep_before=W)
|
||||
image = helper.cached_image
|
||||
assert image.size == (helper.cached_array.shape[1], H)
|
||||
assert np.array_equal(np.asarray(image), helper.cached_array)
|
||||
assert helper.total_scroll_width == image.width
|
||||
|
||||
|
||||
def test_the_built_image_is_kept_until_the_strip_changes():
|
||||
helper = _helper()
|
||||
helper.append_content([_block(50, 1)], item_gap=0)
|
||||
first = helper.cached_image
|
||||
assert helper.cached_image is first
|
||||
helper.append_content([_block(50, 2)], item_gap=0)
|
||||
assert helper.cached_image is not first
|
||||
assert helper.cached_image.width == first.width + 50
|
||||
|
||||
|
||||
def test_an_assigned_image_is_kept_exactly():
|
||||
# Plugins and the multi-display follower assign cached_image themselves.
|
||||
helper = _helper()
|
||||
helper.append_content([_block(50, 1)], item_gap=0)
|
||||
mine = _block(99, 3)
|
||||
helper.cached_image = mine
|
||||
assert helper.cached_image is mine
|
||||
helper.cached_image = None
|
||||
assert helper.cached_image is None
|
||||
|
||||
|
||||
def test_frames_are_the_same_as_with_an_eager_image():
|
||||
lazy = _helper()
|
||||
eager = _helper()
|
||||
blocks = [_block(90, 10 + i) for i in range(6)]
|
||||
for i, block in enumerate(blocks):
|
||||
for helper in (lazy, eager):
|
||||
helper.scroll_position = 60.0 * (i + 1)
|
||||
helper.append_content([block], item_gap=8)
|
||||
helper.drop_scrolled_prefix(keep_before=W)
|
||||
eager.cached_image = Image.fromarray(eager.cached_array) # the old way
|
||||
for x in range(0, lazy.cached_array.shape[1] - W, 7):
|
||||
lazy.scroll_position = eager.scroll_position = float(x)
|
||||
assert lazy.get_visible_portion().tobytes() == \
|
||||
eager.get_visible_portion().tobytes()
|
||||
|
||||
|
||||
def test_a_read_racing_an_extension_does_not_keep_a_stale_image(monkeypatch):
|
||||
# The sync push reads the image on its own thread. If the render thread
|
||||
# extends the strip while that read is building the image, the read gets
|
||||
# the strip as it was, and the next read must not be handed it again.
|
||||
helper = _helper()
|
||||
helper.append_content([_block(40, 1)], item_gap=0)
|
||||
real = Image.fromarray
|
||||
|
||||
def build_while_the_strip_changes(array, *a, **k):
|
||||
monkeypatch.setattr(scroll_helper_module.Image, "fromarray", real)
|
||||
helper.append_content([_block(40, 2)], item_gap=0) # "render thread"
|
||||
return real(array, *a, **k)
|
||||
|
||||
monkeypatch.setattr(scroll_helper_module.Image, "fromarray",
|
||||
build_while_the_strip_changes)
|
||||
before_width = helper.cached_array.shape[1]
|
||||
raced = helper.cached_image
|
||||
assert raced.width == before_width
|
||||
fresh = helper.cached_image
|
||||
assert fresh.width == before_width + 40
|
||||
assert np.array_equal(np.asarray(fresh), helper.cached_array)
|
||||
|
||||
|
||||
def test_clearing_the_cache_forgets_a_deferred_image():
|
||||
helper = _helper()
|
||||
helper.append_content([_block(40, 1)], item_gap=0)
|
||||
helper.clear_cache()
|
||||
assert helper.cached_image is None
|
||||
assert not helper.has_strip()
|
||||
assert helper.get_visible_portion() is None
|
||||
assert helper.remaining_unscrolled() == 0
|
||||
|
||||
|
||||
def test_a_helper_with_no_strip_has_nothing_to_scroll():
|
||||
helper = ScrollHelper(W, H)
|
||||
assert not helper.has_strip()
|
||||
helper.update_scroll_position()
|
||||
assert helper.scroll_position == 0.0
|
||||
assert helper.get_scroll_info()["cached_image_size"] is None
|
||||
|
||||
|
||||
def test_dropping_a_plugins_scroll_cache_does_not_build_it_first(no_fromarray):
|
||||
# Vegas clears a plugin's own scroll cache on the render thread whenever
|
||||
# the plugin updates; that must not build a deferred image to discard it.
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
|
||||
helper = _helper()
|
||||
helper.append_content([_block(40, 1)], item_gap=0)
|
||||
dm = SimpleNamespace(width=W, height=H, image=Image.new("RGB", (W, H)))
|
||||
adapter = PluginAdapter(dm)
|
||||
assert adapter.invalidate_plugin_scroll_cache(
|
||||
SimpleNamespace(scroll_helper=helper), "p")
|
||||
assert helper.cached_array is None and not helper.has_strip()
|
||||
|
||||
|
||||
def test_vegas_extends_without_building_the_image(no_fromarray):
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline
|
||||
|
||||
groups = [[("a", [_block(300, 1)])]] + [[(f"p{i}", [_block(300, i + 2)])]
|
||||
for i in range(6)]
|
||||
|
||||
class Stream:
|
||||
plugin_manager = type("PM", (), {"plugins": {}})()
|
||||
plugin_adapter = None
|
||||
i = 0
|
||||
|
||||
def get_grouped_content_for_composition(self):
|
||||
return groups[0]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return ["a"]
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
self.i += 1
|
||||
return groups[self.i] if self.i < len(groups) else []
|
||||
|
||||
class DM:
|
||||
width, height = W, H
|
||||
|
||||
def __init__(self):
|
||||
self.image = Image.new("RGB", (W, H))
|
||||
|
||||
def set_scrolling_state(self, *a):
|
||||
pass
|
||||
|
||||
def update_display(self):
|
||||
pass
|
||||
|
||||
p = RenderPipeline(VegasModeConfig(lead_in_width=0, continuous_scroll=True),
|
||||
DM(), Stream())
|
||||
# compose pastes into a new image and converts that to the array once;
|
||||
# it never needs fromarray either.
|
||||
assert p.compose_scroll_content()
|
||||
for _ in range(5):
|
||||
p.scroll_helper.scroll_position += 250
|
||||
assert p.extend_scroll_content()
|
||||
assert p.render_frame()
|
||||
assert p.scroll_helper.__dict__["_cached_image"] is None
|
||||
@@ -0,0 +1,88 @@
|
||||
"""ScrollHelper.patch_columns: changing a strip's pixels in place, between frames.
|
||||
|
||||
The primitive a live Vegas element update is built on. It writes exactly the
|
||||
columns it is given and nothing else, never moves the scroll or resizes the
|
||||
strip, and refuses an array it may not write (the multi-display follower's).
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
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 _helper(width=300):
|
||||
rng = np.random.default_rng(1)
|
||||
pixels = rng.integers(0, 255, (H, width, 3), dtype=np.uint8)
|
||||
helper = ScrollHelper(W, H)
|
||||
helper.set_scrolling_image(Image.frombytes("RGB", (width, H), pixels.tobytes()))
|
||||
helper.scroll_position = 42.0
|
||||
return helper
|
||||
|
||||
|
||||
def _patch(width, value=7):
|
||||
return np.full((H, width, 3), value, dtype=np.uint8)
|
||||
|
||||
|
||||
def test_writes_exactly_its_columns():
|
||||
helper = _helper()
|
||||
before = helper.cached_array.copy()
|
||||
assert helper.patch_columns(100, _patch(30)) == H * 30 * 3
|
||||
after = helper.cached_array
|
||||
assert (after[:, 100:130] == 7).all()
|
||||
assert np.array_equal(after[:, :100], before[:, :100])
|
||||
assert np.array_equal(after[:, 130:], before[:, 130:])
|
||||
|
||||
|
||||
def test_moves_nothing_else():
|
||||
helper = _helper()
|
||||
width, position, distance = (helper.total_scroll_width, helper.scroll_position,
|
||||
helper.total_distance_scrolled)
|
||||
helper.patch_columns(10, _patch(20))
|
||||
assert (helper.total_scroll_width, helper.scroll_position,
|
||||
helper.total_distance_scrolled) == (width, position, distance)
|
||||
|
||||
|
||||
def test_clips_at_both_ends():
|
||||
helper = _helper(width=300)
|
||||
assert helper.patch_columns(-10, _patch(30, 1)) == H * 20 * 3
|
||||
assert (helper.cached_array[:, :20] == 1).all()
|
||||
assert helper.patch_columns(290, _patch(30, 2)) == H * 10 * 3
|
||||
assert (helper.cached_array[:, 290:] == 2).all()
|
||||
assert helper.patch_columns(400, _patch(10)) == 0
|
||||
assert helper.patch_columns(-50, _patch(10)) == 0
|
||||
|
||||
|
||||
def test_the_patch_lands_in_the_next_frame():
|
||||
helper = _helper()
|
||||
helper.scroll_position = 100.0
|
||||
helper.patch_columns(110, _patch(10, 200))
|
||||
frame = np.asarray(helper.get_visible_portion())
|
||||
assert (frame[:, 10:20] == 200).all()
|
||||
|
||||
|
||||
def test_refuses_a_read_only_strip():
|
||||
# The follower adopts np.asarray(image), which is read-only.
|
||||
helper = _helper()
|
||||
helper.cached_array = np.asarray(helper.cached_image)
|
||||
assert not helper.cached_array.flags.writeable
|
||||
assert helper.patch_columns(10, _patch(10)) == 0
|
||||
|
||||
|
||||
def test_refuses_a_patch_of_the_wrong_height_and_no_strip():
|
||||
helper = _helper()
|
||||
assert helper.patch_columns(10, np.zeros((H + 1, 5, 3), dtype=np.uint8)) == 0
|
||||
assert ScrollHelper(W, H).patch_columns(0, _patch(5)) == 0
|
||||
|
||||
|
||||
def test_a_deferred_image_is_built_from_the_patched_strip():
|
||||
helper = _helper()
|
||||
helper.append_content([Image.new("RGB", (40, H))], item_gap=0)
|
||||
helper.patch_columns(5, _patch(5, 99))
|
||||
assert (np.asarray(helper.cached_image)[:, 5:10] == 99).all()
|
||||
@@ -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 == {}
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Live Vegas elements: the plugin API and how the adapter fetches them.
|
||||
|
||||
A plugin opts in by implementing get_vegas_elements(); the adapter then asks
|
||||
for elements instead of pictures, but only where that is safe (the background
|
||||
fetch, under the plugin's lock, with live elements switched on) and falls back
|
||||
to get_vegas_content() everywhere else. What makes an element live rides in
|
||||
its image's ``info`` so it survives the adapter's cache and the pipeline's
|
||||
join unchanged. These tests pin all of that.
|
||||
"""
|
||||
import dataclasses
|
||||
import sys
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin # noqa: E402
|
||||
from src.plugin_system.vegas_elements import VegasElement # noqa: E402
|
||||
from src.vegas_mode import elements # noqa: E402
|
||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||
from src.vegas_mode.elements import ElementMeta, LiveEpochs # noqa: E402
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402
|
||||
|
||||
W, H = 128, 32
|
||||
|
||||
|
||||
def _card(width=40, colour=(255, 0, 0), height=H):
|
||||
image = Image.new("RGB", (width, height), (0, 0, 0))
|
||||
ImageDraw.Draw(image).rectangle([0, 0, width - 1, height - 1], outline=colour)
|
||||
return image
|
||||
|
||||
|
||||
class _DM:
|
||||
"""A display manager with the per-thread canvas Vegas renders on."""
|
||||
|
||||
def __init__(self):
|
||||
self.width, self.height = W, H
|
||||
self.image = Image.new("RGB", (W, H))
|
||||
self.offscreen_widths = []
|
||||
|
||||
@contextmanager
|
||||
def offscreen(self, width=None, height=None):
|
||||
self.offscreen_widths.append(width)
|
||||
yield SimpleNamespace(image=Image.new("RGB", (width or W, H)))
|
||||
|
||||
|
||||
class _Plugin(BasePlugin):
|
||||
"""A BasePlugin with both the legacy and the element hooks."""
|
||||
|
||||
def __init__(self, elements_result=None, config=None):
|
||||
self.plugin_id = "p"
|
||||
self.config = config or {}
|
||||
self.elements_result = elements_result
|
||||
self.element_calls = 0
|
||||
self.content_calls = 0
|
||||
self.render_widths = []
|
||||
self.plugin_manager = None
|
||||
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
def display(self, force_clear=False):
|
||||
pass
|
||||
|
||||
def get_vegas_content(self):
|
||||
self.content_calls += 1
|
||||
return [_card(40, (0, 0, 255))]
|
||||
|
||||
def get_vegas_elements(self):
|
||||
self.element_calls += 1
|
||||
self.render_widths.append(self.get_vegas_render_width())
|
||||
result = self.elements_result
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result() if callable(result) else result
|
||||
|
||||
|
||||
class _Legacy(_Plugin):
|
||||
get_vegas_elements = BasePlugin.get_vegas_elements
|
||||
|
||||
|
||||
def _adapter(**cfg):
|
||||
lock = threading.Lock()
|
||||
pm = SimpleNamespace(get_plugin_lock=lambda pid: lock)
|
||||
adapter = PluginAdapter(_DM(), VegasModeConfig(**cfg), plugin_manager=pm)
|
||||
adapter.live_elements_enabled = True
|
||||
adapter.live_epochs = LiveEpochs()
|
||||
return adapter
|
||||
|
||||
|
||||
def _elements():
|
||||
return [VegasElement("card:a", _card(40, (255, 0, 0)), version=1),
|
||||
VegasElement("sep", _card(10, (90, 90, 90)), live=False),
|
||||
VegasElement("card:b", _card(40, (0, 255, 0)), version=2)]
|
||||
|
||||
|
||||
# -- the type and the tag -----------------------------------------------------
|
||||
|
||||
|
||||
def test_element_defaults():
|
||||
element = VegasElement("k", _card())
|
||||
assert element.live and element.refresh_hz == 0 and element.version is None
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
element.key = "other"
|
||||
|
||||
|
||||
def test_a_tag_survives_what_the_plumbing_does_to_an_image():
|
||||
meta = ElementMeta("p", "k", 3, ((H, 10, 3), 1), 0.0, 0.0)
|
||||
image = elements.tag(_card(), meta)
|
||||
for derived in (image.copy(), image.crop((0, 0, 10, H)), image.convert("RGB"),
|
||||
image.resize((20, H))):
|
||||
assert elements.meta_of(derived) == meta
|
||||
assert elements.meta_of(elements.untag(image.copy())) is None
|
||||
assert elements.meta_of(_card()) is None
|
||||
assert elements.meta_of(object()) is None
|
||||
|
||||
|
||||
def test_pinning_pads_with_black_and_freezes_the_pixels():
|
||||
image, array = elements.pin_element(_card(20), 8)
|
||||
assert image.size == (36, H)
|
||||
assert array.shape == (H, 36, 3)
|
||||
assert not array.flags.writeable
|
||||
assert not array[:, :8].any() and not array[:, -8:].any()
|
||||
assert np.array_equal(array[:, 8:28], np.asarray(_card(20)))
|
||||
|
||||
|
||||
def test_the_digest_sees_a_one_pixel_change():
|
||||
_, a = elements.pin_element(_card(20), 0)
|
||||
b = np.array(a)
|
||||
b[5, 5, 0] ^= 1
|
||||
assert elements.pixel_digest(a) != elements.pixel_digest(b)
|
||||
assert elements.pixel_digest(a) == elements.pixel_digest(np.array(a))
|
||||
|
||||
|
||||
def test_epochs_move_on_per_plugin_and_never_repeat():
|
||||
epochs = LiveEpochs()
|
||||
assert epochs.get("a") == 0
|
||||
first = epochs.bump("a")
|
||||
second = epochs.bump("b")
|
||||
assert second > first and epochs.get("a") == first
|
||||
assert epochs.bump("a") > second
|
||||
|
||||
|
||||
# -- BasePlugin ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_base_hooks_do_nothing():
|
||||
plugin = _Legacy()
|
||||
assert plugin.get_vegas_elements() is None
|
||||
assert plugin.redraw_vegas_element("k", 10, H, 0.0) is None
|
||||
|
||||
|
||||
def test_notify_vegas_data_changed_reaches_the_plugin_manager():
|
||||
plugin = _Legacy()
|
||||
plugin.plugin_manager = MagicMock()
|
||||
plugin.notify_vegas_data_changed()
|
||||
plugin.plugin_manager.notify_data_changed.assert_called_once_with("p")
|
||||
plugin.plugin_manager = None
|
||||
plugin.notify_vegas_data_changed() # no manager: nothing to do
|
||||
|
||||
|
||||
# -- the adapter's keyed path --------------------------------------------------
|
||||
|
||||
|
||||
def test_the_background_fetch_asks_for_elements():
|
||||
adapter = _adapter()
|
||||
adapter.live_epochs.bump("p")
|
||||
plugin = _Plugin(_elements)
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert plugin.element_calls == 1 and plugin.content_calls == 0
|
||||
metas = [elements.meta_of(img) for img in images]
|
||||
assert [m.key if m else None for m in metas] == ["card:a", None, "card:b"]
|
||||
assert metas[0].epoch == adapter.live_epochs.get("p")
|
||||
assert metas[0].version == 1
|
||||
|
||||
|
||||
def test_live_elements_are_pinned_not_trimmed():
|
||||
adapter = _adapter(content_padding=8)
|
||||
plugin = _Plugin(_elements)
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
live = [img for img in images if elements.meta_of(img)]
|
||||
assert all(img.width == 40 + 16 for img in live)
|
||||
# The plain separator is trimmed as always: drawn to its edges, it keeps
|
||||
# its width (trimming never widens an image).
|
||||
separator = images[1]
|
||||
assert elements.meta_of(separator) is None
|
||||
assert separator.width == 10
|
||||
|
||||
|
||||
def test_an_elements_digest_matches_its_pixels():
|
||||
adapter = _adapter()
|
||||
images = adapter.get_content(_Plugin(_elements), "p", offscreen_only=True)
|
||||
meta = elements.meta_of(images[0])
|
||||
assert meta.digest == elements.pixel_digest(np.asarray(images[0]))
|
||||
|
||||
|
||||
def test_elements_render_on_a_canvas_of_their_own_at_the_render_width():
|
||||
adapter = _adapter(render_width_pct=50)
|
||||
plugin = _Plugin(_elements)
|
||||
adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert plugin.render_widths == [W // 2]
|
||||
assert adapter.display_manager.offscreen_widths == [W // 2]
|
||||
assert plugin.get_vegas_render_width() == W # restored afterwards
|
||||
|
||||
|
||||
@pytest.mark.parametrize("why", ["disabled", "render thread", "restricted",
|
||||
"plugin opted out", "legacy plugin"])
|
||||
def test_everywhere_else_the_legacy_content_is_used(why):
|
||||
cfg = {"offscreen_prefetch": False} if why == "restricted" else {}
|
||||
adapter = _adapter(**cfg)
|
||||
plugin_cls = _Legacy if why == "legacy plugin" else _Plugin
|
||||
plugin = plugin_cls(_elements, config={"vegas_live": False}
|
||||
if why == "plugin opted out" else None)
|
||||
if why == "disabled":
|
||||
adapter.live_elements_enabled = False
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=(why != "render thread"))
|
||||
assert plugin.element_calls == 0
|
||||
if why != "restricted":
|
||||
assert plugin.content_calls == 1
|
||||
assert all(elements.meta_of(img) is None for img in images)
|
||||
|
||||
|
||||
def test_a_mock_plugin_is_never_asked_for_elements():
|
||||
adapter = _adapter()
|
||||
plugin = MagicMock()
|
||||
plugin.config = {}
|
||||
plugin.get_vegas_content.return_value = [_card()]
|
||||
adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
plugin.get_vegas_elements.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("result", [None, RuntimeError("boom"), "nonsense",
|
||||
[], [object()]])
|
||||
def test_a_broken_or_empty_answer_falls_back_to_legacy_content(result):
|
||||
adapter = _adapter()
|
||||
plugin = _Plugin(result)
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert plugin.content_calls == 1
|
||||
assert images and all(elements.meta_of(img) is None for img in images)
|
||||
|
||||
|
||||
def test_duplicate_keys_keep_the_first():
|
||||
adapter = _adapter()
|
||||
plugin = _Plugin(lambda: [VegasElement("k", _card(40)),
|
||||
VegasElement("k", _card(30))])
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert len(images) == 1 and images[0].width == 40 + 16
|
||||
|
||||
|
||||
def test_elements_are_brought_to_the_display_height_and_rgb():
|
||||
adapter = _adapter()
|
||||
plugin = _Plugin(lambda: [VegasElement("k", _card(40, height=H * 2).convert("RGBA"))])
|
||||
image = adapter.get_content(plugin, "p", offscreen_only=True)[0]
|
||||
assert image.mode == "RGB" and image.height == H
|
||||
assert elements.meta_of(image) is not None
|
||||
|
||||
|
||||
def test_a_keyed_fetch_ignores_legacy_content_in_the_cache():
|
||||
# The first compose runs on the render thread and caches legacy content;
|
||||
# the first background fetch after it must still ask for elements.
|
||||
adapter = _adapter()
|
||||
plugin = _Plugin(_elements)
|
||||
adapter.get_content(plugin, "p", offscreen_only=False)
|
||||
assert plugin.content_calls == 1
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert plugin.element_calls == 1
|
||||
assert elements.meta_of(images[0]) is not None
|
||||
|
||||
|
||||
def test_keyed_content_is_cached_with_its_tags():
|
||||
adapter = _adapter()
|
||||
plugin = _Plugin(_elements)
|
||||
adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
again = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert plugin.element_calls == 1
|
||||
assert elements.meta_of(again[0]).key == "card:a"
|
||||
|
||||
|
||||
def test_an_element_cropped_to_a_width_budget_is_no_longer_live():
|
||||
adapter = _adapter(max_plugin_width_ratio=0.5)
|
||||
wide = Image.new("RGB", (400, H), (255, 255, 255))
|
||||
plugin = _Plugin(lambda: [VegasElement("map", wide)])
|
||||
image = adapter.get_content(plugin, "p", offscreen_only=True)[0]
|
||||
budget = adapter._width_budget(plugin, "p")
|
||||
assert budget // 2 <= image.width < 400
|
||||
# A real window of the element, not a cut in the middle of its padding.
|
||||
assert np.asarray(image).any()
|
||||
assert elements.meta_of(image) is None
|
||||
|
||||
|
||||
def test_an_element_whose_drawing_fits_the_budget_stays_live():
|
||||
adapter = _adapter(max_plugin_width_ratio=0.5)
|
||||
budget = adapter._width_budget(None, "p")
|
||||
# Over the budget only by the black padding pinned on either side.
|
||||
plugin = _Plugin(lambda: [VegasElement("card", _card(budget - 4))])
|
||||
image = adapter.get_content(plugin, "p", offscreen_only=True)[0]
|
||||
assert image.width > budget
|
||||
assert elements.meta_of(image).key == "card"
|
||||
|
||||
|
||||
def test_a_padded_solid_image_over_the_budget_is_cropped_not_slivered():
|
||||
# Legacy content has the same margins after trimming, and the same cut
|
||||
# used to land mid-margin.
|
||||
adapter = _adapter(max_plugin_width_ratio=0.5)
|
||||
budget = adapter._width_budget(None, "p")
|
||||
solid = Image.new("RGB", (budget * 3, H), (255, 255, 255))
|
||||
cropped = adapter._apply_width_budget(
|
||||
[_padded(solid, adapter.config.content_padding)], "p", None)
|
||||
assert budget // 2 <= cropped[0].width
|
||||
assert np.asarray(cropped[0]).any()
|
||||
|
||||
|
||||
def _padded(image, pad):
|
||||
out = Image.new("RGB", (image.width + 2 * pad, image.height))
|
||||
out.paste(image, (pad, 0))
|
||||
return out
|
||||
|
||||
|
||||
def test_rows_under_a_width_budget_keep_their_tags():
|
||||
adapter = _adapter(max_plugin_width_ratio=1.0)
|
||||
plugin = _Plugin(lambda: [VegasElement(f"c{i}", _card(40)) for i in range(6)])
|
||||
images = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
assert 0 < len(images) < 6
|
||||
assert all(elements.meta_of(img) is not None for img in images)
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Where live elements land in the Vegas strip, and that the records stay true.
|
||||
|
||||
The pipeline records each live element's place (ElementRecord) as it builds
|
||||
the strip, in absolute columns that a trim never moves: the strip column is
|
||||
``abs_x - _strip_origin``. Everything a live update will do depends on these
|
||||
being exact, so the check here is the strongest one available -- after any
|
||||
sequence of compose, extend and trim, the strip's pixels at every record are
|
||||
the element's own pixels.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.vegas_mode import elements # noqa: E402
|
||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||
from src.vegas_mode.elements import ElementMeta # noqa: E402
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline # noqa: E402
|
||||
|
||||
W, H = 128, 32
|
||||
|
||||
|
||||
def _live(pid, key, width, seed):
|
||||
"""A tagged, pinned element as the adapter hands it over."""
|
||||
rng = np.random.default_rng(seed)
|
||||
pixels = rng.integers(20, 255, (H, width, 3), dtype=np.uint8)
|
||||
image = Image.frombytes("RGB", (width, H), pixels.tobytes())
|
||||
pinned, array = elements.pin_element(image, 8)
|
||||
return elements.tag(pinned, ElementMeta(
|
||||
pid, key, 1, elements.pixel_digest(array), 0.0, 0.0))
|
||||
|
||||
|
||||
def _plain(width, colour=(200, 200, 200)):
|
||||
image = Image.new("RGB", (width, H), (0, 0, 0))
|
||||
ImageDraw.Draw(image).rectangle([0, 0, width - 1, H - 1], outline=colour)
|
||||
return image
|
||||
|
||||
|
||||
class _Stream:
|
||||
def __init__(self, groups):
|
||||
self.groups = groups
|
||||
self.plugin_manager = type("PM", (), {"plugins": {}})()
|
||||
self.plugin_adapter = None
|
||||
self.i = 0
|
||||
|
||||
def get_grouped_content_for_composition(self):
|
||||
return self.groups[0]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return [pid for pid, _ in self.groups[0]]
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
self.i += 1
|
||||
return self.groups[self.i] if self.i < len(self.groups) else []
|
||||
|
||||
|
||||
class _DM:
|
||||
width, height = W, H
|
||||
|
||||
def __init__(self):
|
||||
self.image = Image.new("RGB", (W, H))
|
||||
|
||||
def set_scrolling_state(self, *a):
|
||||
pass
|
||||
|
||||
def update_display(self):
|
||||
pass
|
||||
|
||||
|
||||
def _pipeline(groups, **cfg):
|
||||
cfg.setdefault("continuous_scroll", True)
|
||||
return RenderPipeline(VegasModeConfig(**cfg), _DM(), _Stream(groups))
|
||||
|
||||
|
||||
def _assert_records_match(p, images_by_key):
|
||||
strip = p.scroll_helper.cached_array
|
||||
for record in p.live_records():
|
||||
x = record.abs_x - p._strip_origin
|
||||
expected = np.asarray(images_by_key[record.key])
|
||||
assert expected.shape[1] == record.width
|
||||
lo = max(0, x)
|
||||
got = strip[:, lo:x + record.width]
|
||||
assert np.array_equal(got, expected[:, lo - x:]), record.key
|
||||
|
||||
|
||||
def _group_images(groups):
|
||||
found = {}
|
||||
for group in groups:
|
||||
for _pid, images in group:
|
||||
for image in images:
|
||||
meta = elements.meta_of(image)
|
||||
if meta:
|
||||
found[meta.key] = image
|
||||
return found
|
||||
|
||||
|
||||
def test_a_compose_records_every_live_element_exactly():
|
||||
groups = [[("a", [_live("a", "a1", 40, 1), _plain(20), _live("a", "a2", 50, 2)]),
|
||||
("b", [_plain(60)]),
|
||||
("c", [_live("c", "c1", 70, 3)])]]
|
||||
p = _pipeline(groups, lead_in_width=10)
|
||||
assert p.compose_scroll_content()
|
||||
keys = [r.key for r in p.live_records()]
|
||||
assert keys == ["a1", "a2", "c1"]
|
||||
assert all(r.width == 16 + {"a1": 40, "a2": 50, "c1": 70}[r.key]
|
||||
for r in p.live_records())
|
||||
_assert_records_match(p, _group_images(groups))
|
||||
|
||||
|
||||
def test_extensions_and_trims_keep_every_record_true():
|
||||
# A strip long enough that the viewport never runs off its end (trimming
|
||||
# is refused while it wraps), advanced by less than each extension adds.
|
||||
groups = [[("a", [_live("a", "a1", 40, 1), _plain(500)])]]
|
||||
for n in range(12):
|
||||
groups.append([(f"p{n}", [_live(f"p{n}", f"k{n}", 30 + n, 10 + n),
|
||||
_plain(25)]),
|
||||
(f"q{n}", [_plain(45)])])
|
||||
p = _pipeline(groups, lead_in_width=0)
|
||||
assert p.compose_scroll_content()
|
||||
images = _group_images(groups)
|
||||
cuts = 0
|
||||
for _ in range(11):
|
||||
width_before = p.scroll_helper.cached_array.shape[1]
|
||||
p.scroll_helper.scroll_position += 150
|
||||
assert p.scroll_helper.scroll_position + W <= width_before
|
||||
position = p.scroll_helper.scroll_position
|
||||
assert p.extend_scroll_content()
|
||||
cut = int(position - p.scroll_helper.scroll_position)
|
||||
assert 0 <= cut <= width_before
|
||||
cuts += cut
|
||||
assert p._strip_origin == cuts
|
||||
_assert_records_match(p, images)
|
||||
# Records wholly trimmed away are forgotten, and only those.
|
||||
for record in p.live_records():
|
||||
assert record.abs_x + record.width > p._strip_origin
|
||||
assert set(p._record_by_seq) == {r.seq for r in p.live_records()}
|
||||
assert cuts > 0
|
||||
|
||||
|
||||
def test_the_first_extension_of_an_empty_strip_starts_at_zero():
|
||||
groups = [[], [("a", [_live("a", "a1", 40, 1)]), ("b", [_live("b", "b1", 20, 2)])]]
|
||||
p = _pipeline(groups)
|
||||
assert p.extend_scroll_content()
|
||||
first = p.live_records()[0]
|
||||
assert first.abs_x == 0 and p._strip_origin == 0
|
||||
_assert_records_match(p, _group_images(groups))
|
||||
|
||||
|
||||
def test_plain_content_is_not_recorded():
|
||||
p = _pipeline([[("a", [_plain(80)])], [("b", [_plain(90)])]])
|
||||
assert p.compose_scroll_content()
|
||||
assert p.extend_scroll_content()
|
||||
assert p.live_records() == ()
|
||||
|
||||
|
||||
def test_a_new_strip_starts_a_new_generation_with_no_records():
|
||||
groups = [[("a", [_live("a", "a1", 40, 1)])]]
|
||||
p = _pipeline(groups)
|
||||
assert p.compose_scroll_content()
|
||||
gen = p._strip_gen
|
||||
assert p.live_records()
|
||||
assert p.compose_scroll_content()
|
||||
assert p._strip_gen == gen + 1
|
||||
assert [r.key for r in p.live_records()] == ["a1"]
|
||||
p.reset()
|
||||
assert p._strip_gen == gen + 2
|
||||
assert p.live_records() == () and p._strip_origin == 0
|
||||
|
||||
|
||||
def test_record_numbers_are_never_reused():
|
||||
groups = [[("a", [_live("a", "a1", 40, 1)])]]
|
||||
p = _pipeline(groups)
|
||||
seen = set()
|
||||
for _ in range(3):
|
||||
assert p.compose_scroll_content()
|
||||
for record in p.live_records():
|
||||
assert record.seq not in seen
|
||||
seen.add(record.seq)
|
||||
|
||||
|
||||
def test_static_markers_still_land_where_they_did():
|
||||
# The marker arithmetic now shares _block_starts with the records.
|
||||
class Stream(_Stream):
|
||||
def is_static_plugin(self, pid):
|
||||
return pid == "pause"
|
||||
|
||||
groups = [[("a", [_plain(100)])],
|
||||
[("b", [_plain(60)]), ("pause", []), ("c", [_plain(70)])]]
|
||||
p = RenderPipeline(VegasModeConfig(continuous_scroll=True, lead_in_width=0,
|
||||
separator_width=32),
|
||||
_DM(), Stream(groups))
|
||||
assert p.compose_scroll_content()
|
||||
strip_end = p.scroll_helper.total_scroll_width
|
||||
assert p.extend_scroll_content()
|
||||
# b starts after one separator and ends 60 later; the pause follows b.
|
||||
assert p._static_markers == ((strip_end + 32 + 60, "pause"),)
|
||||
@@ -0,0 +1,120 @@
|
||||
"""The adapter's side of live redraws: render_live_elements() and its memo.
|
||||
|
||||
The worker asks the adapter to redraw a plugin's elements whenever the plugin's
|
||||
data moves on. What comes back is compared with what the strip shows, so the
|
||||
adapter may skip converting an element it has seen before -- but only when it
|
||||
is provably the same picture, and a bad element must cost only itself.
|
||||
"""
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.plugin_system.vegas_elements import VegasElement # noqa: E402
|
||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||
from src.vegas_mode.elements import LiveEpochs # noqa: E402
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402
|
||||
|
||||
from test.test_vegas_elements_api import _DM, _Plugin, H # noqa: E402
|
||||
|
||||
|
||||
def _card(width=40, colour=(255, 0, 0)):
|
||||
image = Image.new("RGB", (width, H))
|
||||
ImageDraw.Draw(image).rectangle([0, 0, width - 1, H - 1], outline=colour)
|
||||
return image
|
||||
|
||||
|
||||
def _adapter():
|
||||
lock = threading.Lock()
|
||||
pm = SimpleNamespace(get_plugin_lock=lambda pid: lock)
|
||||
adapter = PluginAdapter(_DM(), VegasModeConfig(), plugin_manager=pm)
|
||||
adapter.live_elements_enabled = True
|
||||
adapter.live_epochs = LiveEpochs()
|
||||
return adapter
|
||||
|
||||
|
||||
def _render(adapter, plugin):
|
||||
epoch, rendered = adapter.render_live_elements(plugin, "p", lock_timeout=1.0)
|
||||
return rendered
|
||||
|
||||
|
||||
def test_the_same_image_at_the_same_version_is_not_converted_again():
|
||||
card = _card()
|
||||
plugin = _Plugin(lambda: [VegasElement("c", card, version=1)])
|
||||
adapter = _adapter()
|
||||
first = _render(adapter, plugin)["c"]
|
||||
again = _render(adapter, plugin)["c"]
|
||||
assert again.pixels is first.pixels
|
||||
|
||||
|
||||
def test_a_new_picture_at_the_same_version_is_converted():
|
||||
# A config change (new colours) redraws the card without moving its data
|
||||
# version; the strip must still get the new pixels.
|
||||
colours = iter([(255, 0, 0), (0, 255, 0)])
|
||||
plugin = _Plugin(lambda: [VegasElement("c", _card(colour=next(colours)), version=1)])
|
||||
adapter = _adapter()
|
||||
first = _render(adapter, plugin)["c"]
|
||||
again = _render(adapter, plugin)["c"]
|
||||
assert again.digest != first.digest
|
||||
|
||||
|
||||
def test_the_same_image_at_a_new_version_is_converted():
|
||||
card = _card()
|
||||
versions = iter([1, 2])
|
||||
plugin = _Plugin(lambda: [VegasElement("c", card, version=next(versions))])
|
||||
adapter = _adapter()
|
||||
first = _render(adapter, plugin)["c"]
|
||||
again = _render(adapter, plugin)["c"]
|
||||
assert again.pixels is not first.pixels
|
||||
|
||||
|
||||
def test_invalidating_every_plugin_forgets_what_was_converted():
|
||||
card = _card()
|
||||
plugin = _Plugin(lambda: [VegasElement("c", card, version=1)])
|
||||
adapter = _adapter()
|
||||
first = _render(adapter, plugin)["c"]
|
||||
adapter.invalidate_cache()
|
||||
assert _render(adapter, plugin)["c"].pixels is not first.pixels
|
||||
|
||||
|
||||
def test_an_element_that_cannot_be_converted_costs_only_itself(monkeypatch):
|
||||
plugin = _Plugin(lambda: [VegasElement("bad", _card()), VegasElement("good", _card())])
|
||||
adapter = _adapter()
|
||||
real = adapter._element_image
|
||||
|
||||
def element_image(element):
|
||||
if element.key == "bad":
|
||||
raise OSError("closed file")
|
||||
return real(element)
|
||||
|
||||
monkeypatch.setattr(adapter, "_element_image", element_image)
|
||||
assert set(_render(adapter, plugin)) == {"good"}
|
||||
placed = adapter.get_content(plugin, "p", offscreen_only=True)
|
||||
from src.vegas_mode.elements import meta_of
|
||||
assert [meta_of(img).key for img in placed] == ["good"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [(0, H), (40, 0)])
|
||||
def test_an_empty_image_is_dropped(size):
|
||||
plugin = _Plugin(lambda: [VegasElement("empty", Image.new("RGB", size)),
|
||||
VegasElement("good", _card())])
|
||||
adapter = _adapter()
|
||||
assert set(_render(adapter, plugin)) == {"good"}
|
||||
|
||||
|
||||
def test_a_bad_vegas_width_pct_is_reported_once(caplog):
|
||||
plugin = _Plugin(lambda: [VegasElement("c", _card())], config={"vegas_width_pct": "wide"})
|
||||
adapter = _adapter()
|
||||
with caplog.at_level("WARNING"):
|
||||
for _ in range(5):
|
||||
adapter.resolve_render_width(plugin, "p")
|
||||
assert sum("vegas_width_pct" in r.getMessage() for r in caplog.records) == 1
|
||||
plugin.config["vegas_width_pct"] = 500
|
||||
with caplog.at_level("WARNING"):
|
||||
adapter.resolve_render_width(plugin, "p")
|
||||
assert sum("out of range" in r.getMessage() for r in caplog.records) == 1
|
||||
@@ -0,0 +1,211 @@
|
||||
"""The render thread's half of a live update: RenderPipeline.apply_live_patches.
|
||||
|
||||
Between two frames the render thread copies prepared pixels into the strip.
|
||||
It must do nothing else there -- no drawing, no locks, a bounded number of
|
||||
bytes -- and must refuse a patch that no longer fits: a strip rebuilt since,
|
||||
an element trimmed away or already behind the screen, a patch older than what
|
||||
the strip already shows.
|
||||
"""
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
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.vegas_mode import elements # noqa: E402
|
||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||
from src.vegas_mode.elements import ElementMeta, LivePatch # noqa: E402
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline # noqa: E402
|
||||
|
||||
W, H = 128, 32
|
||||
|
||||
|
||||
def _live(key, width, seed):
|
||||
rng = np.random.default_rng(seed)
|
||||
pixels = rng.integers(20, 255, (H, width, 3), dtype=np.uint8)
|
||||
image = Image.frombytes("RGB", (width, H), pixels.tobytes())
|
||||
pinned, array = elements.pin_element(image, 8)
|
||||
return elements.tag(pinned, ElementMeta("p", key, 1, elements.pixel_digest(array), 0.0, 0.0))
|
||||
|
||||
|
||||
class _Stream:
|
||||
def __init__(self, groups):
|
||||
self.groups = groups
|
||||
self.plugin_manager = type("PM", (), {"plugins": {}})()
|
||||
self.plugin_adapter = None
|
||||
self.i = 0
|
||||
|
||||
def get_grouped_content_for_composition(self):
|
||||
return self.groups[0]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return ["p"]
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
self.i += 1
|
||||
return self.groups[self.i] if self.i < len(self.groups) else []
|
||||
|
||||
|
||||
class _Timing:
|
||||
def __init__(self):
|
||||
self.notes = []
|
||||
|
||||
def note_op(self, kind, nbytes=0):
|
||||
self.notes.append((kind, nbytes))
|
||||
|
||||
|
||||
class _DM:
|
||||
width, height = W, H
|
||||
|
||||
def __init__(self):
|
||||
self.image = Image.new("RGB", (W, H))
|
||||
self.frame_timing = _Timing()
|
||||
|
||||
def set_scrolling_state(self, *a):
|
||||
pass
|
||||
|
||||
def update_display(self):
|
||||
pass
|
||||
|
||||
|
||||
def _pipeline(n=6):
|
||||
groups = [[("p", [_live(f"k{i}", 40, i) for i in range(n)])]]
|
||||
p = RenderPipeline(VegasModeConfig(continuous_scroll=True, lead_in_width=0),
|
||||
_DM(), _Stream(groups))
|
||||
assert p.compose_scroll_content()
|
||||
p.display_manager.frame_timing.notes.clear() # the compose's own note
|
||||
return p
|
||||
|
||||
|
||||
def _patch_for(p, record, value, epoch=None, gen=None):
|
||||
pixels = np.full((H, record.width, 3), value, dtype=np.uint8)
|
||||
pixels.setflags(write=False)
|
||||
return LivePatch(seq=record.seq, strip_gen=p._strip_gen if gen is None else gen,
|
||||
epoch=record.epoch if epoch is None else epoch, pixels=pixels,
|
||||
digest=elements.pixel_digest(pixels), made_at=0.0)
|
||||
|
||||
|
||||
def _offer(p, patch):
|
||||
p._live_slots[patch.seq] = patch
|
||||
p._live_ready.append(patch.seq)
|
||||
|
||||
|
||||
def _columns(p, record):
|
||||
x = record.abs_x - p._strip_origin
|
||||
return p.scroll_helper.cached_array[:, x:x + record.width]
|
||||
|
||||
|
||||
def test_a_patch_is_copied_into_its_columns_and_noted():
|
||||
p = _pipeline()
|
||||
record = p.live_records()[2]
|
||||
_offer(p, _patch_for(p, record, 123))
|
||||
assert p.apply_live_patches() == 1
|
||||
assert (_columns(p, record) == 123).all()
|
||||
assert p._applied[record.seq][1] == elements.pixel_digest(_columns(p, record))
|
||||
assert p.display_manager.frame_timing.notes == [("patch", record.width * H * 3)]
|
||||
|
||||
|
||||
def test_nothing_to_apply_costs_nothing():
|
||||
p = _pipeline()
|
||||
assert p.apply_live_patches() == 0
|
||||
assert p.display_manager.frame_timing.notes == []
|
||||
|
||||
|
||||
def test_at_most_four_patches_a_frame_the_rest_next_frame():
|
||||
p = _pipeline()
|
||||
for record in p.live_records():
|
||||
_offer(p, _patch_for(p, record, 50))
|
||||
assert p.apply_live_patches() == 4
|
||||
assert p.apply_live_patches() == 2
|
||||
|
||||
|
||||
def test_the_byte_budget_still_applies_one():
|
||||
p = _pipeline(n=1)
|
||||
record = p.live_records()[0]
|
||||
big = LivePatch(seq=record.seq, strip_gen=p._strip_gen, epoch=1,
|
||||
pixels=np.full((H, record.width, 3), 9, dtype=np.uint8),
|
||||
digest=((H, record.width, 3), 1), made_at=0.0)
|
||||
p.LIVE_PATCH_BUDGET_SCREENS = 0
|
||||
_offer(p, big)
|
||||
assert p.apply_live_patches() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("why", ["old strip", "unknown element", "older data",
|
||||
"behind the screen"])
|
||||
def test_a_patch_that_no_longer_fits_is_dropped(why):
|
||||
p = _pipeline()
|
||||
record = p.live_records()[0]
|
||||
before = _columns(p, record).copy()
|
||||
patch = _patch_for(p, record, 77)
|
||||
if why == "old strip":
|
||||
patch = _patch_for(p, record, 77, gen=p._strip_gen - 1)
|
||||
elif why == "unknown element":
|
||||
patch = patch._replace(seq=999)
|
||||
elif why == "older data":
|
||||
p._applied[record.seq] = (5, p._applied[record.seq][1])
|
||||
patch = _patch_for(p, record, 77, epoch=4)
|
||||
else:
|
||||
p.scroll_helper.scroll_position = record.abs_x + record.width + 1
|
||||
_offer(p, patch)
|
||||
assert p.apply_live_patches() == 0
|
||||
assert np.array_equal(_columns(p, record), before)
|
||||
|
||||
|
||||
def test_the_latest_patch_wins_and_its_duplicate_entry_is_harmless():
|
||||
p = _pipeline()
|
||||
record = p.live_records()[1]
|
||||
_offer(p, _patch_for(p, record, 10))
|
||||
_offer(p, _patch_for(p, record, 20)) # replaces the slot, queues seq again
|
||||
assert p.apply_live_patches() == 1
|
||||
assert (_columns(p, record) == 20).all()
|
||||
assert not p._live_ready
|
||||
|
||||
|
||||
def test_under_sync_nothing_is_applied_and_the_queue_is_emptied():
|
||||
p = _pipeline()
|
||||
record = p.live_records()[0]
|
||||
before = _columns(p, record).copy()
|
||||
p.sync_manager = object()
|
||||
_offer(p, _patch_for(p, record, 5))
|
||||
assert p.apply_live_patches() == 0
|
||||
assert not p._live_ready and not p._live_slots
|
||||
assert np.array_equal(_columns(p, record), before)
|
||||
|
||||
|
||||
def test_the_render_thread_takes_no_lock_and_draws_nothing(monkeypatch):
|
||||
# Another thread holds every lock a live update could involve; the render
|
||||
# thread's apply must not care, and must not draw or rebuild the strip.
|
||||
p = _pipeline()
|
||||
locks = [p._prefetch_lock, threading.Lock()]
|
||||
for lock in locks:
|
||||
lock.acquire()
|
||||
for name in ("new", "fromarray"):
|
||||
monkeypatch.setattr(Image, name, lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError("drew on the render thread")))
|
||||
monkeypatch.setattr(np, "concatenate", lambda *a, **k: (_ for _ in ()).throw(
|
||||
AssertionError("rebuilt the strip")))
|
||||
try:
|
||||
for record in p.live_records():
|
||||
_offer(p, _patch_for(p, record, 3))
|
||||
started = time.perf_counter()
|
||||
applied = p.apply_live_patches() + p.apply_live_patches()
|
||||
assert time.perf_counter() - started < 0.05
|
||||
assert applied == 6
|
||||
finally:
|
||||
for lock in locks:
|
||||
lock.release()
|
||||
|
||||
|
||||
def test_the_view_is_published_each_frame():
|
||||
p = _pipeline()
|
||||
p.scroll_helper.set_pixels_per_frame(1)
|
||||
assert p.render_frame()
|
||||
view = p._view
|
||||
assert view.abs_right - view.abs_left == W
|
||||
assert view.abs_left == p._strip_origin + int(p.scroll_helper.scroll_position)
|
||||
assert view.abs_end == p._strip_origin + p.scroll_helper.total_scroll_width
|
||||
@@ -0,0 +1,137 @@
|
||||
"""When Vegas live elements are on, and that everywhere else nothing changes.
|
||||
|
||||
Live elements run only when the config allows them and nothing rules them
|
||||
out: multi-display sync (the follower mirrors whole strips only), swap mode,
|
||||
the offscreen kill switch, or a display manager with no off-screen canvas.
|
||||
While they are on, the coordinator listens for plugin updates and moves each
|
||||
plugin's data epoch on; while off, the adapter never asks for elements.
|
||||
"""
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.vegas_mode.coordinator import VegasModeCoordinator # noqa: E402
|
||||
|
||||
|
||||
class _DM:
|
||||
width, height = 128, 32
|
||||
|
||||
def __init__(self, offscreen=True):
|
||||
self.image = Image.new("RGB", (128, 32))
|
||||
if offscreen:
|
||||
self.offscreen = self._offscreen
|
||||
|
||||
@contextmanager
|
||||
def _offscreen(self, width=None, height=None):
|
||||
yield SimpleNamespace(image=Image.new("RGB", (width or 128, 32)))
|
||||
|
||||
def set_scrolling_state(self, *a, **k):
|
||||
pass
|
||||
|
||||
def update_display(self):
|
||||
pass
|
||||
|
||||
|
||||
class _PM:
|
||||
def __init__(self):
|
||||
self.plugins = {}
|
||||
self.listeners = []
|
||||
|
||||
def add_update_listener(self, fn):
|
||||
if fn not in self.listeners:
|
||||
self.listeners.append(fn)
|
||||
|
||||
def remove_update_listener(self, fn):
|
||||
self.listeners = [f for f in self.listeners if f != fn]
|
||||
|
||||
|
||||
def _coordinator(dm=None, **vegas):
|
||||
vegas.setdefault("enabled", True)
|
||||
config = {"display": {"vegas_scroll": vegas}}
|
||||
return VegasModeCoordinator(config, dm or _DM(), _PM())
|
||||
|
||||
|
||||
def test_on_by_default():
|
||||
c = _coordinator()
|
||||
c._apply_live_state()
|
||||
assert c.live_active
|
||||
assert c.plugin_adapter.live_elements_enabled
|
||||
assert c._on_plugin_data_changed in c.plugin_manager.listeners
|
||||
|
||||
|
||||
@pytest.mark.parametrize("why,vegas,dm", [
|
||||
("switched off", {"live_refresh": False}, None),
|
||||
("swap mode", {"continuous_scroll": False}, None),
|
||||
("offscreen kill switch", {"offscreen_prefetch": False}, None),
|
||||
("no offscreen canvas", {}, _DM(offscreen=False)),
|
||||
])
|
||||
def test_off_when_ruled_out(why, vegas, dm):
|
||||
c = _coordinator(dm, **vegas)
|
||||
c._apply_live_state()
|
||||
assert not c.live_active
|
||||
assert not c.plugin_adapter.live_elements_enabled
|
||||
assert c.plugin_manager.listeners == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["leader", "follower"])
|
||||
def test_off_whenever_sync_is_configured(role):
|
||||
c = _coordinator()
|
||||
c.set_sync_manager(SimpleNamespace(role=role))
|
||||
c._apply_live_state()
|
||||
assert not c.live_active
|
||||
assert not c.plugin_adapter.live_elements_enabled
|
||||
|
||||
|
||||
def test_a_standalone_sync_manager_does_not_count():
|
||||
from src.common.sync_manager import SyncRole
|
||||
c = _coordinator()
|
||||
c.set_sync_manager(SimpleNamespace(role=SyncRole.STANDALONE))
|
||||
c._apply_live_state()
|
||||
assert c.live_active
|
||||
|
||||
|
||||
def test_stopping_switches_it_off_and_stops_listening():
|
||||
c = _coordinator()
|
||||
c._apply_live_state()
|
||||
c._is_active = True
|
||||
c.stop()
|
||||
assert not c.live_active and not c.plugin_adapter.live_elements_enabled
|
||||
assert c.plugin_manager.listeners == []
|
||||
|
||||
|
||||
def test_a_config_change_can_switch_it_off_mid_run():
|
||||
c = _coordinator()
|
||||
c._apply_live_state()
|
||||
c._is_active = True
|
||||
c.stream_manager.refresh = MagicMock()
|
||||
c.update_config({"display": {"vegas_scroll": {"enabled": True,
|
||||
"live_refresh": False}}})
|
||||
c._apply_pending_config()
|
||||
assert not c.live_active
|
||||
assert c.plugin_manager.listeners == []
|
||||
|
||||
|
||||
def test_an_update_moves_the_plugins_epoch_on():
|
||||
c = _coordinator()
|
||||
c._apply_live_state()
|
||||
before = c.live_epochs.get("p")
|
||||
for listener in c.plugin_manager.listeners:
|
||||
listener("p")
|
||||
assert c.live_epochs.get("p") > before
|
||||
assert c.plugin_adapter.live_epochs is c.live_epochs
|
||||
|
||||
|
||||
def test_the_state_change_is_logged_once(caplog):
|
||||
c = _coordinator(live_refresh=False)
|
||||
with caplog.at_level("INFO"):
|
||||
c._apply_live_state()
|
||||
c._apply_live_state()
|
||||
lines = [r.message for r in caplog.records if "live elements" in r.message]
|
||||
assert lines == ["Vegas live elements off: switched off (vegas_scroll.live_refresh)"]
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Live Vegas elements end to end: a real plugin through the real ticker.
|
||||
|
||||
The stub fixture plugin (test/fixtures/plugins/vegas-live-stub) is loaded by
|
||||
the real plugin loader onto a real DisplayManager (RGBMatrixEmulator) and a
|
||||
real PluginManager, and the real coordinator runs it: the first compose uses
|
||||
its ordinary Vegas content, the background prefetch asks it for elements, and
|
||||
the strip records where each one landed.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
os.environ["EMULATOR"] = "true"
|
||||
|
||||
import numpy as np # noqa: E402
|
||||
import pytest # noqa: E402
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.plugin_system.plugin_state import PluginState # noqa: E402
|
||||
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.vegas_mode import elements # noqa: E402
|
||||
|
||||
STUB = Path(__file__).resolve().parent / "fixtures" / "plugins" / "vegas-live-stub"
|
||||
PID = "vegas-live-stub"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def dm(tmp_path_factory):
|
||||
from src.display_manager import DisplayManager
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
manager = DisplayManager({
|
||||
"display": {
|
||||
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
|
||||
"parallel": 1, "brightness": 90},
|
||||
"runtime": {"gpio_slowdown": 0},
|
||||
},
|
||||
}, suppress_test_pattern=True)
|
||||
manager._snapshot_path = str(
|
||||
tmp_path_factory.mktemp("live") / "led_matrix_preview.png")
|
||||
if manager.matrix is None:
|
||||
pytest.fail("DisplayManager fell back to matrix=None")
|
||||
yield manager
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ticker(dm, tmp_path):
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
||||
|
||||
pm = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
|
||||
display_manager=dm, cache_manager=None)
|
||||
config = {**build_full_config(STUB, load_harness_spec(STUB), {}),
|
||||
"enabled": True, "map_hz": 4}
|
||||
plugin = _instantiate(PID, load_manifest(STUB), STUB, config, {}, dm)
|
||||
plugin.plugin_manager = pm
|
||||
pm.plugins[PID] = plugin
|
||||
pm.state_manager.set_state(PID, PluginState.ENABLED)
|
||||
|
||||
coordinator = VegasModeCoordinator({"display": {"vegas_scroll": {
|
||||
"enabled": True, "continuous_scroll": True, "plugins_per_cycle": 1,
|
||||
"scroll_speed": 100, "lead_in_width": 0,
|
||||
}}}, dm, pm)
|
||||
yield coordinator, plugin, pm
|
||||
coordinator.stop()
|
||||
pm.stop_update_worker()
|
||||
|
||||
|
||||
def _run_until(coordinator, predicate, seconds=10.0):
|
||||
deadline = time.monotonic() + seconds
|
||||
while time.monotonic() < deadline:
|
||||
coordinator.run_frame()
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.002)
|
||||
return False
|
||||
|
||||
|
||||
def test_the_prefetched_stub_is_placed_as_live_elements(ticker):
|
||||
coordinator, plugin, _pm = ticker
|
||||
assert coordinator.start()
|
||||
assert coordinator.live_active
|
||||
pipeline = coordinator.render_pipeline
|
||||
# The first compose ran on this thread without the plugin's lock, so it
|
||||
# is plain content.
|
||||
assert pipeline.live_records() == ()
|
||||
|
||||
assert _run_until(coordinator, lambda: pipeline.live_records())
|
||||
keys = [r.key for r in pipeline.live_records()]
|
||||
assert {"card:0", "card:5", "map"} <= set(keys)
|
||||
assert "sep" not in keys
|
||||
assert all(r.plugin_id == PID for r in pipeline.live_records())
|
||||
|
||||
# Each record points at the element's pixels: a card is bordered in its
|
||||
# colour, with content_padding black columns either side.
|
||||
strip = pipeline.scroll_helper.cached_array
|
||||
pad = coordinator.vegas_config.content_padding
|
||||
for record in pipeline.live_records():
|
||||
x = record.abs_x - pipeline._strip_origin
|
||||
if x < 0:
|
||||
continue
|
||||
columns = strip[:, x:x + record.width]
|
||||
assert not columns[:, :pad].any() and not columns[:, -pad:].any()
|
||||
assert columns[:, pad:record.width - pad].any()
|
||||
|
||||
|
||||
def test_an_update_moves_the_plugins_epoch_and_new_elements_carry_it(ticker):
|
||||
coordinator, plugin, pm = ticker
|
||||
assert coordinator.start()
|
||||
before = coordinator.live_epochs.get(PID)
|
||||
pm._note_update_completed(PID)
|
||||
epoch = coordinator.live_epochs.get(PID)
|
||||
assert epoch > before
|
||||
coordinator.plugin_adapter.invalidate_cache(PID)
|
||||
images = coordinator.plugin_adapter.get_content(plugin, PID, offscreen_only=True)
|
||||
metas = [elements.meta_of(img) for img in images if elements.meta_of(img)]
|
||||
assert metas and all(m.epoch == epoch for m in metas)
|
||||
|
||||
|
||||
def _columns(pipeline, record):
|
||||
x = record.abs_x - pipeline._strip_origin
|
||||
return pipeline.scroll_helper.cached_array[:, max(0, x):x + record.width].copy()
|
||||
|
||||
|
||||
def _upcoming(pipeline, key_prefix):
|
||||
"""A live record of this kind that is not yet behind the viewport."""
|
||||
left = pipeline._strip_origin + int(pipeline.scroll_helper.scroll_position)
|
||||
for record in pipeline.live_records():
|
||||
if record.key.startswith(key_prefix) and record.abs_x + record.width > left + 40:
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def test_an_update_changes_cards_already_in_the_strip(ticker):
|
||||
coordinator, plugin, pm = ticker
|
||||
assert coordinator.start()
|
||||
pipeline = coordinator.render_pipeline
|
||||
assert _run_until(coordinator, lambda: _upcoming(pipeline, "card:"))
|
||||
record = _upcoming(pipeline, "card:")
|
||||
before = _columns(pipeline, record)
|
||||
|
||||
plugin.update() # new data: every card's bars change
|
||||
pm._note_update_completed(PID) # what the update worker does after it
|
||||
assert _run_until(
|
||||
coordinator,
|
||||
lambda: not np.array_equal(_columns(pipeline, record), before), seconds=5.0)
|
||||
# The card changed in place: same columns, same width, new pixels.
|
||||
assert pipeline._record_by_seq[record.seq] == record
|
||||
assert pipeline._live_worker is not None and pipeline._live_worker.is_alive()
|
||||
|
||||
|
||||
def test_an_animated_element_moves_with_no_update_at_all(ticker):
|
||||
coordinator, _plugin, _pm = ticker
|
||||
assert coordinator.start()
|
||||
pipeline = coordinator.render_pipeline
|
||||
assert _run_until(coordinator, lambda: _upcoming(pipeline, "map"))
|
||||
record = _upcoming(pipeline, "map")
|
||||
before = _columns(pipeline, record)
|
||||
assert _run_until(
|
||||
coordinator,
|
||||
lambda: (pipeline._record_by_seq.get(record.seq) is not None
|
||||
and not np.array_equal(_columns(pipeline, record), before)),
|
||||
seconds=5.0)
|
||||
|
||||
|
||||
def test_stopping_vegas_stops_the_worker(ticker):
|
||||
coordinator, _plugin, _pm = ticker
|
||||
assert coordinator.start()
|
||||
pipeline = coordinator.render_pipeline
|
||||
assert _run_until(coordinator, lambda: pipeline._live_worker is not None)
|
||||
worker = pipeline._live_worker
|
||||
coordinator.stop()
|
||||
worker.join(3)
|
||||
assert not worker.is_alive()
|
||||
assert pipeline._live_worker is None
|
||||
|
||||
|
||||
def test_with_live_refresh_off_the_same_run_is_plain_content(dm, tmp_path, ticker):
|
||||
coordinator, _plugin, _pm = ticker
|
||||
coordinator.vegas_config.live_refresh = False
|
||||
assert coordinator.start()
|
||||
assert not coordinator.live_active
|
||||
pipeline = coordinator.render_pipeline
|
||||
start_width = pipeline.scroll_helper.total_scroll_width
|
||||
assert _run_until(
|
||||
coordinator,
|
||||
lambda: pipeline.stats.get('extensions', 0) >= 1, seconds=10.0)
|
||||
assert pipeline.live_records() == ()
|
||||
assert np.asarray(pipeline.scroll_helper.cached_array).shape[1] > 0
|
||||
assert start_width > 0
|
||||
@@ -0,0 +1,179 @@
|
||||
"""When the live-element worker runs, and what happens to its work when it stops.
|
||||
|
||||
The worker fetches the strip's groups as well as redrawing live elements, so
|
||||
starting it where nothing is live costs a thread for nothing, and stopping it
|
||||
must not lose the group it was fetching: nothing else asks for one until the
|
||||
next extension, which would then fetch inline and stall the scroll.
|
||||
"""
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
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.vegas_mode import elements, render_pipeline # noqa: E402
|
||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||
from src.vegas_mode.elements import ElementMeta # noqa: E402
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline # noqa: E402
|
||||
|
||||
from test.test_vegas_live_apply import _DM # noqa: E402
|
||||
|
||||
H = 32
|
||||
|
||||
|
||||
def _image(width, seed, key=None):
|
||||
rng = np.random.default_rng(seed)
|
||||
pixels = rng.integers(20, 255, (H, width, 3), dtype=np.uint8)
|
||||
image = Image.frombytes("RGB", (width, H), pixels.tobytes())
|
||||
if key is None:
|
||||
return image
|
||||
pinned, array = elements.pin_element(image, 8)
|
||||
return elements.tag(pinned, ElementMeta("p", key, 1, elements.pixel_digest(array), 0.0, 0.0))
|
||||
|
||||
|
||||
class _Stream:
|
||||
def __init__(self, live):
|
||||
self.first = [("p", [_image(40, i, f"k{i}" if live else None) for i in range(6)])]
|
||||
self.plugin_manager = type("PM", (), {"plugins": {}})()
|
||||
self.plugin_adapter = None
|
||||
self.taken = 0
|
||||
|
||||
def get_grouped_content_for_composition(self):
|
||||
return self.first
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return ["p"]
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
self.taken += 1
|
||||
return [("q", [_image(40, 99)])]
|
||||
|
||||
|
||||
class _FakeWorker:
|
||||
"""Stands in for VegasWorker: no thread, and says what it was asked."""
|
||||
|
||||
instances = []
|
||||
alive = True
|
||||
|
||||
def __init__(self, pipeline):
|
||||
self.pipeline = pipeline
|
||||
self.requests = 0
|
||||
self.stopped = False
|
||||
self.on_join = None
|
||||
_FakeWorker.instances.append(self)
|
||||
|
||||
def start(self):
|
||||
pass
|
||||
|
||||
def is_alive(self):
|
||||
return _FakeWorker.alive and not self.stopped
|
||||
|
||||
def request_group(self):
|
||||
self.requests += 1
|
||||
|
||||
def notify_data(self, plugin_id):
|
||||
pass
|
||||
|
||||
def stop(self):
|
||||
# Still finishing its job: alive until joined.
|
||||
self.stopping = True
|
||||
|
||||
def join(self, timeout=None):
|
||||
if self.on_join is not None:
|
||||
self.on_join()
|
||||
self.stopped = True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_worker(monkeypatch):
|
||||
_FakeWorker.instances = []
|
||||
_FakeWorker.alive = True
|
||||
monkeypatch.setattr(render_pipeline, "VegasWorker", _FakeWorker)
|
||||
|
||||
|
||||
def _pipeline(live=True):
|
||||
p = RenderPipeline(VegasModeConfig(continuous_scroll=True, lead_in_width=0),
|
||||
_DM(), _Stream(live))
|
||||
assert p.compose_scroll_content()
|
||||
return p
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout=2.0):
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.005)
|
||||
return False
|
||||
|
||||
|
||||
def test_supervision_starts_no_worker_while_nothing_is_live():
|
||||
p = _pipeline(live=False)
|
||||
p.set_live(True)
|
||||
for _ in range(p.LIVE_SUPERVISE_FRAMES * 2 + 1):
|
||||
p.apply_live_patches()
|
||||
assert p._live_worker is None and not _FakeWorker.instances
|
||||
|
||||
|
||||
def test_live_elements_start_the_worker_and_it_is_asked_for_a_group():
|
||||
p = _pipeline()
|
||||
p.set_live(True)
|
||||
assert isinstance(p._live_worker, _FakeWorker)
|
||||
assert p._live_worker.requests == 1
|
||||
|
||||
|
||||
def test_switching_live_off_hands_group_fetching_back():
|
||||
p = _pipeline()
|
||||
p.set_live(True)
|
||||
worker = p._live_worker
|
||||
p.set_live(False)
|
||||
assert p._live_worker is None and p._retired_worker is worker
|
||||
assert _wait_for(lambda: p._prepared_group is not None)
|
||||
assert worker.stopped # waited for before fetching
|
||||
assert p._prefetch_thread.name == "vegas-strip-prefetch"
|
||||
assert p.stream_manager.taken == 1
|
||||
|
||||
|
||||
def test_a_group_the_stopping_worker_hands_over_is_kept():
|
||||
p = _pipeline()
|
||||
p.set_live(True)
|
||||
worker = p._live_worker
|
||||
handed = [("p", ["from the worker"])]
|
||||
|
||||
def publish():
|
||||
with p._prefetch_lock:
|
||||
p._prepared_group = handed
|
||||
|
||||
worker.on_join = publish
|
||||
p.set_live(False)
|
||||
p._prefetch_thread.join(2)
|
||||
assert p._prepared_group is handed
|
||||
assert p.stream_manager.taken == 0 # nothing fetched over it
|
||||
|
||||
|
||||
def test_a_reset_while_stopping_fetches_nothing():
|
||||
p = _pipeline()
|
||||
p.set_live(True)
|
||||
release = threading.Event()
|
||||
p._live_worker.on_join = lambda: release.wait(2)
|
||||
p.set_live(False)
|
||||
p.reset()
|
||||
release.set()
|
||||
p._prefetch_thread.join(2)
|
||||
assert p._prepared_group is None and p.stream_manager.taken == 0
|
||||
|
||||
|
||||
def test_a_worker_given_up_on_hands_group_fetching_back():
|
||||
p = _pipeline()
|
||||
p.set_live(True)
|
||||
_FakeWorker.alive = False # it, and every restart, dies
|
||||
for _ in range(p.LIVE_WORKER_MAX_DEATHS):
|
||||
p._ensure_live_worker()
|
||||
assert not p._live_enabled and p._live_worker is None
|
||||
assert _wait_for(lambda: p._prepared_group is not None)
|
||||
assert p.stream_manager.taken == 1
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Live patches change live elements' columns and nothing else, whatever happens.
|
||||
|
||||
Two pipelines are built from the same content and put through the same random
|
||||
sequence of scrolling, extending and trimming. One of them also has random
|
||||
patches applied. After every step, the only columns allowed to differ between
|
||||
the two are those of live elements, and each of those must show exactly the
|
||||
last patch applied to it (or its original pixels).
|
||||
"""
|
||||
import collections
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.vegas_mode import elements # noqa: E402
|
||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||
from src.vegas_mode.elements import ElementMeta, LivePatch # noqa: E402
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline # noqa: E402
|
||||
|
||||
W, H = 96, 24
|
||||
|
||||
|
||||
def _live(key, width, seed):
|
||||
rng = np.random.default_rng(seed)
|
||||
pixels = rng.integers(20, 255, (H, width, 3), dtype=np.uint8)
|
||||
image = Image.frombytes("RGB", (width, H), pixels.tobytes())
|
||||
pinned, array = elements.pin_element(image, 8)
|
||||
return elements.tag(pinned, ElementMeta("p", key, 0, elements.pixel_digest(array), 0.0, 0.0))
|
||||
|
||||
|
||||
def _plain(width, seed):
|
||||
image = Image.new("RGB", (width, H))
|
||||
ImageDraw.Draw(image).rectangle([0, 0, width - 1, H - 1],
|
||||
outline=(seed * 37 % 255, 90, 200))
|
||||
return image
|
||||
|
||||
|
||||
def _groups(rng, n):
|
||||
groups = []
|
||||
for g in range(n):
|
||||
group = []
|
||||
for p in range(rng.randint(1, 3)):
|
||||
rows = []
|
||||
for r in range(rng.randint(1, 3)):
|
||||
key = f"g{g}p{p}r{r}"
|
||||
if rng.random() < 0.6:
|
||||
rows.append(_live(key, rng.randint(10, 50), rng.randint(0, 10**6)))
|
||||
else:
|
||||
rows.append(_plain(rng.randint(10, 50), rng.randint(0, 255)))
|
||||
group.append((f"pl{g}{p}", rows))
|
||||
groups.append(group)
|
||||
return groups
|
||||
|
||||
|
||||
class _Stream:
|
||||
def __init__(self, groups):
|
||||
self.groups = groups
|
||||
self.plugin_manager = type("PM", (), {"plugins": {}})()
|
||||
self.plugin_adapter = None
|
||||
self.i = 0
|
||||
|
||||
def get_grouped_content_for_composition(self):
|
||||
return self.groups[0]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return []
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
self.i += 1
|
||||
return self.groups[self.i] if self.i < len(self.groups) else []
|
||||
|
||||
|
||||
class _DM:
|
||||
width, height = W, H
|
||||
|
||||
def __init__(self):
|
||||
self.image = Image.new("RGB", (W, H))
|
||||
|
||||
def set_scrolling_state(self, *a):
|
||||
pass
|
||||
|
||||
def update_display(self):
|
||||
pass
|
||||
|
||||
|
||||
def _pipeline(groups):
|
||||
p = RenderPipeline(VegasModeConfig(continuous_scroll=True, lead_in_width=0,
|
||||
separator_width=12), _DM(), _Stream(groups))
|
||||
assert p.compose_scroll_content()
|
||||
return p
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seed", range(12))
|
||||
def test_patches_touch_only_live_columns(seed):
|
||||
rng = random.Random(seed)
|
||||
groups = _groups(rng, 25)
|
||||
patched, reference = _pipeline(groups), _pipeline(groups)
|
||||
expected = {} # seq -> pixels the patched strip must show
|
||||
counter = collections.Counter()
|
||||
|
||||
for _step in range(60):
|
||||
action = rng.random()
|
||||
if action < 0.35:
|
||||
advance = rng.randint(5, 60)
|
||||
for p in (patched, reference):
|
||||
p.scroll_helper.scroll_position += advance
|
||||
elif action < 0.6:
|
||||
for p in (patched, reference):
|
||||
if p.scroll_helper.remaining_unscrolled() < 4 * W:
|
||||
p.extend_scroll_content()
|
||||
counter["extend"] += 1
|
||||
else:
|
||||
records = patched.live_records()
|
||||
if not records:
|
||||
continue
|
||||
record = rng.choice(records)
|
||||
pixels = np.full((H, record.width, 3), rng.randint(0, 255), dtype=np.uint8)
|
||||
pixels.setflags(write=False)
|
||||
patched._live_slots[record.seq] = LivePatch(
|
||||
seq=record.seq, strip_gen=patched._strip_gen, epoch=0, pixels=pixels,
|
||||
digest=elements.pixel_digest(pixels), made_at=0.0)
|
||||
patched._live_ready.append(record.seq)
|
||||
if patched.apply_live_patches():
|
||||
expected[record.seq] = pixels
|
||||
counter["patch"] += 1
|
||||
|
||||
# The twins agree on everything but live columns.
|
||||
a = patched.scroll_helper.cached_array
|
||||
b = reference.scroll_helper.cached_array
|
||||
assert a.shape == b.shape
|
||||
assert patched._strip_origin == reference._strip_origin
|
||||
live = np.zeros(a.shape[1], dtype=bool)
|
||||
for record in patched.live_records():
|
||||
x = record.abs_x - patched._strip_origin
|
||||
lo, hi = max(0, x), max(0, x + record.width)
|
||||
live[lo:hi] = True
|
||||
if record.seq in expected:
|
||||
assert np.array_equal(a[:, lo:hi], expected[record.seq][:, lo - x:hi - x])
|
||||
assert np.array_equal(a[:, ~live], b[:, ~live])
|
||||
|
||||
assert counter["patch"] > 0
|
||||
@@ -0,0 +1,491 @@
|
||||
"""The live-element worker's choices and hand-overs (src/vegas_mode/live_worker.py).
|
||||
|
||||
The worker is driven here one decision at a time -- _pick() then _run() --
|
||||
against a fake pipeline, so every rule can be pinned without threads or
|
||||
timing: what runs first, what is skipped, what reaches the render thread.
|
||||
"""
|
||||
import collections
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.vegas_mode import live_worker # noqa: E402
|
||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||
from src.vegas_mode.elements import ( # noqa: E402
|
||||
ElementRecord, LiveEpochs, LiveView, RenderedElement, pixel_digest,
|
||||
)
|
||||
from src.vegas_mode.live_worker import VegasWorker # noqa: E402
|
||||
|
||||
W, H = 128, 32
|
||||
NOW = 1000.0
|
||||
|
||||
|
||||
def _pixels(width, value):
|
||||
array = np.full((H, width, 3), value, dtype=np.uint8)
|
||||
array.setflags(write=False)
|
||||
return array
|
||||
|
||||
|
||||
def _element(key, width, value, epoch=1):
|
||||
pixels = _pixels(width, value)
|
||||
return RenderedElement(key=key, epoch=epoch, version=value, pixels=pixels,
|
||||
digest=pixel_digest(pixels), width=width)
|
||||
|
||||
|
||||
def _record(seq, key, abs_x, width=40, pid="p", epoch=0, value=0, hz=0.0):
|
||||
return ElementRecord(seq=seq, plugin_id=pid, key=key, abs_x=abs_x, width=width,
|
||||
epoch=epoch, digest=pixel_digest(_pixels(width, value)),
|
||||
refresh_hz=hz)
|
||||
|
||||
|
||||
class _Adapter:
|
||||
def __init__(self):
|
||||
self.live_epochs = LiveEpochs()
|
||||
self.batches = {} # pid -> {key: RenderedElement}
|
||||
self.redraws = {} # key -> RenderedElement | None
|
||||
self.busy = set()
|
||||
self.calls = []
|
||||
|
||||
def render_live_elements(self, plugin, pid, lock_timeout):
|
||||
self.calls.append(("render", pid, lock_timeout))
|
||||
if pid in self.busy:
|
||||
return None
|
||||
return self.live_epochs.get(pid), self.batches.get(pid, {})
|
||||
|
||||
def redraw_live_element(self, plugin, pid, key, width, height, at):
|
||||
self.calls.append(("redraw", pid, key, width, at))
|
||||
return self.redraws.get(key)
|
||||
|
||||
has_redraw = True
|
||||
|
||||
def has_lock_free_redraw(self, plugin):
|
||||
return self.has_redraw
|
||||
|
||||
|
||||
class _Stream:
|
||||
def __init__(self, adapter):
|
||||
self.plugin_adapter = adapter
|
||||
self.plugin_manager = SimpleNamespace(plugins={"p": object(), "q": object()})
|
||||
self.plans = []
|
||||
self.fetched = []
|
||||
|
||||
def plan_next_group(self, count=None):
|
||||
self.plans.append(count)
|
||||
return ["a", "b", "c"]
|
||||
|
||||
def fetch_group_member(self, pid, offscreen_only=False):
|
||||
self.fetched.append((pid, offscreen_only))
|
||||
return (pid, [f"img-{pid}"])
|
||||
|
||||
|
||||
def _pipeline(gate=True, **cfg):
|
||||
adapter = _Adapter()
|
||||
p = SimpleNamespace(
|
||||
_strip_gen=1, _view=None, _elements=(), _prepared_group=None,
|
||||
_prefetch_lock=threading.Lock(), _prefetch_generation=7, _applied={},
|
||||
_live_slots={}, _live_ready=collections.deque(),
|
||||
display_width=W, display_height=H, frame_interval=0.01,
|
||||
config=VegasModeConfig(**cfg),
|
||||
display_manager=SimpleNamespace(render_gate=_Gate() if gate else None),
|
||||
stream_manager=_Stream(adapter), _prefetch_thread=None)
|
||||
return p, adapter
|
||||
|
||||
|
||||
class _Gate:
|
||||
def __init__(self):
|
||||
self.entered = 0
|
||||
|
||||
def yielding(self):
|
||||
gate = self
|
||||
|
||||
class _Ctx:
|
||||
def __enter__(self):
|
||||
gate.entered += 1
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
return _Ctx()
|
||||
|
||||
|
||||
def _view(left=1000, end=5000, t=NOW):
|
||||
return LiveView(abs_left=left, abs_right=left + W, abs_end=end, t_mono=t)
|
||||
|
||||
|
||||
def _worker(p, records=(), view=None, clock=lambda: NOW):
|
||||
p._elements = tuple(records)
|
||||
for r in records:
|
||||
p._applied[r.seq] = (r.epoch, r.digest)
|
||||
p._view = view if view is not None else _view()
|
||||
return VegasWorker(p, clock=clock)
|
||||
|
||||
|
||||
# -- choosing -----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_an_urgent_group_goes_before_anything():
|
||||
p, adapter = _pipeline()
|
||||
visible = _record(1, "k", 1010)
|
||||
worker = _worker(p, [visible], _view(left=1000, end=1000 + W + 100))
|
||||
adapter.live_epochs.bump("p")
|
||||
worker.request_group()
|
||||
assert worker._pick(NOW) == ("group", None)
|
||||
|
||||
|
||||
def test_visible_data_then_ticks_then_a_normal_group_then_data_ahead():
|
||||
p, adapter = _pipeline()
|
||||
visible = _record(1, "vis", 1010)
|
||||
animated = _record(2, "map", 1060, hz=4)
|
||||
ahead = _record(3, "far", 3000, pid="q")
|
||||
worker = _worker(p, [visible, animated, ahead])
|
||||
adapter.live_epochs.bump("p")
|
||||
adapter.live_epochs.bump("q")
|
||||
worker.request_group()
|
||||
assert worker._pick(NOW) == ("data", "p")
|
||||
worker._handled_epoch[1] = worker._handled_epoch[2] = adapter.live_epochs.get("p")
|
||||
assert worker._pick(NOW) == ("tick", animated)
|
||||
worker._next_tick[2] = NOW + 10
|
||||
assert worker._pick(NOW) == ("group", None)
|
||||
worker._group_wanted = False
|
||||
assert worker._pick(NOW) == ("data", "q")
|
||||
|
||||
|
||||
def test_nothing_behind_the_viewport_is_redrawn():
|
||||
p, adapter = _pipeline()
|
||||
behind = _record(1, "gone", 900, width=40)
|
||||
worker = _worker(p, [behind])
|
||||
adapter.live_epochs.bump("p")
|
||||
assert worker._pick(NOW) is None
|
||||
|
||||
|
||||
def test_only_group_work_while_frames_have_stopped():
|
||||
p, adapter = _pipeline()
|
||||
worker = _worker(p, [_record(1, "k", 1010, hz=4)], _view(t=NOW - 5))
|
||||
adapter.live_epochs.bump("p")
|
||||
assert worker._pick(NOW) is None
|
||||
worker.request_group()
|
||||
assert worker._pick(NOW) == ("group", None)
|
||||
|
||||
|
||||
def test_an_element_placed_from_older_data_is_caught_up():
|
||||
# A group drawn before an update() and placed after it: its records carry
|
||||
# the old epoch, so they are due at once.
|
||||
p, adapter = _pipeline()
|
||||
adapter.live_epochs.bump("p")
|
||||
adapter.live_epochs.bump("p")
|
||||
worker = _worker(p, [_record(1, "k", 1010, epoch=1)])
|
||||
assert worker._pick(NOW) == ("data", "p")
|
||||
|
||||
|
||||
def test_the_data_floor_defers_but_does_not_drop():
|
||||
p, adapter = _pipeline(live_min_interval=2.0)
|
||||
worker = _worker(p, [_record(1, "k", 1010)])
|
||||
adapter.live_epochs.bump("p")
|
||||
worker._last_data_job["p"] = NOW - 1.0
|
||||
assert worker._pick(NOW) is None
|
||||
p._view = _view(t=NOW + 1.5)
|
||||
assert worker._pick(NOW + 1.5) == ("data", "p")
|
||||
|
||||
|
||||
# -- data refreshes ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_refresh_hands_over_only_what_changed():
|
||||
p, adapter = _pipeline()
|
||||
same = _record(1, "same", 1010, value=0)
|
||||
changed = _record(2, "changed", 1060, value=0)
|
||||
worker = _worker(p, [same, changed])
|
||||
epoch = adapter.live_epochs.bump("p")
|
||||
adapter.batches["p"] = {"same": _element("same", 40, 0, epoch),
|
||||
"changed": _element("changed", 40, 9, epoch)}
|
||||
worker._run(("data", "p"))
|
||||
assert list(p._live_ready) == [2]
|
||||
patch = p._live_slots[2]
|
||||
assert patch.epoch == epoch and patch.strip_gen == 1
|
||||
assert worker._handled_epoch == {1: epoch, 2: epoch}
|
||||
assert worker._pick(NOW + 100) is None # nothing left due
|
||||
assert adapter.calls[0] == ("render", "p", live_worker.DATA_LOCK_TIMEOUT)
|
||||
|
||||
|
||||
def test_a_redraw_of_another_width_is_refused_and_said_once(caplog):
|
||||
p, adapter = _pipeline()
|
||||
worker = _worker(p, [_record(1, "k", 1010, width=40)])
|
||||
for n in range(3):
|
||||
epoch = adapter.live_epochs.bump("p")
|
||||
adapter.batches["p"] = {"k": _element("k", 44, n + 1, epoch)}
|
||||
with caplog.at_level("INFO"):
|
||||
worker._run(("data", "p"))
|
||||
assert not p._live_ready
|
||||
assert worker.stats["refused"] == 3
|
||||
assert sum("must not change" in r.message for r in caplog.records) == 1
|
||||
|
||||
|
||||
def test_a_busy_lock_backs_off_instead_of_waiting():
|
||||
p, adapter = _pipeline()
|
||||
worker = _worker(p, [_record(1, "k", 1010)])
|
||||
adapter.live_epochs.bump("p")
|
||||
adapter.busy.add("p")
|
||||
worker._run(("data", "p"))
|
||||
assert worker.stats["lock_busy"] == 1
|
||||
assert worker._backoff_until["p"] == NOW + live_worker.LOCK_BACKOFF_S
|
||||
p._view = _view(t=NOW + 2.5)
|
||||
assert worker._pick(NOW + 0.5) is None # backing off (and floored)
|
||||
adapter.busy.clear()
|
||||
assert worker._pick(NOW + 2.5) == ("data", "p") # tried again later
|
||||
|
||||
|
||||
def test_a_key_the_plugin_dropped_keeps_its_pixels():
|
||||
p, adapter = _pipeline()
|
||||
worker = _worker(p, [_record(1, "gone", 1010)])
|
||||
epoch = adapter.live_epochs.bump("p")
|
||||
adapter.batches["p"] = {}
|
||||
worker._run(("data", "p"))
|
||||
assert not p._live_ready and worker._handled_epoch[1] == epoch
|
||||
|
||||
|
||||
def test_the_latest_hand_over_wins_the_slot():
|
||||
p, adapter = _pipeline()
|
||||
worker = _worker(p, [_record(1, "k", 1010)])
|
||||
for value in (5, 6):
|
||||
epoch = adapter.live_epochs.bump("p")
|
||||
adapter.batches["p"] = {"k": _element("k", 40, value, epoch)}
|
||||
worker._last_data_job.clear()
|
||||
worker._run(("data", "p"))
|
||||
assert list(p._live_ready) == [1, 1]
|
||||
assert p._live_slots[1].pixels[0, 0, 0] == 6
|
||||
|
||||
|
||||
# -- ticks ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_tick_uses_the_lock_free_redraw_and_reschedules():
|
||||
p, adapter = _pipeline(live_max_hz=5)
|
||||
record = _record(1, "map", 1010, width=40, hz=4)
|
||||
worker = _worker(p, [record])
|
||||
adapter.redraws["map"] = _element("map", 40, 3)
|
||||
worker._run(("tick", record))
|
||||
assert adapter.calls[0][0] == "redraw"
|
||||
assert list(p._live_ready) == [1]
|
||||
assert worker._next_tick[1] > 0
|
||||
|
||||
|
||||
def test_a_tick_without_a_redraw_never_waits_for_the_lock():
|
||||
p, adapter = _pipeline()
|
||||
record = _record(1, "map", 1010, hz=4)
|
||||
worker = _worker(p, [record])
|
||||
adapter.has_redraw = False
|
||||
worker._run(("tick", record))
|
||||
assert adapter.calls == [("render", "p", 0.0)]
|
||||
|
||||
|
||||
def test_a_redraw_that_returns_none_is_a_skip_not_a_full_redraw():
|
||||
# None is the plugin saying "nothing new"; answering it with a locked
|
||||
# get_vegas_elements() at the tick rate is exactly what the hook avoids.
|
||||
p, adapter = _pipeline()
|
||||
record = _record(1, "map", 1010, hz=4)
|
||||
worker = _worker(p, [record])
|
||||
adapter.redraws["map"] = None
|
||||
worker._run(("tick", record))
|
||||
assert [c[0] for c in adapter.calls] == ["redraw"]
|
||||
assert not p._live_ready
|
||||
|
||||
|
||||
def test_animation_is_capped_without_the_gate_and_when_slow():
|
||||
p, _ = _pipeline(gate=False, live_max_hz=5)
|
||||
record = _record(1, "map", 1010, hz=4)
|
||||
worker = _worker(p, [record])
|
||||
assert worker._tick_hz(record) == live_worker.UNGATED_MAX_HZ
|
||||
p2, _ = _pipeline(live_max_hz=5)
|
||||
worker2 = _worker(p2, [record])
|
||||
assert worker2._tick_hz(record) == 4
|
||||
worker2._render_ewma[("p", "map")] = live_worker.SLOW_RENDER_S * 2
|
||||
assert worker2._tick_hz(record) == 2
|
||||
p3, _ = _pipeline(live_max_hz=0)
|
||||
assert _worker(p3, [record])._tick_hz(record) == 0
|
||||
|
||||
|
||||
def test_throttling_never_raises_a_slow_elements_rate():
|
||||
p, _ = _pipeline(live_max_hz=5)
|
||||
slow = _record(1, "map", 1010, hz=live_worker.MIN_THROTTLED_HZ / 2)
|
||||
worker = _worker(p, [slow])
|
||||
worker._render_ewma[("p", "map")] = live_worker.SLOW_RENDER_S * 2
|
||||
assert worker._tick_hz(slow) == live_worker.MIN_THROTTLED_HZ / 2
|
||||
|
||||
|
||||
def test_a_tick_left_by_an_element_trimmed_away_does_not_spin_the_worker():
|
||||
p, _ = _pipeline()
|
||||
worker = _worker(p, [_record(1, "map", 1010, hz=4)])
|
||||
worker._next_tick[1] = NOW - 5 # past due
|
||||
p._elements = () # ...and trimmed off the strip
|
||||
assert worker._next_wait(NOW) == live_worker.IDLE_WAIT_S
|
||||
|
||||
|
||||
def test_a_tick_for_an_element_no_longer_animated_is_dropped():
|
||||
p, _ = _pipeline(live_max_hz=0)
|
||||
record = _record(1, "map", 1010, hz=4)
|
||||
worker = _worker(p, [record])
|
||||
worker._next_tick[1] = NOW - 5
|
||||
assert worker._next_wait(NOW) == live_worker.IDLE_WAIT_S
|
||||
assert worker._pick(NOW) is None
|
||||
assert 1 not in worker._next_tick
|
||||
|
||||
|
||||
def test_the_wait_is_until_the_next_tick_on_screen():
|
||||
p, _ = _pipeline(live_max_hz=5)
|
||||
record = _record(1, "map", 1010, hz=4)
|
||||
worker = _worker(p, [record])
|
||||
worker._next_tick[1] = NOW + 0.2
|
||||
assert worker._next_wait(NOW) == pytest.approx(0.2)
|
||||
|
||||
|
||||
def test_ticks_stop_for_an_element_far_ahead():
|
||||
p, _ = _pipeline(live_lead_screens=1.0)
|
||||
far = _record(1, "map", 1000 + 3 * W, hz=4)
|
||||
worker = _worker(p, [far])
|
||||
worker._next_tick[1] = NOW
|
||||
assert worker._pick(NOW) is None
|
||||
assert 1 not in worker._next_tick
|
||||
|
||||
|
||||
# -- groups ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_group_is_fetched_a_member_at_a_time_and_published():
|
||||
p, _ = _pipeline()
|
||||
worker = _worker(p)
|
||||
worker.request_group()
|
||||
worker.request_group() # coalesced
|
||||
for _ in range(3):
|
||||
assert p._prepared_group is None
|
||||
worker._run(worker._pick(NOW))
|
||||
assert p._prepared_group == [("a", ["img-a"]), ("b", ["img-b"]), ("c", ["img-c"])]
|
||||
assert p.stream_manager.plans == [None]
|
||||
assert all(offscreen for _pid, offscreen in p.stream_manager.fetched)
|
||||
assert worker._pick(NOW) is None # the slot is full
|
||||
|
||||
|
||||
def test_a_reset_mid_group_drops_it():
|
||||
p, _ = _pipeline()
|
||||
worker = _worker(p)
|
||||
worker.request_group()
|
||||
worker._run(worker._pick(NOW))
|
||||
p._prefetch_generation += 1
|
||||
worker._run(("group", None))
|
||||
worker._run(("group", None))
|
||||
assert p._prepared_group is None
|
||||
|
||||
|
||||
def test_a_stopped_worker_hands_over_what_it_has_of_a_group():
|
||||
p, _ = _pipeline()
|
||||
worker = _worker(p)
|
||||
worker.request_group()
|
||||
worker._run(worker._pick(NOW)) # one member of three
|
||||
worker.stop()
|
||||
worker._hand_over_partial_group()
|
||||
assert p._prepared_group == [("a", ["img-a"])]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("why", ["reset", "a group already waiting"])
|
||||
def test_a_partial_group_is_not_handed_over(why):
|
||||
p, _ = _pipeline()
|
||||
worker = _worker(p)
|
||||
worker.request_group()
|
||||
worker._run(worker._pick(NOW))
|
||||
if why == "reset":
|
||||
p._prefetch_generation += 1
|
||||
else:
|
||||
p._prepared_group = ["waiting"]
|
||||
worker._hand_over_partial_group()
|
||||
assert p._prepared_group == (None if why == "reset" else ["waiting"])
|
||||
|
||||
|
||||
def test_a_new_worker_waits_for_the_one_it_replaces():
|
||||
import time
|
||||
p, _ = _pipeline()
|
||||
retired = threading.Thread(target=time.sleep, args=(0.05,))
|
||||
retired.start()
|
||||
p._retired_worker = retired
|
||||
_worker(p)._join_legacy_prefetch()
|
||||
assert not retired.is_alive()
|
||||
|
||||
|
||||
def test_every_job_runs_inside_the_gate():
|
||||
p, adapter = _pipeline()
|
||||
worker = _worker(p, [_record(1, "k", 1010)])
|
||||
adapter.live_epochs.bump("p")
|
||||
worker._run(("data", "p"))
|
||||
assert p.display_manager.render_gate.entered == 1
|
||||
|
||||
|
||||
# -- robustness -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_job_that_raises_is_counted_and_the_worker_goes_on():
|
||||
p, adapter = _pipeline()
|
||||
worker = _worker(p, [_record(1, "k", 1010)])
|
||||
adapter.live_epochs.bump("p")
|
||||
|
||||
def explode(*a, **k):
|
||||
raise KeyError("plugin bug")
|
||||
|
||||
adapter.render_live_elements = explode
|
||||
worker._run(("data", "p"))
|
||||
assert worker.stats["errors"] == 1
|
||||
worker.request_group()
|
||||
worker._run(worker._pick(NOW))
|
||||
assert p.stream_manager.plans
|
||||
|
||||
|
||||
def test_a_new_strip_forgets_the_old_ones_bookkeeping():
|
||||
p, _ = _pipeline()
|
||||
worker = _worker(p, [_record(1, "k", 1010)])
|
||||
worker._handled_epoch[1] = 5
|
||||
worker._next_tick[1] = NOW
|
||||
p._strip_gen += 1
|
||||
p._elements = ()
|
||||
worker._pick(NOW)
|
||||
assert worker._handled_epoch == {} and worker._next_tick == {}
|
||||
|
||||
|
||||
def test_the_thread_starts_and_stops():
|
||||
import time
|
||||
p, _ = _pipeline()
|
||||
worker = _worker(p, clock=time.monotonic)
|
||||
worker.start()
|
||||
worker.request_group()
|
||||
for _ in range(200):
|
||||
if p._prepared_group is not None:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
worker.stop()
|
||||
worker.join(2)
|
||||
assert not worker.is_alive()
|
||||
assert p._prepared_group is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("view", [None])
|
||||
def test_no_view_yet_still_fetches_groups(view):
|
||||
p, _ = _pipeline()
|
||||
worker = VegasWorker(p)
|
||||
worker.request_group()
|
||||
assert worker._pick(NOW) == ("group", None)
|
||||
|
||||
|
||||
def test_the_summary_names_the_slowest_redraw(caplog):
|
||||
clock = [NOW]
|
||||
p, adapter = _pipeline()
|
||||
record = _record(1, "map", 1010, hz=4)
|
||||
worker = _worker(p, [record], clock=lambda: clock[0])
|
||||
adapter.redraws["map"] = _element("map", 40, 3)
|
||||
worker._run(("tick", record))
|
||||
clock[0] += live_worker.SUMMARY_INTERVAL_S + 1
|
||||
with caplog.at_level("INFO"):
|
||||
worker._maybe_summarise()
|
||||
line = next(r.getMessage() for r in caplog.records if "Vegas live:" in r.getMessage())
|
||||
assert "slowest redraw" in line and "p 'map'" in line
|
||||
assert worker._slowest_redraw is None # per summary interval
|
||||
@@ -802,7 +802,7 @@ 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_smooth_scroll', 'vegas_overflow_mode', 'vegas_live_refresh']
|
||||
|
||||
if any(k in data for k in vegas_fields):
|
||||
if 'display' not in current_config:
|
||||
@@ -822,6 +822,7 @@ def save_main_config():
|
||||
_set_checkbox(vegas_config, 'dynamic_duration_enabled', 'vegas_dynamic_duration_enabled')
|
||||
_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')
|
||||
|
||||
# max_plugin_width_ratio is the one fractional setting, so it is
|
||||
# handled outside the integer loop below.
|
||||
|
||||
@@ -568,6 +568,17 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-vegas_live_refresh" data-setting-key="display.vegas_scroll.live_refresh">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_live_refresh"
|
||||
name="vegas_live_refresh"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('live_refresh', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Update live content while it scrolls{{ ui.help_tip('For plugins that support it (scores, the flight map), change what is already scrolling when their data changes: a score updates on a card already crossing the screen, and aircraft move on the map. Default: on.\nWith this off every card shows what it was when it was drawn, which can be a minute or more old by the time it scrolls past.\nAlways off with multi-display sync or with Continuous Scroll off.', 'Live Updates') }}</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>
|
||||
|
||||
Reference in New Issue
Block a user