From 94032e210121009b803ca2179abee4a634f00e4f Mon Sep 17 00:00:00 2001 From: ChuckBuilds Date: Wed, 29 Jul 2026 14:31:24 -0400 Subject: [PATCH] Vegas mode: one continuous strip instead of swapping cycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cycle used to be a discrete strip that got replaced: motion stopped, every pixel was substituted at once, and the next group started with the viewport already full. That is the freeze, the flash and the jump. The strip is now extended rather than replaced. ScrollHelper gains append_content(), which adds items on the right without touching scroll_position or total_distance_scrolled, so motion continues and the next group simply arrives from the right. Because completion is measured against total_scroll_width, extending also defers completion — there is no longer a cycle boundary to see. drop_scrolled_prefix() reclaims what has gone past, keeping the strip bounded however long Vegas runs (observed 5,000-11,000px against an unbounded strip otherwise). It shifts total_distance_scrolled and total_scroll_width together so the completion arithmetic is unchanged, and refuses to run while the viewport is wrapping: wrapping reads the head of the strip into the right of the frame, so trimming the head there would visibly change the picture. A test caught that. Groups are prepared off the render thread. The constraint is that 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. get_content() therefore takes offscreen_only: the background thread uses only paths that avoid the canvas, and anything needing it is marked and picked up on the render thread. That puts the expensive work (native renders of leaderboard and baseball cards, seconds each) in the background and leaves the cheap work (display capture, 40-600ms) in the foreground. DisplayManager's capture flag is now thread-local. As a shared flag, a background capture would have suppressed the render loop's own frame pushes for its duration, freezing the panel precisely when the point was to avoid a freeze. Canvas-bound plugins are drained one at a time rather than as a batch: six at once held the render thread for 1.75s. Drains are also spaced by two seconds while the lookahead is healthy, since taking them back to back turns one long stall into a run of short ones. When the strip is genuinely running short the throttle is ignored, because content matters more than smoothness there. Measured on hardware: zero cycle-complete swaps, drains landing 2-4s apart, lookahead holding at 1,200-3,500px, no errors. Set continuous_scroll false to restore the swap behaviour; the old path is intact. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ --- config/config.template.json | 2 + src/common/scroll_helper.py | 122 ++++++++ src/display_manager.py | 19 +- src/vegas_mode/config.py | 27 ++ src/vegas_mode/coordinator.py | 49 ++- src/vegas_mode/plugin_adapter.py | 46 ++- src/vegas_mode/render_pipeline.py | 227 ++++++++++++++ src/vegas_mode/stream_manager.py | 56 ++++ test/test_scroll_helper_continuous.py | 245 +++++++++++++++ test/test_vegas_density.py | 290 ++++++++++++++++++ web_interface/blueprints/api_v3.py | 22 +- .../templates/v3/partials/display.html | 25 ++ 12 files changed, 1110 insertions(+), 20 deletions(-) create mode 100644 test/test_scroll_helper_continuous.py 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.

+
+ +
+ +
+
+ + +
+
+