mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-12 18:16:35 +00:00
Compare commits
8
Commits
v3.8.0
...
8163104581
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8163104581 | ||
|
|
0f68fbcfdc | ||
|
|
13b5264d11 | ||
|
|
430e2312f8 | ||
|
|
1af5d5fe45 | ||
|
|
5edc9195ca | ||
|
|
6c01c2b493 | ||
|
|
0a2ce58026 |
@@ -127,7 +127,9 @@ Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
| `min_content_separation` | int, `24` |
|
||||
| `min_cut_gap` | int, `6` |
|
||||
| `continuous_scroll` | bool, `true` |
|
||||
| `smooth_scroll` | bool, `true` |
|
||||
| `offscreen_prefetch` | bool, `true` — render every plugin's ticker content on the background thread, each on its own canvas. `false` restores handing canvas-bound plugins to the render thread, one pause at a time. Temporary; see [OFFSCREEN_RENDERING.md](OFFSCREEN_RENDERING.md) |
|
||||
| `smooth_scroll` | bool, `true` — move a whole number of pixels per panel refresh, locked to vsync. `scroll_speed` is snapped to the nearest speed the panel can show that way (at 95Hz: 95, 47.5, 31.7 px/s…), measured against the panel's real refresh rate once scrolling starts |
|
||||
| `sub_pixel_blend` | bool, `false` — the older smoothing: advance by elapsed time and blend neighbouring pixel columns. Looks anti-aliased in the web preview but shimmers on the panel and is not locked to the refresh. Overrides `smooth_scroll` when on |
|
||||
| `extend_threshold_screens` | float, `2.0` |
|
||||
| `auto_trim` | bool, `true` |
|
||||
| `trim_threshold` | int, `10` |
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
# Offscreen Rendering
|
||||
|
||||
**Status (2026-09-24):** step 1, offscreen rendering, is implemented
|
||||
(`DisplayManager.offscreen()`, the adapter on the prefetch thread, the plugin
|
||||
lock). Steps 2 and 3 are proposed. When all three land, this file becomes the
|
||||
reference for how plugin content is rendered off the render thread.
|
||||
|
||||
First soak of step 1 on hdpi (50 px/s, `pwm_bits` 7, preview open, 8-minute
|
||||
runs, A/B/B/A):
|
||||
|
||||
| build | late | by 1 | 2 | 3–5 | 6+ | freezes | render-thread fetches |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| #628 | 0.53% | 82 | 2 | 3 | 2 | 3 | 6 |
|
||||
| step 1 | 0.63% | 78 | 63 | 17 | 2 | 1 | 0 |
|
||||
| step 1 | 0.42% | 77 | 23 | 10 | 0 | 0 | 0 |
|
||||
| #628 | 0.37% | 84 | 5 | 3 | 3 | 2 | 14 |
|
||||
|
||||
It does what it was built to: no plugin is fetched on the render thread, and
|
||||
freezes fell from 5 to 1. But frames 2–5 refreshes late rose. The rendering
|
||||
moved to the prefetch thread still needs the GIL, and the render thread waits
|
||||
for it (risk 5 below). The late rate did not improve overall. The 1–2 s
|
||||
freezes appear in both builds and have a separate, not yet identified cause.
|
||||
|
||||
## The problem
|
||||
|
||||
Vegas mode builds its ticker from every plugin's content. Most of that work
|
||||
already happens on a background prefetch thread
|
||||
(`RenderPipeline.start_prefetch`). But any plugin whose content needs the
|
||||
**shared display canvas** is deferred to the render thread
|
||||
(`RenderPipeline.drain_deferred`), one plugin every two seconds. The code's
|
||||
own comments put each of those at 40–600 ms, and the render thread presents no
|
||||
frames while one runs.
|
||||
|
||||
On hdpi (Pi 4, 512×64) most plugins take that path: geochron, tide-display,
|
||||
news, hockey-scoreboard, ledmatrix-stocks, incoming-packages, clock-simple,
|
||||
countdown, birdnet-go, ledmatrix-music and odds-ticker. They arrive in bursts
|
||||
("Whole group deferred; strip will extend as it drains") every minute or so,
|
||||
12 fetches in five minutes. That is the "occasional pause" a viewer sees.
|
||||
|
||||
An 8-minute soak (`scripts/frame_soak.py --preview`) of the #628 build on
|
||||
hdpi:
|
||||
|
||||
| late by | frames |
|
||||
|---|---|
|
||||
| 1 refresh | 238 |
|
||||
| 2 | 32 |
|
||||
| 3–5 | 30 |
|
||||
| 6+ | 5 |
|
||||
| freezes ≥ 250 ms | 2 (0.97 s total) |
|
||||
|
||||
The 3+ rows and the freezes are the pauses. The single-refresh row is a
|
||||
separate problem: the blit is 6 ms of a 10 ms refresh, so there is little
|
||||
slack. It is covered under *What this does not fix*.
|
||||
|
||||
## Why a plugin is canvas-bound
|
||||
|
||||
The plugin-facing canvas is a set of shared attributes on `DisplayManager`:
|
||||
`image`, `draw`, `matrix`, and the `width`/`height` properties that read from
|
||||
`matrix`. Three adapter paths (`src/vegas_mode/plugin_adapter.py`) need them,
|
||||
and each returns `None` under `offscreen_only=True` so the plugin is queued for
|
||||
the render thread:
|
||||
|
||||
1. **Display capture** (`_capture_display_content`): clear the canvas, call
|
||||
`plugin.display()`, copy `display_manager.image`. Used by any plugin
|
||||
without `get_vegas_content()` or a populated `scroll_helper`.
|
||||
2. **Scroll-content generation** (`_trigger_scroll_content_generation`): a
|
||||
ticker plugin whose `scroll_helper.cached_image` is empty is made to build
|
||||
it by calling `display(force_clear=True)` or `_create_scrolling_display()`.
|
||||
Both draw on the canvas.
|
||||
3. **Narrowed rendering** (`DisplayManager.render_size`): swaps the shared
|
||||
`matrix`, `image` and `draw` for a narrower set so the plugin lays out for
|
||||
`render_width_pct`. The render thread would see the swap mid-frame.
|
||||
|
||||
The render thread keeps the canvas coherent only because nothing else touches
|
||||
it at the same time. A background thread can't use it.
|
||||
|
||||
## The design: a per-thread render target
|
||||
|
||||
`capture_mode()` is already per-thread (#423 made its state a
|
||||
`threading.local`, so a background capture no longer suppresses the render
|
||||
loop's pushes). The same move applies to the canvas itself:
|
||||
|
||||
```python
|
||||
with display_manager.offscreen(width=None, height=None) as surface:
|
||||
plugin.display(force_clear=True)
|
||||
content = surface.image.copy()
|
||||
```
|
||||
|
||||
For the **calling thread only**, inside the block:
|
||||
|
||||
| accessor | resolves to |
|
||||
|---|---|
|
||||
| `display_manager.image`, `.draw` | the surface's own image and draw: a fresh black canvas, `fontmode = "1"` |
|
||||
| `display_manager.matrix` | a logical proxy reporting the surface size, so `width`/`height` and plugins that read `matrix.width` follow it. Hardware calls through it (`SetImage`, `SwapOnVSync`, `Clear`, brightness writes) are inert. |
|
||||
| `update_display()`, `clear()` | canvas-only: the block implies capture mode, which is already per-thread |
|
||||
| `set_scrolling_state()`, `set_frame_hold()` | no-ops, so a plugin's `display()` cannot re-pace the live scroll. Today it can, when it is captured on the render thread. |
|
||||
|
||||
Every other thread sees the real canvas, unchanged. The render loop in
|
||||
particular keeps presenting while a plugin draws elsewhere.
|
||||
|
||||
### Implementation sketch
|
||||
|
||||
- `image`, `draw` and `matrix` become properties over `_image`, `_draw` and
|
||||
`_matrix`, plus a thread-local current surface. The getter returns the
|
||||
surface's value when the calling thread has one, else the shared one; setters
|
||||
mirror that. That costs about 0.1 µs per access, and `update_display()` reads
|
||||
each a handful of times per frame. Every existing `self.image = ...` in
|
||||
`DisplayManager` (`clear()`, setup, fallback) keeps working and becomes
|
||||
thread-correct for free.
|
||||
- `render_size()` is rebuilt on `offscreen()`: it creates or narrows the
|
||||
calling thread's surface instead of swapping shared state.
|
||||
- `offscreen()` nests and always restores on exit, including when the plugin
|
||||
raises.
|
||||
- `VisualDisplayManager` (the plugin test harness) gets the same method, for
|
||||
parity.
|
||||
|
||||
### Adapter changes
|
||||
|
||||
- `get_content(offscreen_only=True)` stops returning `None` for the three
|
||||
paths above. Each runs inside `display_manager.offscreen(render_width)`.
|
||||
- `_capture_display_content` and `_trigger_scroll_content_generation` drop
|
||||
their "copy the shared image, restore it afterwards" bookkeeping, since the
|
||||
shared image is never touched.
|
||||
- **Take the plugin's lock.** `PluginManager.get_plugin_lock()` keeps
|
||||
`update()` and `display()` mutually exclusive in normal rotation, but Vegas
|
||||
never takes it, so today's render-thread captures already race
|
||||
`update()`. Off the render thread the adapter can afford to wait: blocking
|
||||
acquire with a timeout (proposed 2 s). On timeout it keeps the cached segment
|
||||
and tries again next group.
|
||||
- `drain_deferred()` and the deferred queue are deleted. The only render-thread
|
||||
fetch left is the inline fallback when no prepared group is ready, which in
|
||||
practice is the first extension. Prefetching at start removes that too.
|
||||
|
||||
## Keeping live content fresh
|
||||
|
||||
Offscreen rendering is also what makes fresh sports scores possible. Today a
|
||||
plugin's segment is drawn when its group is prefetched, and the strip carries
|
||||
7,000–10,000 px of content ahead of the viewport (hdpi logs: "7153px still
|
||||
ahead", "9842px ahead"). At ~100 px/s, a score drawn now reaches the screen
|
||||
70–100 seconds later. When a plugin reports new data, Vegas only drops its
|
||||
cache (`invalidate_pending_updates`), so the change is drawn on the plugin's
|
||||
*next* turn, several minutes later. A segment already in the strip scrolls by
|
||||
with the data it was drawn with.
|
||||
|
||||
That was the right trade while every redraw of a canvas-bound plugin stalled
|
||||
the scroll. Off the render thread a redraw costs the scroll nothing, so the
|
||||
strip can afford three things.
|
||||
|
||||
### 1. Refresh at the gate
|
||||
|
||||
Before a segment enters the viewport, check whether its plugin has updated
|
||||
since the segment was drawn. If it has, redraw it offscreen and replace it
|
||||
while it is still out of sight. Width changes are fine here, because
|
||||
everything from that segment onward is still invisible.
|
||||
|
||||
The gate sits `lead` pixels ahead of the viewport's right edge:
|
||||
`lead = max(one screen, speed × (render time + margin))`. The render time is
|
||||
the plugin's own, measured on each render (sports cards take the longest,
|
||||
hundreds of ms up to seconds per the prefetch notes). A plugin whose render
|
||||
does not finish before its segment reaches the viewport keeps the old segment.
|
||||
The scroll never waits for it.
|
||||
|
||||
Content is then at most `lead / speed` seconds old when it appears, a few
|
||||
seconds instead of minutes, without changing how far ahead the rotation
|
||||
fetches.
|
||||
|
||||
### 2. Replace ahead of the screen
|
||||
|
||||
When a plugin reports new data (the Vegas update tick already names them), any
|
||||
of its segments that are **anywhere ahead of the viewport** are redrawn and
|
||||
replaced straight away, not only at the gate. That covers the long stretch of
|
||||
strip between prefetch and the gate.
|
||||
|
||||
### 3. Update on screen
|
||||
|
||||
A segment that is already **visible** is patched in place when the redrawn
|
||||
version has the same geometry: the same total width, and the same width for
|
||||
each card (a sports plugin returns one image per game, joined with
|
||||
`intra_plugin_gap`). Scoreboard cards keep a fixed layout, so a score change
|
||||
patches in and the digits update as the card scrolls past. The patch is a
|
||||
pixel copy of one card (a 150×64 card is ~29 KB) applied by the render thread
|
||||
between frames, so a frame never shows half of a patch.
|
||||
|
||||
When the geometry differs (a game added or dropped, a card that grew), the
|
||||
visible part cannot change without a jump. Only the cards not yet on screen
|
||||
are replaced, and only if the geometry up to that point is unchanged. Otherwise
|
||||
the segment keeps its snapshot until it has scrolled off.
|
||||
|
||||
### Avoiding wasted work
|
||||
|
||||
- **Change detection.** `run_scheduled_updates_with_changes()` names a plugin
|
||||
whenever its `update()` ran, not when its data changed. On hdpi
|
||||
`clock-simple` and `ledmatrix-music` are named on every 4-second tick. A
|
||||
redraw whose pixels hash the same as the segment's is discarded without a
|
||||
swap.
|
||||
- **Redraw on real updates only.** Vegas makes no API calls. Each plugin
|
||||
fetches on its own schedule, and a redraw is triggered only when the
|
||||
plugin's `update()` has run since its segment was drawn. On hdpi live
|
||||
football, baseball and hockey poll every 30 s (live odds every 60 s,
|
||||
everything else hourly), so a live sports card is redrawn once per poll.
|
||||
- **Floor.** A plugin is redrawn at most once per
|
||||
`vegas_scroll.refresh_min_interval` (proposed 10 s), and never while its
|
||||
previous redraw is still running. The floor never holds back a sports card
|
||||
polling every 30 s. It exists for chatty plugins: `clock-simple` updates
|
||||
every second and `ledmatrix-music` polls every 2 s.
|
||||
- **One worker.** Redraws go through the same background worker as prefetch,
|
||||
one plugin at a time at `nice 10`, under the plugin's lock.
|
||||
|
||||
Data freshness is still bounded by each plugin's own fetch interval (how often
|
||||
it polls live scores). Drawing faster cannot beat the data source.
|
||||
|
||||
### The strip becomes a list of segments
|
||||
|
||||
All three need the strip to be replaceable by segment. Today it is one
|
||||
image (`ScrollHelper.cached_array`, 8,000–20,000 px wide, 1.5–3.8 MB), and
|
||||
`append_content()` rebuilds the whole thing on the render thread for every
|
||||
appended block. That is also a pause source.
|
||||
|
||||
Proposed `SegmentStrip`, used by Vegas in place of the single image:
|
||||
|
||||
- an ordered list of segments: plugin id, card boundaries, a pixel array, the
|
||||
render time, and the plugin data version it was drawn from, plus its
|
||||
x-offset in the strip;
|
||||
- `visible(x, width)` assembles the viewport by slicing across at most a few
|
||||
segments: the same ~100 KB copy per frame that slicing the single image
|
||||
costs today;
|
||||
- append and trim become O(block) list operations, not a copy of the strip;
|
||||
- replace swaps one list entry and shifts the offsets of the segments after it
|
||||
(dozens at most). A same-geometry patch copies pixels into the existing array.
|
||||
|
||||
Every mutation is prepared off the render thread and applied by the render
|
||||
thread at a frame boundary, so the strip the render loop reads is never
|
||||
half-changed.
|
||||
|
||||
### Multi-display sync
|
||||
|
||||
The follower renders from its own copy of the strip, offset from the leader's
|
||||
scroll position. Today the leader sends that copy whole, and only in
|
||||
`start_new_cycle()` (`send_scroll_image`), plus the scroll position every
|
||||
frame. Continuous scroll, the default, extends and trims the strip without
|
||||
starting a new cycle, and nothing sends those changes. From reading the code,
|
||||
the follower therefore probably falls out of step after the first extension
|
||||
already, before any of this design. That is untested; it needs a two-Pi rig.
|
||||
|
||||
With a segment strip, keeping the follower identical becomes **replaying the
|
||||
leader's operations**:
|
||||
|
||||
- Every strip mutation (append, trim, replace, patch) is one operation in
|
||||
strip coordinates. The leader applies it and sends the same operation to the
|
||||
follower over the existing TCP channel. Segments are small: a card is ~29 KB
|
||||
raw and compresses well.
|
||||
- Operations on off-screen segments apply on arrival. A patch to a segment
|
||||
that is on either panel carries an *apply at scroll position X* stamp a
|
||||
couple of hundred milliseconds ahead. Both sides apply it when their scroll
|
||||
position passes X, so both panels change on the same frame, within the
|
||||
existing position-sync jitter.
|
||||
- Each operation carries a sequence number. A follower that sees a gap (a
|
||||
reconnect, a dropped message) asks for a full snapshot, which is today's
|
||||
`send_scroll_image` path.
|
||||
|
||||
That also fixes the probable continuous-mode gap as a side effect, since
|
||||
appends and trims become operations too. Until it is in place, fresh-content
|
||||
updates are disabled while sync is active.
|
||||
|
||||
## Risks, and what was checked
|
||||
|
||||
1. **Plugins holding their own reference to the shared `draw` or `image`.**
|
||||
They would keep drawing into the shared canvas, and routing by thread can't
|
||||
redirect them. A grep of the 49 plugins installed on hdpi found none storing
|
||||
`display_manager.draw` or `.image` in an attribute (a pattern search, so
|
||||
indirect aliasing would slip past it). A plugin that did would
|
||||
draw into an image nobody displays, which trims to a blank segment. That is
|
||||
not corruption, and it is no worse than today.
|
||||
2. **Plugins calling the matrix directly.** None in the audit. Inside
|
||||
`offscreen()` the proxy makes it inert anyway.
|
||||
3. **Font thread-safety.** `FontManager` shares font objects across plugins.
|
||||
Measured on Pillow 12.3, two threads rendering text take 1.94× as long as
|
||||
one, so text rendering holds the GIL and FreeType is never entered
|
||||
concurrently. Re-check if Pillow changes that.
|
||||
4. **Plugin thread-safety.** `display()` moves to the prefetch thread. The
|
||||
plugin lock makes it exclusive with `update()`, which is more protection
|
||||
than it has today. Threads a plugin starts itself are not covered, as today.
|
||||
5. **The GIL.** Moving 40–600 ms of plugin rendering off the render thread
|
||||
removes the pauses, but the work still needs the GIL. Pillow drawing holds
|
||||
it, and a waiting thread only gets it back after the switch interval
|
||||
(default 5 ms). Expect some single-refresh late frames while a prefetch
|
||||
runs. Measure with the soak. Lowering `sys.setswitchinterval` during Vegas
|
||||
(e.g. 1 ms) is a one-line experiment, and a render process separate from
|
||||
plugin work is the structural answer (the "native presenter" step).
|
||||
|
||||
## What this does not fix
|
||||
|
||||
- **The blit.** Copying a 512×64 frame into the matrix (`SetImage`) is ~6 ms at
|
||||
8 PWM bits on a Pi 4, leaving ~4 ms of slack per refresh. That is the main
|
||||
source of the single-refresh late frames. Holding frames for two refreshes
|
||||
(≈50 px/s) doubles the budget. Cutting the blit itself is the native-presenter
|
||||
step.
|
||||
- **Live refreshes pushed from `update()`.** Some sports plugins call
|
||||
`display()` and `update_display()` from inside `update()`, which runs on the
|
||||
update worker and can push to the panel mid-Vegas. That is a separate
|
||||
hazard. `offscreen()` gives a tool for it (run the update worker offscreen
|
||||
while Vegas owns the panel), but it is out of scope here.
|
||||
|
||||
## Test plan
|
||||
|
||||
- **Unit, `DisplayManager`:** one thread inside `offscreen()` draws while
|
||||
another reads `image`/`draw`/`matrix`/`width`/`height` and sees the real
|
||||
canvas. Also: `update_display()` and `set_scrolling_state()` are inert inside;
|
||||
`render_size()` narrows only the calling thread; nesting and exceptions
|
||||
restore state.
|
||||
- **Unit, adapter:** a stub display-capture plugin and a stub scroll-helper
|
||||
plugin both return content with `offscreen_only=True`, and nothing is queued
|
||||
for the render thread. The plugin lock is taken, and a timeout keeps the cached
|
||||
segment.
|
||||
- **Emulator integration:** a stub canvas-bound plugin whose `display()` sleeps
|
||||
300 ms. The Vegas render loop never goes a frame without presenting (frame
|
||||
timing recorder: zero freezes).
|
||||
- **Unit, `SegmentStrip`:** the viewport assembled across segment boundaries
|
||||
matches slicing one concatenated image, pixel for pixel. Append, trim,
|
||||
replace-ahead and same-geometry patch each leave every other column
|
||||
unchanged. A geometry-changing patch of a visible segment is refused.
|
||||
- **Freshness:** a stub sports plugin whose score changes every second. The
|
||||
score on screen is never older than `lead / speed` plus the plugin's fetch
|
||||
interval. A visible card's digits change without the frame-timing recorder
|
||||
seeing a late frame. An unchanged redraw is discarded.
|
||||
- **Hardware:** an hdpi soak, A/B against the #628 build, alternating order.
|
||||
Targets: no freezes, an empty 6+ bucket, the 3–5 bucket near zero, and the late
|
||||
rate below 0.66%. Plus, for freshness: log each segment's age when it enters
|
||||
the viewport, and compare the median and max before and after.
|
||||
|
||||
## Rollout
|
||||
|
||||
Three changes, each soaked on hdpi before the next:
|
||||
|
||||
1. **Offscreen rendering:** `offscreen()`, the adapter on the prefetch thread,
|
||||
and the plugin lock. Removes the render-thread pauses.
|
||||
2. **`SegmentStrip`:** Vegas's strip becomes a list of segments. Removes the
|
||||
whole-strip copy on append. No visible behaviour change.
|
||||
3. **Fresh content:** refresh at the gate, replace ahead, patch on screen,
|
||||
with change detection and the rate limit.
|
||||
|
||||
`display.vegas_scroll.offscreen_prefetch` (default `true`) restores today's
|
||||
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.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. Keep the kill switch, or ship without one?
|
||||
2. Plugin lock timeout: skip the plugin and keep its cached segment (proposed),
|
||||
or wait longer?
|
||||
3. `refresh_min_interval`: 10 s proposed. It only limits chatty plugins;
|
||||
live sports are redrawn once per 30 s poll regardless.
|
||||
4. Multi-display sync: is there a two-Pi rig to test on? Operation replay is
|
||||
proposed as part of the segment strip (step 2), with fresh content
|
||||
disabled under sync until it has been verified on real hardware.
|
||||
@@ -207,7 +207,10 @@ Fixed by rebuilding the binding: `scripts/build_rgbmatrix_nogil.sh`.
|
||||
### 3. Sub-pixel blending was wrong for this display
|
||||
|
||||
Enabling it made things worse, not better — see the rule at the top. It is off
|
||||
by default and only Vegas mode opts in via `set_sub_pixel_scrolling(True)`.
|
||||
by default everywhere. Vegas mode used to opt in; it now scrolls in whole
|
||||
pixels locked to the refresh like the plugin tickers, and keeps the blend only
|
||||
behind `display.vegas_scroll.sub_pixel_blend` (default `false`). The blend is
|
||||
also why text looked anti-aliased in the web preview while the panel shimmered.
|
||||
|
||||
### 4. Frame-based stepping raced the vsync clock
|
||||
|
||||
|
||||
+226
-43
@@ -133,6 +133,84 @@ class _LogicalMatrix:
|
||||
setattr(object.__getattribute__(self, "_matrix"), name, value)
|
||||
|
||||
|
||||
class _OffscreenMatrix(_LogicalMatrix):
|
||||
"""``display_manager.matrix`` as a thread drawing off-screen sees it.
|
||||
|
||||
Reports the surface's size, so plugins that lay out from ``matrix.width``
|
||||
follow it, and swallows every write that would reach the hardware. Nothing
|
||||
drawn off-screen may touch the panel the render loop is driving. Method
|
||||
names mirror the rgbmatrix API they stand in for.
|
||||
"""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
__slots__ = ()
|
||||
|
||||
def SetImage(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
"""Inert: off-screen drawing never reaches the panel."""
|
||||
|
||||
def SetPixel(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
"""Inert: off-screen drawing never reaches the panel."""
|
||||
|
||||
def Clear(self) -> None:
|
||||
"""Inert: off-screen drawing never reaches the panel."""
|
||||
|
||||
def Fill(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
"""Inert: off-screen drawing never reaches the panel."""
|
||||
|
||||
def SwapOnVSync(self, canvas: Any, *_args: Any, **_kwargs: Any) -> Any:
|
||||
"""Inert: hands the canvas straight back without waiting on the panel."""
|
||||
return canvas
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
"""Inert: brightness and other writes stay off the real matrix."""
|
||||
|
||||
|
||||
class _OffscreenSurface:
|
||||
"""One thread's private canvas while it renders off-screen.
|
||||
|
||||
See :meth:`DisplayManager.offscreen`.
|
||||
"""
|
||||
|
||||
__slots__ = ("draw", "image", "matrix")
|
||||
|
||||
def __init__(self, width: int, height: int, real_matrix: Any) -> None:
|
||||
self.image = Image.new('RGB', (width, height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
# 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
self.draw.fontmode = "1"
|
||||
self.matrix = (_OffscreenMatrix(real_matrix, width, height)
|
||||
if real_matrix is not None else None)
|
||||
|
||||
|
||||
def _per_thread_canvas_attr(name: str) -> property:
|
||||
"""A DisplayManager attribute that resolves per thread.
|
||||
|
||||
A thread inside :meth:`DisplayManager.offscreen` reads and writes its own
|
||||
surface's ``name``; every other thread reads and writes the shared value,
|
||||
exactly as when this was a plain attribute. Existing ``self.image = ...``
|
||||
assignments therefore keep working and become thread-correct as they are.
|
||||
"""
|
||||
shared = "_shared_" + name
|
||||
|
||||
def fget(self: "DisplayManager") -> Any:
|
||||
surface = self._current_surface() # pylint: disable=protected-access
|
||||
if surface is not None:
|
||||
return getattr(surface, name)
|
||||
try:
|
||||
return self.__dict__[shared]
|
||||
except KeyError:
|
||||
raise AttributeError(name) from None
|
||||
|
||||
def fset(self: "DisplayManager", value: Any) -> None:
|
||||
surface = self._current_surface() # pylint: disable=protected-access
|
||||
if surface is not None:
|
||||
setattr(surface, name, value)
|
||||
else:
|
||||
self.__dict__[shared] = value
|
||||
|
||||
return property(fget, fset, doc=f"The plugin-facing ``{name}``, per thread.")
|
||||
|
||||
|
||||
# Moved to src/display_geometry.py so the web preview, Starlark magnify and
|
||||
# sync handshake compute the display size exactly as DisplayManager does
|
||||
# without importing rgbmatrix. Aliased here for existing callers.
|
||||
@@ -166,6 +244,11 @@ class DisplayManager:
|
||||
cls._instance = super(DisplayManager, cls).__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
# The plugin-facing canvas. Per thread: see offscreen().
|
||||
image = _per_thread_canvas_attr("image")
|
||||
draw = _per_thread_canvas_attr("draw")
|
||||
matrix = _per_thread_canvas_attr("matrix")
|
||||
|
||||
def __init__(self, config: Dict[str, Any] = None, force_fallback: bool = False, suppress_test_pattern: bool = False):
|
||||
start_time = time.time()
|
||||
self.config = config or {}
|
||||
@@ -179,6 +262,9 @@ class DisplayManager:
|
||||
# suppress the render loop's own frame pushes for the duration, freezing
|
||||
# the panel exactly when the point was to avoid a freeze.
|
||||
self._capture_state = threading.local()
|
||||
# Per-thread off-screen surface. While a thread is inside offscreen(),
|
||||
# image, draw and matrix resolve to its own canvas; see offscreen().
|
||||
self._surface_state = threading.local()
|
||||
# Double-sided mode state (resolved in _setup_matrix). When disabled,
|
||||
# the logical image is blitted to the matrix unchanged.
|
||||
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
||||
@@ -203,6 +289,10 @@ class DisplayManager:
|
||||
self._last_snapshot_touch_ts = 0.0
|
||||
self._last_snapshot_digest: Optional[int] = None
|
||||
self._snapshot_dir_prepared = False
|
||||
# Background writer used mid-scroll; see _write_snapshot_if_due.
|
||||
self._snapshot_cond = threading.Condition()
|
||||
self._snapshot_pending: Optional[Image.Image] = None
|
||||
self._snapshot_thread: Optional[threading.Thread] = None
|
||||
self._viewer_check_ts = 0.0
|
||||
self._viewer_fresh = False
|
||||
self._viewer_was_fresh = False
|
||||
@@ -660,11 +750,67 @@ class DisplayManager:
|
||||
Entering this context prevents those writes without affecting the PIL
|
||||
image buffer, which the adapter reads to extract content.
|
||||
"""
|
||||
# Restore rather than clear: capture_mode() inside offscreen() must not
|
||||
# switch suppression off for the rest of the off-screen block.
|
||||
was_active = self._capture_mode_active
|
||||
self._capture_mode_active = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._capture_mode_active = False
|
||||
self._capture_mode_active = was_active
|
||||
|
||||
def _current_surface(self) -> Optional[_OffscreenSurface]:
|
||||
"""The calling thread's off-screen surface, or None."""
|
||||
state = self.__dict__.get('_surface_state')
|
||||
return getattr(state, 'surface', None) if state is not None else None
|
||||
|
||||
def _writes_suppressed(self) -> bool:
|
||||
"""True when the calling thread must not touch the panel or its pacing."""
|
||||
return self._capture_mode_active or self._current_surface() is not None
|
||||
|
||||
@contextmanager
|
||||
def offscreen(self, width: Optional[int] = None, height: Optional[int] = None):
|
||||
"""Give the calling thread its own canvas to draw on.
|
||||
|
||||
Inside the block, for the calling thread only, ``image``, ``draw`` and
|
||||
``matrix`` (and so ``width``/``height``) are a fresh black canvas of the
|
||||
requested size, and nothing reaches the hardware: ``update_display()``
|
||||
and the hardware half of ``clear()`` are skipped, and
|
||||
``set_scrolling_state()``/``set_frame_hold()`` cannot re-pace the live
|
||||
scroll. Every other thread, the render loop above all, keeps seeing the
|
||||
real canvas.
|
||||
|
||||
That is what lets Vegas mode render a plugin on its background prefetch
|
||||
thread. The shared canvas used to be the only one, so any plugin that
|
||||
drew on it (display capture, scroll-content generation, narrowed
|
||||
rendering) had to be fetched on the render thread, stalling the scroll
|
||||
for 40-600ms each. See docs/OFFSCREEN_RENDERING.md.
|
||||
|
||||
Blocks nest; each restores the one outside it, also on an exception.
|
||||
|
||||
Args:
|
||||
width: Width of the surface, clamped to the size this thread sees
|
||||
now. Defaults to that size.
|
||||
height: Height, likewise.
|
||||
|
||||
Yields:
|
||||
The surface. ``surface.image`` is what the plugin drew.
|
||||
"""
|
||||
state = self.__dict__.get('_surface_state')
|
||||
if state is None:
|
||||
state = self._surface_state = threading.local()
|
||||
|
||||
current_w, current_h = self.width, self.height
|
||||
target_w = max(1, min(int(width), current_w)) if width else current_w
|
||||
target_h = max(1, min(int(height), current_h)) if height else current_h
|
||||
|
||||
surface = _OffscreenSurface(target_w, target_h, self.matrix)
|
||||
previous = getattr(state, 'surface', None)
|
||||
state.surface = surface
|
||||
try:
|
||||
yield surface
|
||||
finally:
|
||||
state.surface = previous
|
||||
|
||||
@contextmanager
|
||||
def render_size(self, width: int, height: Optional[int] = None):
|
||||
@@ -684,18 +830,14 @@ class DisplayManager:
|
||||
indirection that double-sided mode relies on, so plugins see a
|
||||
consistent size from every accessor.
|
||||
|
||||
Only meaningful inside :meth:`capture_mode` — this swaps the shared
|
||||
image buffer, so the render loop must not be writing to it concurrently.
|
||||
Built on :meth:`offscreen`, so the narrower canvas belongs to the
|
||||
calling thread alone; the render loop keeps drawing on the real one.
|
||||
|
||||
Args:
|
||||
width: Logical width to report, clamped to at least 1 and to the
|
||||
real panel width (a larger canvas would overflow the hardware).
|
||||
height: Logical height, defaulting to the current height.
|
||||
"""
|
||||
real_matrix = self.matrix
|
||||
prev_image = getattr(self, 'image', None)
|
||||
prev_draw = getattr(self, 'draw', None)
|
||||
|
||||
current_w = self.width
|
||||
current_h = self.height
|
||||
target_w = max(1, min(int(width), current_w))
|
||||
@@ -706,21 +848,8 @@ class DisplayManager:
|
||||
yield
|
||||
return
|
||||
|
||||
try:
|
||||
if real_matrix is not None:
|
||||
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
|
||||
# With no hardware, the width/height properties fall through to
|
||||
# self.image, so swapping the buffer below is enough on its own.
|
||||
self.image = Image.new('RGB', (target_w, target_h))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
with self.offscreen(target_w, target_h):
|
||||
yield
|
||||
finally:
|
||||
self.matrix = real_matrix
|
||||
if prev_image is not None:
|
||||
self.image = prev_image
|
||||
if prev_draw is not None:
|
||||
self.draw = prev_draw
|
||||
|
||||
def _composite_double_sided(self):
|
||||
"""Tile the logical screen across the full physical chain.
|
||||
@@ -765,6 +894,12 @@ class DisplayManager:
|
||||
need to know about it.
|
||||
"""
|
||||
try:
|
||||
if self._writes_suppressed():
|
||||
# This thread is drawing off-screen. Checked before the lock,
|
||||
# so it never contends with the render loop's swap, and before
|
||||
# the fallback branch, so captured content never reaches the
|
||||
# web preview either.
|
||||
return
|
||||
with self._update_lock:
|
||||
if self.matrix is None:
|
||||
# Fallback mode - no actual hardware to update
|
||||
@@ -773,9 +908,6 @@ class DisplayManager:
|
||||
self._write_snapshot_if_due()
|
||||
return
|
||||
|
||||
if self._capture_mode_active:
|
||||
return # Skip hardware write — content is being captured off-screen
|
||||
|
||||
digest = None
|
||||
frame_checksum = None
|
||||
if self._dirty_tracking_enabled:
|
||||
@@ -856,7 +988,7 @@ class DisplayManager:
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
|
||||
if not self._capture_mode_active:
|
||||
if not self._writes_suppressed():
|
||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||
# Failures are non-fatal — the image buffer is already black above, so
|
||||
# the next update_display() call will push clean content regardless.
|
||||
@@ -1458,6 +1590,8 @@ class DisplayManager:
|
||||
Reset to 1 whenever scrolling stops, so one plugin's pacing cannot
|
||||
leak into the next thing on screen.
|
||||
"""
|
||||
if self._writes_suppressed():
|
||||
return # a plugin drawing off-screen cannot re-pace the live scroll
|
||||
try:
|
||||
value = int(refreshes)
|
||||
except (TypeError, ValueError):
|
||||
@@ -1486,6 +1620,10 @@ class DisplayManager:
|
||||
the lifetime exactly the scroll, and the default of 1 means any caller
|
||||
that does not care gets a new frame every refresh.
|
||||
"""
|
||||
if self._writes_suppressed():
|
||||
# A plugin captured for Vegas calls this from its own display();
|
||||
# it must not change the live scroll's state or frame hold.
|
||||
return
|
||||
current_time = time.time()
|
||||
self._scrolling_state['is_scrolling'] = is_scrolling
|
||||
if is_scrolling:
|
||||
@@ -1669,7 +1807,41 @@ class DisplayManager:
|
||||
self._last_snapshot_touch_ts = now
|
||||
return
|
||||
|
||||
# WRITE: ensure directory permissions once, not per frame
|
||||
# WRITE. Mid-scroll the PNG encode goes to a background thread: at
|
||||
# 512x64 it takes 12-14ms on a Pi 4, longer than a 95Hz refresh,
|
||||
# so on the render thread every preview write made the next swap
|
||||
# miss its vsync -- five visible hitches a second, but only while
|
||||
# someone had the web preview open. Pillow releases the GIL while
|
||||
# it compresses, so the encode no longer holds the loop up. Static
|
||||
# frames still write inline: nothing is moving to disturb.
|
||||
if self.is_currently_scrolling():
|
||||
self._queue_snapshot(self.image.copy())
|
||||
else:
|
||||
self._save_snapshot(self.image)
|
||||
self._last_snapshot_ts = now
|
||||
self._last_snapshot_touch_ts = now
|
||||
self._last_snapshot_digest = digest
|
||||
except Exception as e:
|
||||
self._log_snapshot_failure(e)
|
||||
|
||||
def _log_snapshot_failure(self, error: Exception) -> None:
|
||||
# Snapshot failures must never break display — but they must not
|
||||
# be silent either: the snapshot's mtime is the web UI's display
|
||||
# mirror AND its hardware-liveness proxy, so a quietly failing
|
||||
# write freezes the mirror and makes health checks lie (seen in
|
||||
# the field: a stale root-owned /tmp file froze it for a day).
|
||||
# Warn at most once per 5 minutes to avoid log spam.
|
||||
now = time.time()
|
||||
if (now - self._snapshot_fail_log_ts) > 300:
|
||||
self._snapshot_fail_log_ts = now
|
||||
logger.warning("Snapshot write failing (web preview/health "
|
||||
"mirror is stale): %s", error)
|
||||
else:
|
||||
logger.debug(f"Snapshot write skipped: {error}")
|
||||
|
||||
def _save_snapshot(self, image: Image.Image) -> None:
|
||||
"""Encode ``image`` to the snapshot path atomically. Raises on failure."""
|
||||
# Ensure directory permissions once, not per frame
|
||||
snapshot_path_obj = Path(self._snapshot_path)
|
||||
if not self._snapshot_dir_prepared:
|
||||
# Never modify /tmp permissions - it has special system
|
||||
@@ -1693,7 +1865,7 @@ class DisplayManager:
|
||||
prefix=f".{snapshot_path_obj.name}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(_fd, "wb") as _f:
|
||||
self.image.save(_f, format='PNG')
|
||||
image.save(_f, format='PNG')
|
||||
os.chmod(tmp_path, 0o644)
|
||||
os.replace(tmp_path, self._snapshot_path)
|
||||
except Exception:
|
||||
@@ -1704,25 +1876,36 @@ class DisplayManager:
|
||||
except OSError:
|
||||
pass
|
||||
# Fallback to direct save if replace not supported
|
||||
self.image.save(self._snapshot_path, format='PNG')
|
||||
image.save(self._snapshot_path, format='PNG')
|
||||
# Set proper file permissions after saving
|
||||
try:
|
||||
ensure_file_permissions(snapshot_path_obj, get_assets_file_mode())
|
||||
except Exception:
|
||||
pass
|
||||
self._last_snapshot_ts = now
|
||||
self._last_snapshot_touch_ts = now
|
||||
self._last_snapshot_digest = digest
|
||||
|
||||
def _queue_snapshot(self, image: Image.Image) -> None:
|
||||
"""Hand a frame to the snapshot writer thread; the newest frame wins.
|
||||
|
||||
One slot, not a queue: if the writer is still encoding when the next
|
||||
frame is due, the waiting frame is simply replaced. The preview wants
|
||||
the latest frame, and a backlog would only cost memory and CPU.
|
||||
"""
|
||||
with self._snapshot_cond:
|
||||
self._snapshot_pending = image
|
||||
if self._snapshot_thread is None or not self._snapshot_thread.is_alive():
|
||||
self._snapshot_thread = threading.Thread(
|
||||
target=self._snapshot_writer, daemon=True,
|
||||
name="snapshot-writer")
|
||||
self._snapshot_thread.start()
|
||||
self._snapshot_cond.notify()
|
||||
|
||||
def _snapshot_writer(self) -> None:
|
||||
while True:
|
||||
with self._snapshot_cond:
|
||||
while self._snapshot_pending is None:
|
||||
self._snapshot_cond.wait()
|
||||
image, self._snapshot_pending = self._snapshot_pending, None
|
||||
try:
|
||||
self._save_snapshot(image)
|
||||
except Exception as e:
|
||||
# Snapshot failures must never break display — but they must not
|
||||
# be silent either: the snapshot's mtime is the web UI's display
|
||||
# mirror AND its hardware-liveness proxy, so a quietly failing
|
||||
# write freezes the mirror and makes health checks lie (seen in
|
||||
# the field: a stale root-owned /tmp file froze it for a day).
|
||||
# Warn at most once per 5 minutes to avoid log spam.
|
||||
if (now - self._snapshot_fail_log_ts) > 300:
|
||||
self._snapshot_fail_log_ts = now
|
||||
logger.warning("Snapshot write failing (web preview/health "
|
||||
"mirror is stale): %s", e)
|
||||
else:
|
||||
logger.debug(f"Snapshot write skipped: {e}")
|
||||
self._log_snapshot_failure(e)
|
||||
@@ -18,7 +18,7 @@ _draw_bdf_text, get_font_height, get_text_width, draw_text,
|
||||
draw_text_with_icons, draw_weather_icon (and the _draw_sun/_draw_cloud/
|
||||
_draw_rain/_draw_snow/_draw_storm family), format_date_with_ordinal,
|
||||
capture_mode, set_scrolling_state, is_currently_scrolling,
|
||||
process_deferred_updates, update_display, render_size. A behavior
|
||||
process_deferred_updates, update_display, render_size, offscreen. A behavior
|
||||
change to any of those in DisplayManager must be mirrored here, or
|
||||
plugin visual tests will pass against stale behavior.
|
||||
"""
|
||||
@@ -237,11 +237,39 @@ class VisualTestDisplayManager:
|
||||
wraps every off-screen content fetch in this context, so the harness
|
||||
must provide it for that code path to be exercisable in tests.
|
||||
"""
|
||||
was_active = self._capture_mode_active
|
||||
self._capture_mode_active = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._capture_mode_active = False
|
||||
self._capture_mode_active = was_active
|
||||
|
||||
@contextmanager
|
||||
def offscreen(self, width: Optional[int] = None, height: Optional[int] = None):
|
||||
"""
|
||||
Interface parity with DisplayManager.offscreen().
|
||||
|
||||
Vegas mode's PluginAdapter draws every plugin on a canvas of its own.
|
||||
The real display manager keeps that canvas per thread; the harness is
|
||||
single-threaded, so it swaps a fresh canvas in and restores the old one,
|
||||
which is all a test can observe.
|
||||
"""
|
||||
prev = (self.image, self.draw, self._width, self._height,
|
||||
self.matrix, self._capture_mode_active)
|
||||
target_w = max(1, min(int(width), self._width)) if width else self._width
|
||||
target_h = max(1, min(int(height), self._height)) if height else self._height
|
||||
try:
|
||||
self._width, self._height = target_w, target_h
|
||||
self.matrix = _MatrixProxy(target_w, target_h)
|
||||
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
# Match production: 1-bit text, so goldens show what the panel shows.
|
||||
self.draw.fontmode = "1"
|
||||
self._capture_mode_active = True
|
||||
yield self
|
||||
finally:
|
||||
(self.image, self.draw, self._width, self._height,
|
||||
self.matrix, self._capture_mode_active) = prev
|
||||
|
||||
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
|
||||
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
|
||||
|
||||
@@ -58,13 +58,30 @@ class VegasModeConfig:
|
||||
# switched off at the start of every cycle.
|
||||
lead_in_width: int = 0
|
||||
|
||||
# Blend between neighbouring pixel positions so motion happens at the frame
|
||||
# rate rather than the scroll speed. With integer positioning the number of
|
||||
# distinct frames per second equals scroll_speed, so at 50px/s the motion is
|
||||
# 50 discrete 1px steps however fast the loop runs. The trade is a slight
|
||||
# horizontal softening of text, since each frame is a blend of two positions.
|
||||
# Lock motion to the panel: a whole number of pixels per presented frame,
|
||||
# each frame held for a whole number of refreshes, with SwapOnVSync as the
|
||||
# clock (see src/common/scroll_config.py). scroll_speed is snapped to the
|
||||
# nearest speed the panel can show that way. Off falls back to advancing by
|
||||
# elapsed time, which drifts against the refresh and judders.
|
||||
smooth_scroll: bool = True
|
||||
|
||||
# The older way of smoothing: advance by elapsed time and blend the two
|
||||
# neighbouring pixel positions each frame. It looks anti-aliased in the web
|
||||
# preview, but on the panel the blended columns shimmer (the library's
|
||||
# brightness curve makes a 50% blend far dimmer than half), text softens,
|
||||
# and the loop is not tied to the refresh, so it still misses frames.
|
||||
# Measured on a 512x64 chain at 95Hz: 73-89fps, p99 20-28ms. Takes
|
||||
# precedence over smooth_scroll's whole-pixel pacing when on.
|
||||
sub_pixel_blend: bool = False
|
||||
|
||||
# Render every plugin's ticker content on the background prefetch thread,
|
||||
# each on a canvas of its own (DisplayManager.offscreen), instead of
|
||||
# handing plugins that draw on the display canvas to the render thread one
|
||||
# at a time. Each of those cost the scroll a 40-600ms pause. False restores
|
||||
# that path; it is kept for one release in case a plugin misbehaves when
|
||||
# drawn off the render thread. See docs/OFFSCREEN_RENDERING.md.
|
||||
offscreen_prefetch: bool = True
|
||||
|
||||
# Keep one continuous strip, extending it with the next group of plugins as
|
||||
# the scroll approaches the end, instead of composing a fresh strip and
|
||||
# swapping it in. A swap stops the motion, substitutes every pixel at once
|
||||
@@ -192,7 +209,9 @@ class VegasModeConfig:
|
||||
vegas_config.get('min_content_separation', 24)),
|
||||
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
|
||||
smooth_scroll=vegas_config.get('smooth_scroll', True),
|
||||
sub_pixel_blend=bool(vegas_config.get('sub_pixel_blend', False)),
|
||||
continuous_scroll=vegas_config.get('continuous_scroll', True),
|
||||
offscreen_prefetch=bool(vegas_config.get('offscreen_prefetch', True)),
|
||||
extend_threshold_screens=float(
|
||||
vegas_config.get('extend_threshold_screens', 2.0)),
|
||||
auto_trim=vegas_config.get('auto_trim', True),
|
||||
@@ -232,7 +251,9 @@ class VegasModeConfig:
|
||||
'min_content_separation': self.min_content_separation,
|
||||
'min_cut_gap': self.min_cut_gap,
|
||||
'smooth_scroll': self.smooth_scroll,
|
||||
'sub_pixel_blend': self.sub_pixel_blend,
|
||||
'continuous_scroll': self.continuous_scroll,
|
||||
'offscreen_prefetch': self.offscreen_prefetch,
|
||||
'extend_threshold_screens': self.extend_threshold_screens,
|
||||
'auto_trim': self.auto_trim,
|
||||
'trim_threshold': self.trim_threshold,
|
||||
|
||||
@@ -101,7 +101,8 @@ class VegasModeCoordinator:
|
||||
self.plugin_manager = plugin_manager
|
||||
|
||||
# Initialize components
|
||||
self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
|
||||
self.plugin_adapter = PluginAdapter(
|
||||
display_manager, self.vegas_config, plugin_manager=plugin_manager)
|
||||
self.stream_manager = StreamManager(
|
||||
self.vegas_config,
|
||||
plugin_manager,
|
||||
@@ -420,7 +421,6 @@ class VegasModeCoordinator:
|
||||
# Update static mode plugin list on iteration start
|
||||
self._update_static_mode_plugins()
|
||||
|
||||
frame_interval = self.vegas_config.get_frame_interval()
|
||||
if self.vegas_config.continuous_scroll:
|
||||
# The strip is continuously extended and trimmed, so its width says
|
||||
# nothing about how long to run. This is only how often control
|
||||
@@ -488,7 +488,10 @@ class VegasModeCoordinator:
|
||||
# quarter of the budget spent not rendering. Subtracting the work
|
||||
# already done keeps the pacing target while reclaiming that time,
|
||||
# and yields the GIL either way so other threads still run.
|
||||
# Read every frame: a config change applied mid-iteration can
|
||||
# switch between crisp and blended pacing.
|
||||
frame_elapsed = time.monotonic() - frame_started
|
||||
frame_interval = self.render_pipeline.frame_interval
|
||||
time.sleep(max(0.0, frame_interval - frame_elapsed))
|
||||
|
||||
# Measured before the sleep: time spent working, not pacing.
|
||||
@@ -518,20 +521,20 @@ class VegasModeCoordinator:
|
||||
if current_time - last_fps_log_time >= fps_log_interval:
|
||||
fps = fps_frame_count / (current_time - last_fps_log_time)
|
||||
p99 = _percentile(sorted(frame_times), 0.99)
|
||||
target = self.vegas_config.target_fps
|
||||
target = self.render_pipeline.target_fps
|
||||
degraded = target > 0 and fps < target * _FPS_HEALTHY_FRACTION
|
||||
due = (current_time - self._fps_last_health_log
|
||||
>= _FPS_HEARTBEAT_INTERVAL)
|
||||
if degraded or self._fps_was_degraded or due:
|
||||
logger.info(
|
||||
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms",
|
||||
"Vegas FPS: %.1f (target: %.0f, frames: %d) p99 %.1fms worst %.1fms",
|
||||
fps, target, fps_frame_count,
|
||||
p99 * 1000.0, frame_worst * 1000.0
|
||||
)
|
||||
self._fps_last_health_log = current_time
|
||||
else:
|
||||
logger.debug(
|
||||
"Vegas FPS: %.1f (target: %d, frames: %d) p99 %.1fms worst %.1fms",
|
||||
"Vegas FPS: %.1f (target: %.0f, frames: %d) p99 %.1fms worst %.1fms",
|
||||
fps, target, fps_frame_count,
|
||||
p99 * 1000.0, frame_worst * 1000.0
|
||||
)
|
||||
@@ -559,7 +562,7 @@ class VegasModeCoordinator:
|
||||
# main loop's _tick_plugin_updates() finds all intervals already
|
||||
# satisfied on return, so the inter-iteration gap is <1 ms and the
|
||||
# display never shows a frozen frame between iterations.
|
||||
_UPDATE_TICK_FRAMES = max(1, int(self.vegas_config.target_fps * 4)) # every 4 s regardless of FPS
|
||||
_UPDATE_TICK_FRAMES = max(1, int(self.render_pipeline.target_fps * 4)) # every 4 s regardless of FPS
|
||||
if (self._update_callback and
|
||||
frame_count % _UPDATE_TICK_FRAMES == 0 and
|
||||
not self._update_tick_running):
|
||||
|
||||
@@ -8,7 +8,7 @@ implement get_vegas_content() and fallback capture of display() output.
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from contextlib import nullcontext
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
|
||||
@@ -33,7 +33,13 @@ class PluginAdapter:
|
||||
2. Fallback: Capture display_manager.image after calling plugin.display()
|
||||
"""
|
||||
|
||||
def __init__(self, display_manager: Any, config: Optional[Any] = None):
|
||||
#: How long a background fetch waits for a plugin's update() to finish
|
||||
#: before skipping the plugin this round. Off the render thread waiting
|
||||
#: costs nothing visible; it only delays that one plugin's content.
|
||||
PLUGIN_LOCK_TIMEOUT = 2.0
|
||||
|
||||
def __init__(self, display_manager: Any, config: Optional[Any] = None,
|
||||
plugin_manager: Optional[Any] = None):
|
||||
"""
|
||||
Initialize the plugin adapter.
|
||||
|
||||
@@ -42,8 +48,13 @@ class PluginAdapter:
|
||||
config: VegasModeConfig controlling trim behaviour. When omitted,
|
||||
trimming runs with the dataclass defaults, so existing callers
|
||||
and tests keep working unchanged.
|
||||
plugin_manager: Source of the per-plugin lock that keeps a
|
||||
background fetch from running a plugin's display() while its
|
||||
update() is mid-flight. Optional: without it, fetches take no
|
||||
lock, as they always did.
|
||||
"""
|
||||
self.display_manager = display_manager
|
||||
self.plugin_manager = plugin_manager
|
||||
if config is None:
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
config = VegasModeConfig()
|
||||
@@ -98,13 +109,14 @@ class PluginAdapter:
|
||||
Args:
|
||||
plugin: Plugin instance to get content from
|
||||
plugin_id: Plugin identifier for logging
|
||||
offscreen_only: Skip every path that touches the shared display
|
||||
canvas, for callers running off the render thread. The canvas
|
||||
and the matrix proxy are process-wide mutable state, so
|
||||
narrowing or capturing through them from another thread would
|
||||
corrupt the frame the render loop is pushing. Returns None when
|
||||
the plugin can only be served that way, leaving the caller to
|
||||
fetch it on the render thread.
|
||||
offscreen_only: The caller is off the render thread. Every content
|
||||
path draws on a canvas of its own (DisplayManager.offscreen),
|
||||
so all of them are safe there; the fetch also takes the
|
||||
plugin's lock, waiting up to PLUGIN_LOCK_TIMEOUT for a running
|
||||
update() to finish. With ``offscreen_prefetch`` switched off,
|
||||
the old behaviour applies instead: paths that need a canvas
|
||||
return None, leaving the caller to fetch the plugin on the
|
||||
render thread.
|
||||
|
||||
Returns:
|
||||
List of PIL Images representing plugin content, or None if no content
|
||||
@@ -124,11 +136,77 @@ class PluginAdapter:
|
||||
)
|
||||
return cached
|
||||
|
||||
# The old contract, kept behind the switch: background callers may
|
||||
# not draw, so anything needing a canvas is left for the render thread.
|
||||
restricted = offscreen_only and not getattr(
|
||||
self.config, 'offscreen_prefetch', True)
|
||||
if not offscreen_only or restricted:
|
||||
return self._fetch_content(plugin, plugin_id, restricted)
|
||||
|
||||
with self._plugin_lock(plugin_id) as acquired:
|
||||
if not acquired:
|
||||
logger.warning(
|
||||
"[%s] update() still running after %.0fs; skipping it this "
|
||||
"round", plugin_id, self.PLUGIN_LOCK_TIMEOUT
|
||||
)
|
||||
return None
|
||||
return self._fetch_content(plugin, plugin_id, restricted=False)
|
||||
|
||||
@contextmanager
|
||||
def _plugin_lock(self, plugin_id: str):
|
||||
"""Hold the plugin's update/display lock, waiting a bounded time.
|
||||
|
||||
Yields whether it was acquired. Yields True, holding nothing, when
|
||||
there is no plugin manager to ask -- the behaviour before the lock was
|
||||
taken here at all.
|
||||
"""
|
||||
if not hasattr(self.plugin_manager, 'get_plugin_lock'):
|
||||
yield True
|
||||
return
|
||||
lock = self.plugin_manager.get_plugin_lock(plugin_id)
|
||||
acquired = lock.acquire(timeout=self.PLUGIN_LOCK_TIMEOUT)
|
||||
try:
|
||||
yield acquired
|
||||
finally:
|
||||
if acquired:
|
||||
lock.release()
|
||||
|
||||
@contextmanager
|
||||
def _isolated_canvas(self, width: Optional[int] = None):
|
||||
"""A canvas for the plugin to draw on that nothing else sees.
|
||||
|
||||
DisplayManager.offscreen() gives the calling thread its own canvas, so
|
||||
this is safe on any thread and leaves the shared canvas untouched.
|
||||
Older display managers and test doubles without it get the previous
|
||||
behaviour: capture on the shared canvas, narrowed with render_size,
|
||||
then restore it -- which is only safe on the render thread.
|
||||
"""
|
||||
offscreen = getattr(self.display_manager, 'offscreen', None)
|
||||
if offscreen is not None:
|
||||
with offscreen(width):
|
||||
yield
|
||||
return
|
||||
|
||||
original_image = self.display_manager.image.copy()
|
||||
try:
|
||||
with self._capture(), self._render_at(width or self.display_width):
|
||||
yield
|
||||
finally:
|
||||
self.display_manager.image = original_image
|
||||
|
||||
def _fetch_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""Every content path in order: native, scroll helper, display capture.
|
||||
|
||||
``restricted`` is the pre-offscreen contract for background callers:
|
||||
skip every path that needs a canvas and return None instead.
|
||||
"""
|
||||
# Try native Vegas content method first
|
||||
has_native = hasattr(plugin, 'get_vegas_content')
|
||||
logger.debug("[%s] Has get_vegas_content: %s", plugin_id, has_native)
|
||||
if has_native:
|
||||
content = self._get_native_content(plugin, plugin_id, offscreen_only)
|
||||
content = self._get_native_content(plugin, plugin_id, restricted)
|
||||
if content:
|
||||
total_width = sum(img.width for img in content)
|
||||
logger.debug(
|
||||
@@ -141,7 +219,7 @@ class PluginAdapter:
|
||||
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
|
||||
has_scroll_helper = hasattr(plugin, 'scroll_helper')
|
||||
logger.debug("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper)
|
||||
content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only)
|
||||
content = self._get_scroll_helper_content(plugin, plugin_id, restricted)
|
||||
if content:
|
||||
total_width = sum(img.width for img in content)
|
||||
logger.debug(
|
||||
@@ -152,8 +230,8 @@ class PluginAdapter:
|
||||
if has_scroll_helper:
|
||||
logger.debug("[%s] ScrollHelper content returned None", plugin_id)
|
||||
|
||||
if offscreen_only:
|
||||
# Display capture needs the shared canvas; leave it to the caller.
|
||||
if restricted:
|
||||
# Display capture needs a canvas; leave it to the caller.
|
||||
logger.debug(
|
||||
"[%s] Needs display capture, deferring to the render thread",
|
||||
plugin_id
|
||||
@@ -685,7 +763,7 @@ class PluginAdapter:
|
||||
return img.crop((start, 0, end, img.height))
|
||||
|
||||
def _get_native_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
|
||||
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool = False
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Get content via plugin's native get_vegas_content() method.
|
||||
@@ -714,22 +792,21 @@ class PluginAdapter:
|
||||
|
||||
plugin._vegas_render_width = render_width
|
||||
try:
|
||||
# capture_mode unconditionally, even at full width. Building
|
||||
# Vegas content is an off-screen operation, but a plugin is free
|
||||
# to call update_display() while doing it — and outside
|
||||
# capture_mode that write lands on the hardware, flashing the
|
||||
# panel mid-scroll. The narrowing context is separate because it
|
||||
# is a no-op at full width.
|
||||
if offscreen_only:
|
||||
# _render_at swaps the shared canvas, so it is unsafe here.
|
||||
# _vegas_render_width is set regardless: a plugin reading
|
||||
# get_vegas_render_width() still gets its narrow size, and
|
||||
# one that only reads matrix.width renders full width and is
|
||||
# trimmed instead.
|
||||
# On a canvas of its own even at full width. Building Vegas
|
||||
# content is an off-screen operation, but a plugin is free to
|
||||
# call update_display() while doing it, and on the shared canvas
|
||||
# that write would land on the hardware, flashing the panel
|
||||
# mid-scroll.
|
||||
if restricted:
|
||||
# Restricted (offscreen_prefetch off): no canvas of our own,
|
||||
# so no narrowing. _vegas_render_width is set regardless: a
|
||||
# plugin reading get_vegas_render_width() still gets its
|
||||
# narrow size, and one that only reads matrix.width renders
|
||||
# full width and is trimmed instead.
|
||||
with self._capture():
|
||||
result = plugin.get_vegas_content()
|
||||
else:
|
||||
with self._capture(), self._render_at(render_width):
|
||||
with self._isolated_canvas(render_width):
|
||||
result = plugin.get_vegas_content()
|
||||
finally:
|
||||
plugin._vegas_render_width = None
|
||||
@@ -810,7 +887,7 @@ class PluginAdapter:
|
||||
return None
|
||||
|
||||
def _get_scroll_helper_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
|
||||
self, plugin: 'BasePlugin', plugin_id: str, restricted: bool = False
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Get content from plugin's scroll_helper if available.
|
||||
@@ -844,7 +921,7 @@ class PluginAdapter:
|
||||
"[%s] scroll_helper.cached_image is None, triggering content generation",
|
||||
plugin_id
|
||||
)
|
||||
if offscreen_only:
|
||||
if restricted:
|
||||
# Generating it calls display(), which needs the canvas.
|
||||
logger.debug(
|
||||
"[%s] scroll_helper cache empty; deferring generation "
|
||||
@@ -994,12 +1071,8 @@ class PluginAdapter:
|
||||
Returns:
|
||||
The generated cached_image or None
|
||||
"""
|
||||
original_image = None
|
||||
try:
|
||||
# Save display state to restore after
|
||||
original_image = self.display_manager.image.copy()
|
||||
|
||||
with self._capture():
|
||||
with self._isolated_canvas():
|
||||
# Method 1: Try _create_scrolling_display (stocks pattern)
|
||||
if hasattr(plugin, '_create_scrolling_display'):
|
||||
logger.debug(
|
||||
@@ -1055,11 +1128,6 @@ class PluginAdapter:
|
||||
logger.exception("[%s] Error triggering scroll content", plugin_id)
|
||||
return None
|
||||
|
||||
finally:
|
||||
# Restore original display state
|
||||
if original_image is not None:
|
||||
self.display_manager.image = original_image
|
||||
|
||||
def _capture_display_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
) -> Optional[List[Image.Image]]:
|
||||
@@ -1073,12 +1141,7 @@ class PluginAdapter:
|
||||
Returns:
|
||||
List with single captured image, or None
|
||||
"""
|
||||
original_image = None
|
||||
try:
|
||||
# Save current display state
|
||||
original_image = self.display_manager.image.copy()
|
||||
logger.debug("[%s] Fallback: saved original display state", plugin_id)
|
||||
|
||||
# Ensure plugin has fresh data before capturing
|
||||
has_update_data = hasattr(plugin, 'update_data')
|
||||
logger.debug("[%s] Fallback: has update_data=%s", plugin_id, has_update_data)
|
||||
@@ -1089,12 +1152,12 @@ class PluginAdapter:
|
||||
except (AttributeError, RuntimeError, OSError):
|
||||
logger.exception("[%s] Fallback: update_data() failed", plugin_id)
|
||||
|
||||
# Clear and call plugin display — use capture_mode to suppress hardware writes
|
||||
# that plugins may trigger internally via update_display().
|
||||
# Clear and call plugin display on a canvas of its own: nothing it
|
||||
# draws, and no update_display() it calls, reaches the panel.
|
||||
#
|
||||
# render_size narrows the canvas the plugin lays out against, so a
|
||||
# plugin that spreads across the whole panel produces a compact
|
||||
# arrangement rather than one that has to be cropped afterwards.
|
||||
# The canvas is render_width wide, so a plugin that spreads across
|
||||
# the whole panel produces a compact arrangement rather than one
|
||||
# that has to be cropped afterwards.
|
||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||
if render_width != self.display_width:
|
||||
logger.debug(
|
||||
@@ -1102,7 +1165,7 @@ class PluginAdapter:
|
||||
plugin_id, render_width, self.display_width
|
||||
)
|
||||
|
||||
with self._capture(), self._render_at(render_width):
|
||||
with self._isolated_canvas(render_width):
|
||||
self.display_manager.clear()
|
||||
logger.debug("[%s] Fallback: display cleared, calling display()", plugin_id)
|
||||
|
||||
@@ -1136,7 +1199,7 @@ class PluginAdapter:
|
||||
plugin_id
|
||||
)
|
||||
# Try once more with force_clear=True
|
||||
with self._capture(), self._render_at(render_width):
|
||||
with self._isolated_canvas(render_width):
|
||||
self.display_manager.clear()
|
||||
plugin.display(force_clear=True)
|
||||
captured = self.display_manager.image.copy()
|
||||
@@ -1173,12 +1236,6 @@ class PluginAdapter:
|
||||
)
|
||||
return None
|
||||
|
||||
finally:
|
||||
# Always restore original image to prevent display corruption
|
||||
if original_image is not None:
|
||||
self.display_manager.image = original_image
|
||||
logger.debug("[%s] Fallback: restored original display state", plugin_id)
|
||||
|
||||
def _is_blank_image(
|
||||
self, img: Image.Image, return_ratio: bool = False
|
||||
) -> Union[bool, Tuple[bool, float]]:
|
||||
|
||||
@@ -13,6 +13,7 @@ from collections import deque
|
||||
from typing import Optional, List, Any, Dict, Deque, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
|
||||
from src.common.scroll_config import solve_crisp
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.geometry import separation_gap
|
||||
@@ -40,6 +41,13 @@ class RenderPipeline:
|
||||
# stalls land in separate moments rather than one run of hitches.
|
||||
DEFERRED_DRAIN_INTERVAL = 2.0
|
||||
|
||||
# Swaps timed before trusting a refresh measurement: about a second.
|
||||
REFRESH_SAMPLES = 96
|
||||
# Frames skipped first, while the hold from whatever ran before settles.
|
||||
REFRESH_WARMUP_FRAMES = 8
|
||||
# A panel measured within this fraction of its cap is keeping up with it.
|
||||
REFRESH_TOLERANCE = 0.03
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: VegasModeConfig,
|
||||
@@ -79,6 +87,11 @@ class RenderPipeline:
|
||||
logger
|
||||
)
|
||||
|
||||
# The panel's real refresh rate, measured from our own vsync-blocked
|
||||
# swaps once scrolling starts. None until then; see _measure_refresh.
|
||||
self._measured_hz: Optional[float] = None
|
||||
self._swap_times: Deque[float] = deque(maxlen=self.REFRESH_SAMPLES + 1)
|
||||
|
||||
# Configure scroll helper
|
||||
self._configure_scroll_helper()
|
||||
|
||||
@@ -122,9 +135,35 @@ class RenderPipeline:
|
||||
|
||||
def _configure_scroll_helper(self) -> None:
|
||||
"""Configure ScrollHelper with current settings."""
|
||||
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
|
||||
self.scroll_helper.set_scroll_delay(self.config.scroll_delay)
|
||||
self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll)
|
||||
self.scroll_helper.set_sub_pixel_scrolling(self.config.sub_pixel_blend)
|
||||
|
||||
# With smooth_scroll the strip moves a whole number of pixels per
|
||||
# presented frame, each frame held for frame_hold panel refreshes, and
|
||||
# SwapOnVSync is the clock -- the same crisp pacing the plugin tickers
|
||||
# use (src/common/scroll_config.py). The time-based path below has no
|
||||
# fixed relation to the refresh: at 90px/s on a panel refreshing at
|
||||
# 95Hz every frame lands 0.95px on, so text is re-blended at a
|
||||
# different phase each refresh, and any frame that misses a vsync is
|
||||
# followed by a double step. Measured on a 512x64 chain: 73fps against
|
||||
# a 95Hz panel, p99 21-28ms -- a visible hitch every few frames.
|
||||
self._crisp = None
|
||||
self._frame_hold = 1
|
||||
if self.config.smooth_scroll and not self.config.sub_pixel_blend:
|
||||
self._crisp = solve_crisp(self.config.scroll_speed, self._refresh_hz())
|
||||
self._frame_hold = self._crisp.frame_hold
|
||||
self.scroll_helper.set_frame_based_scrolling(False)
|
||||
self.scroll_helper.set_scroll_speed(self._crisp.pixels_per_second)
|
||||
self.scroll_helper.set_pixels_per_frame(self._crisp.pixels_per_frame)
|
||||
logger.info(
|
||||
"Vegas scroll: %s (asked for %d px/s on a %.0fHz panel)",
|
||||
self._crisp.describe(), self.config.scroll_speed, self._refresh_hz()
|
||||
)
|
||||
self._apply_dynamic_duration_settings()
|
||||
return
|
||||
|
||||
self.scroll_helper.set_pixels_per_frame(None)
|
||||
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
|
||||
|
||||
# Config scroll_speed is always pixels per second, but ScrollHelper
|
||||
# takes it in different units depending on frame_based_scrolling:
|
||||
@@ -139,6 +178,9 @@ class RenderPipeline:
|
||||
self.scroll_helper.set_scroll_speed(pixels_per_frame)
|
||||
else:
|
||||
self.scroll_helper.set_scroll_speed(self.config.scroll_speed)
|
||||
self._apply_dynamic_duration_settings()
|
||||
|
||||
def _apply_dynamic_duration_settings(self) -> None:
|
||||
self.scroll_helper.set_dynamic_duration_settings(
|
||||
enabled=self.config.dynamic_duration_enabled,
|
||||
min_duration=self.config.min_cycle_duration,
|
||||
@@ -146,6 +188,87 @@ class RenderPipeline:
|
||||
buffer=0.1 # 10% buffer
|
||||
)
|
||||
|
||||
def _cap_hz(self) -> float:
|
||||
"""The panel's refresh cap, as the display manager reports it."""
|
||||
try:
|
||||
hz = float(getattr(self.display_manager, 'refresh_hz', 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
hz = 0.0
|
||||
return hz if hz > 0 else 100.0
|
||||
|
||||
def _refresh_hz(self) -> float:
|
||||
"""The refresh to solve the crisp speed against: measured, else the cap."""
|
||||
return self._measured_hz or self._cap_hz()
|
||||
|
||||
def _measure_refresh(self) -> None:
|
||||
"""Time our swaps to learn the rate the panel really refreshes at.
|
||||
|
||||
limit_refresh_rate_hz is a cap, not a rate. A long single chain cannot
|
||||
reach a high one: 4x128x64 at pwm_bits 8 refreshes at ~95Hz under a
|
||||
120Hz cap. Solving against the cap then picks a speed built for a
|
||||
refresh the panel never delivers -- 90px/s at "120Hz" is 3px every 4
|
||||
refreshes, visibly jumpy, where the real 95Hz allows 1px every refresh.
|
||||
|
||||
SwapOnVSync blocks for frame_hold refreshes, so the median gap between
|
||||
swaps is frame_hold refresh periods. The median ignores the odd frame
|
||||
that missed its vsync or waited on a recompose. Measured once: the
|
||||
refresh only changes with the hardware config, which restarts us.
|
||||
"""
|
||||
if self._crisp is None or self._measured_hz is not None:
|
||||
return
|
||||
if getattr(self.display_manager, 'matrix', None) is None:
|
||||
return # No hardware: nothing blocks, so there is nothing to time.
|
||||
if self.stats['frames_rendered'] < self.REFRESH_WARMUP_FRAMES:
|
||||
return
|
||||
self._swap_times.append(time.monotonic())
|
||||
if len(self._swap_times) <= self.REFRESH_SAMPLES:
|
||||
return
|
||||
|
||||
times = list(self._swap_times)
|
||||
gaps = sorted(b - a for a, b in zip(times, times[1:]))
|
||||
median = gaps[len(gaps) // 2]
|
||||
self._swap_times.clear()
|
||||
if median <= 0:
|
||||
return
|
||||
measured = self._frame_hold / median
|
||||
cap = self._cap_hz()
|
||||
if measured >= cap * (1.0 - self.REFRESH_TOLERANCE):
|
||||
self._measured_hz = cap
|
||||
return
|
||||
self._measured_hz = round(measured, 1)
|
||||
logger.info(
|
||||
"Vegas: panel refreshes at %.1fHz, below its %.0fHz cap; "
|
||||
"re-solving the scroll speed for the real rate",
|
||||
self._measured_hz, cap
|
||||
)
|
||||
self._configure_scroll_helper()
|
||||
|
||||
@property
|
||||
def target_fps(self) -> float:
|
||||
"""Frames per second this scroll presents when it keeps up."""
|
||||
if self._crisp is not None:
|
||||
return self._crisp.frames_per_second
|
||||
return float(self.config.target_fps)
|
||||
|
||||
@property
|
||||
def frame_interval(self) -> float:
|
||||
"""Shortest time the render loop should spend on one frame.
|
||||
|
||||
With crisp pacing SwapOnVSync already blocks for frame_hold refreshes,
|
||||
so this is only a floor for when the swap does not block (no hardware,
|
||||
or the emulator). It must not exceed the real refresh period: a loop
|
||||
that sleeps even slightly longer than the panel drifts against it and
|
||||
misses a refresh every few frames -- which is what target_fps 90 on a
|
||||
95Hz panel did. The configured cap is at least the real refresh, so
|
||||
hold / cap never exceeds hold real periods -- the measured rate is
|
||||
deliberately not used here.
|
||||
"""
|
||||
if self._crisp is not None:
|
||||
# 0.9: the floor must sit strictly below the real period, or the
|
||||
# surplus accumulates frame over frame until one misses.
|
||||
return 0.9 * self._frame_hold / self._cap_hz()
|
||||
return self._frame_interval
|
||||
|
||||
def compose_scroll_content(self) -> bool:
|
||||
"""
|
||||
Compose content from stream manager into scrollable image.
|
||||
@@ -397,9 +520,12 @@ class RenderPipeline:
|
||||
grouped = [(pid, imgs) for pid, imgs in grouped if imgs]
|
||||
|
||||
if not grouped:
|
||||
# Everything in this group is queued; the queue will extend the
|
||||
# strip as it drains, so this is not a failure.
|
||||
if deferred:
|
||||
# Everything in this group is queued; the queue will extend
|
||||
# the strip as it drains, so this is not a failure.
|
||||
logger.info("Whole group deferred; strip will extend as it drains")
|
||||
else:
|
||||
logger.info("Nothing to show in this group; fetching the next")
|
||||
self.start_prefetch()
|
||||
return bool(deferred)
|
||||
|
||||
@@ -570,10 +696,11 @@ class RenderPipeline:
|
||||
self.sync_manager.send_scroll_x(self.scroll_helper.scroll_position)
|
||||
|
||||
# Update scrolling state
|
||||
self.display_manager.set_scrolling_state(True)
|
||||
self.display_manager.set_scrolling_state(True, self._frame_hold)
|
||||
|
||||
# Track statistics
|
||||
self.stats['frames_rendered'] += 1
|
||||
self._measure_refresh()
|
||||
frame_time = time.time() - frame_start
|
||||
self._track_frame_time(frame_time)
|
||||
|
||||
|
||||
@@ -763,6 +763,10 @@ class StreamManager:
|
||||
Ordered list of (plugin_id, images). ``images`` is None when the
|
||||
plugin could not be served under ``offscreen_only``, so the caller
|
||||
can fetch just those on the render thread while keeping the order.
|
||||
That only happens with ``offscreen_prefetch`` switched off: every
|
||||
content path now draws on a canvas of its own, so a background
|
||||
fetch that comes back empty had nothing to show, and ``images``
|
||||
is an empty list rather than a request for the render thread.
|
||||
"""
|
||||
if count is None:
|
||||
count = self.config.plugins_per_cycle
|
||||
@@ -780,6 +784,9 @@ class StreamManager:
|
||||
|
||||
plugins = getattr(self.plugin_manager, 'plugins', {})
|
||||
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
|
||||
# Only the old contract hands anything back to the render thread.
|
||||
defer_empty = offscreen_only and not getattr(
|
||||
self.config, 'offscreen_prefetch', True)
|
||||
|
||||
for plugin_id in ids:
|
||||
plugin = plugins.get(plugin_id)
|
||||
@@ -794,7 +801,10 @@ class StreamManager:
|
||||
continue
|
||||
if images:
|
||||
self.stats['segments_fetched'] += 1
|
||||
group.append((plugin_id, images if images else None))
|
||||
if images:
|
||||
group.append((plugin_id, images))
|
||||
else:
|
||||
group.append((plugin_id, None if defer_empty else []))
|
||||
|
||||
return group
|
||||
|
||||
|
||||
@@ -357,3 +357,57 @@ class TestFrameHoldLifetime:
|
||||
assert dm._frame_hold == 1
|
||||
finally:
|
||||
dm.set_scrolling_state(False)
|
||||
|
||||
|
||||
class TestSnapshotOffRenderThread:
|
||||
"""Mid-scroll, the preview PNG is encoded off the render thread.
|
||||
|
||||
At 512x64 the encode takes 12-14ms on a Pi 4 -- longer than a refresh --
|
||||
so doing it inline made the next swap miss its vsync five times a second
|
||||
whenever the web preview was open.
|
||||
"""
|
||||
|
||||
def _record_saves(self, dm, monkeypatch):
|
||||
import threading
|
||||
threads = []
|
||||
done = threading.Event()
|
||||
real = dm._save_snapshot
|
||||
|
||||
def recording(image):
|
||||
threads.append(threading.current_thread().name)
|
||||
real(image)
|
||||
done.set()
|
||||
|
||||
monkeypatch.setattr(dm, "_save_snapshot", recording)
|
||||
return threads, done
|
||||
|
||||
def _due(self, dm, tmp_path, colour):
|
||||
dm._snapshot_path = str(tmp_path / "snap.png")
|
||||
dm._last_snapshot_ts = 0.0
|
||||
dm._last_snapshot_touch_ts = 0.0
|
||||
dm._last_snapshot_digest = None
|
||||
dm.draw.rectangle([0, 0, 10, 4], fill=colour)
|
||||
|
||||
def test_scrolling_frames_are_encoded_on_the_writer_thread(
|
||||
self, dm, tmp_path, monkeypatch):
|
||||
import threading
|
||||
threads, done = self._record_saves(dm, monkeypatch)
|
||||
self._due(dm, tmp_path, (0, 255, 255))
|
||||
dm.set_scrolling_state(True)
|
||||
try:
|
||||
dm.update_display()
|
||||
assert done.wait(5), "the snapshot writer never wrote the frame"
|
||||
finally:
|
||||
dm.set_scrolling_state(False)
|
||||
assert threads == ["snapshot-writer"]
|
||||
assert threads[0] != threading.current_thread().name
|
||||
assert os.path.exists(dm._snapshot_path)
|
||||
|
||||
def test_static_frames_are_still_written_inline(
|
||||
self, dm, tmp_path, monkeypatch):
|
||||
import threading
|
||||
threads, _ = self._record_saves(dm, monkeypatch)
|
||||
self._due(dm, tmp_path, (255, 0, 255))
|
||||
dm.set_scrolling_state(False)
|
||||
dm.update_display()
|
||||
assert threads == [threading.current_thread().name]
|
||||
|
||||
@@ -140,6 +140,9 @@ def vegas_coordinator(controller):
|
||||
'enabled': True, 'max_cycle_duration': VEGAS_ITERATION_SECONDS}}})
|
||||
assert coord.vegas_config.continuous_scroll
|
||||
coord.render_pipeline = MagicMock()
|
||||
# Real numbers: the loop sleeps and reports against these.
|
||||
coord.render_pipeline.frame_interval = coord.vegas_config.get_frame_interval()
|
||||
coord.render_pipeline.target_fps = float(coord.vegas_config.target_fps)
|
||||
coord.stream_manager = MagicMock()
|
||||
coord.display_manager = controller.display_manager
|
||||
coord.stats = {'cycles_completed': 0, 'interruptions': 0}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
"""Per-thread off-screen rendering (DisplayManager.offscreen) and its Vegas use.
|
||||
|
||||
The display canvas used to be one shared object, so any plugin that drew on it
|
||||
could only be rendered on the render thread, stalling the scroll for 40-600ms
|
||||
each. offscreen() gives the calling thread a canvas of its own. These tests pin
|
||||
the property that makes that safe: another thread's drawing never reaches what
|
||||
the render loop sees, presents or paces by.
|
||||
|
||||
Runs against RGBMatrixEmulator, exercising the real DisplayManager.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
|
||||
os.environ["EMULATOR"] = "true"
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402
|
||||
from src.vegas_mode.stream_manager import StreamManager # noqa: E402
|
||||
|
||||
WIDTH, HEIGHT = 128, 32
|
||||
YELLOW = (255, 255, 0)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def dm(tmp_path_factory):
|
||||
from src.display_manager import DisplayManager
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
manager = DisplayManager({
|
||||
"display": {
|
||||
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
|
||||
"parallel": 1, "brightness": 90},
|
||||
"runtime": {"gpio_slowdown": 0},
|
||||
},
|
||||
}, suppress_test_pattern=True)
|
||||
manager._snapshot_path = str(
|
||||
tmp_path_factory.mktemp("offscreen") / "led_matrix_preview.png")
|
||||
if manager.matrix is None:
|
||||
pytest.fail("DisplayManager fell back to matrix=None; see the "
|
||||
"'Failed to initialize RGB Matrix' log line above.")
|
||||
yield manager
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh(dm):
|
||||
"""A known shared canvas, and scroll state reset around each test."""
|
||||
dm.image = Image.new('RGB', (WIDTH, HEIGHT))
|
||||
from PIL import ImageDraw
|
||||
dm.draw = ImageDraw.Draw(dm.image)
|
||||
dm.set_scrolling_state(False)
|
||||
yield dm
|
||||
dm.set_scrolling_state(False)
|
||||
|
||||
|
||||
class _Spy:
|
||||
"""Counts calls to one method of one object, and still calls it."""
|
||||
|
||||
def __init__(self, obj, name):
|
||||
self.obj, self.name, self.count = obj, name, 0
|
||||
self._orig = getattr(obj, name)
|
||||
|
||||
def __enter__(self):
|
||||
def counting(*args, **kwargs):
|
||||
self.count += 1
|
||||
return self._orig(*args, **kwargs)
|
||||
setattr(self.obj, self.name, counting)
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
setattr(self.obj, self.name, self._orig)
|
||||
|
||||
|
||||
def _in_thread(fn):
|
||||
"""Run fn on another thread and return its result, re-raising its error."""
|
||||
box = {}
|
||||
|
||||
def run():
|
||||
try:
|
||||
box["value"] = fn()
|
||||
except BaseException as exc: # surfaced below
|
||||
box["error"] = exc
|
||||
|
||||
thread = threading.Thread(target=run)
|
||||
thread.start()
|
||||
thread.join(10)
|
||||
assert not thread.is_alive(), "worker hung"
|
||||
if "error" in box:
|
||||
raise box["error"]
|
||||
return box.get("value")
|
||||
|
||||
|
||||
class TestIsolation:
|
||||
def test_another_threads_drawing_is_invisible_here(self, fresh):
|
||||
dm = fresh
|
||||
shared = dm.image
|
||||
inside, release = threading.Event(), threading.Event()
|
||||
seen = {}
|
||||
|
||||
def worker():
|
||||
with dm.offscreen(40) as surface:
|
||||
dm.draw.rectangle([0, 0, 39, 31], fill=YELLOW)
|
||||
seen.update(width=dm.width, matrix_width=dm.matrix.width,
|
||||
own_image=dm.image is surface.image)
|
||||
inside.set()
|
||||
release.wait(5)
|
||||
seen["restored"] = dm.image is shared
|
||||
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
try:
|
||||
assert inside.wait(5)
|
||||
# While the worker is mid-draw, this thread sees the real canvas.
|
||||
assert dm.image is shared
|
||||
assert (dm.width, dm.matrix.width) == (WIDTH, WIDTH)
|
||||
assert shared.getpixel((0, 0)) == (0, 0, 0)
|
||||
finally:
|
||||
release.set()
|
||||
thread.join(5)
|
||||
assert seen == {"width": 40, "matrix_width": 40, "own_image": True,
|
||||
"restored": True}
|
||||
|
||||
def test_the_render_loop_keeps_presenting_while_another_thread_draws(self, fresh):
|
||||
dm = fresh
|
||||
inside, release = threading.Event(), threading.Event()
|
||||
|
||||
def worker():
|
||||
with dm.offscreen():
|
||||
dm.draw.rectangle([0, 0, 10, 10], fill=YELLOW)
|
||||
dm.update_display() # must not reach the panel
|
||||
inside.set()
|
||||
release.wait(5)
|
||||
|
||||
with _Spy(dm.matrix, "SwapOnVSync") as swaps:
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
try:
|
||||
assert inside.wait(5)
|
||||
dm.draw.point((5, 5), fill=(0, 0, 255))
|
||||
dm.update_display() # the render thread's own frame
|
||||
finally:
|
||||
release.set()
|
||||
thread.join(5)
|
||||
assert swaps.count == 1
|
||||
|
||||
def test_assignments_go_to_the_callers_canvas(self, fresh):
|
||||
dm = fresh
|
||||
shared = dm.image
|
||||
replacement = Image.new('RGB', (WIDTH, HEIGHT), (1, 2, 3))
|
||||
|
||||
def worker():
|
||||
with dm.offscreen():
|
||||
dm.image = replacement # e.g. clear() inside display()
|
||||
return dm.image is replacement
|
||||
|
||||
assert _in_thread(worker) is True
|
||||
assert dm.image is shared
|
||||
|
||||
def test_render_size_narrows_only_the_calling_thread(self, fresh):
|
||||
dm = fresh
|
||||
inside, release = threading.Event(), threading.Event()
|
||||
seen = {}
|
||||
|
||||
def worker():
|
||||
with dm.render_size(32):
|
||||
seen["width"] = dm.width
|
||||
inside.set()
|
||||
release.wait(5)
|
||||
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
try:
|
||||
assert inside.wait(5)
|
||||
assert dm.width == WIDTH
|
||||
finally:
|
||||
release.set()
|
||||
thread.join(5)
|
||||
assert seen["width"] == 32
|
||||
|
||||
|
||||
class TestNothingReachesThePanel:
|
||||
def test_update_display_clear_and_pacing_are_inert_inside(self, fresh):
|
||||
dm = fresh
|
||||
real_matrix = dm.matrix
|
||||
hold_before = dm._frame_hold
|
||||
with _Spy(real_matrix, "SwapOnVSync") as swaps, \
|
||||
_Spy(dm.offscreen_canvas, "Clear") as clears:
|
||||
with dm.offscreen() as surface:
|
||||
dm.draw.rectangle([0, 0, 5, 5], fill=YELLOW)
|
||||
dm.update_display()
|
||||
drawn = dm.image
|
||||
dm.clear()
|
||||
# clear() replaced the surface's image, not the shared one.
|
||||
assert dm.image is surface.image and dm.image is not drawn
|
||||
assert dm.image.getpixel((0, 0)) == (0, 0, 0)
|
||||
dm.set_scrolling_state(True, 3)
|
||||
dm.set_frame_hold(4)
|
||||
dm.matrix.SwapOnVSync(object()) # inert through the proxy too
|
||||
assert swaps.count == 0
|
||||
assert clears.count == 0
|
||||
assert dm._frame_hold == hold_before
|
||||
assert dm._scrolling_state['is_scrolling'] is False
|
||||
|
||||
def test_capture_mode_inside_offscreen_does_not_end_suppression(self, fresh):
|
||||
dm = fresh
|
||||
real_matrix = dm.matrix
|
||||
with _Spy(real_matrix, "SwapOnVSync") as swaps:
|
||||
with dm.offscreen():
|
||||
with dm.capture_mode():
|
||||
pass
|
||||
dm.draw.point((1, 1), fill=YELLOW)
|
||||
dm.update_display()
|
||||
assert swaps.count == 0
|
||||
|
||||
def test_nesting_and_exceptions_restore_state(self, fresh):
|
||||
dm = fresh
|
||||
shared = dm.image
|
||||
with pytest.raises(RuntimeError):
|
||||
with dm.offscreen(100):
|
||||
with dm.offscreen(50):
|
||||
assert dm.width == 50
|
||||
raise RuntimeError("plugin failed mid-draw")
|
||||
assert dm.image is shared
|
||||
assert dm.width == WIDTH
|
||||
assert not dm._writes_suppressed()
|
||||
|
||||
with dm.offscreen(100) as outer:
|
||||
with dm.offscreen(50):
|
||||
pass
|
||||
assert dm.image is outer.image
|
||||
assert dm.width == 100
|
||||
|
||||
|
||||
# --- Vegas: the adapter draws plugins off the render thread ------------------
|
||||
|
||||
class CapturePlugin:
|
||||
"""A plugin with neither get_vegas_content nor a scroll helper: captured."""
|
||||
|
||||
def __init__(self, display_manager):
|
||||
self.display_manager = display_manager
|
||||
self.calls = 0
|
||||
|
||||
def display(self, force_clear=False):
|
||||
self.calls += 1
|
||||
self.display_manager.draw.rectangle([0, 0, 30, 20], fill=YELLOW)
|
||||
self.display_manager.update_display()
|
||||
|
||||
|
||||
class ScrollingPlugin:
|
||||
"""A ticker whose scroll image only exists once display() has run."""
|
||||
|
||||
def __init__(self, display_manager):
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
self.display_manager = display_manager
|
||||
self.scroll_helper = ScrollHelper(WIDTH, HEIGHT)
|
||||
|
||||
def display(self, force_clear=False):
|
||||
item = Image.new('RGB', (300, HEIGHT), YELLOW)
|
||||
self.scroll_helper.create_scrolling_image([item], item_gap=0, element_gap=0)
|
||||
self.display_manager.set_scrolling_state(True, 5)
|
||||
self.display_manager.update_display()
|
||||
|
||||
|
||||
def _adapter(dm, **config):
|
||||
return PluginAdapter(dm, VegasModeConfig(**config))
|
||||
|
||||
|
||||
def _has_yellow(images):
|
||||
return any(YELLOW in {img.getpixel((x, y)) for x in range(min(img.width, 40))
|
||||
for y in range(img.height)} for img in images)
|
||||
|
||||
|
||||
class TestAdapterOffTheRenderThread:
|
||||
def test_display_capture_runs_on_a_background_thread(self, fresh):
|
||||
dm = fresh
|
||||
shared = dm.image
|
||||
before = shared.tobytes()
|
||||
plugin = CapturePlugin(dm)
|
||||
adapter = _adapter(dm)
|
||||
|
||||
with _Spy(dm.matrix, "SwapOnVSync") as swaps:
|
||||
images = _in_thread(
|
||||
lambda: adapter.get_content(plugin, "capture-bg", offscreen_only=True))
|
||||
|
||||
assert images and _has_yellow(images)
|
||||
assert dm.image is shared and shared.tobytes() == before
|
||||
assert swaps.count == 0
|
||||
|
||||
def test_scroll_content_is_generated_on_a_background_thread(self, fresh):
|
||||
dm = fresh
|
||||
plugin = ScrollingPlugin(dm)
|
||||
adapter = _adapter(dm)
|
||||
hold_before = dm._frame_hold
|
||||
|
||||
images = _in_thread(
|
||||
lambda: adapter.get_content(plugin, "scroll-bg", offscreen_only=True))
|
||||
|
||||
assert images and _has_yellow(images)
|
||||
# The plugin's own set_scrolling_state(True, 5) did not re-pace Vegas.
|
||||
assert dm._frame_hold == hold_before
|
||||
assert dm._scrolling_state['is_scrolling'] is False
|
||||
|
||||
def test_with_the_switch_off_background_capture_is_left_for_the_render_thread(self, fresh):
|
||||
dm = fresh
|
||||
plugin = CapturePlugin(dm)
|
||||
adapter = _adapter(dm, offscreen_prefetch=False)
|
||||
|
||||
images = _in_thread(
|
||||
lambda: adapter.get_content(plugin, "capture-legacy", offscreen_only=True))
|
||||
|
||||
assert images is None
|
||||
assert plugin.calls == 0
|
||||
|
||||
|
||||
class TestPluginLock:
|
||||
def test_a_background_fetch_waits_for_update_then_skips(self, fresh):
|
||||
dm = fresh
|
||||
lock = threading.Lock()
|
||||
plugin = CapturePlugin(dm)
|
||||
adapter = PluginAdapter(dm, VegasModeConfig(),
|
||||
plugin_manager=SimpleNamespace(
|
||||
get_plugin_lock=lambda plugin_id: lock))
|
||||
adapter.PLUGIN_LOCK_TIMEOUT = 0.05
|
||||
|
||||
with lock: # update() in progress
|
||||
skipped = _in_thread(
|
||||
lambda: adapter.get_content(plugin, "locked", offscreen_only=True))
|
||||
assert skipped is None
|
||||
assert plugin.calls == 0
|
||||
|
||||
served = _in_thread(
|
||||
lambda: adapter.get_content(plugin, "locked", offscreen_only=True))
|
||||
assert served and plugin.calls == 1
|
||||
assert not lock.locked()
|
||||
|
||||
|
||||
class TestStreamDoesNotDeferEmptyResults:
|
||||
def _group(self, offscreen_prefetch):
|
||||
config = VegasModeConfig(offscreen_prefetch=offscreen_prefetch)
|
||||
adapter = SimpleNamespace(get_content=lambda *a, **k: None)
|
||||
stream = StreamManager(config, SimpleNamespace(plugins={"p": object()}),
|
||||
adapter)
|
||||
stream.refresh = lambda: None
|
||||
stream._ordered_plugins = ["p"]
|
||||
return stream.take_next_group(count=1, offscreen_only=True)
|
||||
|
||||
def test_nothing_to_show_is_not_sent_to_the_render_thread(self):
|
||||
assert self._group(offscreen_prefetch=True) == [("p", [])]
|
||||
|
||||
def test_the_old_contract_still_defers_with_the_switch_off(self):
|
||||
assert self._group(offscreen_prefetch=False) == [("p", None)]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Vegas scrolls in whole pixels locked to the panel refresh.
|
||||
|
||||
It used to advance by elapsed time, blend neighbouring columns, and pace itself
|
||||
with a sleep to target_fps. On a 512x64 chain refreshing at 95Hz that ran at
|
||||
73-89fps with p99 frames of 20-28ms: the sleep drifted against the refresh and
|
||||
missed a vsync every few frames, and the blend shimmered on the panel.
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.common.scroll_config import solve_crisp # noqa: E402
|
||||
from src.vegas_mode import render_pipeline as rp_module # noqa: E402
|
||||
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline # noqa: E402
|
||||
|
||||
W, H = 128, 32
|
||||
|
||||
|
||||
class FakeStream:
|
||||
def get_grouped_content_for_composition(self):
|
||||
return [('a', [Image.new('RGB', (4000, H), (255, 255, 255))])]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return ['a']
|
||||
|
||||
|
||||
class FakeDM:
|
||||
width = W
|
||||
height = H
|
||||
|
||||
def __init__(self, refresh_hz=100.0, hardware=True):
|
||||
self.refresh_hz = refresh_hz
|
||||
self.matrix = object() if hardware else None
|
||||
self.image = Image.new('RGB', (W, H))
|
||||
self.holds = []
|
||||
|
||||
def set_scrolling_state(self, is_scrolling, frame_hold=1):
|
||||
self.holds.append(frame_hold)
|
||||
|
||||
def update_display(self):
|
||||
pass
|
||||
|
||||
|
||||
def _pipeline(dm=None, **cfg):
|
||||
p = RenderPipeline(VegasModeConfig(lead_in_width=0, **cfg), dm or FakeDM(),
|
||||
FakeStream())
|
||||
assert p.compose_scroll_content()
|
||||
return p
|
||||
|
||||
|
||||
def test_default_steps_whole_pixels_and_holds_frames():
|
||||
dm = FakeDM(refresh_hz=100.0)
|
||||
p = _pipeline(dm, scroll_speed=50)
|
||||
want = solve_crisp(50, 100.0)
|
||||
assert p.scroll_helper.fixed_pixels_per_frame == want.pixels_per_frame
|
||||
assert not p.scroll_helper.sub_pixel_scrolling
|
||||
|
||||
before = p.scroll_helper.scroll_position
|
||||
p.render_frame()
|
||||
assert p.scroll_helper.scroll_position - before == want.pixels_per_frame
|
||||
# The hold is what makes 1px every 2 refreshes 50px/s rather than 100.
|
||||
assert dm.holds[-1] == want.frame_hold == 2
|
||||
assert p.target_fps == want.frames_per_second
|
||||
|
||||
|
||||
def test_sleep_floor_stays_below_the_refresh_period():
|
||||
# A floor at or above the real period accumulates until a frame misses.
|
||||
p = _pipeline(FakeDM(refresh_hz=100.0), scroll_speed=50)
|
||||
assert p.frame_interval < p._frame_hold / 100.0
|
||||
|
||||
|
||||
def test_sub_pixel_blend_keeps_the_old_time_based_blend():
|
||||
dm = FakeDM()
|
||||
p = _pipeline(dm, sub_pixel_blend=True, target_fps=90)
|
||||
assert p.scroll_helper.fixed_pixels_per_frame is None
|
||||
assert p.scroll_helper.sub_pixel_scrolling
|
||||
p.render_frame()
|
||||
assert dm.holds[-1] == 1
|
||||
assert p.frame_interval == 1.0 / 90
|
||||
assert p.target_fps == 90
|
||||
|
||||
|
||||
def _run_swaps(p, period, frames):
|
||||
"""Render `frames` frames whose swaps are `period` seconds apart."""
|
||||
clock = [1000.0]
|
||||
|
||||
def monotonic():
|
||||
return clock[0]
|
||||
|
||||
with patch.object(rp_module.time, 'monotonic', monotonic):
|
||||
for _ in range(frames):
|
||||
p.render_frame()
|
||||
clock[0] += period
|
||||
|
||||
|
||||
def test_a_panel_below_its_cap_is_measured_and_the_speed_re_solved():
|
||||
# 4x128x64 on one chain: capped at 120Hz, really 95Hz. Against the cap
|
||||
# 90px/s solves to 3px every 4 refreshes; against 95Hz, 1px every one.
|
||||
p = _pipeline(FakeDM(refresh_hz=120.0), scroll_speed=90)
|
||||
assert p._crisp.pixels_per_frame == 3
|
||||
|
||||
frames = RenderPipeline.REFRESH_WARMUP_FRAMES + RenderPipeline.REFRESH_SAMPLES + 2
|
||||
_run_swaps(p, p._frame_hold / 95.0, frames)
|
||||
|
||||
assert p._measured_hz == 95.0
|
||||
assert (p._crisp.pixels_per_frame, p._crisp.frame_hold) == (1, 1)
|
||||
# The floor still comes from the cap, not the measurement.
|
||||
assert p.frame_interval < 1 / 95.0
|
||||
|
||||
|
||||
def test_a_panel_that_keeps_up_with_its_cap_is_left_alone():
|
||||
p = _pipeline(FakeDM(refresh_hz=100.0), scroll_speed=50)
|
||||
crisp = p._crisp
|
||||
frames = RenderPipeline.REFRESH_WARMUP_FRAMES + RenderPipeline.REFRESH_SAMPLES + 2
|
||||
_run_swaps(p, p._frame_hold / 99.5, frames)
|
||||
assert p._measured_hz == 100.0
|
||||
assert p._crisp == crisp
|
||||
|
||||
|
||||
def test_no_measurement_without_hardware():
|
||||
# Nothing blocks in the emulator, so swap gaps say nothing about a panel.
|
||||
p = _pipeline(FakeDM(refresh_hz=120.0, hardware=False), scroll_speed=90)
|
||||
frames = RenderPipeline.REFRESH_WARMUP_FRAMES + RenderPipeline.REFRESH_SAMPLES + 2
|
||||
_run_swaps(p, 1 / 50.0, frames)
|
||||
assert p._measured_hz is None
|
||||
@@ -841,6 +841,10 @@ class TestCycleEndsBeforeWrap:
|
||||
return p
|
||||
|
||||
def _advance_to(self, pipeline, distance):
|
||||
# render_frame() steps before it checks, and a whole-pixel pace steps
|
||||
# a fixed amount; start one step short so the checked frame is at
|
||||
# `distance`.
|
||||
distance -= pipeline.scroll_helper.fixed_pixels_per_frame or 0
|
||||
pipeline.scroll_helper.total_distance_scrolled = distance
|
||||
pipeline.scroll_helper.scroll_position = float(distance)
|
||||
|
||||
|
||||
@@ -523,7 +523,7 @@
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_target_fps" data-setting-key="display.vegas_scroll.target_fps">
|
||||
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Frames per second the Vegas ticker aims to render.\nHigher = smoother scrolling but more CPU. Default: 125 (smoothest). Drop to 60/90 if the Pi runs hot.', 'Target FPS') }}</label>
|
||||
<label for="vegas_target_fps" class="block text-sm font-medium text-gray-700">Target FPS{{ ui.help_tip('Only used when Smooth motion is off; with it on, the panel refresh sets the frame rate.\nFrames per second the Vegas ticker aims to render. Default: 125.', 'Target FPS') }}</label>
|
||||
<select id="vegas_target_fps" name="vegas_target_fps" class="form-control">
|
||||
<option value="60" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 60 %}selected{% endif %}>60 FPS (Lower CPU)</option>
|
||||
<option value="90" {% if main_config.display.get('vegas_scroll', {}).get('target_fps', 125) == 90 %}selected{% endif %}>90 FPS (Balanced)</option>
|
||||
@@ -564,7 +564,7 @@
|
||||
name="vegas_smooth_scroll"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('smooth_scroll', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Smooth sub-pixel motion{{ ui.help_tip('Blend between neighbouring pixel positions so the ticker moves once per rendered frame instead of once per pixel. Default: on.\nWithout it, motion happens only as often as the scroll speed in pixels per second — at 50 px/s that is 50 steps a second however fast the display renders, which reads as a slight judder. The trade is that text softens very slightly horizontally, since each frame blends two positions. Turn it off if you prefer maximum crispness.', 'Smooth Scrolling') }}</span>
|
||||
<span class="ml-2 text-sm text-gray-700">Smooth motion{{ ui.help_tip('Move the ticker a whole pixel at a time in step with the panel refresh, so every frame moves the same distance and text stays sharp. Default: on.\nThe scroll speed snaps to the nearest speed the panel can show this way: on a 100Hz panel, 100 px/s is one pixel every refresh and 50 px/s one pixel every second refresh. The real refresh rate is measured once scrolling starts, since a long chain often cannot reach its refresh cap.\nWith this off the ticker moves by elapsed time instead, which drifts against the refresh and judders.', 'Smooth Scrolling') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user