diff --git a/config/config.template.json b/config/config.template.json index e2feede2..831ef12d 100644 --- a/config/config.template.json +++ b/config/config.template.json @@ -135,6 +135,8 @@ "render_width_pct": 100, "min_content_separation": 24, "min_cut_gap": 6, + "continuous_scroll": true, + "extend_threshold_screens": 2.0, "auto_trim": true, "trim_threshold": 10, "content_padding": 8, diff --git a/src/common/scroll_helper.py b/src/common/scroll_helper.py index ee7eed1c..6916e5ed 100644 --- a/src/common/scroll_helper.py +++ b/src/common/scroll_helper.py @@ -648,6 +648,128 @@ class ScrollHelper: """ return self.scroll_complete + def append_content(self, content_items: list, + item_gap: int = 32, + element_gap: int = 0) -> bool: + """ + Append items to the right of the existing strip, preserving scroll state. + + Lets a caller keep one continuous strip instead of replacing it. Vegas + mode uses this so the next group of plugins scrolls in from the right + rather than the strip being swapped out underneath the viewer — a swap + shows as a flash and a hard cut to already-full-screen content. + + ``scroll_position`` and ``total_distance_scrolled`` are untouched, so + motion continues uninterrupted; only the strip gets longer. Because + completion is measured against ``total_scroll_width``, extending the + strip also defers completion, which is the intent. + + Args: + content_items: Images to append, in order + item_gap: Gap between appended items, and between the existing + content and the first appended item + element_gap: Extra gap after each item, mirroring + create_scrolling_image + + Returns: + True if content was appended + """ + if not content_items: + return False + + if self.cached_image is None or self.cached_array is None: + # Nothing to extend yet — this is just the first build. + self.create_scrolling_image( + content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0) + return True + + gap = max(0, item_gap) + addition_width = ( + sum(img.width for img in content_items) + + gap * len(content_items) # one leading gap per item + + element_gap * len(content_items) + ) + + addition = Image.new('RGB', (addition_width, self.display_height), (0, 0, 0)) + x = 0 + for img in content_items: + x += gap # separate from whatever precedes + addition.paste(img, (x, 0)) + x += img.width + element_gap + + # numpy concatenate then one conversion back, rather than allocating a + # full-width PIL image and pasting twice: the strip can be tens of + # thousands of columns wide and this runs on the render path. + self.cached_array = np.concatenate( + (self.cached_array, np.array(addition)), axis=1) + self.cached_image = Image.fromarray(self.cached_array) + self.total_scroll_width = self.cached_image.width + self.scroll_complete = False + + self.logger.info( + "Appended %d item(s) (%dpx) to scroll strip: now %dpx, position %.0f", + len(content_items), addition_width, self.total_scroll_width, + self.scroll_position + ) + return True + + def drop_scrolled_prefix(self, keep_before: int = 0) -> int: + """ + Discard columns that have already scrolled past, to bound memory. + + A continuously extended strip would otherwise grow without limit. All + the positional state is shifted by the amount removed so the visible + frame and the completion arithmetic are unchanged: + ``total_distance_scrolled`` and ``total_scroll_width`` both shrink by the + same amount, preserving their difference. + + Args: + keep_before: Columns to retain behind the current position, as a + safety margin against a caller reading slightly behind it + + Returns: + Number of columns actually removed + """ + if self.cached_image is None or self.cached_array is None: + return 0 + + # While the viewport wraps, get_visible_portion fills its right-hand side + # from the *head* of the strip, so trimming the head would change what + # is on screen. Continuous mode extends before ever reaching that state; + # refusing here keeps "trimming is invisible" true unconditionally. + if self.scroll_position + self.display_width > self.cached_image.width: + return 0 + + cut = int(self.scroll_position) - max(0, keep_before) + if cut <= 0: + return 0 + # Never trim so far that the remaining strip is narrower than the + # viewport, or get_visible_portion has nothing to slice. + cut = min(cut, max(0, self.cached_image.width - self.display_width)) + if cut <= 0: + return 0 + + # .copy() so the original buffer is released rather than kept alive by + # a numpy view. + self.cached_array = self.cached_array[:, cut:].copy() + self.cached_image = Image.fromarray(self.cached_array) + self.total_scroll_width = self.cached_image.width + self.scroll_position -= cut + self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut) + + self.logger.debug( + "Dropped %dpx of scrolled strip: now %dpx, position %.0f", + cut, self.total_scroll_width, self.scroll_position + ) + return cut + + def remaining_unscrolled(self) -> int: + """Columns of strip still to the right of the viewport.""" + if self.cached_image is None: + return 0 + return max(0, self.total_scroll_width - int(self.scroll_position) + - self.display_width) + def reset_scroll(self) -> None: """ Reset scroll position to beginning. diff --git a/src/display_manager.py b/src/display_manager.py index 5db9e521..d96fef35 100644 --- a/src/display_manager.py +++ b/src/display_manager.py @@ -186,8 +186,14 @@ class DisplayManager: self.config = config or {} self._force_fallback = force_fallback self._suppress_test_pattern = suppress_test_pattern - # When True, update_display() and clear() skip hardware writes (used during off-screen content capture) - self._capture_mode_active = False + # Per-thread capture state. update_display() and clear() skip hardware + # writes while the *calling* thread is capturing content off-screen. + # + # Thread-local rather than a plain flag because Vegas mode prepares + # upcoming content on a background thread: a shared flag set there would + # 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() # 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 @@ -520,6 +526,15 @@ class DisplayManager: except Exception as e: logger.error(f"Error drawing test pattern: {e}", exc_info=True) + @property + def _capture_mode_active(self) -> bool: + """True while the calling thread is capturing content off-screen.""" + return getattr(self._capture_state, 'active', False) + + @_capture_mode_active.setter + def _capture_mode_active(self, value: bool) -> None: + self._capture_state.active = bool(value) + @contextmanager def capture_mode(self): """Suppress hardware output during off-screen content capture. diff --git a/src/vegas_mode/config.py b/src/vegas_mode/config.py index a4fe7801..0a89e0c0 100644 --- a/src/vegas_mode/config.py +++ b/src/vegas_mode/config.py @@ -58,6 +58,18 @@ class VegasModeConfig: # switched off at the start of every cycle. lead_in_width: int = 0 + # 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 + # and restarts with the viewport already full — read as a freeze, a flash + # and a jump. Extending means the next group simply scrolls in from the + # right. Set false to restore the swap behaviour. + continuous_scroll: bool = True + + # Extend once the unscrolled remainder falls below this many screen widths. + # Needs to be more than one so the join is prepared before it is on screen. + extend_threshold_screens: float = 2.0 + # How many plugins are composed into one scroll cycle. Kept separate from # buffer_ahead (which is only a prefetch low-water mark) because the two # were previously the same number: a buffer_ahead of 2 meant just 3 plugins @@ -117,6 +129,9 @@ class VegasModeConfig: min_content_separation=int( vegas_config.get('min_content_separation', 24)), min_cut_gap=int(vegas_config.get('min_cut_gap', 6)), + continuous_scroll=vegas_config.get('continuous_scroll', True), + extend_threshold_screens=float( + vegas_config.get('extend_threshold_screens', 2.0)), auto_trim=vegas_config.get('auto_trim', True), trim_threshold=int(vegas_config.get('trim_threshold', 10)), content_padding=int(vegas_config.get('content_padding', 8)), @@ -146,6 +161,8 @@ class VegasModeConfig: 'render_width_pct': self.render_width_pct, 'min_content_separation': self.min_content_separation, 'min_cut_gap': self.min_cut_gap, + 'continuous_scroll': self.continuous_scroll, + 'extend_threshold_screens': self.extend_threshold_screens, 'auto_trim': self.auto_trim, 'trim_threshold': self.trim_threshold, 'content_padding': self.content_padding, @@ -248,6 +265,11 @@ class VegasModeConfig: "min_content_separation must be between 0 and 256, " f"got {self.min_content_separation}") + if not 1.0 <= self.extend_threshold_screens <= 10.0: + errors.append( + "extend_threshold_screens must be between 1.0 and 10.0, " + f"got {self.extend_threshold_screens}") + if not 1 <= self.min_cut_gap <= 128: errors.append( "min_cut_gap must be between 1 and 128, " @@ -322,6 +344,11 @@ class VegasModeConfig: vegas_config['min_content_separation']) if 'min_cut_gap' in vegas_config: self.min_cut_gap = int(vegas_config['min_cut_gap']) + if 'continuous_scroll' in vegas_config: + self.continuous_scroll = vegas_config['continuous_scroll'] + if 'extend_threshold_screens' in vegas_config: + self.extend_threshold_screens = float( + vegas_config['extend_threshold_screens']) if 'auto_trim' in vegas_config: self.auto_trim = vegas_config['auto_trim'] if 'trim_threshold' in vegas_config: diff --git a/src/vegas_mode/coordinator.py b/src/vegas_mode/coordinator.py index 4ec9d8df..eb953969 100644 --- a/src/vegas_mode/coordinator.py +++ b/src/vegas_mode/coordinator.py @@ -233,6 +233,11 @@ class VegasModeCoordinator: self._should_stop = False self._start_time = time.time() + # Line up the next group immediately, so the first extension is already + # warm rather than stalling the scroll to fetch it. + if self.vegas_config.continuous_scroll: + self.render_pipeline.start_prefetch() + logger.info("Vegas mode started") return True @@ -301,16 +306,33 @@ class VegasModeCoordinator: if has_pending_update: self._apply_pending_config() - # Check if we need to start a new cycle - if self.render_pipeline.is_cycle_complete(): - if not self.render_pipeline.start_new_cycle(): - logger.warning("Failed to start new Vegas cycle") - return False - self.stats['cycles_completed'] += 1 + if self.vegas_config.continuous_scroll: + # Extend the strip before the scroll can reach its end, so the next + # group arrives from the right and motion never stops. No cycle + # boundary, so no freeze, no substitution and no restart with the + # viewport already full. + # Trickle in the plugins that can only be fetched here, one per + # frame, before considering a further extension. + if self.render_pipeline.has_deferred(): + self.render_pipeline.drain_deferred() + elif self.render_pipeline.needs_extension(): + if self.render_pipeline.extend_scroll_content(): + self.stats['cycles_completed'] += 1 + elif self.render_pipeline.is_cycle_complete(): + # Extension failed and the strip has run out: fall back to + # the swap rather than sitting on a dead frame. + self.render_pipeline.start_new_cycle() + else: + # Check if we need to start a new cycle + if self.render_pipeline.is_cycle_complete(): + if not self.render_pipeline.start_new_cycle(): + logger.warning("Failed to start new Vegas cycle") + return False + self.stats['cycles_completed'] += 1 - # Check for hot-swap opportunities - if self.render_pipeline.should_recompose(): - self.render_pipeline.hot_swap_content() + # Check for hot-swap opportunities + if self.render_pipeline.should_recompose(): + self.render_pipeline.hot_swap_content() # Render frame return self.render_pipeline.render_frame() @@ -337,7 +359,14 @@ class VegasModeCoordinator: self._update_static_mode_plugins() frame_interval = self.vegas_config.get_frame_interval() - duration = self.render_pipeline.get_dynamic_duration() + if self.vegas_config.continuous_scroll: + # The strip is continuously extended and trimmed, so its width says + # nothing about how long to run. This is only how often control + # returns to the display controller; interrupts are still checked + # every few frames, so it costs nothing to make it a fixed period. + duration = float(self.vegas_config.max_cycle_duration) + else: + duration = self.render_pipeline.get_dynamic_duration() start_time = time.time() frame_count = 0 fps_log_interval = 5.0 # Log FPS every 5 seconds diff --git a/src/vegas_mode/plugin_adapter.py b/src/vegas_mode/plugin_adapter.py index dc8e197d..d02fdc00 100644 --- a/src/vegas_mode/plugin_adapter.py +++ b/src/vegas_mode/plugin_adapter.py @@ -73,7 +73,8 @@ class PluginAdapter: self.display_width, self.display_height ) - def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Image.Image]]: + def get_content(self, plugin: 'BasePlugin', plugin_id: str, + offscreen_only: bool = False) -> Optional[List[Image.Image]]: """ Get scrollable content from a plugin. @@ -82,6 +83,13 @@ 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. Returns: List of PIL Images representing plugin content, or None if no content @@ -105,7 +113,7 @@ class PluginAdapter: has_native = hasattr(plugin, 'get_vegas_content') logger.info("[%s] Has get_vegas_content: %s", plugin_id, has_native) if has_native: - content = self._get_native_content(plugin, plugin_id) + content = self._get_native_content(plugin, plugin_id, offscreen_only) if content: total_width = sum(img.width for img in content) logger.info( @@ -118,7 +126,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.info("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper) - content = self._get_scroll_helper_content(plugin, plugin_id) + content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only) if content: total_width = sum(img.width for img in content) logger.info( @@ -129,6 +137,14 @@ class PluginAdapter: if has_scroll_helper: logger.info("[%s] ScrollHelper content returned None", plugin_id) + if offscreen_only: + # Display capture needs the shared canvas; leave it to the caller. + logger.info( + "[%s] Needs display capture, deferring to the render thread", + plugin_id + ) + return None + # Fall back to display capture logger.info("[%s] Trying fallback display capture...", plugin_id) content = self._capture_display_content(plugin, plugin_id) @@ -451,7 +467,7 @@ class PluginAdapter: return img.crop((start, 0, end, img.height)) def _get_native_content( - self, plugin: 'BasePlugin', plugin_id: str + self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False ) -> Optional[List[Image.Image]]: """ Get content via plugin's native get_vegas_content() method. @@ -486,8 +502,17 @@ class PluginAdapter: # 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. - with self._capture(), self._render_at(render_width): - result = plugin.get_vegas_content() + 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. + with self._capture(): + result = plugin.get_vegas_content() + else: + with self._capture(), self._render_at(render_width): + result = plugin.get_vegas_content() finally: plugin._vegas_render_width = None @@ -567,7 +592,7 @@ class PluginAdapter: return None def _get_scroll_helper_content( - self, plugin: 'BasePlugin', plugin_id: str + self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False ) -> Optional[List[Image.Image]]: """ Get content from plugin's scroll_helper if available. @@ -601,6 +626,13 @@ class PluginAdapter: "[%s] scroll_helper.cached_image is None, triggering content generation", plugin_id ) + if offscreen_only: + # Generating it calls display(), which needs the canvas. + logger.info( + "[%s] scroll_helper cache empty; deferring generation " + "to the render thread", plugin_id + ) + return None # Try to trigger scroll content generation cached_image = self._trigger_scroll_content_generation( plugin, plugin_id, scroll_helper diff --git a/src/vegas_mode/render_pipeline.py b/src/vegas_mode/render_pipeline.py index 4c063518..d3bb434f 100644 --- a/src/vegas_mode/render_pipeline.py +++ b/src/vegas_mode/render_pipeline.py @@ -35,6 +35,10 @@ class RenderPipeline: - Track scroll cycle completion """ + # Minimum gap between fetches of canvas-bound plugins, so their individual + # stalls land in separate moments rather than one run of hitches. + DEFERRED_DRAIN_INTERVAL = 2.0 + def __init__( self, config: VegasModeConfig, @@ -82,6 +86,14 @@ class RenderPipeline: self._staging_scroll_image: Optional[Image.Image] = None self._buffer_lock = threading.Lock() + # Group prepared off the render thread, waiting to be appended. + self._prepared_group = None + # Plugins that need the shared canvas, appended one at a time. + self._deferred_queue: List[str] = [] + self._last_drain_time = 0.0 + self._prefetch_thread: Optional[threading.Thread] = None + self._prefetch_lock = threading.Lock() + # Render state self._is_rendering = False self._cycle_complete = False @@ -207,6 +219,217 @@ class RenderPipeline: logger.exception("Error composing scroll content") return False + def needs_extension(self) -> bool: + """ + Whether the strip should be extended with the next group of plugins. + + Cheap enough to call every frame: it is arithmetic over cached state. + """ + if not self.config.continuous_scroll or not self.scroll_helper.cached_image: + return False + threshold = int(self.display_width * self.config.extend_threshold_screens) + return self.scroll_helper.remaining_unscrolled() <= threshold + + def start_prefetch(self) -> None: + """ + Begin preparing the next group in the background, if not already doing so. + + This is what makes the join seamless rather than merely continuous: + fetching a group costs 0.5-4.8s (rendering leaderboard and baseball cards + dominates), and doing it on the render thread stalls the scroll for that + long. Off the render thread there is a whole group's scroll time to work + in, so by the time the strip needs extending the content is already sat + waiting. + + Only paths that avoid the shared display canvas run here; anything + needing it is marked and picked up on the render thread, where it is + safe. Those are the cheap ones — display capture measured 12-14ms + against seconds for the native renders. + """ + if not self.config.continuous_scroll: + return + + with self._prefetch_lock: + if self._prefetch_thread is not None and self._prefetch_thread.is_alive(): + return + if self._prepared_group is not None: + return # already have one waiting + + def _work(): + try: + group = self.stream_manager.take_next_group(offscreen_only=True) + except Exception: + logger.exception("Background prefetch failed") + group = [] + with self._prefetch_lock: + self._prepared_group = group + + self._prefetch_thread = threading.Thread( + target=_work, daemon=True, name="vegas-strip-prefetch") + self._prefetch_thread.start() + + def drain_deferred(self) -> bool: + """ + Fetch one queued canvas-bound plugin and append it to the strip. + + Called once per frame. These plugins cannot be prepared off the render + thread — display capture and scroll-content generation both need the + shared canvas — so each costs roughly 290ms here. Doing one at a time + spreads that out instead of stalling for the whole group at once, and the + strip's lookahead means nothing runs dry while they arrive. + + The cost is that a deferred plugin appears slightly after the group it + came with, which is a fair trade for a smooth scroll. + + Returns: + True if a plugin was appended + """ + if not self._deferred_queue: + return False + + # Space the drains out. Each costs 40-600ms, and taking them back to + # back turns one long stall into a train of short ones — barely better. + # With a healthy lookahead there is no hurry, so wait a beat between + # them; when the strip is actually running short, fetch immediately. + threshold = int(self.display_width * self.config.extend_threshold_screens) + urgent = self.scroll_helper.remaining_unscrolled() <= threshold + if not urgent: + now = time.time() + if now - self._last_drain_time < self.DEFERRED_DRAIN_INTERVAL: + return False + self._last_drain_time = now + else: + self._last_drain_time = time.time() + + plugin_id = self._deferred_queue.pop(0) + plugins = getattr(self.stream_manager.plugin_manager, 'plugins', {}) + plugin = plugins.get(plugin_id) + if plugin is None: + return False + + try: + images = self.stream_manager.plugin_adapter.get_content(plugin, plugin_id) + except Exception: + logger.exception("[%s] Error fetching deferred content", plugin_id) + return False + + if not images: + return False + + appended = self.scroll_helper.append_content( + content_items=[self._join_plugin_rows(images)], + item_gap=self.config.separator_width, + element_gap=0, + ) + if appended: + with self._buffer_lock: + self._active_scroll_image = self.scroll_helper.cached_image + logger.info( + "[%s] Appended deferred content: strip now %dpx, %dpx ahead", + plugin_id, self.scroll_helper.total_scroll_width, + self.scroll_helper.remaining_unscrolled() + ) + return appended + + def has_deferred(self) -> bool: + """Whether any canvas-bound plugins are still queued.""" + return bool(self._deferred_queue) + + def _claim_prepared_group(self): + """Take the prefetched group, if one is ready.""" + with self._prefetch_lock: + group = self._prepared_group + self._prepared_group = None + return group + + def extend_scroll_content(self) -> bool: + """ + Append the next group of plugins to the strip, without interrupting motion. + + This is what replaces the swap. Scroll position is untouched, so the new + content simply arrives from the right; there is no substitution to see + and no restart with the viewport already full. + + Consumed columns behind the viewport are then released, keeping the strip + bounded however long Vegas runs. + + Returns: + True if the strip was extended + """ + try: + grouped = self._claim_prepared_group() + if grouped is None: + # Nothing prepared (first extension, or prefetch still running). + # Fetch inline; the scroll hitches, but content keeps flowing. + logger.info("No prepared group ready; fetching inline") + grouped = self.stream_manager.take_next_group() + + if not grouped: + logger.warning("No content available to extend the scroll strip") + return False + + # 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 + # 1.75s. They are trickled in one per frame by drain_deferred(), + # which the strip's lookahead comfortably absorbs. + deferred = [pid for pid, images in grouped if images is None] + if deferred: + self._deferred_queue.extend(deferred) + logger.info( + "Queued %d plugin(s) needing the render thread: %s", + len(deferred), ', '.join(deferred) + ) + + 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") + self.start_prefetch() + return bool(deferred) + + blocks = [] + total_rows = 0 + for _plugin_id, images in grouped: + total_rows += len(images) + blocks.append(self._join_plugin_rows(images)) + + appended = self.scroll_helper.append_content( + content_items=blocks, + item_gap=self.config.separator_width, + element_gap=0, + ) + if not appended: + return False + + # Keep a screen's worth behind the viewport as a safety margin. + self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width) + + with self._buffer_lock: + self._active_scroll_image = self.scroll_helper.cached_image + + self._segments_in_scroll = [pid for pid, _ in grouped] + self.stats['composition_count'] += 1 + self.stats['extensions'] = self.stats.get('extensions', 0) + 1 + + logger.info( + "Extended scroll strip with %d plugin block(s), %d rows: " + "strip now %dpx, %dpx still ahead of the viewport", + len(blocks), total_rows, self.scroll_helper.total_scroll_width, + self.scroll_helper.remaining_unscrolled() + ) + + # Line up the group after this one straight away, so it is ready + # well before the strip runs short again. + self.start_prefetch() + return True + + except (ValueError, TypeError, OSError, RuntimeError): + logger.exception("Error extending scroll content") + return False + def _join_plugin_rows(self, images: List[Image.Image]) -> Image.Image: """ Concatenate one plugin's images into a single block. @@ -281,6 +504,10 @@ class RenderPipeline: # # A strip no wider than the display never wraps, and subtracting # would make the cycle complete instantly, so clamp in that case. + # In continuous mode there is no cycle to complete: the strip is + # extended before the scroll can reach its end, so the wrap is never + # entered and motion never stops. The completion path below stays for + # the swap behaviour and as a backstop if an extension fails. wrap_point = self.scroll_helper.total_scroll_width if wrap_point > self.display_width: wrap_point -= self.display_width diff --git a/src/vegas_mode/stream_manager.py b/src/vegas_mode/stream_manager.py index bd15277d..7f43b24f 100644 --- a/src/vegas_mode/stream_manager.py +++ b/src/vegas_mode/stream_manager.py @@ -580,6 +580,62 @@ class StreamManager: grouped.append((segment.plugin_id, list(segment.images))) return grouped + def take_next_group( + self, count: Optional[int] = None, offscreen_only: bool = False + ) -> List[Tuple[str, Optional[List[Image.Image]]]]: + """ + Fetch and hand over the next slice of the rotation. + + For continuous scrolling, where the strip is extended rather than + replaced. Advances the rotation index so plugins come round in order + across an unbroken strip, and bypasses the active buffer entirely — that + buffer exists to stage a *replacement* cycle, which continuous mode has + no use for. + + Args: + count: Number of plugins to gather, defaulting to plugins_per_cycle + offscreen_only: Only use content paths that avoid the shared display + canvas, for use off the render thread + + Returns: + 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. + """ + if count is None: + count = self.config.plugins_per_cycle + + self.refresh() + + with self._buffer_lock: + if not self._ordered_plugins: + return [] + total = len(self._ordered_plugins) + ids = [] + for _ in range(min(max(1, count), total)): + ids.append(self._ordered_plugins[self._prefetch_index]) + self._prefetch_index = (self._prefetch_index + 1) % total + + plugins = getattr(self.plugin_manager, 'plugins', {}) + group: List[Tuple[str, Optional[List[Image.Image]]]] = [] + + for plugin_id in ids: + plugin = plugins.get(plugin_id) + if not plugin: + continue + try: + images = self.plugin_adapter.get_content( + plugin, plugin_id, offscreen_only=offscreen_only) + except Exception: + logger.exception("[%s] ERROR fetching content", plugin_id) + self.stats['fetch_errors'] += 1 + continue + if images: + self.stats['segments_fetched'] += 1 + group.append((plugin_id, images if images else None)) + + return group + def advance_cycle(self) -> None: """ Advance to next cycle by clearing the active buffer. diff --git a/test/test_scroll_helper_continuous.py b/test/test_scroll_helper_continuous.py new file mode 100644 index 00000000..6737b577 --- /dev/null +++ b/test/test_scroll_helper_continuous.py @@ -0,0 +1,245 @@ +""" +Tests for ScrollHelper's continuous-strip primitives. + +append_content extends the strip to the right without disturbing motion, and +drop_scrolled_prefix reclaims what has already gone past. Together they let a +caller keep one endless strip instead of swapping a new one in, which is what +shows as a flash and a hard cut to already-full-screen content. +""" + +import numpy as np +import pytest +from PIL import Image + +from src.common.scroll_helper import ScrollHelper +from src.vegas_mode.geometry import column_has_ink + +W, H = 128, 32 + + +def helper(): + return ScrollHelper(W, H) + + +def block(width, colour=(255, 255, 255), height=H): + return Image.new('RGB', (width, height), colour) + + +class TestAppendContent: + def test_first_append_builds_the_strip(self): + sh = helper() + assert sh.append_content([block(100)], item_gap=0) + assert sh.cached_image is not None + assert sh.total_scroll_width == sh.cached_image.width + + def test_strip_grows_by_content_plus_gaps(self): + sh = helper() + sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0) + assert sh.cached_image.width == 100 + + sh.append_content([block(50)], item_gap=10, element_gap=0) + # one leading gap of 10 then the 50px block + assert sh.cached_image.width == 160 + assert sh.total_scroll_width == 160 + + def test_scroll_position_is_preserved(self): + sh = helper() + sh.create_scrolling_image([block(400)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 137.0 + sh.total_distance_scrolled = 137.0 + + sh.append_content([block(200)], item_gap=16) + assert sh.scroll_position == 137.0 + assert sh.total_distance_scrolled == 137.0 + + def test_appending_defers_completion(self): + sh = helper() + sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_complete = True + + sh.append_content([block(200)], item_gap=0) + assert not sh.scroll_complete + assert sh.total_distance_scrolled < sh.total_scroll_width + + def test_existing_pixels_are_untouched(self): + sh = helper() + original = block(80, (10, 200, 10)) + sh.create_scrolling_image([original], item_gap=0, element_gap=0, lead_gap=0) + before = sh.cached_image.crop((0, 0, 80, H)).tobytes() + + sh.append_content([block(40, (200, 10, 10))], item_gap=8) + assert sh.cached_image.crop((0, 0, 80, H)).tobytes() == before + + def test_appended_content_sits_after_the_gap(self): + sh = helper() + sh.create_scrolling_image([block(50)], item_gap=0, element_gap=0, lead_gap=0) + sh.append_content([block(30)], item_gap=12) + + ink = column_has_ink(sh.cached_image) + assert ink[:50].all() + assert not ink[50:62].any() # the 12px gap + assert ink[62:92].all() + + def test_array_and_image_stay_consistent(self): + # get_visible_portion slices cached_array but bounds-checks against + # cached_image.width, so a mismatch corrupts frames. + sh = helper() + sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0) + sh.append_content([block(100)], item_gap=8) + assert sh.cached_array.shape[1] == sh.cached_image.width + assert sh.cached_array.shape[0] == sh.cached_image.height + + def test_visible_portion_still_renders_after_append(self): + sh = helper() + sh.create_scrolling_image([block(300)], item_gap=0, element_gap=0, lead_gap=0) + sh.append_content([block(300)], item_gap=8) + sh.scroll_position = 250.0 + frame = sh.get_visible_portion() + assert frame is not None and frame.size == (W, H) + + def test_empty_append_is_a_no_op(self): + sh = helper() + sh.create_scrolling_image([block(100)], item_gap=0, element_gap=0, lead_gap=0) + assert sh.append_content([]) is False + assert sh.cached_image.width == 100 + + def test_repeated_appends_accumulate(self): + sh = helper() + sh.append_content([block(100)], item_gap=0) + for _ in range(5): + sh.append_content([block(100)], item_gap=0) + assert sh.cached_image.width == 600 + + +class TestDropScrolledPrefix: + def test_removes_consumed_columns(self): + sh = helper() + sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 500.0 + sh.total_distance_scrolled = 500.0 + + removed = sh.drop_scrolled_prefix(keep_before=0) + assert removed == 500 + assert sh.cached_image.width == 500 + assert sh.scroll_position == 0.0 + + def test_keeps_the_requested_margin(self): + sh = helper() + sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 500.0 + sh.drop_scrolled_prefix(keep_before=100) + assert sh.scroll_position == 100.0 + assert sh.cached_image.width == 600 + + def test_completion_difference_is_preserved(self): + # total_distance_scrolled and total_scroll_width must shift together, or + # trimming would spuriously complete or un-complete the cycle. + sh = helper() + sh.create_scrolling_image([block(1000)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 600.0 + sh.total_distance_scrolled = 600.0 + before = sh.total_scroll_width - sh.total_distance_scrolled + + sh.drop_scrolled_prefix(keep_before=0) + assert sh.total_scroll_width - sh.total_distance_scrolled == before + + def test_never_trims_below_the_viewport(self): + sh = helper() + sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 190.0 + sh.drop_scrolled_prefix(keep_before=0) + assert sh.cached_image.width >= W + + def test_no_op_before_anything_has_scrolled(self): + sh = helper() + sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0) + assert sh.drop_scrolled_prefix(keep_before=0) == 0 + assert sh.cached_image.width == 500 + + def test_no_op_with_no_strip(self): + assert helper().drop_scrolled_prefix() == 0 + + def test_visible_frame_is_unchanged_by_trimming(self): + # The whole point: trimming is invisible. Same pixels on screen before + # and after. Position chosen so the viewport is well clear of the end, + # i.e. not wrapping. + sh = helper() + items = [block(200, (255, 0, 0)), block(200, (0, 255, 0)), + block(200, (0, 0, 255))] + sh.create_scrolling_image(items, item_gap=20, element_gap=0, lead_gap=0) + sh.scroll_position = 300.0 + before = sh.get_visible_portion().tobytes() + + assert sh.drop_scrolled_prefix(keep_before=0) > 0, "trim should have run" + after = sh.get_visible_portion().tobytes() + assert after == before + + def test_refuses_to_trim_while_the_viewport_wraps(self): + # Wrapping reads the head of the strip into the right of the frame, so + # trimming the head there would visibly change the picture. + sh = helper() + sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 150.0 # 150 + 128 > 200, so wrapping + before = sh.get_visible_portion().tobytes() + assert sh.drop_scrolled_prefix(keep_before=0) == 0 + assert sh.get_visible_portion().tobytes() == before + + def test_array_and_image_stay_consistent_after_trim(self): + sh = helper() + sh.create_scrolling_image([block(900)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 400.0 + sh.drop_scrolled_prefix(keep_before=0) + assert sh.cached_array.shape[1] == sh.cached_image.width + + +class TestRemainingUnscrolled: + def test_counts_content_right_of_the_viewport(self): + sh = helper() + sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0) + assert sh.remaining_unscrolled() == 500 - W + + def test_shrinks_as_the_strip_scrolls(self): + sh = helper() + sh.create_scrolling_image([block(500)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 200.0 + assert sh.remaining_unscrolled() == 500 - 200 - W + + def test_never_negative(self): + sh = helper() + sh.create_scrolling_image([block(200)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 500.0 + assert sh.remaining_unscrolled() == 0 + + def test_zero_with_no_strip(self): + assert helper().remaining_unscrolled() == 0 + + def test_grows_when_content_is_appended(self): + sh = helper() + sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0) + sh.scroll_position = 100.0 + before = sh.remaining_unscrolled() + assert before > 0, "fixture should leave content ahead of the viewport" + sh.append_content([block(400)], item_gap=0) + assert sh.remaining_unscrolled() == before + 400 + + +class TestContinuousScrollingEndToEnd: + def test_strip_can_be_extended_indefinitely_at_bounded_size(self): + """The invariant that makes this viable: extend + trim keeps the strip + bounded while motion never stops.""" + sh = helper() + sh.create_scrolling_image([block(600)], item_gap=0, element_gap=0, lead_gap=0) + + widths = [] + for _ in range(20): + sh.scroll_position += 200 + sh.total_distance_scrolled += 200 + if sh.remaining_unscrolled() < 2 * W: + sh.append_content([block(600)], item_gap=16) + sh.drop_scrolled_prefix(keep_before=W) + widths.append(sh.cached_image.width) + # A frame must always be renderable. + assert sh.get_visible_portion() is not None + + assert max(widths) < 3000, f"strip grew unbounded: max {max(widths)}" + assert not sh.scroll_complete, "continuous strip should never complete" diff --git a/test/test_vegas_density.py b/test/test_vegas_density.py index 2e5a3e6e..210833c6 100644 --- a/test/test_vegas_density.py +++ b/test/test_vegas_density.py @@ -1219,3 +1219,293 @@ class TestCaptureModeAlwaysHeld: adapter.get_content(Boom(dm), 'boom') assert dm.capture_depth == 0 + + +class TestContinuousExtension: + """ + Continuous mode extends one strip instead of swapping in a new one, so the + next group scrolls in from the right: no freeze, no substitution, and no + restart with the viewport already full. + """ + + def _pipeline(self, groups, **cfg): + """groups: list of lists of (plugin_id, [images]) handed out in turn.""" + from src.vegas_mode.render_pipeline import RenderPipeline + + class FakeStream: + def __init__(self): + self.calls = [] + self.plugin_manager = type('PM', (), {'plugins': {}})() + self.plugin_adapter = None + self._i = 0 + + def get_grouped_content_for_composition(self): + return groups[0] if groups else [] + + def get_active_plugin_ids(self): + return [pid for pid, _ in (groups[0] if groups else [])] + + def take_next_group(self, count=None, offscreen_only=False): + self.calls.append(offscreen_only) + if self._i >= len(groups): + return [] + g = groups[self._i] + self._i += 1 + return g + + class DM: + width = DISPLAY_W + height = DISPLAY_H + + def __init__(self): + self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H)) + self.pushes = 0 + + def set_scrolling_state(self, *a): + pass + + def update_display(self): + self.pushes += 1 + + cfg.setdefault('lead_in_width', 0) + stream = FakeStream() + return RenderPipeline(VegasModeConfig(**cfg), DM(), stream), stream + + def _block(self, w): + return Image.new('RGB', (w, DISPLAY_H), (255, 255, 255)) + + def test_needs_extension_only_near_the_end(self): + p, _ = self._pipeline([[('a', [self._block(400)])]], + continuous_scroll=True, extend_threshold_screens=2.0) + p.compose_scroll_content() + # 400px strip on a 512px display: already inside the threshold. + assert p.needs_extension() + + def test_no_extension_when_plenty_remains(self): + p, _ = self._pipeline([[('a', [self._block(4000)])]], + continuous_scroll=True, extend_threshold_screens=2.0) + p.compose_scroll_content() + assert not p.needs_extension() + + def test_disabled_never_extends(self): + p, _ = self._pipeline([[('a', [self._block(100)])]], + continuous_scroll=False) + p.compose_scroll_content() + assert not p.needs_extension() + + def test_extension_grows_the_strip_and_keeps_position(self): + groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]] + p, _ = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + before_width = p.scroll_helper.total_scroll_width + p.scroll_helper.scroll_position = 120.0 + + assert p.extend_scroll_content() + assert p.scroll_helper.total_scroll_width > before_width + assert p.scroll_helper.scroll_position == 120.0 + + def test_extension_never_completes_the_cycle(self): + groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]] + p, _ = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + p.extend_scroll_content() + assert not p.scroll_helper.scroll_complete + + def test_deferred_plugins_are_dropped_when_unresolvable(self): + # A None entry means "needs the render thread"; with no plugin instance + # available it must be skipped rather than crashing or inserting a gap. + groups = [[('a', [self._block(300)])], + [('needs-canvas', None), ('b', [self._block(300)])]] + p, _ = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + assert p.extend_scroll_content() + assert p.scroll_helper.total_scroll_width > 300 + + def test_empty_next_group_fails_cleanly(self): + # compose_scroll_content does not consume a group, so the first extend + # takes groups[0]; the second finds nothing left. + groups = [[('a', [self._block(300)])], []] + p, _ = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + assert p.extend_scroll_content() is True + assert p.extend_scroll_content() is False + + def test_prefetch_requests_offscreen_only(self): + # The background thread must never take a canvas-touching path. + groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]] + p, stream = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + p.start_prefetch() + if p._prefetch_thread: + p._prefetch_thread.join(timeout=5) + assert stream.calls == [True] + + def test_prepared_group_is_used_without_refetching(self): + groups = [[('a', [self._block(600)])], [('b', [self._block(600)])]] + p, stream = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + p.start_prefetch() + if p._prefetch_thread: + p._prefetch_thread.join(timeout=5) + + assert p.extend_scroll_content() + # One offscreen prefetch, then one more kicked off for the group after. + assert stream.calls[0] is True + assert p._prepared_group is None or isinstance(p._prepared_group, list) + + def test_strip_stays_bounded_over_many_extensions(self): + groups = [[('g%d' % i, [self._block(600)])] for i in range(30)] + p, _ = self._pipeline(groups, continuous_scroll=True) + p.compose_scroll_content() + + widths = [] + for _ in range(25): + p.scroll_helper.scroll_position += 300 + p.scroll_helper.total_distance_scrolled += 300 + if p.needs_extension(): + p.extend_scroll_content() + widths.append(p.scroll_helper.total_scroll_width) + assert max(widths) < 6000, f"strip grew unbounded: {max(widths)}" + + +class TestDeferredDraining: + """ + Canvas-bound plugins cannot be prepared off the render thread, so they are + queued and appended one per frame. Doing all of them at once held the render + thread for 1.75s on hardware. + """ + + def _pipeline(self, group, plugin_images, **cfg): + from src.vegas_mode.render_pipeline import RenderPipeline + + class FakeAdapter: + def __init__(self, mapping): + self.mapping = mapping + self.calls = [] + + def get_content(self, plugin, plugin_id, offscreen_only=False): + self.calls.append((plugin_id, offscreen_only)) + return self.mapping.get(plugin_id) + + class FakeStream: + def __init__(self): + self.plugin_manager = type( + 'PM', (), {'plugins': {pid: object() for pid in plugin_images}})() + self.plugin_adapter = FakeAdapter(plugin_images) + self._served = False + + def get_grouped_content_for_composition(self): + return [('seed', [Image.new('RGB', (600, DISPLAY_H), (255, 255, 255))])] + + def get_active_plugin_ids(self): + return ['seed'] + + def take_next_group(self, count=None, offscreen_only=False): + if self._served: + return [] + self._served = True + return group + + class DM: + width = DISPLAY_W + height = DISPLAY_H + + def __init__(self): + self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H)) + + def set_scrolling_state(self, *a): + pass + + def update_display(self): + pass + + cfg.setdefault('lead_in_width', 0) + cfg.setdefault('continuous_scroll', True) + stream = FakeStream() + p = RenderPipeline(VegasModeConfig(**cfg), DM(), stream) + p.compose_scroll_content() + return p, stream + + def _img(self, w): + return [Image.new('RGB', (w, DISPLAY_H), (255, 255, 255))] + + def test_deferred_plugins_are_queued_not_fetched_inline(self): + group = [('ready', self._img(200)), ('needs-canvas', None)] + p, stream = self._pipeline(group, {'needs-canvas': self._img(150)}) + p.extend_scroll_content() + # Only queued at this point — no fetch for it yet. + assert p.has_deferred() + assert all(pid != 'needs-canvas' for pid, _ in stream.plugin_adapter.calls) + + def test_draining_appends_one_at_a_time(self): + group = [('a', None), ('b', None), ('c', None)] + images = {k: self._img(120) for k in ('a', 'b', 'c')} + p, _ = self._pipeline(group, images) + p.extend_scroll_content() + + widths = [p.scroll_helper.total_scroll_width] + drained = 0 + while p.has_deferred(): + assert p.drain_deferred() + drained += 1 + widths.append(p.scroll_helper.total_scroll_width) + assert drained == 3 + assert widths == sorted(widths), "each drain should extend the strip" + + def test_drain_uses_the_full_path_not_offscreen(self): + group = [('needs-canvas', None)] + p, stream = self._pipeline(group, {'needs-canvas': self._img(150)}) + p.extend_scroll_content() + p.drain_deferred() + assert ('needs-canvas', False) in stream.plugin_adapter.calls + + def test_drain_is_a_no_op_with_an_empty_queue(self): + p, _ = self._pipeline([('a', self._img(200))], {}) + p.extend_scroll_content() + assert not p.has_deferred() + assert p.drain_deferred() is False + + def test_scroll_position_survives_draining(self): + group = [('a', None), ('b', None)] + p, _ = self._pipeline(group, {k: self._img(120) for k in ('a', 'b')}) + p.extend_scroll_content() + p.scroll_helper.scroll_position = 200.0 + while p.has_deferred(): + p.drain_deferred() + assert p.scroll_helper.scroll_position == 200.0 + + def test_a_plugin_yielding_nothing_is_dropped_from_the_queue(self): + group = [('empty', None)] + p, _ = self._pipeline(group, {'empty': None}) + p.extend_scroll_content() + assert p.drain_deferred() is False + assert not p.has_deferred(), "must not retry forever" + + def test_all_deferred_group_still_reports_progress(self): + # Nothing appendable right now, but the queue will extend the strip. + group = [('a', None), ('b', None)] + p, _ = self._pipeline(group, {k: self._img(100) for k in ('a', 'b')}) + assert p.extend_scroll_content() is True + assert p.has_deferred() + + def test_drains_are_spaced_when_lookahead_is_healthy(self): + # With plenty of strip ahead there is no hurry, so consecutive drains + # must be throttled rather than firing back to back. + group = [('a', None), ('b', None)] + p, _ = self._pipeline(group, {k: self._img(4000) for k in ('a', 'b')}) + p.extend_scroll_content() + + assert p.drain_deferred() is True # first one goes through + # Strip is now long, so the next is deferred by the interval. + assert p.scroll_helper.remaining_unscrolled() > ( + DISPLAY_W * p.config.extend_threshold_screens) + assert p.drain_deferred() is False + assert p.has_deferred(), "queue must be kept, not dropped" + + def test_urgent_drain_ignores_the_throttle(self): + # When the strip is nearly exhausted, content matters more than smoothness. + group = [('a', None), ('b', None)] + p, _ = self._pipeline(group, {k: self._img(80) for k in ('a', 'b')}) + p.extend_scroll_content() + assert p.drain_deferred() is True + assert p.drain_deferred() is True diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 7df0942b..d30d770d 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -924,7 +924,8 @@ def save_main_config(): 'vegas_max_plugin_width_ratio', 'vegas_dynamic_duration_enabled', 'vegas_min_cycle_duration', 'vegas_max_cycle_duration', 'vegas_intra_plugin_gap', 'vegas_render_width_pct', - 'vegas_min_content_separation', 'vegas_min_cut_gap'] + 'vegas_min_content_separation', 'vegas_min_cut_gap', + 'vegas_continuous_scroll', 'vegas_extend_threshold_screens'] if any(k in data for k in vegas_fields): if 'display' not in current_config: @@ -942,9 +943,28 @@ def save_main_config(): vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim')) vegas_config['dynamic_duration_enabled'] = _coerce_to_bool( data.get('vegas_dynamic_duration_enabled')) + vegas_config['continuous_scroll'] = _coerce_to_bool( + data.get('vegas_continuous_scroll')) # max_plugin_width_ratio is the one fractional setting, so it is # handled outside the integer loop below. + if data.get('vegas_extend_threshold_screens') not in ('', None): + try: + screens = float(data['vegas_extend_threshold_screens']) + except (ValueError, TypeError): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for vegas_extend_threshold_screens: " + "must be a number" + }), 400 + if not (1.0 <= screens <= 10.0): + return jsonify({ + 'status': 'error', + 'message': "Invalid value for vegas_extend_threshold_screens: " + "must be between 1.0 and 10.0" + }), 400 + vegas_config['extend_threshold_screens'] = screens + if data.get('vegas_max_plugin_width_ratio') not in ('', None): try: ratio = float(data['vegas_max_plugin_width_ratio']) diff --git a/web_interface/templates/v3/partials/display.html b/web_interface/templates/v3/partials/display.html index e2a271e3..78da91d9 100644 --- a/web_interface/templates/v3/partials/display.html +++ b/web_interface/templates/v3/partials/display.html @@ -499,6 +499,31 @@

Cycle Pacing

How long one pass through the ticker lasts, and how many plugins it covers.

+
+ +
+ +
+
+ + +
+
+