mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
feat(vegas): live elements update in place while they scroll (#697)
* perf(timing): say which render-thread work a late frame followed The soak already says how often a moving frame reached the panel late, but not what the render thread was doing just before it. Vegas does two kinds of work there between frames -- building its strip (compose, extend) and, with live elements, patching changed pixels into it -- and deciding whether either is affordable needs their own numbers. - FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame. Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind; aggregate() still takes frames without ops. The file schema is unchanged. - Vegas tags compose and every strip extension (with the bytes it copied). - frame_soak prints an "after work" table: frames, late %, freezes and MB moved per kind, only when something tagged its work. - render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes / --patch-every / --patch-where (in-place column writes, as a live element update does) and --extend-every-screens / --extend-width (append + trim on a fixed cadence that holds the strip's width). No runtime behaviour changes: this is the measurement gate for live Vegas elements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the frame-op attribution and bench modes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(scroll): build the strip's PIL image only when something reads it Every Vegas strip extension rebuilt ScrollHelper.cached_image from cached_array in full, twice (append, then trim), on the render thread: Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a Pi 4 (measured on ledpi), about two thirds of an extension's render-thread cost. Nothing on the frame path reads the image's pixels; every frame is cut from the array. cached_image is now a property. append_content and drop_scrolled_prefix defer it; the first read builds it from the array it started with and keeps it only if the strip has not changed meanwhile, so a sync push racing an extension cannot leave a stale image cached. Assigning cached_image stores exactly what was assigned, as before. has_strip() says whether there is a strip without building its image; the helper's frame path, Vegas and the adapter's scroll-cache invalidation use it. The strip is also no longer held in memory twice. In Vegas the image is now built only by a multi-display sync push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(vegas): live elements -- a plugin API for content that changes while it scrolls Vegas bakes each plugin's pictures into one strip, so a card already on its way across the panel keeps what it showed when it was drawn. This adds the API and bookkeeping for content that can be updated in place; the worker that redraws and swaps it follows separately. No shipped plugin implements the hook yet, so nothing changes for users. Plugin API (core 3.8.0), all no-ops by default: - BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version, live, refresh_hz)]: named, fixed-width pieces of Vegas content. - BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free redraw for content that changes with time. - BasePlugin.notify_vegas_data_changed(): data that lands outside update(). - src/plugin_system/vegas_elements.py (VegasElement, re-exported from base_plugin). Core: - PluginAdapter asks a plugin that implements the hook for elements on the background fetch only (under its lock, on its own canvas); every other path keeps get_vegas_content(). Live elements are pinned (padded with content_padding, never trimmed), tagged with their key, digest and data epoch in Image.info so the existing cache and group plumbing carry them unchanged, and untagged if a width budget crops them. - RenderPipeline records where each live element lands (ElementRecord), in absolute strip columns a trim does not move; the block-start arithmetic is shared with the STATIC markers. - PluginManager update listeners (add/remove_update_listener, notify_data_changed): told the moment update() completes, not at the next ~4s Vegas poll. The coordinator uses one to move each plugin's data epoch on. - vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval, live_lead_screens; per-plugin core-owned vegas_live. Live elements are off under multi-display sync, in swap mode and with offscreen_prefetch off. - scripts/check_plugin.py checks the element contract (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub is a working example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(vegas): live elements update in place while they scroll One background worker (src/vegas_mode/live_worker.py) redraws a plugin's live elements when its data epoch moves on (update listener) or on their refresh_hz, nearest the screen first, and hands changed pixels lock-free to the render thread, which copies them into the strip between frames (RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most four patches or two screens of bytes a frame, no drawing or locks there. The worker takes over group prefetch once a live element is placed, runs inside the render gate, and is supervised. Update tick 1s while live elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md describes what was built and why SegmentStrip was not needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- 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
|
- `scripts/check_plugin.py` checks the element contract for any plugin that
|
||||||
implements it (`src/plugin_system/testing/vegas.py`), and
|
implements it (`src/plugin_system/testing/vegas.py`), and
|
||||||
`test/fixtures/plugins/vegas-live-stub` is a working example.
|
`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
|
### Scrolling
|
||||||
|
|
||||||
|
|||||||
+113
-150
@@ -1,9 +1,10 @@
|
|||||||
# Offscreen Rendering
|
# 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
|
(`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
|
lock), and so are live elements, which grew out of steps 2 and 3 below: see
|
||||||
reference for how plugin content is rendered off the render thread.
|
*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
|
First soak of step 1 on hdpi (50 px/s, `pwm_bits` 7, preview open, 8-minute
|
||||||
runs, A/B/B/A):
|
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
|
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.
|
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
|
Offscreen rendering is also what makes fresh content possible. A plugin's
|
||||||
plugin's segment is drawn when its group is prefetched, and the strip carries
|
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
|
7,000-10,000 px of content ahead of the viewport, so at ~100 px/s a score drawn
|
||||||
ahead", "9842px ahead"). At ~100 px/s, a score drawn now reaches the screen
|
then reaches the screen 70-100 seconds later -- and once in the strip it never
|
||||||
70–100 seconds later. When a plugin reports new data, Vegas only drops its
|
changed: when a plugin reported new data, Vegas only dropped its caches, so the
|
||||||
cache (`invalidate_pending_updates`), so the change is drawn on the plugin's
|
change appeared on the plugin's *next* turn, minutes later.
|
||||||
*next* turn, several minutes later. A segment already in the strip scrolls by
|
|
||||||
with the data it was drawn with.
|
|
||||||
|
|
||||||
That was the right trade while every redraw of a canvas-bound plugin stalled
|
A plugin can now hand Vegas **live elements** instead of pictures
|
||||||
the scroll. Off the render thread a redraw costs the scroll nothing, so the
|
(`BasePlugin.get_vegas_elements()`, see "Live Vegas elements" in
|
||||||
strip can afford three things.
|
[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
|
On a Pi the render thread has about 4 ms of slack per refresh at 512x64 after
|
||||||
since the segment was drawn. If it has, redraw it offscreen and replace it
|
the ~6 ms blit, and a scoreboard card is ~29 ms of Pillow work that holds the
|
||||||
while it is still out of sight. Width changes are fine here, because
|
GIL. Drawing on the render thread is out of the question at any rate, so the
|
||||||
everything from that segment onward is still invisible.
|
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:
|
### How an update reaches the screen
|
||||||
`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.
|
|
||||||
|
|
||||||
Content is then at most `lead / speed` seconds old when it appears, a few
|
1. A plugin's `update()` completes. The update worker calls
|
||||||
seconds instead of minutes, without changing how far ahead the rotation
|
`PluginManager._note_update_completed`, which calls the update listeners
|
||||||
fetches.
|
(`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
|
Elements that change with **time** rather than data (an aircraft moving
|
||||||
of its segments that are **anywhere ahead of the viewport** are redrawn and
|
between position reports) ask for `refresh_hz`; the worker calls
|
||||||
replaced straight away, not only at the gate. That covers the long stretch of
|
`redraw_vegas_element()` -- without the plugin's lock, from state the plugin
|
||||||
strip between prefetch and the gate.
|
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
|
A live element is never trimmed to its ink: the adapter pads it with
|
||||||
version has the same geometry: the same total width, and the same width for
|
`content_padding` black columns either side and pins its width, and a redraw
|
||||||
each card (a sports plugin returns one image per game, joined with
|
at any other width is refused (it shows the next time the plugin comes round).
|
||||||
`intra_plugin_gap`). Scoreboard cards keep a fixed layout, so a score change
|
Records keep **absolute** strip columns -- the strip column plus everything
|
||||||
patches in and the digits update as the card scrolls past. The patch is a
|
trimmed off the front since the strip was composed -- so a trim moves one
|
||||||
pixel copy of one card (a 150×64 card is ~29 KB) applied by the render thread
|
origin rather than every record. Nothing on screen is ever moved, inserted or
|
||||||
between frames, so a frame never shows half of a patch.
|
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
|
### Why not a SegmentStrip
|
||||||
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.
|
|
||||||
|
|
||||||
### 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
|
### When it is off
|
||||||
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.
|
|
||||||
|
|
||||||
Data freshness is still bounded by each plugin's own fetch interval (how often
|
- `display.vegas_scroll.live_refresh: false` (the kill switch; also in the
|
||||||
it polls live scores). Drawing faster cannot beat the data source.
|
web UI), or `vegas_live: false` in one plugin's section.
|
||||||
|
- Always under multi-display sync: the follower mirrors whole strips only, so
|
||||||
### The strip becomes a list of segments
|
a patch would never reach it. (Continuous-mode sync has a separate problem:
|
||||||
|
the follower is not sent extensions or trims at all.)
|
||||||
All three need the strip to be replaceable by segment. Today it is one
|
- In swap mode (`continuous_scroll: false`) and with `offscreen_prefetch:
|
||||||
image (`ScrollHelper.cached_array`, 8,000–20,000 px wide, 1.5–3.8 MB), and
|
false`.
|
||||||
`append_content()` rebuilds the whole thing on the render thread for every
|
- For plugins without the hook, which are drawn and placed exactly as before,
|
||||||
appended block. That is also a pause source.
|
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()`.
|
||||||
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.
|
|
||||||
|
|
||||||
## Risks, and what was checked
|
## 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
|
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
|
(≈50 px/s) doubles the budget. Cutting the blit itself is the native-presenter
|
||||||
step.
|
step.
|
||||||
- **Live refreshes pushed from `update()`.** Some sports plugins call
|
- **Multi-display sync in continuous mode.** The follower is sent the whole
|
||||||
`display()` and `update_display()` from inside `update()`, which runs on the
|
strip only at a new cycle and on connect, never the extensions and trims of
|
||||||
update worker and can push to the panel mid-Vegas. That is a separate
|
continuous mode, so it drifts from the leader after the first extension.
|
||||||
hazard. `offscreen()` gives a tool for it (run the update worker offscreen
|
Live elements stay off under sync for that reason.
|
||||||
while Vegas owns the panel), but it is out of scope here.
|
|
||||||
|
|
||||||
## Test plan
|
## Test plan
|
||||||
|
|
||||||
@@ -348,14 +313,15 @@ updates are disabled while sync is active.
|
|||||||
- **Emulator integration:** a stub canvas-bound plugin whose `display()` sleeps
|
- **Emulator integration:** a stub canvas-bound plugin whose `display()` sleeps
|
||||||
300 ms. The Vegas render loop never goes a frame without presenting (frame
|
300 ms. The Vegas render loop never goes a frame without presenting (frame
|
||||||
timing recorder: zero freezes).
|
timing recorder: zero freezes).
|
||||||
- **Unit, `SegmentStrip`:** the viewport assembled across segment boundaries
|
- **Live elements** (`test/test_vegas_live_*.py`,
|
||||||
matches slicing one concatenated image, pixel for pixel. Append, trim,
|
`test/test_vegas_elements_*.py`, `test/test_scroll_helper_patch.py`): every
|
||||||
replace-ahead and same-geometry patch each leave every other column
|
record points at exactly its element's pixels through any sequence of
|
||||||
unchanged. A geometry-changing patch of a visible segment is refused.
|
compose, extend and trim; a patch changes only its element's columns (a
|
||||||
- **Freshness:** a stub sports plugin whose score changes every second. The
|
property test against a twin strip that is never patched); the render
|
||||||
score on screen is never older than `lead / speed` plus the plugin's fetch
|
thread's apply takes no lock and draws nothing; the worker's priorities,
|
||||||
interval. A visible card's digits change without the frame-timing recorder
|
floors, backoff and hand-over; and, end to end on the emulator with the stub
|
||||||
seeing a late frame. An unchanged redraw is discarded.
|
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.
|
- **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
|
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
|
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
|
## 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,
|
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores the
|
||||||
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
|
|
||||||
deferred path when `false`, and `display.vegas_scroll.live_refresh` (default
|
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
|
## Open questions
|
||||||
|
|
||||||
1. Keep the kill switch, or ship without one?
|
1. Multi-display sync: is there a two-Pi rig to test on? Replaying strip
|
||||||
2. Plugin lock timeout: skip the plugin and keep its cached segment (proposed),
|
operations to the follower (append, trim, patch, in absolute columns) would
|
||||||
or wait longer?
|
fix continuous-mode sync and let live elements run under it.
|
||||||
3. `refresh_min_interval`: 10 s proposed. It only limits chatty plugins;
|
2. Is the `extend` cost worth a segment list after the lazy image? The soak's
|
||||||
live sports are redrawn once per 30 s poll regardless.
|
"after work" table answers it per rig.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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"`). |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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/mocks.py
|
||||||
src/plugin_system/testing/plugin_test_base.py
|
src/plugin_system/testing/plugin_test_base.py
|
||||||
src/plugin_system/testing/sizes.py
|
src/plugin_system/testing/sizes.py
|
||||||
|
src/plugin_system/testing/vegas.py
|
||||||
|
src/plugin_system/vegas_elements.py
|
||||||
src/redaction.py
|
src/redaction.py
|
||||||
src/scan_order.py
|
src/scan_order.py
|
||||||
src/startup_validator.py
|
src/startup_validator.py
|
||||||
src/vegas_mode/__init__.py
|
src/vegas_mode/__init__.py
|
||||||
src/vegas_mode/config.py
|
src/vegas_mode/config.py
|
||||||
src/vegas_mode/coordinator.py
|
src/vegas_mode/coordinator.py
|
||||||
|
src/vegas_mode/elements.py
|
||||||
src/vegas_mode/geometry.py
|
src/vegas_mode/geometry.py
|
||||||
|
src/vegas_mode/live_worker.py
|
||||||
src/vegas_mode/stream_manager.py
|
src/vegas_mode/stream_manager.py
|
||||||
src/web_interface/api_helpers.py
|
src/web_interface/api_helpers.py
|
||||||
src/web_interface/config_arrays.py
|
src/web_interface/config_arrays.py
|
||||||
|
|||||||
@@ -780,6 +780,43 @@ class ScrollHelper:
|
|||||||
)
|
)
|
||||||
return cut
|
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:
|
def remaining_unscrolled(self) -> int:
|
||||||
"""Columns of strip still to the right of the viewport."""
|
"""Columns of strip still to the right of the viewport."""
|
||||||
if not self.has_strip():
|
if not self.has_strip():
|
||||||
|
|||||||
@@ -915,8 +915,7 @@ class DisplayManager:
|
|||||||
``display.dirty_tracking: false`` if a redraw issue is ever suspected.
|
``display.dirty_tracking: false`` if a redraw issue is ever suspected.
|
||||||
|
|
||||||
Serialized via ``_update_lock``: plugins can call this directly from
|
Serialized via ``_update_lock``: plugins can call this directly from
|
||||||
background threads (e.g. sports base classes push an immediate
|
background threads of their own, so without a lock two callers
|
||||||
"live" refresh from inside update()), so without a lock two callers
|
|
||||||
could both pass the digest check before either writes it back,
|
could both pass the digest check before either writes it back,
|
||||||
double-pushing a frame, or interleave the offscreen/current canvas
|
double-pushing a frame, or interleave the offscreen/current canvas
|
||||||
swap below. The lock is scoped to this method, so callers never
|
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,
|
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."""
|
"""Call ``plugin.get_vegas_elements()`` as the Vegas ticker does."""
|
||||||
render_width = int(width or display_manager.width)
|
render_width = int(width or display_manager.width)
|
||||||
with _as_vegas_canvas(plugin, display_manager, render_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")
|
report.errors.append(f"{where} appears twice; keys must be unique")
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
if not isinstance(element.image, Image.Image):
|
image: Any = element.image # typed Image, but a plugin may pass anything
|
||||||
report.errors.append(f"{where} image is a {type(element.image).__name__}")
|
if not isinstance(image, Image.Image):
|
||||||
|
report.errors.append(f"{where} image is a {type(image).__name__}")
|
||||||
continue
|
continue
|
||||||
if element.image.height != height:
|
if element.image.height != height:
|
||||||
report.errors.append(
|
report.errors.append(
|
||||||
@@ -296,12 +297,12 @@ def check_plugin_vegas_elements(plugin_id: str, plugin_dir: Any, config: dict,
|
|||||||
if run_update:
|
if run_update:
|
||||||
try:
|
try:
|
||||||
plugin.update()
|
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 "
|
report.warnings.append(f"update() had no network ({exc!r}); checked "
|
||||||
"with whatever data the plugin starts with")
|
"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 = check_vegas_elements(plugin, display_manager)
|
||||||
checked.warnings[:0] = report.warnings
|
checked.warnings[:0] = report.warnings
|
||||||
return checked
|
return checked
|
||||||
|
|||||||
@@ -387,6 +387,9 @@ class VegasModeCoordinator:
|
|||||||
adapter = getattr(self, 'plugin_adapter', None)
|
adapter = getattr(self, 'plugin_adapter', None)
|
||||||
if adapter is not None:
|
if adapter is not None:
|
||||||
adapter.live_elements_enabled = active
|
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)
|
plugin_manager = getattr(self, 'plugin_manager', None)
|
||||||
add = getattr(plugin_manager, 'add_update_listener', None)
|
add = getattr(plugin_manager, 'add_update_listener', None)
|
||||||
remove = getattr(plugin_manager, 'remove_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.
|
"""Update listener: a plugin's data may have changed.
|
||||||
|
|
||||||
Runs on the update worker with the plugin's lock held, so it only
|
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.live_epochs.bump(plugin_id)
|
||||||
|
self.render_pipeline.notify_live_data(plugin_id)
|
||||||
|
|
||||||
def _install_render_gate(self) -> None:
|
def _install_render_gate(self) -> None:
|
||||||
"""Gate the prefetch thread on the render thread's swaps; see VegasModeConfig."""
|
"""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.
|
# game still shown as live the next morning.
|
||||||
self.render_pipeline.refresh_updated_plugins()
|
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
|
# Extend the strip before the scroll can reach its end, so the next
|
||||||
# group arrives from the right and motion never stops. No cycle
|
# group arrives from the right and motion never stops. No cycle
|
||||||
# boundary, so no freeze, no substitution and no restart with the
|
# 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
|
# main loop's _tick_plugin_updates() finds all intervals already
|
||||||
# satisfied on return, so the inter-iteration gap is <1 ms and the
|
# satisfied on return, so the inter-iteration gap is <1 ms and the
|
||||||
# display never shows a frozen frame between iterations.
|
# 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
|
if (self._update_callback and
|
||||||
frame_count % _UPDATE_TICK_FRAMES == 0 and
|
frame_count % _UPDATE_TICK_FRAMES == 0 and
|
||||||
not self._update_tick_running):
|
not self._update_tick_running):
|
||||||
|
|||||||
@@ -60,6 +60,41 @@ class ElementRecord(NamedTuple):
|
|||||||
refresh_hz: float
|
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:
|
def tag(image: Image.Image, meta: ElementMeta) -> Image.Image:
|
||||||
"""Mark ``image`` as the live element ``meta`` describes. Returns it."""
|
"""Mark ``image`` as the live element ``meta`` describes. Returns it."""
|
||||||
image.info[INFO_KEY] = meta
|
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 threading
|
||||||
import time
|
import time
|
||||||
from contextlib import contextmanager, nullcontext
|
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 PIL import Image
|
||||||
|
|
||||||
from src.common.scroll_helper import ScrollHelper
|
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 (
|
from src.vegas_mode.elements import (
|
||||||
ElementMeta,
|
ElementMeta,
|
||||||
LiveEpochs,
|
LiveEpochs,
|
||||||
|
RenderedElement,
|
||||||
meta_of,
|
meta_of,
|
||||||
pin_element,
|
pin_element,
|
||||||
pixel_digest,
|
pixel_digest,
|
||||||
@@ -109,6 +110,11 @@ class PluginAdapter:
|
|||||||
# Element problems already reported, so a plugin with a bad hook logs
|
# Element problems already reported, so a plugin with a bad hook logs
|
||||||
# once rather than on every fetch.
|
# once rather than on every fetch.
|
||||||
self._element_warnings: set = set()
|
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(
|
logger.debug(
|
||||||
"PluginAdapter initialized: display=%dx%d",
|
"PluginAdapter initialized: display=%dx%d",
|
||||||
@@ -198,18 +204,20 @@ class PluginAdapter:
|
|||||||
return bool(raw)
|
return bool(raw)
|
||||||
|
|
||||||
@contextmanager
|
@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.
|
"""Hold the plugin's update/display lock, waiting a bounded time.
|
||||||
|
|
||||||
Yields whether it was acquired. Yields True, holding nothing, when
|
Yields whether it was acquired. Yields True, holding nothing, when
|
||||||
there is no plugin manager to ask -- the behaviour before the lock was
|
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'):
|
if not hasattr(self.plugin_manager, 'get_plugin_lock'):
|
||||||
yield True
|
yield True
|
||||||
return
|
return
|
||||||
lock = self.plugin_manager.get_plugin_lock(plugin_id)
|
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:
|
try:
|
||||||
yield acquired
|
yield acquired
|
||||||
finally:
|
finally:
|
||||||
@@ -455,18 +463,21 @@ class PluginAdapter:
|
|||||||
if isinstance(plugin_cfg, dict):
|
if isinstance(plugin_cfg, dict):
|
||||||
raw = plugin_cfg.get('vegas_width_pct')
|
raw = plugin_cfg.get('vegas_width_pct')
|
||||||
if raw not in (None, ''):
|
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:
|
try:
|
||||||
candidate = int(raw)
|
candidate = int(raw)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
logger.warning(
|
self._warn_element_once(
|
||||||
"[%s] Invalid vegas_width_pct %r, ignoring", plugin_id, raw)
|
plugin_id, "Invalid vegas_width_pct %r, ignoring", raw,
|
||||||
|
once_key=repr(raw))
|
||||||
else:
|
else:
|
||||||
if 10 <= candidate <= 100:
|
if 10 <= candidate <= 100:
|
||||||
pct = candidate
|
pct = candidate
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
self._warn_element_once(
|
||||||
"[%s] vegas_width_pct %d out of range 10-100, ignoring",
|
plugin_id, "vegas_width_pct %d out of range 10-100, ignoring",
|
||||||
plugin_id, candidate)
|
candidate, once_key=repr(raw))
|
||||||
|
|
||||||
if pct >= 100:
|
if pct >= 100:
|
||||||
return self.display_width
|
return self.display_width
|
||||||
@@ -675,8 +686,7 @@ class PluginAdapter:
|
|||||||
|
|
||||||
if len(images) == 1:
|
if len(images) == 1:
|
||||||
only = images[0]
|
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 * self._padding() <= budget:
|
||||||
if meta_of(only) is not None and only.width - 2 * pad <= budget:
|
|
||||||
# A live element's pinned margins are not content. One whose
|
# A live element's pinned margins are not content. One whose
|
||||||
# drawing fits the budget is kept whole, and live, rather than
|
# drawing fits the budget is kept whole, and live, rather than
|
||||||
# cut for the sake of its own blank padding.
|
# cut for the sake of its own blank padding.
|
||||||
@@ -840,9 +850,14 @@ class PluginAdapter:
|
|||||||
)
|
)
|
||||||
return img.crop((start, 0, end, img.height))
|
return img.crop((start, 0, end, img.height))
|
||||||
|
|
||||||
def _warn_element_once(self, plugin_id: str, problem: str, *args: Any) -> None:
|
def _warn_element_once(self, plugin_id: str, problem: str, *args: Any,
|
||||||
"""Report a plugin's element problem once per process, then quietly."""
|
once_key: Optional[str] = None) -> None:
|
||||||
key = (plugin_id, problem)
|
"""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:
|
if key in self._element_warnings:
|
||||||
logger.debug("[%s] " + problem, plugin_id, *args)
|
logger.debug("[%s] " + problem, plugin_id, *args)
|
||||||
return
|
return
|
||||||
@@ -885,16 +900,98 @@ class PluginAdapter:
|
|||||||
"be used (%r); using its get_vegas_content() instead", exc)
|
"be used (%r); using its get_vegas_content() instead", exc)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _images_from_elements(
|
def render_live_elements(
|
||||||
self, result: Any, plugin_id: str, epoch: int
|
self, plugin: 'BasePlugin', plugin_id: str, lock_timeout: float
|
||||||
) -> Optional[List[Image.Image]]:
|
) -> Optional[Tuple[int, Dict[str, RenderedElement]]]:
|
||||||
"""Turn get_vegas_elements()'s answer into images for the pipeline.
|
"""Redraw a plugin's live elements for the live-element worker.
|
||||||
|
|
||||||
Live elements come out pinned (RGB, display height, content_padding
|
Like the keyed fetch, but for a strip that already holds the elements:
|
||||||
black each side, never trimmed afterwards) and tagged with their
|
no cache (the caller knows the plugin's data moved on), and each live
|
||||||
ElementMeta; plain ones (``live=False``) come out as ordinary content.
|
element comes back as a RenderedElement to compare with what the strip
|
||||||
Anything that is not a usable element is dropped with a warning; a
|
shows. An element whose ``version`` is the one already redrawn reuses
|
||||||
duplicate key keeps its first element.
|
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:
|
if result is None:
|
||||||
return None
|
return None
|
||||||
@@ -903,11 +1000,8 @@ class PluginAdapter:
|
|||||||
plugin_id, "get_vegas_elements() returned %s, expected a list "
|
plugin_id, "get_vegas_elements() returned %s, expected a list "
|
||||||
"of VegasElement", type(result).__name__)
|
"of VegasElement", type(result).__name__)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
padding = self.config.content_padding if self.config.auto_trim else 0
|
|
||||||
now = time.monotonic()
|
|
||||||
seen = set()
|
seen = set()
|
||||||
images: List[Image.Image] = []
|
valid: List[VegasElement] = []
|
||||||
for element in result:
|
for element in result:
|
||||||
if not (isinstance(element, VegasElement)
|
if not (isinstance(element, VegasElement)
|
||||||
and isinstance(element.key, str) and element.key
|
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",
|
"not a VegasElement with a key and an image (%s); skipping it",
|
||||||
type(element).__name__)
|
type(element).__name__)
|
||||||
continue
|
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:
|
if element.key in seen:
|
||||||
self._warn_element_once(
|
self._warn_element_once(
|
||||||
plugin_id, "get_vegas_elements() returned key %r twice; "
|
plugin_id, "get_vegas_elements() returned key %r twice; "
|
||||||
"keeping the first", element.key)
|
"keeping the first", element.key)
|
||||||
continue
|
continue
|
||||||
seen.add(element.key)
|
seen.add(element.key)
|
||||||
|
valid.append(element)
|
||||||
|
return valid
|
||||||
|
|
||||||
image = element.image
|
def _element_image(self, element: VegasElement) -> Image.Image:
|
||||||
if image.height != self.display_height:
|
"""An element's image at the display's height, in RGB."""
|
||||||
image = image.resize((image.width, self.display_height),
|
image = element.image
|
||||||
Image.Resampling.LANCZOS)
|
if image.height != self.display_height:
|
||||||
if image.mode != 'RGB':
|
image = image.resize((image.width, self.display_height),
|
||||||
image = image.convert('RGB')
|
Image.Resampling.LANCZOS)
|
||||||
|
if image.mode != 'RGB':
|
||||||
|
image = image.convert('RGB')
|
||||||
|
return image
|
||||||
|
|
||||||
if not element.live:
|
def _padding(self) -> int:
|
||||||
# Plain content; a tag copied from a reused image must not
|
"""Black columns a live element carries each side: what trimming would leave."""
|
||||||
# make it live by accident.
|
return self.config.content_padding if self.config.auto_trim else 0
|
||||||
images.append(untag(image.copy()) if meta_of(image) else image)
|
|
||||||
continue
|
|
||||||
|
|
||||||
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:
|
try:
|
||||||
refresh_hz = max(0.0, float(element.refresh_hz or 0.0))
|
image = self._element_image(element)
|
||||||
except (TypeError, ValueError):
|
if not element.live:
|
||||||
refresh_hz = 0.0
|
# 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(
|
images.append(tag(pinned, ElementMeta(
|
||||||
plugin_id=plugin_id, key=element.key, epoch=epoch,
|
plugin_id=plugin_id, key=element.key, epoch=epoch,
|
||||||
digest=pixel_digest(pixels), rendered_at=now,
|
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
|
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(
|
def _get_native_content(
|
||||||
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool = False
|
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool = False
|
||||||
) -> Optional[List[Image.Image]]:
|
) -> Optional[List[Image.Image]]:
|
||||||
@@ -1510,6 +1691,9 @@ class PluginAdapter:
|
|||||||
self._content_cache.pop(plugin_id, None)
|
self._content_cache.pop(plugin_id, None)
|
||||||
else:
|
else:
|
||||||
self._content_cache.clear()
|
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(
|
def invalidate_plugin_scroll_cache(
|
||||||
self, plugin: 'BasePlugin', plugin_id: str
|
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.common.scroll_helper import ScrollHelper
|
||||||
from src.matrix_support import DEFAULT_REFRESH_LIMIT_HZ
|
from src.matrix_support import DEFAULT_REFRESH_LIMIT_HZ
|
||||||
from src.vegas_mode.config import VegasModeConfig
|
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.geometry import separation_gap
|
||||||
from src.vegas_mode.stream_manager import StreamManager
|
from src.vegas_mode.stream_manager import StreamManager
|
||||||
|
|
||||||
@@ -78,6 +79,26 @@ class RenderPipeline:
|
|||||||
# anything computed against the old strip can tell.
|
# anything computed against the old strip can tell.
|
||||||
_strip_gen: int = 0
|
_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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config: VegasModeConfig,
|
config: VegasModeConfig,
|
||||||
@@ -129,6 +150,16 @@ class RenderPipeline:
|
|||||||
self._cycle_complete = False
|
self._cycle_complete = False
|
||||||
self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll
|
self._segments_in_scroll: List[str] = [] # Plugin IDs in current scroll
|
||||||
self._record_by_seq: Dict[int, ElementRecord] = {}
|
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).
|
# The sub-pixel path's pacing; the crisp path solves its own (frame_interval).
|
||||||
self._frame_interval = config.get_frame_interval()
|
self._frame_interval = config.get_frame_interval()
|
||||||
@@ -474,23 +505,43 @@ class RenderPipeline:
|
|||||||
if not self.config.continuous_scroll:
|
if not self.config.continuous_scroll:
|
||||||
return
|
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:
|
with self._prefetch_lock:
|
||||||
if self._prefetch_thread is not None and self._prefetch_thread.is_alive():
|
if self._prefetch_thread is not None and self._prefetch_thread.is_alive():
|
||||||
return
|
return
|
||||||
if self._prepared_group is not None:
|
if self._prepared_group is not None:
|
||||||
return # already have one waiting
|
return # already have one waiting
|
||||||
generation = self._prefetch_generation
|
generation = self._prefetch_generation
|
||||||
|
retired = self._retired_worker
|
||||||
|
|
||||||
def _work():
|
def _work():
|
||||||
# Deprioritise against the render loop. Linux applies nice
|
# Deprioritise against the render loop for CPU time (Linux
|
||||||
# per-thread, and the heavy lifting here is PIL and numpy work
|
# applies nice per thread). Nice does nothing about the GIL,
|
||||||
# that releases the GIL, so the scheduler can actually act on
|
# which Pillow's drawing holds (docs/OFFSCREEN_RENDERING.md,
|
||||||
# it — without this the prefetch competes for the same cores and
|
# risk 5); the render gate below is what keeps this thread off
|
||||||
# costs frames.
|
# it while the render thread needs it.
|
||||||
try:
|
try:
|
||||||
os.nice(10)
|
os.nice(10)
|
||||||
except (OSError, AttributeError):
|
except (OSError, AttributeError):
|
||||||
pass
|
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
|
# With vegas_scroll.prefetch_gate on, run only while the render
|
||||||
# thread waits on vsync; see src/common/render_gate.py.
|
# thread waits on vsync; see src/common/render_gate.py.
|
||||||
gate = getattr(self.display_manager, 'render_gate', None)
|
gate = getattr(self.display_manager, 'render_gate', None)
|
||||||
@@ -503,7 +554,8 @@ class RenderPipeline:
|
|||||||
with self._prefetch_lock:
|
with self._prefetch_lock:
|
||||||
if generation != self._prefetch_generation:
|
if generation != self._prefetch_generation:
|
||||||
return # Vegas was reset while this was fetching
|
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(
|
self._prefetch_thread = threading.Thread(
|
||||||
target=_work, daemon=True, name="vegas-strip-prefetch")
|
target=_work, daemon=True, name="vegas-strip-prefetch")
|
||||||
@@ -825,8 +877,12 @@ class RenderPipeline:
|
|||||||
if new:
|
if new:
|
||||||
self._elements = self._elements + tuple(new)
|
self._elements = self._elements + tuple(new)
|
||||||
by_seq = self.__dict__.setdefault('_record_by_seq', {})
|
by_seq = self.__dict__.setdefault('_record_by_seq', {})
|
||||||
|
applied = self.__dict__.setdefault('_applied', {})
|
||||||
for record in new:
|
for record in new:
|
||||||
by_seq[record.seq] = record
|
by_seq[record.seq] = record
|
||||||
|
applied[record.seq] = (record.epoch, record.digest)
|
||||||
|
if self._live_enabled:
|
||||||
|
self._ensure_live_worker()
|
||||||
return len(new)
|
return len(new)
|
||||||
|
|
||||||
def _forget_trimmed_records(self, cut: int) -> None:
|
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)
|
kept = tuple(r for r in records if r.abs_x + r.width > origin)
|
||||||
if len(kept) != len(records):
|
if len(kept) != len(records):
|
||||||
by_seq = self.__dict__.setdefault('_record_by_seq', {})
|
by_seq = self.__dict__.setdefault('_record_by_seq', {})
|
||||||
|
applied = self.__dict__.setdefault('_applied', {})
|
||||||
|
slots = self.__dict__.setdefault('_live_slots', {})
|
||||||
for record in records:
|
for record in records:
|
||||||
if record.abs_x + record.width <= origin:
|
if record.abs_x + record.width <= origin:
|
||||||
by_seq.pop(record.seq, None)
|
by_seq.pop(record.seq, None)
|
||||||
|
applied.pop(record.seq, None)
|
||||||
|
slots.pop(record.seq, None)
|
||||||
self._elements = kept
|
self._elements = kept
|
||||||
|
|
||||||
def _reset_records(self) -> None:
|
def _reset_records(self) -> None:
|
||||||
@@ -852,6 +912,145 @@ class RenderPipeline:
|
|||||||
self._strip_origin = 0
|
self._strip_origin = 0
|
||||||
self._elements = ()
|
self._elements = ()
|
||||||
self._record_by_seq = {}
|
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, ...]:
|
def live_records(self) -> Tuple[ElementRecord, ...]:
|
||||||
"""The live elements in the strip, in the order they were placed."""
|
"""The live elements in the strip, in the order they were placed."""
|
||||||
@@ -875,6 +1074,13 @@ class RenderPipeline:
|
|||||||
|
|
||||||
# Update scroll position
|
# Update scroll position
|
||||||
self.scroll_helper.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.
|
# Determine if the cycle is done.
|
||||||
#
|
#
|
||||||
@@ -1187,6 +1393,7 @@ class RenderPipeline:
|
|||||||
self._prepared_group = None
|
self._prepared_group = None
|
||||||
self._deferred_queue = []
|
self._deferred_queue = []
|
||||||
self._static_markers = ()
|
self._static_markers = ()
|
||||||
|
self._stop_live_worker()
|
||||||
self._reset_records()
|
self._reset_records()
|
||||||
|
|
||||||
self.display_manager.set_scrolling_state(False)
|
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
|
A STATIC plugin is also returned with an empty list, unfetched: it
|
||||||
pauses the scroll instead of adding to it (see is_static_plugin).
|
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:
|
if count is None:
|
||||||
count = self.config.plugins_per_cycle
|
count = self.config.plugins_per_cycle
|
||||||
|
|
||||||
@@ -730,37 +744,38 @@ class StreamManager:
|
|||||||
for _ in range(min(max(1, count), total)):
|
for _ in range(min(max(1, count), total)):
|
||||||
ids.append(self._ordered_plugins[self._prefetch_index])
|
ids.append(self._ordered_plugins[self._prefetch_index])
|
||||||
self._prefetch_index = (self._prefetch_index + 1) % total
|
self._prefetch_index = (self._prefetch_index + 1) % total
|
||||||
|
return ids
|
||||||
|
|
||||||
plugins = getattr(self.plugin_manager, 'plugins', {})
|
def fetch_group_member(
|
||||||
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
|
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.
|
# Only the old contract hands anything back to the render thread.
|
||||||
defer_empty = offscreen_only and not getattr(
|
defer_empty = offscreen_only and not getattr(
|
||||||
self.config, 'offscreen_prefetch', True)
|
self.config, 'offscreen_prefetch', True)
|
||||||
|
return (plugin_id, None if defer_empty else [])
|
||||||
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
|
|
||||||
|
|
||||||
def advance_cycle(self) -> None:
|
def advance_cycle(self) -> None:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -119,6 +119,21 @@ class TestJsonPartialSaves:
|
|||||||
'continuous_scroll', 'smooth_scroll'):
|
'continuous_scroll', 'smooth_scroll'):
|
||||||
assert vegas[key] is True, key
|
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):
|
def test_double_sided_axis_only_keeps_enabled(self, api_v3_client, saved):
|
||||||
resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'})
|
resp = _post_json(api_v3_client, {'double_sided_axis': 'horizontal'})
|
||||||
assert resp.status_code == 200, resp.get_json()
|
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)
|
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):
|
def test_with_live_refresh_off_the_same_run_is_plain_content(dm, tmp_path, ticker):
|
||||||
coordinator, _plugin, _pm = ticker
|
coordinator, _plugin, _pm = ticker
|
||||||
coordinator.vegas_config.live_refresh = False
|
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_intra_plugin_gap', 'vegas_render_width_pct',
|
||||||
'vegas_min_content_separation', 'vegas_min_cut_gap',
|
'vegas_min_content_separation', 'vegas_min_cut_gap',
|
||||||
'vegas_continuous_scroll', 'vegas_extend_threshold_screens',
|
'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 any(k in data for k in vegas_fields):
|
||||||
if 'display' not in current_config:
|
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, 'dynamic_duration_enabled', 'vegas_dynamic_duration_enabled')
|
||||||
_set_checkbox(vegas_config, 'continuous_scroll', 'vegas_continuous_scroll')
|
_set_checkbox(vegas_config, 'continuous_scroll', 'vegas_continuous_scroll')
|
||||||
_set_checkbox(vegas_config, 'smooth_scroll', 'vegas_smooth_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
|
# max_plugin_width_ratio is the one fractional setting, so it is
|
||||||
# handled outside the integer loop below.
|
# handled outside the integer loop below.
|
||||||
|
|||||||
@@ -568,6 +568,17 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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="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">
|
<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>
|
<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