mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-10 17:16:36 +00:00
feat(vegas): render every plugin's ticker content off the render thread
The plugin-facing canvas (DisplayManager.image, draw, matrix) was one shared object, so any plugin whose Vegas content needed it -- display capture, scroll-content generation, narrowed rendering -- was deferred to the render thread and fetched there one at a time. On hdpi that is most plugins, and each fetch stalled the scroll: news ~320ms, hockey ~660ms, in bursts whenever the strip extended. DisplayManager.offscreen() gives the calling thread a canvas of its own. image, draw and matrix are now properties that resolve to the thread's surface while it is inside the block and to the shared canvas otherwise, so the ~100 existing uses become thread-correct unchanged. Inside, update_display(), the hardware half of clear(), and set_scrolling_state()/ set_frame_hold() are inert, so a plugin drawn for Vegas can neither reach the panel nor re-pace the live scroll. render_size() is rebuilt on it. capture_mode() now restores the previous state instead of clearing it, so it cannot end suppression inside an offscreen block. The adapter draws every path on its own canvas (_isolated_canvas) and drops the copy-and-restore of the shared image, which from a background thread would have written a stale frame back over the render loop's. Background fetches take the plugin's update/display lock, waiting up to 2s for a running update() and skipping the plugin that round otherwise; Vegas never took that lock, so render-thread captures already raced update(). A background fetch that comes back empty is no longer queued for the render thread. vegas_scroll.offscreen_prefetch (default true) restores the old deferred path when false. See docs/OFFSCREEN_RENDERING.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+156
-25
@@ -133,6 +133,81 @@ 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.
|
||||
"""
|
||||
|
||||
__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)
|
||||
self.draw.fontmode = "1" # 1-bit text: the panel has no partial brightness, so AA only smears glyphs.
|
||||
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()
|
||||
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()
|
||||
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 +241,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 +259,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
|
||||
@@ -664,11 +747,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):
|
||||
@@ -688,18 +827,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))
|
||||
@@ -710,21 +845,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.
|
||||
@@ -769,6 +891,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
|
||||
@@ -777,9 +905,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:
|
||||
@@ -860,7 +985,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.
|
||||
@@ -1462,6 +1587,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):
|
||||
@@ -1490,6 +1617,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:
|
||||
|
||||
@@ -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,38 @@ 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)
|
||||
self.draw.fontmode = "1" # Match production: 1-bit text, so goldens show what the panel shows.
|
||||
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,14 @@ 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
|
||||
|
||||
# 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
|
||||
@@ -203,6 +211,7 @@ class VegasModeConfig:
|
||||
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),
|
||||
@@ -244,6 +253,7 @@ class VegasModeConfig:
|
||||
'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,
|
||||
|
||||
@@ -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,78 @@ 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.
|
||||
"""
|
||||
get_lock = getattr(self.plugin_manager, 'get_plugin_lock', None)
|
||||
if get_lock is None:
|
||||
yield True
|
||||
return
|
||||
lock = get_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 +220,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 +231,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 +764,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 +793,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 +888,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 +922,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 +1072,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 +1129,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 +1142,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 +1153,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 +1166,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 +1200,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 +1237,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]]:
|
||||
|
||||
@@ -520,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.
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user