mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +00:00
fix(vegas): pause for STATIC plugins where their turn falls in the strip (#651)
The static trigger peeked at the front of StreamManager's segment buffer, which continuous scrolling (the default) never advances -- it extends the strip with take_next_group() -- so the same first segment was examined on every frame. A STATIC plugin paused the scroll only if it was first, once, at startup; otherwise it scrolled past as ordinary content. Swap mode had the same problem for any STATIC plugin not first in its cycle. The render pipeline now records a marker (strip column, plugin id) for each STATIC plugin where the strip is built -- composition and every extension -- shifts the markers when the scrolled prefix is trimmed, and clears them on reset. The coordinator pauses when the scroll reaches the next marker: a tuple comparison per frame instead of a lock, a plugin lookup and a get_vegas_display_mode() call. take_next_group() no longer renders STATIC plugins' content. The pause calls display() under the plugin lock and is timed with the monotonic clock. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,8 @@ accepts both, but the store flags the old spelling as deprecated
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Vegas: a plugin set to `vegas_mode: "static"` pauses the scroll for its turn again. The pause was triggered by peeking at the front of a segment buffer that continuous scrolling (the default) never advances, so a static plugin paused only if it happened to be first, once, at startup, and otherwise scrolled past as ordinary content; swap mode had the same problem for any static plugin not first in its cycle. The render pipeline now marks where each static plugin's turn falls in the strip and the scroll pauses when it gets there. The pause runs the plugin's `display()` under its plugin lock, and a static plugin's content is no longer rendered for the strip.
|
||||
|
||||
- The display loop no longer spins at 100% CPU when no enabled mode has anything to show (for example, only a sports plugin enabled in its off-season). After one full rotation of empty modes it checks one mode per second until something shows; live content still takes over at once.
|
||||
|
||||
- Contributor tooling and docs:
|
||||
|
||||
@@ -23,7 +23,6 @@ from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.plugin_adapter import PluginAdapter
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline
|
||||
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.plugin_system.plugin_manager import PluginManager
|
||||
@@ -78,6 +77,10 @@ class VegasModeCoordinator:
|
||||
- Provide status and control interface
|
||||
"""
|
||||
|
||||
#: How long a STATIC pause waits for the plugin's lock (held while its
|
||||
#: update() runs) before skipping that turn.
|
||||
STATIC_LOCK_TIMEOUT = 1.0
|
||||
|
||||
# Class-level so coordinators built without __init__ (tests) have it.
|
||||
_last_live_check: float = float('-inf')
|
||||
|
||||
@@ -542,8 +545,7 @@ class VegasModeCoordinator:
|
||||
if not self._handle_static_pause(static_plugin):
|
||||
# Static pause was interrupted
|
||||
return False
|
||||
# After static pause, skip this segment and continue
|
||||
self.stream_manager.get_next_segment() # Consume the segment
|
||||
# The trigger consumed the plugin's marker; carry on scrolling.
|
||||
continue
|
||||
|
||||
# Run frame
|
||||
@@ -822,32 +824,30 @@ class VegasModeCoordinator:
|
||||
"""
|
||||
Check if a STATIC mode plugin should take over display.
|
||||
|
||||
Called during iteration to detect when scroll should pause
|
||||
for a static plugin display.
|
||||
Called every frame. The render pipeline marks where each STATIC
|
||||
plugin's turn falls in the strip, and this reports the one the scroll
|
||||
has just reached.
|
||||
|
||||
This used to peek at the front of the stream manager's segment
|
||||
buffer, which continuous scrolling (the default) never advances: it
|
||||
extends the strip with take_next_group() instead. The same first
|
||||
segment was examined on every frame, so a STATIC plugin paused the
|
||||
scroll only if it happened to be first, once, at startup -- and
|
||||
otherwise just scrolled past as ordinary content. Swap mode fared no
|
||||
better: nothing advanced the buffer mid-cycle either.
|
||||
|
||||
Returns:
|
||||
Plugin instance if static pause should begin, None otherwise
|
||||
"""
|
||||
# Get the next plugin that would be displayed
|
||||
next_segment = self.stream_manager.peek_next_segment()
|
||||
if not next_segment:
|
||||
plugin_id = self.render_pipeline.next_static_trigger()
|
||||
if not plugin_id:
|
||||
return None
|
||||
|
||||
plugin_id = next_segment.plugin_id
|
||||
plugin = self.plugin_manager.get_plugin(plugin_id)
|
||||
|
||||
if not plugin:
|
||||
logger.debug("[%s] STATIC turn reached, but the plugin is no longer loaded",
|
||||
plugin_id)
|
||||
return None
|
||||
|
||||
# Check if this plugin is configured for STATIC mode
|
||||
try:
|
||||
display_mode = plugin.get_vegas_display_mode()
|
||||
if display_mode == VegasDisplayMode.STATIC:
|
||||
return plugin
|
||||
except (AttributeError, TypeError):
|
||||
logger.exception("Error checking vegas mode for %s", plugin_id)
|
||||
|
||||
return None
|
||||
|
||||
def _handle_static_pause(self, plugin: 'BasePlugin') -> bool:
|
||||
"""
|
||||
@@ -877,15 +877,32 @@ class VegasModeCoordinator:
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
|
||||
try:
|
||||
# Display the plugin using its standard display() method
|
||||
# Display the plugin using its standard display() method, under
|
||||
# its plugin lock like every other display() call: without it this
|
||||
# could draw while the update worker is inside the plugin's
|
||||
# update(). If update() holds the lock past the wait, skip this
|
||||
# turn rather than stall the marquee.
|
||||
get_lock = getattr(self.plugin_manager, 'get_plugin_lock', None)
|
||||
plugin_lock = get_lock(plugin_id) if get_lock else None
|
||||
if plugin_lock is not None and not plugin_lock.acquire(
|
||||
timeout=self.STATIC_LOCK_TIMEOUT):
|
||||
logger.info("Static pause skipped for %s: its update() is still running",
|
||||
plugin_id)
|
||||
return True
|
||||
try:
|
||||
plugin.display(force_clear=True)
|
||||
finally:
|
||||
if plugin_lock is not None:
|
||||
plugin_lock.release()
|
||||
self.display_manager.update_display()
|
||||
|
||||
# Wait for the plugin's display duration
|
||||
# Wait for the plugin's display duration. Monotonic, like the
|
||||
# iteration clock: an NTP step on an RTC-less Pi would otherwise
|
||||
# end the pause at once or stretch it by the correction.
|
||||
duration = plugin.get_display_duration()
|
||||
start = time.time()
|
||||
start = time.monotonic()
|
||||
|
||||
while time.time() - start < duration:
|
||||
while time.monotonic() - start < duration:
|
||||
# Check for interruptions
|
||||
if self._should_stop:
|
||||
logger.info("Static pause interrupted by stop request")
|
||||
@@ -905,7 +922,7 @@ class VegasModeCoordinator:
|
||||
|
||||
logger.info(
|
||||
"Static pause completed for %s after %.1fs",
|
||||
plugin_id, time.time() - start
|
||||
plugin_id, time.monotonic() - start
|
||||
)
|
||||
|
||||
except Exception:
|
||||
|
||||
@@ -11,7 +11,7 @@ import time
|
||||
import threading
|
||||
from collections import deque
|
||||
from contextlib import nullcontext
|
||||
from typing import Optional, List, Any, Dict, Deque
|
||||
from typing import Optional, List, Any, Dict, Deque, Tuple
|
||||
from PIL import Image
|
||||
|
||||
from src.common.scroll_config import solve_crisp
|
||||
@@ -58,6 +58,14 @@ class RenderPipeline:
|
||||
# pipelines built without __init__ (tests) still have it.
|
||||
_prefetch_generation = 0
|
||||
|
||||
# Where each STATIC plugin's turn falls in the strip, as (x, plugin_id)
|
||||
# in ascending strip columns: x is the end of the content before it. When
|
||||
# that column reaches the right edge of the viewport, the coordinator
|
||||
# pauses the scroll for the plugin (next_static_trigger). Always replaced,
|
||||
# never mutated, so the class-level default is safe for pipelines built
|
||||
# without __init__ (tests).
|
||||
_static_markers: Tuple[Tuple[int, str], ...] = ()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: VegasModeConfig,
|
||||
@@ -282,6 +290,7 @@ class RenderPipeline:
|
||||
# Content grouped by plugin, so a separator can be placed at the
|
||||
# plugin boundaries only.
|
||||
grouped = self.stream_manager.get_grouped_content_for_composition()
|
||||
self._static_markers = ()
|
||||
|
||||
if not grouped:
|
||||
logger.warning("No content available for composition")
|
||||
@@ -317,6 +326,8 @@ class RenderPipeline:
|
||||
logger.error("ScrollHelper failed to create cached image")
|
||||
return False
|
||||
|
||||
self._static_markers = self._markers_for_composition(blocks)
|
||||
|
||||
# Track which plugins are in this scroll (get safely via buffer status)
|
||||
self._segments_in_scroll = self.stream_manager.get_active_plugin_ids()
|
||||
|
||||
@@ -344,6 +355,56 @@ class RenderPipeline:
|
||||
logger.exception("Error composing scroll content")
|
||||
return False
|
||||
|
||||
def _markers_for_composition(self, blocks: List[Image.Image]) -> Tuple[Tuple[int, str], ...]:
|
||||
"""Static markers for a strip just built by create_scrolling_image.
|
||||
|
||||
Mirrors its layout: lead_in_width, then each block followed by
|
||||
separator_width except the last. A STATIC plugin's marker is the end
|
||||
of whatever content precedes it (the lead-in, if nothing does).
|
||||
"""
|
||||
layout_fn = getattr(self.stream_manager, 'get_static_layout', None)
|
||||
if layout_fn is None:
|
||||
return ()
|
||||
layout = layout_fn()
|
||||
if not any(is_static for _pid, is_static in layout):
|
||||
return ()
|
||||
markers = []
|
||||
end = max(0, int(self.config.lead_in_width))
|
||||
x = end
|
||||
block_index = 0
|
||||
for plugin_id, is_static in layout:
|
||||
if is_static:
|
||||
markers.append((end, plugin_id))
|
||||
continue
|
||||
if block_index >= len(blocks):
|
||||
break
|
||||
end = x + blocks[block_index].width
|
||||
x = end + self.config.separator_width
|
||||
block_index += 1
|
||||
return tuple(markers)
|
||||
|
||||
def _add_static_markers(self, markers: List[Tuple[int, str]]) -> None:
|
||||
if markers:
|
||||
self._static_markers = tuple(
|
||||
sorted(self._static_markers + tuple(markers), key=lambda m: m[0]))
|
||||
|
||||
def next_static_trigger(self) -> Optional[str]:
|
||||
"""
|
||||
The STATIC plugin whose turn the scroll has just reached, if any.
|
||||
|
||||
Called every frame by the coordinator, so it is a comparison against
|
||||
the first marker and nothing more. The marker is consumed: a plugin
|
||||
pauses the scroll once per place it holds in the strip.
|
||||
"""
|
||||
markers = self._static_markers
|
||||
if not markers:
|
||||
return None
|
||||
x, plugin_id = markers[0]
|
||||
if x > self.scroll_helper.scroll_position + self.display_width:
|
||||
return None
|
||||
self._static_markers = markers[1:]
|
||||
return plugin_id
|
||||
|
||||
def needs_extension(self) -> bool:
|
||||
"""
|
||||
Whether the strip should be extended with the next group of plugins.
|
||||
@@ -507,6 +568,21 @@ class RenderPipeline:
|
||||
logger.warning("No content available to extend the scroll strip")
|
||||
return False
|
||||
|
||||
# STATIC plugins pause the scroll instead of joining the strip.
|
||||
# Note each one's place -- how many of this group's blocks come
|
||||
# before it -- and take it out before anything else sees it.
|
||||
is_static = getattr(self.stream_manager, 'is_static_plugin', None)
|
||||
statics: List[Tuple[int, str]] = []
|
||||
content: List[Tuple[str, Optional[List[Image.Image]]]] = []
|
||||
for pid, images in grouped:
|
||||
if is_static is not None and is_static(pid):
|
||||
statics.append((sum(1 for _p, imgs in content if imgs), pid))
|
||||
else:
|
||||
content.append((pid, images))
|
||||
grouped = content
|
||||
strip_end = (self.scroll_helper.cached_image.width
|
||||
if self.scroll_helper.cached_image is not None else 0)
|
||||
|
||||
# Plugins the background thread had to defer need the shared canvas,
|
||||
# so they can only be fetched here. Queue them rather than doing all
|
||||
# of them now: measured, six in one go held the render thread for
|
||||
@@ -523,6 +599,9 @@ class RenderPipeline:
|
||||
grouped = [(pid, imgs) for pid, imgs in grouped if imgs]
|
||||
|
||||
if not grouped:
|
||||
# Nothing is appended, so each STATIC turn falls at the end
|
||||
# of the strip as it stands.
|
||||
self._add_static_markers([(strip_end, pid) for _n, pid in statics])
|
||||
if deferred:
|
||||
# Everything in this group is queued; the queue will extend
|
||||
# the strip as it drains, so this is not a failure.
|
||||
@@ -538,6 +617,7 @@ class RenderPipeline:
|
||||
total_rows += len(images)
|
||||
blocks.append(self._join_plugin_rows(images))
|
||||
|
||||
had_strip = self.scroll_helper.cached_image is not None
|
||||
appended = self.scroll_helper.append_content(
|
||||
content_items=blocks,
|
||||
item_gap=self.config.separator_width,
|
||||
@@ -546,8 +626,25 @@ class RenderPipeline:
|
||||
if not appended:
|
||||
return False
|
||||
|
||||
if statics:
|
||||
# Where each block ends, laid out as append_content does: a
|
||||
# separator before every block, or -- when there was no strip
|
||||
# to extend -- as create_scrolling_image does with no lead-in.
|
||||
gap = max(0, self.config.separator_width)
|
||||
ends = []
|
||||
x = strip_end if had_strip else -gap
|
||||
for block in blocks:
|
||||
x += gap + block.width
|
||||
ends.append(x)
|
||||
self._add_static_markers([
|
||||
(ends[n - 1] if n > 0 else strip_end, pid) for n, pid in statics
|
||||
])
|
||||
|
||||
# Keep a screen's worth behind the viewport as a safety margin.
|
||||
self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
|
||||
cut = self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
|
||||
if cut and self._static_markers:
|
||||
self._static_markers = tuple(
|
||||
(max(0, x - cut), pid) for x, pid in self._static_markers)
|
||||
|
||||
self._segments_in_scroll = [pid for pid, _ in grouped]
|
||||
self.stats['composition_count'] += 1
|
||||
@@ -935,6 +1032,7 @@ class RenderPipeline:
|
||||
self._prefetch_generation += 1
|
||||
self._prepared_group = None
|
||||
self._deferred_queue = []
|
||||
self._static_markers = ()
|
||||
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
|
||||
|
||||
@@ -667,6 +667,37 @@ class StreamManager:
|
||||
grouped.append((segment.plugin_id, list(segment.images)))
|
||||
return grouped
|
||||
|
||||
def get_static_layout(self) -> List[Tuple[str, bool]]:
|
||||
"""
|
||||
The buffer's composition order, with STATIC segments kept in place.
|
||||
|
||||
get_grouped_content_for_composition() drops STATIC segments because
|
||||
they contribute no columns; this says where they sat. Each entry is
|
||||
(plugin_id, is_static), and only segments that composition keeps or
|
||||
that are STATIC are listed, so the non-static entries line up one to
|
||||
one with get_grouped_content_for_composition()'s groups.
|
||||
"""
|
||||
layout: List[Tuple[str, bool]] = []
|
||||
with self._buffer_lock:
|
||||
for segment in self._active_buffer:
|
||||
if segment.display_mode == VegasDisplayMode.STATIC:
|
||||
layout.append((segment.plugin_id, True))
|
||||
elif segment.images:
|
||||
layout.append((segment.plugin_id, False))
|
||||
return layout
|
||||
|
||||
def is_static_plugin(self, plugin_id: str) -> bool:
|
||||
"""Whether a loaded plugin asks Vegas to pause for it (STATIC mode)."""
|
||||
plugin = getattr(self.plugin_manager, 'plugins', {}).get(plugin_id)
|
||||
if plugin is None:
|
||||
return False
|
||||
try:
|
||||
return plugin.get_vegas_display_mode() == VegasDisplayMode.STATIC
|
||||
except Exception:
|
||||
logger.debug("[%s] get_vegas_display_mode() failed; treating as not STATIC",
|
||||
plugin_id, exc_info=True)
|
||||
return False
|
||||
|
||||
def take_next_group(
|
||||
self, count: Optional[int] = None, offscreen_only: bool = False
|
||||
) -> List[Tuple[str, Optional[List[Image.Image]]]]:
|
||||
@@ -692,6 +723,8 @@ class StreamManager:
|
||||
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.
|
||||
A STATIC plugin is also returned with an empty list, unfetched: it
|
||||
pauses the scroll instead of adding to it (see is_static_plugin).
|
||||
"""
|
||||
if count is None:
|
||||
count = self.config.plugins_per_cycle
|
||||
@@ -717,6 +750,12 @@ class StreamManager:
|
||||
plugin = plugins.get(plugin_id)
|
||||
if not plugin:
|
||||
continue
|
||||
if self.is_static_plugin(plugin_id):
|
||||
# A STATIC plugin pauses the scroll rather than scrolling by,
|
||||
# so it contributes no columns. It keeps its place in the
|
||||
# group (empty) so the pipeline can mark where its turn falls.
|
||||
group.append((plugin_id, []))
|
||||
continue
|
||||
try:
|
||||
images = self.plugin_adapter.get_content(
|
||||
plugin, plugin_id, offscreen_only=offscreen_only)
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""STATIC Vegas plugins pause the scroll when the scroll reaches their turn.
|
||||
|
||||
The coordinator used to decide by peeking at the front of the stream
|
||||
manager's segment buffer. Continuous scrolling (the default) never advances
|
||||
that buffer -- it extends the strip with take_next_group() -- so the same
|
||||
first segment was examined on every frame: a STATIC plugin paused the scroll
|
||||
only if it happened to be first, once, at startup, and otherwise scrolled past
|
||||
as ordinary content. The render pipeline now marks where each STATIC plugin's
|
||||
turn falls in the strip, and the coordinator pauses when the scroll gets there.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from collections import deque
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from src.plugin_system.base_plugin import VegasDisplayMode
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
from src.vegas_mode.render_pipeline import RenderPipeline
|
||||
|
||||
W, H = 64, 32
|
||||
SEP = 32
|
||||
|
||||
|
||||
def block(width):
|
||||
return Image.new('RGB', (width, H), (255, 255, 255))
|
||||
|
||||
|
||||
class DM:
|
||||
width = W
|
||||
height = H
|
||||
|
||||
def set_scrolling_state(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class FakeStream:
|
||||
"""Stands in for StreamManager: a composed buffer, then extension groups."""
|
||||
|
||||
def __init__(self, layout, statics=(), groups=()):
|
||||
# layout: [(plugin_id, width or None for STATIC)]
|
||||
self.layout = layout
|
||||
self.statics = set(statics) | {pid for pid, w in layout if w is None}
|
||||
self.groups = deque(groups)
|
||||
self.plugin_manager = SimpleNamespace(plugins={})
|
||||
|
||||
def get_grouped_content_for_composition(self):
|
||||
return [(pid, [block(w)]) for pid, w in self.layout if w is not None]
|
||||
|
||||
def get_static_layout(self):
|
||||
return [(pid, w is None) for pid, w in self.layout]
|
||||
|
||||
def get_active_plugin_ids(self):
|
||||
return [pid for pid, _ in self.layout]
|
||||
|
||||
def is_static_plugin(self, plugin_id):
|
||||
return plugin_id in self.statics
|
||||
|
||||
def take_next_group(self, count=None, offscreen_only=False):
|
||||
return self.groups.popleft() if self.groups else []
|
||||
|
||||
|
||||
def pipeline(stream, **cfg):
|
||||
cfg.setdefault('separator_width', SEP)
|
||||
cfg.setdefault('lead_in_width', 0)
|
||||
p = RenderPipeline(VegasModeConfig(**cfg), DM(), stream)
|
||||
p.start_prefetch = lambda: None # keep extension on this thread
|
||||
return p
|
||||
|
||||
|
||||
def scroll_to(p, x):
|
||||
p.scroll_helper.scroll_position = float(x)
|
||||
|
||||
|
||||
class TestComposedStrip:
|
||||
def test_a_static_plugin_mid_strip_triggers_when_the_scroll_reaches_it(self):
|
||||
p = pipeline(FakeStream([('a', 100), ('s', None), ('b', 50)]), lead_in_width=10)
|
||||
assert p.compose_scroll_content()
|
||||
# 's' follows 'a', which ends at 10 + 100 = 110.
|
||||
scroll_to(p, 110 - W - 1)
|
||||
assert p.next_static_trigger() is None
|
||||
scroll_to(p, 110 - W)
|
||||
assert p.next_static_trigger() == 's'
|
||||
# Consumed: one pause per place in the strip.
|
||||
assert p.next_static_trigger() is None
|
||||
|
||||
def test_static_content_is_not_in_the_strip(self):
|
||||
p = pipeline(FakeStream([('a', 100), ('s', None), ('b', 50)]))
|
||||
assert p.compose_scroll_content()
|
||||
assert p.scroll_helper.cached_image.width == 100 + SEP + 50
|
||||
|
||||
def test_a_static_plugin_first_triggers_at_once(self):
|
||||
p = pipeline(FakeStream([('s', None), ('a', 100)]))
|
||||
assert p.compose_scroll_content()
|
||||
assert p.next_static_trigger() == 's'
|
||||
|
||||
def test_no_static_plugins_no_triggers(self):
|
||||
p = pipeline(FakeStream([('a', 100), ('b', 50)]))
|
||||
assert p.compose_scroll_content()
|
||||
scroll_to(p, 1000)
|
||||
assert p.next_static_trigger() is None
|
||||
|
||||
|
||||
class TestContinuousExtension:
|
||||
def test_a_static_plugin_in_a_later_group_triggers_at_its_place(self):
|
||||
# The reported bug: in continuous mode, a STATIC plugin anywhere but
|
||||
# first never paused the scroll at all.
|
||||
stream = FakeStream([('a', 200)], statics={'s'},
|
||||
groups=[[('c', [block(40)]), ('s', []), ('d', [block(30)])]])
|
||||
p = pipeline(stream)
|
||||
assert p.compose_scroll_content()
|
||||
assert p.extend_scroll_content()
|
||||
# The strip had 200 columns; 'c' is appended after a separator and
|
||||
# 's' follows it: 200 + 32 + 40 = 272. 's' adds no columns.
|
||||
assert p.scroll_helper.cached_image.width == 200 + SEP + 40 + SEP + 30
|
||||
scroll_to(p, 272 - W - 1)
|
||||
assert p.next_static_trigger() is None
|
||||
scroll_to(p, 272 - W)
|
||||
assert p.next_static_trigger() == 's'
|
||||
|
||||
def test_markers_follow_the_strip_when_its_head_is_trimmed(self):
|
||||
stream = FakeStream([('a', 400)], statics={'s'},
|
||||
groups=[[('c', [block(40)]), ('s', [])]])
|
||||
p = pipeline(stream)
|
||||
assert p.compose_scroll_content()
|
||||
# Far enough along that extension trims the head of the strip.
|
||||
scroll_to(p, 300)
|
||||
assert p.extend_scroll_content()
|
||||
cut = 300 - int(p.scroll_helper.scroll_position)
|
||||
assert cut > 0
|
||||
marker = 400 + SEP + 40 - cut
|
||||
scroll_to(p, marker - W - 1)
|
||||
assert p.next_static_trigger() is None
|
||||
scroll_to(p, marker - W)
|
||||
assert p.next_static_trigger() == 's'
|
||||
|
||||
def test_a_group_of_only_static_plugins_marks_the_strip_end(self):
|
||||
stream = FakeStream([('a', 200)], statics={'s'}, groups=[[('s', [])]])
|
||||
p = pipeline(stream)
|
||||
assert p.compose_scroll_content()
|
||||
p.extend_scroll_content()
|
||||
scroll_to(p, 200 - W)
|
||||
assert p.next_static_trigger() == 's'
|
||||
|
||||
def test_reset_forgets_markers(self):
|
||||
p = pipeline(FakeStream([('a', 100), ('s', None), ('b', 50)]))
|
||||
assert p.compose_scroll_content()
|
||||
p.reset()
|
||||
scroll_to(p, 1000)
|
||||
assert p.next_static_trigger() is None
|
||||
|
||||
|
||||
class TestStreamManagerSkipsStaticContent:
|
||||
def _stream(self, plugins):
|
||||
from src.vegas_mode.stream_manager import StreamManager
|
||||
sm = StreamManager.__new__(StreamManager)
|
||||
sm._buffer_lock = threading.RLock()
|
||||
sm._ordered_plugins = list(plugins)
|
||||
sm._prefetch_index = 0
|
||||
sm.config = SimpleNamespace(plugins_per_cycle=len(plugins), offscreen_prefetch=True)
|
||||
sm.plugin_manager = SimpleNamespace(plugins=plugins)
|
||||
sm.plugin_adapter = MagicMock()
|
||||
sm.plugin_adapter.get_content.return_value = [block(10)]
|
||||
sm.stats = {'segments_fetched': 0, 'fetch_errors': 0}
|
||||
sm.refresh = lambda: None
|
||||
return sm
|
||||
|
||||
def _plugin(self, mode):
|
||||
return SimpleNamespace(get_vegas_display_mode=lambda: mode)
|
||||
|
||||
def test_static_plugins_keep_their_place_but_are_not_rendered(self):
|
||||
sm = self._stream({
|
||||
'a': self._plugin(VegasDisplayMode.SCROLL),
|
||||
's': self._plugin(VegasDisplayMode.STATIC),
|
||||
})
|
||||
group = sm.take_next_group()
|
||||
assert [pid for pid, _ in group] == ['a', 's']
|
||||
assert dict(group)['s'] == []
|
||||
fetched = [c.args[1] for c in sm.plugin_adapter.get_content.call_args_list]
|
||||
assert fetched == ['a']
|
||||
|
||||
def test_static_layout_lines_up_with_composition(self):
|
||||
from src.vegas_mode.stream_manager import ContentSegment, StreamManager
|
||||
sm = StreamManager.__new__(StreamManager)
|
||||
sm._buffer_lock = threading.RLock()
|
||||
sm._active_buffer = deque([
|
||||
ContentSegment('a', [block(10)], VegasDisplayMode.SCROLL),
|
||||
ContentSegment('empty', [], VegasDisplayMode.SCROLL),
|
||||
ContentSegment('s', [], VegasDisplayMode.STATIC),
|
||||
ContentSegment('b', [block(10)], VegasDisplayMode.SCROLL),
|
||||
])
|
||||
assert sm.get_static_layout() == [('a', False), ('s', True), ('b', False)]
|
||||
assert [pid for pid, _ in sm.get_grouped_content_for_composition()] == ['a', 'b']
|
||||
|
||||
|
||||
class TestCoordinatorStaticPause:
|
||||
def _coord(self, plugin, lock=None):
|
||||
from src.vegas_mode.coordinator import VegasModeCoordinator
|
||||
coord = VegasModeCoordinator.__new__(VegasModeCoordinator)
|
||||
coord.render_pipeline = MagicMock()
|
||||
coord.render_pipeline.get_scroll_position.return_value = 0
|
||||
coord.display_manager = MagicMock()
|
||||
locks = {plugin.plugin_id: lock or threading.Lock()}
|
||||
coord.plugin_manager = SimpleNamespace(
|
||||
get_plugin={plugin.plugin_id: plugin}.get,
|
||||
get_plugin_lock=locks.__getitem__)
|
||||
coord._state_lock = threading.Lock()
|
||||
coord._static_pause_active = False
|
||||
coord._saved_scroll_position = None
|
||||
coord._should_stop = False
|
||||
coord._live_priority_active = False
|
||||
coord._live_priority_check = None
|
||||
coord._interrupt_check = None
|
||||
coord.stats = {'static_pauses': 0}
|
||||
return coord
|
||||
|
||||
def _plugin(self):
|
||||
plugin = MagicMock()
|
||||
plugin.plugin_id = 'clock'
|
||||
plugin.get_display_duration.return_value = 0
|
||||
return plugin
|
||||
|
||||
def test_trigger_comes_from_the_pipeline(self):
|
||||
plugin = self._plugin()
|
||||
coord = self._coord(plugin)
|
||||
coord.render_pipeline.next_static_trigger.return_value = 'clock'
|
||||
assert coord._check_static_plugin_trigger() is plugin
|
||||
coord.render_pipeline.next_static_trigger.return_value = None
|
||||
assert coord._check_static_plugin_trigger() is None
|
||||
|
||||
def test_pause_displays_under_the_plugin_lock(self):
|
||||
plugin = self._plugin()
|
||||
lock = threading.Lock()
|
||||
seen = []
|
||||
plugin.display.side_effect = lambda **k: seen.append(lock.locked())
|
||||
coord = self._coord(plugin, lock)
|
||||
assert coord._handle_static_pause(plugin) is True
|
||||
assert seen == [True]
|
||||
assert not lock.locked()
|
||||
|
||||
def test_pause_is_skipped_while_update_holds_the_lock(self):
|
||||
plugin = self._plugin()
|
||||
lock = threading.Lock()
|
||||
lock.acquire()
|
||||
coord = self._coord(plugin, lock)
|
||||
coord.STATIC_LOCK_TIMEOUT = 0.01
|
||||
try:
|
||||
assert coord._handle_static_pause(plugin) is True
|
||||
finally:
|
||||
lock.release()
|
||||
plugin.display.assert_not_called()
|
||||
Reference in New Issue
Block a user