mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
feat(vegas): render plugin content off the render thread, and keep it off the GIL when the panel needs it (#630)
DisplayManager.offscreen() gives a thread its own canvas, so Vegas renders every plugin's ticker content on its prefetch thread instead of pausing the scroll for canvas-bound plugins on the render thread. A render gate (src/common/render_gate.py, vegas_scroll.prefetch_gate, on by default with the GIL-releasing binding) lets the prefetch thread run Python only while the render thread waits in SwapOnVSync: on hdpi, frames 2+ refreshes late fell eightfold and late frames overall from 0.90% to 0.60%. See docs/OFFSCREEN_RENDERING.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
"""Let a background thread run Python only while the render thread waits on vsync.
|
||||
|
||||
With plugin rendering moved to Vegas's prefetch thread (DisplayManager.offscreen,
|
||||
#630) the render thread no longer stops for it, but it still shares the GIL
|
||||
with it. The render thread spends most of each refresh inside SwapOnVSync,
|
||||
which releases the GIL, and needs it back the moment the swap returns. If the
|
||||
prefetch thread is running Python right then, the render thread waits: up to
|
||||
the switch interval (5ms) behind bytecode, and for as long as a C call that
|
||||
keeps the GIL takes. On hdpi that showed up as frames 2-5 refreshes late while
|
||||
a group was being prepared.
|
||||
|
||||
The gate turns that around. The display manager opens it just before each swap,
|
||||
with a deadline shortly ahead of the refresh the swap will return on, and
|
||||
closes it when the swap returns. A thread inside ``gate.yielding()`` checks it on
|
||||
every Python and C call through a profile hook, and once the window has closed
|
||||
it parks -- blocked on a condition, GIL released -- until the next swap opens
|
||||
it. The render thread then finds the GIL free when its refresh arrives, and the
|
||||
background work runs in time the render thread was only spending waiting.
|
||||
|
||||
Parking a thread is only safe if nothing the render thread needs is stuck
|
||||
behind it, so it is never parked:
|
||||
|
||||
* while it holds a lock registered with ``guard()`` (the Vegas buffers and
|
||||
caches the render thread also takes);
|
||||
* inside logging, threading, importlib or the cache, all of which take locks the
|
||||
render thread can take too;
|
||||
* when there is no render loop to protect -- no swap for ``STALE_SECONDS``, as
|
||||
on a static screen or a stalled frame.
|
||||
|
||||
And a parked thread is never held more than ``MAX_WAIT_SECONDS`` at a time, so
|
||||
whatever the gate gets wrong costs a frame, not a freeze. The render thread
|
||||
itself is never gated, whatever it calls.
|
||||
|
||||
It gates the prefetch thread only. Gating the ESPN fetch threads as well was
|
||||
tried for the hourly sports refresh, twenty-odd of them at once, and measured
|
||||
worse on hdpi (0.85% late frames without it, 1.14% with it, across a burst every
|
||||
five minutes): each parked thread has to take the GIL again just to park at the
|
||||
end of every window, and the fetches ran two to three times as long.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any, Callable, Deque, List, Optional
|
||||
|
||||
#: Park background threads this long before the refresh a swap will return on,
|
||||
#: so a short C call already under way has finished by then.
|
||||
MARGIN_SECONDS = 0.002
|
||||
|
||||
#: The longest a background thread is parked in one go.
|
||||
MAX_WAIT_SECONDS = 0.05
|
||||
|
||||
#: No swap for this long means there is no render loop running to protect.
|
||||
STALE_SECONDS = 0.05
|
||||
|
||||
#: Swaps needed before the refresh period is trusted enough to open a window.
|
||||
MIN_SAMPLES = 8
|
||||
|
||||
#: Parking inside any of these modules could hold a lock the render thread
|
||||
#: takes: logging handler locks, Condition and Event internals, the module
|
||||
#: import locks, and the disk and memory cache locks. Matched by module name,
|
||||
#: not file path: a path can say "cache" or "logging" for reasons of its own --
|
||||
#: a virtualenv under ~/.cache, or GitHub's /opt/hostedtoolcache, where every
|
||||
#: stdlib frame would otherwise count and the gate would never park anything.
|
||||
_UNSAFE_MODULES = frozenset({
|
||||
"logging", "threading", "importlib", "src.cache_manager", "src.cache",
|
||||
})
|
||||
_UNSAFE_PREFIXES = ("logging.", "importlib.", "_frozen_importlib", "src.cache.")
|
||||
|
||||
|
||||
def _unsafe(frame: Any, base: Any) -> bool:
|
||||
"""True if a frame above ``base`` comes from somewhere parking could deadlock.
|
||||
|
||||
``base`` is the frame that entered ``yielding()``; what lies below it (the
|
||||
thread's own bootstrap in threading.py) holds nothing.
|
||||
"""
|
||||
while frame is not None and frame is not base:
|
||||
name = frame.f_globals.get("__name__") or ""
|
||||
if name in _UNSAFE_MODULES or name.startswith(_UNSAFE_PREFIXES):
|
||||
return True
|
||||
frame = frame.f_back
|
||||
return False
|
||||
|
||||
|
||||
def swap_releases_gil() -> Optional[bool]:
|
||||
"""Whether the loaded rgbmatrix binding releases the GIL, or None if none is loaded.
|
||||
|
||||
The rebuilt binding links PyEval_SaveThread and the stock one never does.
|
||||
The same test as src.common.frame_timing.binding_releases_gil (#629); one
|
||||
of the two goes once both have landed.
|
||||
"""
|
||||
module = sys.modules.get("rgbmatrix.core")
|
||||
path = getattr(module, "__file__", None)
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
return b"PyEval_SaveThread" in handle.read()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _held(lock: Any) -> bool:
|
||||
"""Is ``lock`` held? RLocks report this thread's ownership; plain locks, anyone's."""
|
||||
is_owned = getattr(lock, "_is_owned", None)
|
||||
if is_owned is not None:
|
||||
return is_owned()
|
||||
return lock.locked()
|
||||
|
||||
|
||||
class RenderGate:
|
||||
"""Opened by the render thread around each swap; honoured by background threads."""
|
||||
|
||||
def __init__(self, clock: Callable[[], float] = time.monotonic):
|
||||
self.clock = clock
|
||||
self._cond = threading.Condition()
|
||||
self._generation = 0
|
||||
self._open_until = 0.0
|
||||
self._last_return: Optional[float] = None
|
||||
self._periods: Deque[float] = deque(maxlen=64)
|
||||
self._period: Optional[float] = None
|
||||
self._guarded: List[Any] = []
|
||||
self._local = threading.local()
|
||||
self._render_ident: Optional[int] = None
|
||||
#: How often, and for how long in all, background threads were parked.
|
||||
self.parks = 0
|
||||
self.parked_seconds = 0.0
|
||||
|
||||
def guard(self, *locks: Any) -> None:
|
||||
"""Never park a thread while it holds (or, for a plain Lock, anyone holds) these."""
|
||||
self._guarded.extend(lock for lock in locks if lock is not None)
|
||||
|
||||
# -- render thread -----------------------------------------------------
|
||||
|
||||
def refresh_period(self) -> Optional[float]:
|
||||
"""The panel's refresh period from recent swaps, or None until known.
|
||||
|
||||
The 10th percentile of the gaps between swap returns, each divided by
|
||||
the hold: a late frame only ever lengthens a gap, so the low end is
|
||||
the panel's own period.
|
||||
"""
|
||||
return self._period
|
||||
|
||||
def before_swap(self, hold: int) -> None:
|
||||
"""The render thread is about to block in SwapOnVSync: open the window."""
|
||||
hold = max(1, int(hold))
|
||||
period = self._period
|
||||
now = self.clock()
|
||||
last = self._last_return
|
||||
if period and last is not None and now - last < STALE_SECONDS:
|
||||
# The swap returns on the first refresh boundary after both the
|
||||
# current frame's hold is up and this frame has been handed over;
|
||||
# boundaries fall a whole period apart from the last return.
|
||||
refreshes = max(hold, math.ceil((now - last) / period))
|
||||
open_until = last + refreshes * period - MARGIN_SECONDS
|
||||
else:
|
||||
open_until = 0.0 # no rhythm to predict from: leave threads be
|
||||
with self._cond:
|
||||
self._open_until = open_until
|
||||
self._generation += 1
|
||||
self._cond.notify_all()
|
||||
|
||||
def after_swap(self, hold: int) -> None:
|
||||
"""The swap returned and the render thread needs the GIL: close the window."""
|
||||
now = self.clock()
|
||||
self._open_until = 0.0
|
||||
if self._render_ident is None:
|
||||
# The first thread to swap is the render loop. A plugin pushing a
|
||||
# live refresh from its update thread swaps too, but must not take
|
||||
# over its exemption.
|
||||
self._render_ident = threading.get_ident()
|
||||
last = self._last_return
|
||||
if last is not None and now - last < STALE_SECONDS:
|
||||
self._periods.append((now - last) / max(1, int(hold)))
|
||||
if len(self._periods) >= MIN_SAMPLES:
|
||||
ordered = sorted(self._periods)
|
||||
self._period = ordered[len(ordered) // 10]
|
||||
self._last_return = now
|
||||
|
||||
# -- background threads ------------------------------------------------
|
||||
|
||||
def _should_park(self, frame: Any, now: float) -> bool:
|
||||
if now < self._open_until:
|
||||
return False # inside the window
|
||||
last = self._last_return
|
||||
if last is None or now - last > STALE_SECONDS or self._period is None:
|
||||
return False # no render loop to protect
|
||||
for lock in self._guarded:
|
||||
if _held(lock):
|
||||
return False
|
||||
return not _unsafe(frame, getattr(self._local, "base", None))
|
||||
|
||||
def _hook(self, frame: Any, _event: str, _arg: Any) -> None:
|
||||
now = self.clock()
|
||||
if not self._should_park(frame, now):
|
||||
return
|
||||
generation = self._generation
|
||||
with self._cond:
|
||||
self._cond.wait_for(lambda: self._generation != generation,
|
||||
timeout=MAX_WAIT_SECONDS)
|
||||
self.parks += 1
|
||||
self.parked_seconds += self.clock() - now
|
||||
|
||||
def yielding(self) -> "_Yielding":
|
||||
"""``with gate.yielding():`` runs the block giving way to the render thread."""
|
||||
return _Yielding(self)
|
||||
|
||||
|
||||
class _Yielding:
|
||||
"""Installs a gate's profile hook on the thread for the length of a block."""
|
||||
|
||||
def __init__(self, gate: RenderGate):
|
||||
self.gate = gate
|
||||
self._previous: Any = None
|
||||
self._previous_base: Any = None
|
||||
self._skipped = False
|
||||
|
||||
def __enter__(self) -> RenderGate:
|
||||
gate = self.gate
|
||||
# pylint: disable=protected-access
|
||||
if threading.get_ident() == gate._render_ident:
|
||||
self._skipped = True # parking the render thread parks the display
|
||||
return gate
|
||||
local = gate._local
|
||||
self._previous_base = getattr(local, "base", None)
|
||||
if self._previous_base is None:
|
||||
# Nested blocks keep the outermost frame, so everything the thread
|
||||
# entered since it first gave way is still checked for locks.
|
||||
local.base = sys._getframe(1)
|
||||
self._previous = sys.getprofile()
|
||||
sys.setprofile(gate._hook)
|
||||
return gate
|
||||
|
||||
def __exit__(self, *_exc: Any) -> None:
|
||||
if self._skipped:
|
||||
return
|
||||
sys.setprofile(self._previous)
|
||||
self.gate._local.base = self._previous_base # pylint: disable=protected-access
|
||||
|
||||
+173
-24
@@ -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.")
|
||||
|
||||
|
||||
|
||||
class DisplayManager:
|
||||
"""
|
||||
@@ -160,6 +238,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 {}
|
||||
@@ -173,6 +256,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
|
||||
@@ -238,6 +324,11 @@ class DisplayManager:
|
||||
# See src/common/scroll_config.py and scripts/scroll_speeds.py.
|
||||
self._frame_hold = 1
|
||||
|
||||
# A src.common.render_gate.RenderGate while Vegas runs with
|
||||
# vegas_scroll.prefetch_gate on: opened around each swap so the
|
||||
# prefetch thread only runs Python while this thread waits on vsync.
|
||||
self.render_gate = None
|
||||
|
||||
# Timing of every presented frame, whoever drew it, for
|
||||
# scripts/frame_soak.py. See src/common/frame_timing.py.
|
||||
self.frame_timing = FrameTimingRecorder(info=self._frame_timing_info())
|
||||
@@ -644,7 +735,10 @@ class DisplayManager:
|
||||
@property
|
||||
def _capture_mode_active(self) -> bool:
|
||||
"""True while the calling thread is capturing content off-screen."""
|
||||
return getattr(self._capture_state, 'active', False)
|
||||
# Read like _current_surface(): a DisplayManager built without
|
||||
# __init__ (tests do) has no per-thread state, and captures nothing.
|
||||
state = self.__dict__.get('_capture_state')
|
||||
return getattr(state, 'active', False) if state is not None else False
|
||||
|
||||
@_capture_mode_active.setter
|
||||
def _capture_mode_active(self, value: bool) -> None:
|
||||
@@ -660,11 +754,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 +834,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,19 +852,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._new_canvas(target_w, target_h)
|
||||
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.
|
||||
@@ -763,6 +898,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
|
||||
@@ -771,9 +912,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:
|
||||
@@ -816,7 +954,12 @@ class DisplayManager:
|
||||
# Swap buffers immediately. framerate_fraction holds the frame
|
||||
# for N refreshes; SwapOnVSync blocks for all of them, which is
|
||||
# what paces the render loop to the chosen frame rate.
|
||||
gate = self.render_gate
|
||||
if gate is not None:
|
||||
gate.before_swap(self._frame_hold)
|
||||
self.matrix.SwapOnVSync(self.offscreen_canvas, self._frame_hold)
|
||||
if gate is not None:
|
||||
gate.after_swap(self._frame_hold)
|
||||
presented_at = time.perf_counter()
|
||||
self.frame_timing.record(
|
||||
blit_done - blit_started, presented_at - blit_done,
|
||||
@@ -888,7 +1031,7 @@ class DisplayManager:
|
||||
|
||||
self._new_canvas(self.matrix.width, self.matrix.height)
|
||||
|
||||
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.
|
||||
@@ -1480,6 +1623,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):
|
||||
@@ -1508,6 +1653,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()
|
||||
# Scrolling callers set this every frame; log transitions only.
|
||||
changed = self._scrolling_state['is_scrolling'] != is_scrolling
|
||||
|
||||
@@ -18,7 +18,7 @@ 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.
|
||||
|
||||
@@ -243,11 +243,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,
|
||||
|
||||
@@ -74,6 +74,31 @@ class VegasModeConfig:
|
||||
# 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
|
||||
|
||||
# How long another thread may hold the GIL before the render thread's
|
||||
# request forces it to yield, in ms, while Vegas runs. CPython's default is
|
||||
# 5ms. Plugin rendering on the prefetch thread and plugin updates hold the
|
||||
# GIL in Pillow and Python code, and a frame waiting its turn for 5ms at a
|
||||
# time misses its refresh. 0 leaves the interpreter default alone.
|
||||
# Experimental. On hdpi it did less than prefetch_gate (0.90% -> 0.78% late
|
||||
# against 0.60%; see docs/OFFSCREEN_RENDERING.md), so it stays off.
|
||||
switch_interval_ms: float = 0.0
|
||||
|
||||
# Let the prefetch thread run Python only while the render thread is
|
||||
# blocked waiting for vsync, and park it the rest of the time, so the
|
||||
# render thread never waits for the GIL when its refresh comes round. Needs
|
||||
# a binding that releases the GIL in SwapOnVSync; off otherwise. On hdpi
|
||||
# it cut frames two or more refreshes late eightfold, and late frames
|
||||
# overall from 0.90% to 0.60%. See src/common/render_gate.py.
|
||||
prefetch_gate: 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
|
||||
@@ -207,6 +232,9 @@ class VegasModeConfig:
|
||||
smooth_scroll=get('smooth_scroll', d.smooth_scroll),
|
||||
sub_pixel_blend=bool(get('sub_pixel_blend', d.sub_pixel_blend)),
|
||||
continuous_scroll=get('continuous_scroll', d.continuous_scroll),
|
||||
offscreen_prefetch=bool(get('offscreen_prefetch', d.offscreen_prefetch)),
|
||||
switch_interval_ms=float(get('switch_interval_ms', d.switch_interval_ms) or 0.0),
|
||||
prefetch_gate=bool(get('prefetch_gate', d.prefetch_gate)),
|
||||
extend_threshold_screens=float(
|
||||
get('extend_threshold_screens', d.extend_threshold_screens)),
|
||||
auto_trim=get('auto_trim', d.auto_trim),
|
||||
@@ -250,6 +278,9 @@ class VegasModeConfig:
|
||||
'smooth_scroll': self.smooth_scroll,
|
||||
'sub_pixel_blend': self.sub_pixel_blend,
|
||||
'continuous_scroll': self.continuous_scroll,
|
||||
'offscreen_prefetch': self.offscreen_prefetch,
|
||||
'switch_interval_ms': self.switch_interval_ms,
|
||||
'prefetch_gate': self.prefetch_gate,
|
||||
'extend_threshold_screens': self.extend_threshold_screens,
|
||||
'auto_trim': self.auto_trim,
|
||||
'trim_threshold': self.trim_threshold,
|
||||
|
||||
@@ -13,10 +13,12 @@ Supports three display modes per plugin:
|
||||
|
||||
import logging
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
from typing import Optional, Dict, Any, List, Callable, TYPE_CHECKING
|
||||
|
||||
from src.common import render_gate
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
@@ -101,7 +103,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,
|
||||
@@ -276,6 +279,8 @@ class VegasModeCoordinator:
|
||||
# due immediately so the first sample confirms the marquee is up.
|
||||
self._fps_last_health_log = 0.0
|
||||
self._fps_was_degraded = False
|
||||
self._apply_switch_interval()
|
||||
self._install_render_gate()
|
||||
|
||||
# Line up the next group immediately, so the first extension is already
|
||||
# warm rather than stalling the scroll to fetch it.
|
||||
@@ -298,6 +303,9 @@ class VegasModeCoordinator:
|
||||
self.stats['total_runtime_seconds'] += time.time() - self._start_time
|
||||
self._start_time = None
|
||||
|
||||
self._restore_switch_interval()
|
||||
self._remove_render_gate()
|
||||
|
||||
# Cleanup components
|
||||
self.render_pipeline.reset()
|
||||
self.stream_manager.reset()
|
||||
@@ -305,6 +313,57 @@ class VegasModeCoordinator:
|
||||
|
||||
logger.info("Vegas mode stopped")
|
||||
|
||||
def _apply_switch_interval(self) -> None:
|
||||
"""Shorten the GIL switch interval for the run; see VegasModeConfig."""
|
||||
ms = self.vegas_config.switch_interval_ms
|
||||
if not ms or ms <= 0:
|
||||
return
|
||||
if getattr(self, '_saved_switch_interval', None) is None:
|
||||
self._saved_switch_interval = sys.getswitchinterval()
|
||||
sys.setswitchinterval(ms / 1000.0)
|
||||
logger.info("Vegas: GIL switch interval %.1fms (was %.1fms)",
|
||||
ms, self._saved_switch_interval * 1000.0)
|
||||
|
||||
def _restore_switch_interval(self) -> None:
|
||||
saved = getattr(self, '_saved_switch_interval', None)
|
||||
if saved is not None:
|
||||
sys.setswitchinterval(saved)
|
||||
self._saved_switch_interval = None
|
||||
|
||||
def _install_render_gate(self) -> None:
|
||||
"""Gate the prefetch thread on the render thread's swaps; see VegasModeConfig."""
|
||||
if not self.vegas_config.prefetch_gate:
|
||||
return
|
||||
if getattr(self.display_manager, 'render_gate', None) is not None:
|
||||
return
|
||||
releases = render_gate.swap_releases_gil()
|
||||
if releases is None:
|
||||
logger.debug("Vegas: no prefetch gate -- no hardware binding loaded")
|
||||
return
|
||||
if not releases:
|
||||
# On by default, so this is every stock install: say so once per
|
||||
# run, not as a warning.
|
||||
logger.info("Vegas: no prefetch gate -- this rgbmatrix binding keeps "
|
||||
"the GIL in SwapOnVSync (scripts/build_rgbmatrix_nogil.sh)")
|
||||
return
|
||||
gate = render_gate.RenderGate()
|
||||
# Locks the render thread takes too: never park the prefetch holding one.
|
||||
gate.guard(self._state_lock,
|
||||
getattr(self.stream_manager, '_buffer_lock', None),
|
||||
getattr(self.render_pipeline, '_buffer_lock', None),
|
||||
getattr(self.render_pipeline, '_prefetch_lock', None),
|
||||
getattr(self.plugin_adapter, '_cache_lock', None))
|
||||
self.display_manager.render_gate = gate
|
||||
logger.info("Vegas: prefetch gated on vsync")
|
||||
|
||||
def _remove_render_gate(self) -> None:
|
||||
gate = getattr(self.display_manager, 'render_gate', None)
|
||||
if gate is None:
|
||||
return
|
||||
self.display_manager.render_gate = None
|
||||
logger.info("Vegas: prefetch gate parked the prefetch %d times, %.1fs in all",
|
||||
gate.parks, gate.parked_seconds)
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pause Vegas mode (for live priority interruption)."""
|
||||
with self._state_lock:
|
||||
@@ -524,6 +583,10 @@ class VegasModeCoordinator:
|
||||
fps, target, fps_frame_count,
|
||||
p99 * 1000.0, frame_worst * 1000.0
|
||||
)
|
||||
gate = getattr(self.display_manager, 'render_gate', None)
|
||||
if gate is not None:
|
||||
logger.info("Vegas: prefetch parked %d times, %.1fs in all",
|
||||
gate.parks, gate.parked_seconds)
|
||||
self._fps_last_health_log = current_time
|
||||
else:
|
||||
logger.debug(
|
||||
|
||||
@@ -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()
|
||||
@@ -91,13 +102,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
|
||||
@@ -117,11 +129,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(
|
||||
@@ -134,7 +212,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(
|
||||
@@ -145,8 +223,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
|
||||
@@ -682,7 +760,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.
|
||||
@@ -711,22 +789,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
|
||||
@@ -807,7 +884,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.
|
||||
@@ -841,7 +918,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 "
|
||||
@@ -991,12 +1068,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(
|
||||
@@ -1052,11 +1125,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]]:
|
||||
@@ -1070,12 +1138,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)
|
||||
@@ -1086,12 +1149,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(
|
||||
@@ -1099,7 +1162,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)
|
||||
|
||||
@@ -1133,7 +1196,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()
|
||||
@@ -1170,12 +1233,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]]:
|
||||
|
||||
@@ -10,6 +10,7 @@ import os
|
||||
import time
|
||||
import threading
|
||||
from collections import deque
|
||||
from contextlib import nullcontext
|
||||
from typing import Optional, List, Any, Dict, Deque
|
||||
from PIL import Image
|
||||
|
||||
@@ -383,8 +384,12 @@ class RenderPipeline:
|
||||
os.nice(10)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
# With vegas_scroll.prefetch_gate on, run only while the render
|
||||
# thread waits on vsync; see src/common/render_gate.py.
|
||||
gate = getattr(self.display_manager, 'render_gate', None)
|
||||
try:
|
||||
group = self.stream_manager.take_next_group(offscreen_only=True)
|
||||
with gate.yielding() if gate is not None else nullcontext():
|
||||
group = self.stream_manager.take_next_group(offscreen_only=True)
|
||||
except Exception:
|
||||
logger.exception("Background prefetch failed")
|
||||
group = []
|
||||
@@ -509,9 +514,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.
|
||||
logger.info("Whole group deferred; strip will extend as it drains")
|
||||
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)
|
||||
|
||||
|
||||
@@ -688,6 +688,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
|
||||
@@ -705,6 +709,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)
|
||||
@@ -719,7 +726,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user