mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
feat(vegas): live elements update in place while they scroll
One background worker (src/vegas_mode/live_worker.py) redraws a plugin's live elements when its data epoch moves on (update listener) or on their refresh_hz, nearest the screen first, and hands changed pixels lock-free to the render thread, which copies them into the strip between frames (RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most four patches or two screens of bytes a frame, no drawing or locks there. The worker takes over group prefetch once a live element is placed, runs inside the render gate, and is supervised. Update tick 1s while live elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md describes what was built and why SegmentStrip was not needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -368,6 +368,22 @@ read any of them:
|
||||
- `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`).
|
||||
|
||||
### Scrolling
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
@@ -419,7 +419,7 @@ class MyScoreboard(BasePlugin):
|
||||
|---|---|
|
||||
| `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; lets the ticker skip unchanged elements. `None` means "compare pixels". |
|
||||
| `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. |
|
||||
|
||||
|
||||
@@ -72,13 +72,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
|
||||
|
||||
@@ -780,6 +780,43 @@ 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 not self.has_strip():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -67,7 +67,7 @@ def _as_vegas_canvas(plugin: Any, display_manager: Any, width: int) -> Iterator[
|
||||
|
||||
|
||||
def render_vegas_elements(plugin: Any, display_manager: Any,
|
||||
width: Optional[int] = None) -> Optional[list]:
|
||||
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):
|
||||
@@ -151,8 +151,9 @@ def check_vegas_elements(plugin: Any, display_manager: Any) -> VegasElementRepor
|
||||
report.errors.append(f"{where} appears twice; keys must be unique")
|
||||
continue
|
||||
seen.add(key)
|
||||
if not isinstance(element.image, Image.Image):
|
||||
report.errors.append(f"{where} image is a {type(element.image).__name__}")
|
||||
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(
|
||||
@@ -296,12 +297,12 @@ def check_plugin_vegas_elements(plugin_id: str, plugin_dir: Any, config: dict,
|
||||
if run_update:
|
||||
try:
|
||||
plugin.update()
|
||||
except _TOLERATED_UPDATE_ERRORS as exc:
|
||||
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")
|
||||
except Exception as exc: # noqa: BLE001 - a plugin's update can raise anything
|
||||
report.errors.append(f"update() raised {exc!r}")
|
||||
return report
|
||||
checked = check_vegas_elements(plugin, display_manager)
|
||||
checked.warnings[:0] = report.warnings
|
||||
return checked
|
||||
|
||||
@@ -387,6 +387,9 @@ class VegasModeCoordinator:
|
||||
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)
|
||||
@@ -405,9 +408,11 @@ class VegasModeCoordinator:
|
||||
"""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; whatever redraws happen later read it.
|
||||
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."""
|
||||
@@ -504,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
|
||||
@@ -715,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):
|
||||
|
||||
@@ -60,6 +60,41 @@ class ElementRecord(NamedTuple):
|
||||
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
|
||||
|
||||
@@ -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,7 +9,7 @@ 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
|
||||
@@ -18,6 +18,7 @@ from src.plugin_system.vegas_elements import VegasElement
|
||||
from src.vegas_mode.elements import (
|
||||
ElementMeta,
|
||||
LiveEpochs,
|
||||
RenderedElement,
|
||||
meta_of,
|
||||
pin_element,
|
||||
pixel_digest,
|
||||
@@ -109,6 +110,11 @@ class PluginAdapter:
|
||||
# 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",
|
||||
@@ -198,18 +204,20 @@ class PluginAdapter:
|
||||
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:
|
||||
@@ -455,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
|
||||
@@ -675,8 +686,7 @@ class PluginAdapter:
|
||||
|
||||
if len(images) == 1:
|
||||
only = images[0]
|
||||
pad = self.config.content_padding if self.config.auto_trim else 0
|
||||
if meta_of(only) is not None and only.width - 2 * pad <= budget:
|
||||
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.
|
||||
@@ -840,9 +850,14 @@ class PluginAdapter:
|
||||
)
|
||||
return img.crop((start, 0, end, img.height))
|
||||
|
||||
def _warn_element_once(self, plugin_id: str, problem: str, *args: Any) -> None:
|
||||
"""Report a plugin's element problem once per process, then quietly."""
|
||||
key = (plugin_id, problem)
|
||||
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
|
||||
@@ -885,16 +900,98 @@ class PluginAdapter:
|
||||
"be used (%r); using its get_vegas_content() instead", exc)
|
||||
return None
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
Anything that is not a usable element is dropped with a warning; a
|
||||
duplicate key keeps its first element.
|
||||
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
|
||||
@@ -903,11 +1000,8 @@ class PluginAdapter:
|
||||
plugin_id, "get_vegas_elements() returned %s, expected a list "
|
||||
"of VegasElement", type(result).__name__)
|
||||
return None
|
||||
|
||||
padding = self.config.content_padding if self.config.auto_trim else 0
|
||||
now = time.monotonic()
|
||||
seen = set()
|
||||
images: List[Image.Image] = []
|
||||
valid: List[VegasElement] = []
|
||||
for element in result:
|
||||
if not (isinstance(element, VegasElement)
|
||||
and isinstance(element.key, str) and element.key
|
||||
@@ -917,37 +1011,124 @@ class PluginAdapter:
|
||||
"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
|
||||
|
||||
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')
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
pinned, pixels = pin_element(image, padding)
|
||||
@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:
|
||||
refresh_hz = max(0.0, float(element.refresh_hz or 0.0))
|
||||
except (TypeError, ValueError):
|
||||
refresh_hz = 0.0
|
||||
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=refresh_hz, version=element.version)))
|
||||
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]]:
|
||||
@@ -1510,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
|
||||
|
||||
@@ -19,7 +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, meta_of
|
||||
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
|
||||
|
||||
@@ -78,6 +79,26 @@ class RenderPipeline:
|
||||
# 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,
|
||||
@@ -129,6 +150,16 @@ class RenderPipeline:
|
||||
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()
|
||||
@@ -474,23 +505,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)
|
||||
@@ -503,7 +554,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")
|
||||
@@ -825,8 +877,12 @@ class RenderPipeline:
|
||||
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:
|
||||
@@ -841,9 +897,13 @@ class RenderPipeline:
|
||||
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:
|
||||
@@ -852,6 +912,145 @@ class RenderPipeline:
|
||||
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."""
|
||||
@@ -875,6 +1074,13 @@ class RenderPipeline:
|
||||
|
||||
# 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.
|
||||
#
|
||||
@@ -1187,6 +1393,7 @@ 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:
|
||||
"""
|
||||
|
||||
@@ -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,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,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
|
||||
@@ -122,6 +122,64 @@ def test_an_update_moves_the_plugins_epoch_and_new_elements_carry_it(ticker):
|
||||
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
|
||||
|
||||
@@ -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