mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a413849891 | ||
|
|
7b6b7cd153 | ||
|
|
932e89ccb8 | ||
|
|
94032e2101 | ||
|
|
554fb858af | ||
|
|
a1c528a091 |
@@ -134,6 +134,10 @@
|
||||
"intra_plugin_gap": 8,
|
||||
"render_width_pct": 100,
|
||||
"min_content_separation": 24,
|
||||
"min_cut_gap": 6,
|
||||
"continuous_scroll": true,
|
||||
"smooth_scroll": true,
|
||||
"extend_threshold_screens": 2.0,
|
||||
"auto_trim": true,
|
||||
"trim_threshold": 10,
|
||||
"content_padding": 8,
|
||||
@@ -141,6 +145,7 @@
|
||||
"lead_in_width": 0,
|
||||
"plugins_per_cycle": 6,
|
||||
"max_plugin_width_ratio": 3.0,
|
||||
"overflow_mode": "rotate",
|
||||
"dynamic_duration_enabled": true,
|
||||
"min_cycle_duration": 60,
|
||||
"max_cycle_duration": 240
|
||||
|
||||
+185
-4
@@ -348,13 +348,72 @@ class ScrollHelper:
|
||||
"""
|
||||
if not self.cached_image or self.cached_array is None:
|
||||
return None
|
||||
|
||||
# Use integer pixel positioning for high FPS scrolling (like stock ticker)
|
||||
|
||||
start_x_int = int(self.scroll_position)
|
||||
end_x_int = start_x_int + self.display_width
|
||||
|
||||
# Fast integer pixel path (no interpolation - high frame rate provides smoothness)
|
||||
|
||||
# Integer positioning quantises motion to whole pixels, so the number of
|
||||
# distinct frames per second equals the scroll speed in px/s, no matter
|
||||
# how fast the loop renders. At 50px/s and 78fps that made 36% of frames
|
||||
# identical: the extra frames cost work and bought nothing. Blending
|
||||
# between the two neighbouring positions gives motion at the frame rate
|
||||
# instead of the step rate.
|
||||
if self.sub_pixel_scrolling:
|
||||
fractional = self.scroll_position - start_x_int
|
||||
if fractional > 0.0:
|
||||
return self._blend_visible_portion(start_x_int, fractional)
|
||||
|
||||
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
||||
|
||||
def _blend_visible_portion(self, start_x: int, fractional: float) -> Image.Image:
|
||||
"""
|
||||
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
|
||||
|
||||
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
|
||||
installed on the target devices (HAS_SCIPY is False there), which is why
|
||||
the pre-existing sub-pixel path was dead code — get_visible_portion never
|
||||
consulted the flag, and the scipy fallback would not have interpolated
|
||||
anyway.
|
||||
|
||||
Args:
|
||||
start_x: Left column of the earlier of the two frames
|
||||
fractional: How far between the two, in [0, 1)
|
||||
|
||||
Returns:
|
||||
The blended frame
|
||||
"""
|
||||
width = self.display_width
|
||||
strip_width = self.cached_array.shape[1]
|
||||
|
||||
if start_x + width + 1 <= strip_width:
|
||||
# Slice the backing array directly. Going via
|
||||
# _get_visible_portion_integer would build two PIL images only for
|
||||
# them to be converted straight back to arrays, which measured 15x
|
||||
# the cost of the integer path.
|
||||
near = self.cached_array[:, start_x:start_x + width]
|
||||
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
|
||||
else:
|
||||
# Close enough to the end that one of the slices wraps; let the
|
||||
# integer path handle that and pay the conversion. Continuous mode
|
||||
# extends the strip before reaching here, so this is the rare case.
|
||||
near = np.asarray(
|
||||
self._get_visible_portion_integer(start_x, start_x + width))
|
||||
far = np.asarray(
|
||||
self._get_visible_portion_integer(start_x + 1, start_x + 1 + width))
|
||||
|
||||
# Fixed-point rather than float32: integer multiply-add on uint16 is
|
||||
# markedly faster than float maths on the Pi's ARM cores, and 8 bits of
|
||||
# weight is finer than the panel can show.
|
||||
weight = int(fractional * 256.0)
|
||||
blended = (
|
||||
(near.astype(np.uint16) * (256 - weight)
|
||||
+ far.astype(np.uint16) * weight) >> 8
|
||||
).astype(np.uint8)
|
||||
|
||||
return Image.frombytes(
|
||||
'RGB', (width, self.display_height),
|
||||
np.ascontiguousarray(blended).tobytes()
|
||||
)
|
||||
|
||||
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
||||
"""Fast integer pixel extraction (no interpolation).
|
||||
@@ -648,6 +707,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.
|
||||
|
||||
+17
-2
@@ -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.
|
||||
|
||||
@@ -58,12 +58,51 @@ class VegasModeConfig:
|
||||
# switched off at the start of every cycle.
|
||||
lead_in_width: int = 0
|
||||
|
||||
# Blend between neighbouring pixel positions so motion happens at the frame
|
||||
# rate rather than the scroll speed. With integer positioning the number of
|
||||
# distinct frames per second equals scroll_speed, so at 50px/s the motion is
|
||||
# 50 discrete 1px steps however fast the loop runs. The trade is a slight
|
||||
# horizontal softening of text, since each frame is a blend of two positions.
|
||||
smooth_scroll: bool = True
|
||||
|
||||
# Keep one continuous strip, extending it with the next group of plugins as
|
||||
# the scroll approaches the end, instead of composing a fresh strip and
|
||||
# swapping it in. A swap stops the motion, substitutes every pixel at once
|
||||
# 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
|
||||
# per cycle, so a 20-plugin install took seven cycles to come around.
|
||||
plugins_per_cycle: int = 6
|
||||
|
||||
# Minimum run of blank columns that counts as a boundary between items when
|
||||
# an oversized segment has to be narrowed. Measured on rendered text, the
|
||||
# gaps between characters are a single column while gaps between items are
|
||||
# 8px and up, so anything above 1 stops a cut landing inside a word. Cutting
|
||||
# mid-word orphaned the tail into the next cycle, which showed up as a lone
|
||||
# letter floating between two unrelated plugins.
|
||||
min_cut_gap: int = 6
|
||||
|
||||
# What to do when a plugin's content exceeds its width budget.
|
||||
#
|
||||
# "rotate" — advance a window each cycle so everything is seen eventually.
|
||||
# Right for interchangeable items: news headlines, odds, stocks.
|
||||
# "truncate" — always show the start. Right for ordered content, where a
|
||||
# window into the middle is meaningless: a league table that
|
||||
# shows ranks 1-6 then resumes at 7 two rotations later reads
|
||||
# as out of order and out of context.
|
||||
#
|
||||
# Override per plugin with vegas_overflow.
|
||||
overflow_mode: str = "rotate"
|
||||
|
||||
# Cap on one plugin's share of a cycle, as a multiple of display width.
|
||||
# A single ticker returning 7,000px would otherwise hold the panel for over
|
||||
# two minutes. Overflow is deferred to later cycles rather than discarded.
|
||||
@@ -108,6 +147,11 @@ class VegasModeConfig:
|
||||
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
|
||||
min_content_separation=int(
|
||||
vegas_config.get('min_content_separation', 24)),
|
||||
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
|
||||
smooth_scroll=vegas_config.get('smooth_scroll', True),
|
||||
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)),
|
||||
@@ -116,6 +160,7 @@ class VegasModeConfig:
|
||||
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
|
||||
max_plugin_width_ratio=float(
|
||||
vegas_config.get('max_plugin_width_ratio', 3.0)),
|
||||
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
|
||||
plugin_order=list(vegas_config.get('plugin_order', [])),
|
||||
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
|
||||
target_fps=int(vegas_config.get('target_fps', 125)),
|
||||
@@ -136,6 +181,10 @@ class VegasModeConfig:
|
||||
'intra_plugin_gap': self.intra_plugin_gap,
|
||||
'render_width_pct': self.render_width_pct,
|
||||
'min_content_separation': self.min_content_separation,
|
||||
'min_cut_gap': self.min_cut_gap,
|
||||
'smooth_scroll': self.smooth_scroll,
|
||||
'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,
|
||||
@@ -143,6 +192,7 @@ class VegasModeConfig:
|
||||
'lead_in_width': self.lead_in_width,
|
||||
'plugins_per_cycle': self.plugins_per_cycle,
|
||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
||||
'overflow_mode': self.overflow_mode,
|
||||
'plugin_order': self.plugin_order,
|
||||
'excluded_plugins': list(self.excluded_plugins),
|
||||
'target_fps': self.target_fps,
|
||||
@@ -238,6 +288,16 @@ 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, "
|
||||
f"got {self.min_cut_gap}")
|
||||
|
||||
if self.intra_plugin_gap < 0:
|
||||
errors.append(
|
||||
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
|
||||
@@ -276,6 +336,11 @@ class VegasModeConfig:
|
||||
errors.append(
|
||||
f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}")
|
||||
|
||||
if self.overflow_mode not in ('rotate', 'truncate'):
|
||||
errors.append(
|
||||
"overflow_mode must be 'rotate' or 'truncate', "
|
||||
f"got {self.overflow_mode!r}")
|
||||
|
||||
if self.max_plugin_width_ratio < 0:
|
||||
errors.append(
|
||||
"max_plugin_width_ratio must be >= 0 "
|
||||
@@ -305,6 +370,15 @@ class VegasModeConfig:
|
||||
if 'min_content_separation' in vegas_config:
|
||||
self.min_content_separation = int(
|
||||
vegas_config['min_content_separation'])
|
||||
if 'min_cut_gap' in vegas_config:
|
||||
self.min_cut_gap = int(vegas_config['min_cut_gap'])
|
||||
if 'smooth_scroll' in vegas_config:
|
||||
self.smooth_scroll = vegas_config['smooth_scroll']
|
||||
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:
|
||||
@@ -320,6 +394,8 @@ class VegasModeConfig:
|
||||
if 'max_plugin_width_ratio' in vegas_config:
|
||||
self.max_plugin_width_ratio = float(
|
||||
vegas_config['max_plugin_width_ratio'])
|
||||
if 'overflow_mode' in vegas_config:
|
||||
self.overflow_mode = str(vegas_config['overflow_mode'])
|
||||
if 'plugin_order' in vegas_config:
|
||||
self.plugin_order = list(vegas_config['plugin_order'])
|
||||
if 'excluded_plugins' in vegas_config:
|
||||
|
||||
@@ -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
|
||||
@@ -347,6 +376,8 @@ class VegasModeCoordinator:
|
||||
logger.info("Starting Vegas iteration for %.1fs", duration)
|
||||
|
||||
while True:
|
||||
frame_started = time.time()
|
||||
|
||||
# Check for STATIC mode plugin that should pause scroll
|
||||
static_plugin = self._check_static_plugin_trigger()
|
||||
if static_plugin:
|
||||
@@ -367,8 +398,14 @@ class VegasModeCoordinator:
|
||||
# Paused for live priority - let caller handle
|
||||
return False
|
||||
|
||||
# Sleep for frame interval
|
||||
time.sleep(frame_interval)
|
||||
# Sleep only the remainder of the frame budget. This used to sleep
|
||||
# the whole interval on top of however long the frame took, so at a
|
||||
# measured 31.6ms per frame a fixed 8ms of that was pure idle — a
|
||||
# quarter of the budget spent not rendering. Subtracting the work
|
||||
# already done keeps the pacing target while reclaiming that time,
|
||||
# and yields the GIL either way so other threads still run.
|
||||
frame_elapsed = time.time() - frame_started
|
||||
time.sleep(max(0.0, frame_interval - frame_elapsed))
|
||||
|
||||
# Increment frame count and check for interrupt periodically
|
||||
frame_count += 1
|
||||
|
||||
@@ -16,7 +16,7 @@ All column scans go through numpy: a Python-level per-column loop over a
|
||||
path.
|
||||
"""
|
||||
|
||||
from typing import NamedTuple, Optional, Tuple
|
||||
from typing import List, NamedTuple, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
@@ -192,6 +192,83 @@ def separation_gap(
|
||||
return max(minimum, target - existing, 0)
|
||||
|
||||
|
||||
def blank_runs(
|
||||
img: Image.Image,
|
||||
min_run: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> List[Tuple[int, int]]:
|
||||
"""
|
||||
Find maximal runs of blank columns at least ``min_run`` wide.
|
||||
|
||||
Distinguishes item boundaries from letter spacing. Measured on real
|
||||
rendered text, the gaps *between characters* are a single column, while the
|
||||
gaps a plugin puts *between items* are 8px and up (the stocks ticker uses
|
||||
32px, baseball 48px). Treating any blank column as a cut point therefore
|
||||
slices words in half; requiring a run excludes letter spacing.
|
||||
|
||||
Args:
|
||||
img: Image to scan
|
||||
min_run: Minimum consecutive blank columns to qualify
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
List of (start, end) half-open column ranges, in left-to-right order
|
||||
"""
|
||||
blank = ~column_has_ink(img, threshold)
|
||||
if not blank.any():
|
||||
return []
|
||||
|
||||
# Vectorised run detection: pad with False so runs touching either edge get
|
||||
# a boundary, then read starts and ends off the first difference. A Python
|
||||
# loop here would be far too slow on a 17,000px ticker strip.
|
||||
padded = np.concatenate(([False], blank, [False]))
|
||||
diff = np.diff(padded.astype(np.int8))
|
||||
starts = np.flatnonzero(diff == 1)
|
||||
ends = np.flatnonzero(diff == -1)
|
||||
|
||||
long_enough = (ends - starts) >= max(1, min_run)
|
||||
return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist()))
|
||||
|
||||
|
||||
def find_item_boundary(
|
||||
img: Image.Image,
|
||||
target: int,
|
||||
min_run: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> Optional[int]:
|
||||
"""
|
||||
Find the column nearest ``target`` that sits inside a gap between items.
|
||||
|
||||
Used to narrow an oversized segment without cutting through a word. Only
|
||||
runs of at least ``min_run`` blank columns are considered, so the
|
||||
single-column gaps between characters are never chosen — cutting there
|
||||
orphaned the tail of a word into the following cycle, which is how a lone
|
||||
"y" from "Wednesday" ended up floating between two unrelated plugins.
|
||||
|
||||
Args:
|
||||
img: Image to cut
|
||||
target: Preferred cut column
|
||||
min_run: Minimum blank-run width that counts as an item boundary
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
A column inside a qualifying gap, or None when the image has no such
|
||||
gap at all — in which case the caller must not cut it.
|
||||
"""
|
||||
runs = blank_runs(img, min_run, threshold)
|
||||
if not runs:
|
||||
return None
|
||||
|
||||
# Nearest point of the nearest run. For a run left of target that is its
|
||||
# end (content resumes just after), for a run right of target its start
|
||||
# (content stopped just before) — the right choice in both directions.
|
||||
def clamp_to_run(run: Tuple[int, int]) -> int:
|
||||
start, end = run
|
||||
return max(start, min(target, end - 1))
|
||||
|
||||
return min((clamp_to_run(r) for r in runs), key=lambda c: abs(c - target))
|
||||
|
||||
|
||||
def find_blank_cut(
|
||||
img: Image.Image,
|
||||
target: int,
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.geometry import (
|
||||
find_blank_cut,
|
||||
blank_runs,
|
||||
separation_gap,
|
||||
trim_to_content,
|
||||
)
|
||||
@@ -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,30 +113,38 @@ 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(
|
||||
"[%s] Native content SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
return self._finalize(content, plugin_id, 'native')
|
||||
return self._finalize(content, plugin_id, 'native', plugin)
|
||||
logger.info("[%s] Native content returned None", plugin_id)
|
||||
|
||||
# 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(
|
||||
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
return self._finalize(content, plugin_id, 'scroll_helper')
|
||||
return self._finalize(content, plugin_id, 'scroll_helper', plugin)
|
||||
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)
|
||||
@@ -138,7 +154,7 @@ class PluginAdapter:
|
||||
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
return self._finalize(content, plugin_id, 'fallback')
|
||||
return self._finalize(content, plugin_id, 'fallback', plugin)
|
||||
|
||||
logger.warning(
|
||||
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
|
||||
@@ -147,7 +163,8 @@ class PluginAdapter:
|
||||
return None
|
||||
|
||||
def _finalize(
|
||||
self, images: List[Image.Image], plugin_id: str, source: str
|
||||
self, images: List[Image.Image], plugin_id: str, source: str,
|
||||
plugin: Optional['BasePlugin'] = None
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Trim dead space off a segment, then cache it.
|
||||
@@ -174,7 +191,7 @@ class PluginAdapter:
|
||||
# turning off margin cropping should not let one plugin hold the
|
||||
# panel for minutes. Skipping it here previously let a 14,848px
|
||||
# segment through untouched.
|
||||
kept = self._apply_width_budget(list(images), plugin_id)
|
||||
kept = self._apply_width_budget(list(images), plugin_id, plugin)
|
||||
self._cache_content(plugin_id, kept)
|
||||
return kept
|
||||
|
||||
@@ -219,11 +236,29 @@ class PluginAdapter:
|
||||
len(kept), dropped_blank
|
||||
)
|
||||
|
||||
kept = self._apply_width_budget(kept, plugin_id)
|
||||
kept = self._apply_width_budget(kept, plugin_id, plugin)
|
||||
|
||||
self._cache_content(plugin_id, kept)
|
||||
return kept
|
||||
|
||||
def _capture(self):
|
||||
"""
|
||||
Context manager suppressing hardware writes while plugin render code runs.
|
||||
|
||||
Degrades to a no-op when the display manager predates capture_mode. As
|
||||
with _render_at, losing the suppression risks a visible flash, whereas
|
||||
raising would be swallowed by the broad handlers upstream and drop the
|
||||
plugin's content entirely — much worse.
|
||||
"""
|
||||
capture_mode = getattr(self.display_manager, 'capture_mode', None)
|
||||
if capture_mode is None:
|
||||
logger.debug(
|
||||
"display_manager has no capture_mode(); plugin writes during "
|
||||
"content capture may reach the panel"
|
||||
)
|
||||
return nullcontext()
|
||||
return capture_mode()
|
||||
|
||||
def _render_at(self, width: int):
|
||||
"""
|
||||
Context manager narrowing the plugin-facing canvas to ``width``.
|
||||
@@ -298,15 +333,74 @@ class PluginAdapter:
|
||||
threshold=self.config.trim_threshold,
|
||||
)
|
||||
|
||||
def _width_budget(self) -> int:
|
||||
"""Maximum columns one plugin may occupy in a cycle. 0 means unlimited."""
|
||||
def _plugin_setting(self, plugin: 'BasePlugin', key: str):
|
||||
"""Read a per-plugin config override, or None if absent."""
|
||||
plugin_cfg = getattr(plugin, 'config', None)
|
||||
if not isinstance(plugin_cfg, dict):
|
||||
return None
|
||||
value = plugin_cfg.get(key)
|
||||
return None if value in (None, '') else value
|
||||
|
||||
def resolve_overflow_mode(self, plugin: 'BasePlugin', plugin_id: str) -> str:
|
||||
"""
|
||||
How to handle content that exceeds this plugin's width budget.
|
||||
|
||||
'rotate' advances a window each cycle so everything is seen eventually,
|
||||
which suits interchangeable items. 'truncate' always shows the start,
|
||||
which suits ordered content — a league table that shows ranks 1-6 and
|
||||
then resumes at 7 two rotations later reads as out of order, and nobody
|
||||
needs rank 23 in a ticker anyway.
|
||||
|
||||
Per-plugin ``vegas_overflow`` wins over the global ``overflow_mode``.
|
||||
"""
|
||||
raw = self._plugin_setting(plugin, 'vegas_overflow')
|
||||
if raw is not None:
|
||||
candidate = str(raw).strip().lower()
|
||||
if candidate in ('rotate', 'truncate'):
|
||||
return candidate
|
||||
logger.warning(
|
||||
"[%s] Invalid vegas_overflow %r, expected 'rotate' or 'truncate'",
|
||||
plugin_id, raw
|
||||
)
|
||||
return self.config.overflow_mode
|
||||
|
||||
def _width_budget(self, plugin: Optional['BasePlugin'] = None,
|
||||
plugin_id: str = '') -> int:
|
||||
"""
|
||||
Maximum columns one plugin may occupy in a cycle. 0 means unlimited.
|
||||
|
||||
A per-plugin ``vegas_max_width_screens`` overrides the global ratio, so
|
||||
content that has to stay whole can be given room (or uncapped with 0)
|
||||
without lifting the cap on every ticker.
|
||||
"""
|
||||
ratio = self.config.max_plugin_width_ratio
|
||||
|
||||
if plugin is not None:
|
||||
raw = self._plugin_setting(plugin, 'vegas_max_width_screens')
|
||||
if raw is not None:
|
||||
try:
|
||||
candidate = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"[%s] Invalid vegas_max_width_screens %r, ignoring",
|
||||
plugin_id, raw
|
||||
)
|
||||
else:
|
||||
if candidate >= 0:
|
||||
ratio = candidate
|
||||
else:
|
||||
logger.warning(
|
||||
"[%s] vegas_max_width_screens must be >= 0, got %s",
|
||||
plugin_id, candidate
|
||||
)
|
||||
|
||||
if ratio <= 0:
|
||||
return 0
|
||||
return int(self.display_width * ratio)
|
||||
|
||||
def _apply_width_budget(
|
||||
self, images: List[Image.Image], plugin_id: str
|
||||
self, images: List[Image.Image], plugin_id: str,
|
||||
plugin: Optional['BasePlugin'] = None
|
||||
) -> List[Image.Image]:
|
||||
"""
|
||||
Hold one plugin to its share of a cycle.
|
||||
@@ -325,7 +419,9 @@ class PluginAdapter:
|
||||
Images that fit the budget, starting from the plugin's current
|
||||
rotation offset.
|
||||
"""
|
||||
budget = self._width_budget()
|
||||
budget = self._width_budget(plugin, plugin_id)
|
||||
mode = (self.resolve_overflow_mode(plugin, plugin_id)
|
||||
if plugin is not None else self.config.overflow_mode)
|
||||
|
||||
# Count the gaps the compositor will actually insert, not just the
|
||||
# pixels of the rows — otherwise a plugin with many rows quietly
|
||||
@@ -343,9 +439,15 @@ class PluginAdapter:
|
||||
return images
|
||||
|
||||
if len(images) == 1:
|
||||
return [self._crop_to_budget(images[0], budget, plugin_id)]
|
||||
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
|
||||
|
||||
start = self._item_offsets.get(plugin_id, 0) % len(images)
|
||||
if mode == 'truncate':
|
||||
# Ordered content: always show from the top. Deliberately does not
|
||||
# advance the offset, so the same opening items appear every time
|
||||
# rather than the viewer being shown the middle of a ranked list.
|
||||
start = 0
|
||||
else:
|
||||
start = self._item_offsets.get(plugin_id, 0) % len(images)
|
||||
selected: List[Image.Image] = []
|
||||
used = 0
|
||||
consumed = 0
|
||||
@@ -363,17 +465,24 @@ class PluginAdapter:
|
||||
used += cost
|
||||
consumed += 1
|
||||
|
||||
self._item_offsets[plugin_id] = (start + consumed) % len(images)
|
||||
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
||||
"from offset %d; remainder deferred to a later cycle",
|
||||
plugin_id, budget, len(selected), len(images), used, start
|
||||
)
|
||||
if mode == 'truncate':
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: showing the first %d of %d row(s) "
|
||||
"(%dpx incl. gaps); the rest are not shown (overflow=truncate)",
|
||||
plugin_id, budget, len(selected), len(images), used
|
||||
)
|
||||
else:
|
||||
self._item_offsets[plugin_id] = (start + consumed) % len(images)
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
|
||||
"from offset %d; remainder deferred to a later cycle",
|
||||
plugin_id, budget, len(selected), len(images), used, start
|
||||
)
|
||||
return selected
|
||||
|
||||
def _crop_to_budget(
|
||||
self, img: Image.Image, budget: int, plugin_id: str
|
||||
self, img: Image.Image, budget: int, plugin_id: str,
|
||||
mode: str = 'rotate'
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Narrow a single oversized image to the budget, advancing a window
|
||||
@@ -382,32 +491,68 @@ class PluginAdapter:
|
||||
The cut is snapped to the nearest blank column so it does not slice
|
||||
through a glyph or logo and leave half a character at the panel edge.
|
||||
"""
|
||||
offset = self._item_offsets.get(plugin_id, 0)
|
||||
if offset >= img.width:
|
||||
if mode == 'truncate':
|
||||
# Always the start of the strip, so a ranked table is never entered
|
||||
# from the middle.
|
||||
offset = 0
|
||||
else:
|
||||
offset = self._item_offsets.get(plugin_id, 0)
|
||||
if offset >= img.width:
|
||||
offset = 0
|
||||
|
||||
# Snap both edges to blank columns. The search radius is generous
|
||||
# enough to clear a wide glyph but small enough not to distort the
|
||||
# requested budget much.
|
||||
snap = max(8, self.display_width // 16)
|
||||
start = find_blank_cut(img, offset, snap, self.config.trim_threshold)
|
||||
end = find_blank_cut(
|
||||
img, min(start + budget, img.width), snap, self.config.trim_threshold)
|
||||
if end <= start:
|
||||
end = min(start + budget, img.width)
|
||||
# Cut only where the plugin left a real gap between items. Snapping to
|
||||
# any blank column used to pick the single-column gaps between
|
||||
# characters, splitting a word and orphaning its tail into the next
|
||||
# cycle — a lone "y" from "Wednesday" floating between two unrelated
|
||||
# plugins. Overshooting the budget is the lesser evil.
|
||||
min_run = max(2, self.config.min_cut_gap)
|
||||
gaps = blank_runs(img, min_run, self.config.trim_threshold)
|
||||
|
||||
# Next cycle resumes where this one stopped; wrap when the strip ends.
|
||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||
if not gaps:
|
||||
# No internal gaps means continuous content — a map, a chart, a
|
||||
# photo — where any column is as good as any other, so cut to the
|
||||
# budget exactly. The gap rule exists to protect discrete items
|
||||
# (words, ticker entries); it would be wrong to let a solid image
|
||||
# escape the cap in its name.
|
||||
end = min(offset + budget, img.width)
|
||||
if mode != 'truncate':
|
||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: cropped continuous %dpx image to "
|
||||
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
|
||||
plugin_id, budget, img.width, offset, end, min_run,
|
||||
"" if mode != 'truncate' else "; showing the start only"
|
||||
)
|
||||
return img.crop((offset, 0, end, img.height))
|
||||
|
||||
# Cut mid-gap so the content either side keeps some breathing room.
|
||||
cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps})
|
||||
|
||||
start = max((c for c in cuts if c <= offset), default=0)
|
||||
later = [c for c in cuts if c > start]
|
||||
if not later:
|
||||
end = img.width
|
||||
else:
|
||||
within = [c for c in later if c <= start + budget]
|
||||
# No boundary inside the budget: take the next one and overrun,
|
||||
# because the alternative is cutting through an item.
|
||||
end = max(within) if within else min(later)
|
||||
|
||||
if mode != 'truncate':
|
||||
# Next cycle resumes where this one stopped; wrap when the strip ends.
|
||||
self._item_offsets[plugin_id] = 0 if end >= img.width else end
|
||||
|
||||
logger.info(
|
||||
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
|
||||
"(%dpx), window advances next cycle",
|
||||
plugin_id, budget, img.width, start, end, end - start
|
||||
"(%dpx) at item boundaries, %s",
|
||||
plugin_id, budget, img.width, start, end, end - start,
|
||||
"showing the start only (overflow=truncate)"
|
||||
if mode == 'truncate' else "window advances next cycle"
|
||||
)
|
||||
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.
|
||||
@@ -428,17 +573,30 @@ class PluginAdapter:
|
||||
# the narrower value with no changes of its own; one that wants to
|
||||
# be explicit can read get_vegas_render_width().
|
||||
render_width = self.resolve_render_width(plugin, plugin_id)
|
||||
if render_width != self.display_width:
|
||||
logger.info(
|
||||
"[%s] Native: requesting %dpx instead of %dpx",
|
||||
plugin_id, render_width, self.display_width
|
||||
)
|
||||
|
||||
plugin._vegas_render_width = render_width
|
||||
try:
|
||||
if render_width == self.display_width:
|
||||
result = plugin.get_vegas_content()
|
||||
# capture_mode unconditionally, even at full width. Building
|
||||
# Vegas content is an off-screen operation, but a plugin is free
|
||||
# to call update_display() while doing it — and outside
|
||||
# capture_mode that write lands on the hardware, flashing the
|
||||
# panel mid-scroll. The narrowing context is separate because it
|
||||
# is a no-op at full width.
|
||||
if offscreen_only:
|
||||
# _render_at swaps the shared canvas, so it is unsafe here.
|
||||
# _vegas_render_width is set regardless: a plugin reading
|
||||
# get_vegas_render_width() still gets its narrow size, and
|
||||
# one that only reads matrix.width renders full width and is
|
||||
# trimmed instead.
|
||||
with self._capture():
|
||||
result = plugin.get_vegas_content()
|
||||
else:
|
||||
logger.info(
|
||||
"[%s] Native: requesting %dpx instead of %dpx",
|
||||
plugin_id, render_width, self.display_width
|
||||
)
|
||||
with self.display_manager.capture_mode(), \
|
||||
self._render_at(render_width):
|
||||
with self._capture(), self._render_at(render_width):
|
||||
result = plugin.get_vegas_content()
|
||||
finally:
|
||||
plugin._vegas_render_width = None
|
||||
@@ -519,7 +677,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.
|
||||
@@ -553,6 +711,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
|
||||
@@ -701,7 +866,7 @@ class PluginAdapter:
|
||||
# Save display state to restore after
|
||||
original_image = self.display_manager.image.copy()
|
||||
|
||||
with self.display_manager.capture_mode():
|
||||
with self._capture():
|
||||
# Method 1: Try _create_scrolling_display (stocks pattern)
|
||||
if hasattr(plugin, '_create_scrolling_display'):
|
||||
logger.info(
|
||||
@@ -804,8 +969,7 @@ class PluginAdapter:
|
||||
plugin_id, render_width, self.display_width
|
||||
)
|
||||
|
||||
with self.display_manager.capture_mode(), \
|
||||
self._render_at(render_width):
|
||||
with self._capture(), self._render_at(render_width):
|
||||
self.display_manager.clear()
|
||||
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
|
||||
|
||||
@@ -839,8 +1003,7 @@ class PluginAdapter:
|
||||
plugin_id
|
||||
)
|
||||
# Try once more with force_clear=True
|
||||
with self.display_manager.capture_mode(), \
|
||||
self._render_at(render_width):
|
||||
with self._capture(), self._render_at(render_width):
|
||||
self.display_manager.clear()
|
||||
plugin.display(force_clear=True)
|
||||
captured = self.display_manager.image.copy()
|
||||
|
||||
@@ -6,6 +6,7 @@ Uses the existing ScrollHelper for numpy-optimized scroll operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from collections import deque
|
||||
@@ -35,6 +36,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 +87,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
|
||||
@@ -111,6 +124,7 @@ class RenderPipeline:
|
||||
"""Configure ScrollHelper with current settings."""
|
||||
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
|
||||
self.scroll_helper.set_scroll_delay(self.config.scroll_delay)
|
||||
self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll)
|
||||
|
||||
# Config scroll_speed is always pixels per second, but ScrollHelper
|
||||
# interprets it differently based on frame_based_scrolling mode:
|
||||
@@ -207,6 +221,226 @@ 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():
|
||||
# Deprioritise against the render loop. Linux applies nice
|
||||
# per-thread, and the heavy lifting here is PIL and numpy work
|
||||
# that releases the GIL, so the scheduler can actually act on
|
||||
# it — without this the prefetch competes for the same cores and
|
||||
# costs frames.
|
||||
try:
|
||||
os.nice(10)
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
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 +515,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
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"
|
||||
|
||||
|
||||
class TestSubPixelBlending:
|
||||
"""
|
||||
Integer positioning quantises motion to whole pixels, so distinct frames per
|
||||
second equals scroll speed regardless of frame rate — at 50px/s and 78fps,
|
||||
36% of frames were identical. Blending between neighbouring positions gives
|
||||
motion at the frame rate instead.
|
||||
"""
|
||||
|
||||
def _strip(self, width=2000):
|
||||
rng = np.random.default_rng(0)
|
||||
arr = (rng.random((H, width, 3)) * 255).astype(np.uint8)
|
||||
sh = helper()
|
||||
sh.create_scrolling_image([Image.fromarray(arr)],
|
||||
item_gap=0, element_gap=0, lead_gap=0)
|
||||
return sh
|
||||
|
||||
def _frame(self, sh, pos, subpixel):
|
||||
sh.sub_pixel_scrolling = subpixel
|
||||
sh.scroll_position = pos
|
||||
return np.asarray(sh.get_visible_portion()).astype(int)
|
||||
|
||||
def test_integer_mode_ignores_the_fraction(self):
|
||||
sh = self._strip()
|
||||
a = self._frame(sh, 500.0, False)
|
||||
b = self._frame(sh, 500.9, False)
|
||||
assert np.array_equal(a, b), "integer positioning should not move sub-pixel"
|
||||
|
||||
def test_blending_moves_within_a_pixel(self):
|
||||
sh = self._strip()
|
||||
a = self._frame(sh, 500.0, True)
|
||||
b = self._frame(sh, 500.5, True)
|
||||
assert not np.array_equal(a, b)
|
||||
|
||||
def test_zero_fraction_matches_the_integer_frame(self):
|
||||
# No interpolation to do, so it must be pixel-identical and take the
|
||||
# cheap path.
|
||||
sh = self._strip()
|
||||
assert np.array_equal(self._frame(sh, 700.0, True),
|
||||
self._frame(sh, 700.0, False))
|
||||
|
||||
def test_blend_is_monotonic_between_neighbours(self):
|
||||
# Marching the fraction from 0 to 1 should approach the next integer
|
||||
# frame, not wander.
|
||||
sh = self._strip()
|
||||
target = self._frame(sh, 501.0, False)
|
||||
dists = []
|
||||
for frac in (0.0, 0.25, 0.5, 0.75):
|
||||
f = self._frame(sh, 500.0 + frac, True)
|
||||
dists.append(np.abs(f - target).mean())
|
||||
assert dists == sorted(dists, reverse=True), f"not converging: {dists}"
|
||||
|
||||
def test_blend_endpoints_bracket_the_two_frames(self):
|
||||
sh = self._strip()
|
||||
near = self._frame(sh, 500.0, False)
|
||||
far = self._frame(sh, 501.0, False)
|
||||
mid = self._frame(sh, 500.5, True)
|
||||
# Every blended pixel must lie between its two sources.
|
||||
lo = np.minimum(near, far)
|
||||
hi = np.maximum(near, far)
|
||||
assert (mid >= lo - 1).all() and (mid <= hi + 1).all()
|
||||
|
||||
def test_output_size_and_mode_are_unchanged(self):
|
||||
sh = self._strip()
|
||||
sh.sub_pixel_scrolling = True
|
||||
sh.scroll_position = 300.4
|
||||
frame = sh.get_visible_portion()
|
||||
assert frame.size == (W, H)
|
||||
assert frame.mode == 'RGB'
|
||||
|
||||
def test_works_near_the_end_of_the_strip(self):
|
||||
# One of the two slices wraps here; must not raise or missize.
|
||||
sh = self._strip(width=600)
|
||||
sh.sub_pixel_scrolling = True
|
||||
sh.scroll_position = float(600 - W // 2) + 0.5
|
||||
frame = sh.get_visible_portion()
|
||||
assert frame is not None and frame.size == (W, H)
|
||||
|
||||
def test_works_at_the_very_last_column(self):
|
||||
sh = self._strip(width=600)
|
||||
sh.sub_pixel_scrolling = True
|
||||
sh.scroll_position = 599.5
|
||||
assert sh.get_visible_portion().size == (W, H)
|
||||
|
||||
@pytest.mark.parametrize("frac", [0.01, 0.1, 0.33, 0.5, 0.67, 0.9, 0.99])
|
||||
def test_never_raises_across_the_fraction_range(self, frac):
|
||||
sh = self._strip()
|
||||
sh.sub_pixel_scrolling = True
|
||||
sh.scroll_position = 400.0 + frac
|
||||
assert sh.get_visible_portion().size == (W, H)
|
||||
+669
-1
@@ -3,6 +3,8 @@ Tests for the Vegas mode density work: dead-space trimming in PluginAdapter
|
||||
and the configurable lead-in gap in ScrollHelper.
|
||||
"""
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
@@ -16,13 +18,45 @@ DISPLAY_H = 64
|
||||
|
||||
|
||||
class FakeDisplayManager:
|
||||
"""Minimal stand-in; the native content path never touches the canvas."""
|
||||
"""Stand-in offering the same contexts the real DisplayManager does.
|
||||
|
||||
capture_mode and render_size must both be present: the adapter degrades
|
||||
gracefully when they are missing, so a fake without them would silently
|
||||
exercise the degraded path instead of the real one.
|
||||
"""
|
||||
|
||||
width = DISPLAY_W
|
||||
height = DISPLAY_H
|
||||
|
||||
def __init__(self):
|
||||
self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H))
|
||||
self.draw = None
|
||||
self._capture_mode_active = False
|
||||
|
||||
@contextmanager
|
||||
def capture_mode(self):
|
||||
self._capture_mode_active = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._capture_mode_active = False
|
||||
|
||||
@contextmanager
|
||||
def render_size(self, width, height=None):
|
||||
prev = self.image
|
||||
target_w = max(1, min(int(width), DISPLAY_W))
|
||||
target_h = max(1, min(int(height) if height else DISPLAY_H, DISPLAY_H))
|
||||
try:
|
||||
self.image = Image.new('RGB', (target_w, target_h))
|
||||
yield
|
||||
finally:
|
||||
self.image = prev
|
||||
|
||||
def clear(self):
|
||||
self.image = Image.new('RGB', self.image.size)
|
||||
|
||||
def update_display(self):
|
||||
pass
|
||||
|
||||
|
||||
class NativePlugin:
|
||||
@@ -969,3 +1003,637 @@ class TestRotationAcrossMultipleCycles:
|
||||
for _ in range(10):
|
||||
adapter.invalidate_cache('rows')
|
||||
assert adapter.get_content(NativePlugin(rows), 'rows')
|
||||
|
||||
|
||||
def word_strip(words, letter_w=7, letter_gap=1, item_gap=32, height=DISPLAY_H):
|
||||
"""
|
||||
Build a ticker-like strip: 'words' of solid blocks separated by 1px, with
|
||||
a wide gap between words. Mirrors real rendered text, where the measured
|
||||
gap between characters is a single column and the gap between items is 8px+.
|
||||
|
||||
Returns (image, list of (word_start, word_end) column ranges).
|
||||
"""
|
||||
spans = []
|
||||
x = 0
|
||||
for w_i, letters in enumerate(words):
|
||||
start = x
|
||||
for l_i in range(letters):
|
||||
x += letter_w
|
||||
if l_i < letters - 1:
|
||||
x += letter_gap
|
||||
spans.append((start, x))
|
||||
if w_i < len(words) - 1:
|
||||
x += item_gap
|
||||
img = Image.new('RGB', (x, height), (0, 0, 0))
|
||||
for w_i, letters in enumerate(words):
|
||||
sx = spans[w_i][0]
|
||||
for l_i in range(letters):
|
||||
lx = sx + l_i * (letter_w + letter_gap)
|
||||
img.paste(Image.new('RGB', (letter_w, height), (255, 255, 255)), (lx, 0))
|
||||
return img, spans
|
||||
|
||||
|
||||
class TestCutsNeverSplitWords:
|
||||
"""
|
||||
A cut placed in a 1px inter-letter gap orphans the tail of a word into the
|
||||
next cycle. That is what produced a lone "y" from "Wednesday" floating
|
||||
between two unrelated plugins.
|
||||
"""
|
||||
|
||||
def test_cut_lands_in_an_item_gap_not_between_letters(self):
|
||||
from src.vegas_mode.geometry import blank_runs
|
||||
img, spans = word_strip([9, 9, 9, 9, 9]) # five 9-letter words
|
||||
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2,
|
||||
min_cut_gap=6)
|
||||
# Budget deliberately lands mid-word if cut naively.
|
||||
images = adapter.get_content(NativePlugin([img]), 'ticker')
|
||||
cut_width = images[0].width
|
||||
|
||||
# Every qualifying gap midpoint is a legal cut; assert we used one.
|
||||
legal = {0, img.width} | {(a + b) // 2 for a, b in blank_runs(img, 6)}
|
||||
assert cut_width in {c for c in legal}, \
|
||||
f"cut at {cut_width} is not an item boundary; legal: {sorted(legal)}"
|
||||
|
||||
def test_no_partial_letter_at_either_edge(self):
|
||||
# A split letter shows as a lit column touching the crop edge with the
|
||||
# rest of its glyph missing. Requiring the edges to be blank is the
|
||||
# simplest way to assert we cut inside a gap.
|
||||
from src.vegas_mode.geometry import column_has_ink
|
||||
img, _ = word_strip([9, 9, 9, 9, 9, 9])
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.25,
|
||||
min_cut_gap=6)
|
||||
out = adapter.get_content(NativePlugin([img]), 'ticker')[0]
|
||||
ink = column_has_ink(out)
|
||||
assert not ink[0] or not ink[-1] or out.width == img.width, \
|
||||
"crop edges land on ink, so a glyph was cut through"
|
||||
|
||||
def test_rotation_never_orphans_a_fragment(self):
|
||||
# Walk the window across the whole strip and assert no slice is a
|
||||
# narrow sliver, which is what an orphaned letter looks like.
|
||||
img, _ = word_strip([9] * 8)
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2,
|
||||
min_cut_gap=6)
|
||||
widths = []
|
||||
for _ in range(12):
|
||||
adapter.invalidate_cache('ticker')
|
||||
out = adapter.get_content(NativePlugin([img]), 'ticker')
|
||||
assert out
|
||||
widths.append(out[0].width)
|
||||
# A single 7px letter is the fragment signature; nothing that narrow.
|
||||
assert min(widths) > 10, f"orphaned fragment in {widths}"
|
||||
|
||||
def test_continuous_image_is_still_cut_to_budget(self):
|
||||
# A map or chart has no item gaps; the gap rule must not let it escape
|
||||
# the cap, because any column there is as good as another.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
|
||||
min_cut_gap=6)
|
||||
solid = canvas([(0, 4000)], width=4000)
|
||||
out = adapter.get_content(NativePlugin([solid]), 'geochron')[0]
|
||||
assert out.width == DISPLAY_W
|
||||
|
||||
def test_overruns_budget_rather_than_splitting(self):
|
||||
# One very long item with no internal gap: the cut must wait for the
|
||||
# next real boundary even though that exceeds the budget.
|
||||
img, spans = word_strip([80, 9], letter_gap=1, item_gap=32)
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.1,
|
||||
min_cut_gap=6)
|
||||
out = adapter.get_content(NativePlugin([img]), 'longitem')[0]
|
||||
assert out.width > int(DISPLAY_W * 0.1), \
|
||||
"should overrun to the next boundary instead of cutting the item"
|
||||
|
||||
def test_min_cut_gap_of_two_still_excludes_letter_spacing(self):
|
||||
from src.vegas_mode.geometry import blank_runs
|
||||
img, _ = word_strip([9, 9, 9])
|
||||
# 1px letter gaps must never qualify, whatever the setting.
|
||||
assert all(end - start >= 2 for start, end in blank_runs(img, 2))
|
||||
|
||||
|
||||
def test_permissive_gap_rule_would_have_split_a_word(self):
|
||||
"""
|
||||
Pins the actual regression. With min_cut_gap=1 the 1px gaps between
|
||||
letters qualify as cut points, so the crop lands inside a word; with the
|
||||
default it can only land in the wide gaps between items.
|
||||
"""
|
||||
from src.vegas_mode.geometry import blank_runs
|
||||
img, _ = word_strip([9, 9, 9, 9, 9])
|
||||
|
||||
letter_gaps = {(a + b) // 2 for a, b in blank_runs(img, 1)}
|
||||
item_gaps = {(a + b) // 2 for a, b in blank_runs(img, 6)}
|
||||
|
||||
# The permissive rule offers many more cut points, and the extra ones
|
||||
# are exactly the mid-word positions.
|
||||
assert len(letter_gaps) > len(item_gaps)
|
||||
mid_word = letter_gaps - item_gaps
|
||||
assert mid_word, "expected inter-letter gaps to exist in the fixture"
|
||||
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=0.2,
|
||||
min_cut_gap=6)
|
||||
out = adapter.get_content(NativePlugin([img]), 'ticker')[0]
|
||||
assert out.width not in mid_word, \
|
||||
f"cut at {out.width} is a mid-word position"
|
||||
|
||||
|
||||
class TestCaptureModeAlwaysHeld:
|
||||
"""
|
||||
Building Vegas content is off-screen work, but plugins are free to call
|
||||
update_display() while doing it. Outside capture_mode that write reaches the
|
||||
hardware and flashes the panel mid-scroll, so every path that runs plugin
|
||||
render code must hold capture_mode — including at full width, where the
|
||||
narrowing context is a no-op.
|
||||
"""
|
||||
|
||||
class RecordingDM:
|
||||
width = DISPLAY_W
|
||||
height = DISPLAY_H
|
||||
|
||||
def __init__(self):
|
||||
self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H))
|
||||
self.draw = None
|
||||
self.capture_depth = 0
|
||||
self.hardware_writes_while_uncaptured = 0
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def capture_mode(self):
|
||||
self.capture_depth += 1
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.capture_depth -= 1
|
||||
|
||||
@contextmanager
|
||||
def render_size(self, width, height=None):
|
||||
yield
|
||||
|
||||
def clear(self):
|
||||
self.image = Image.new('RGB', (DISPLAY_W, DISPLAY_H))
|
||||
|
||||
def update_display(self):
|
||||
if self.capture_depth == 0:
|
||||
self.hardware_writes_while_uncaptured += 1
|
||||
|
||||
class PushyPlugin:
|
||||
"""A plugin that pushes to the display while building Vegas content."""
|
||||
|
||||
def __init__(self, dm, images):
|
||||
self.display_manager = dm
|
||||
self.config = {}
|
||||
self._images = images
|
||||
|
||||
def get_vegas_content(self):
|
||||
self.display_manager.update_display()
|
||||
return self._images
|
||||
|
||||
def _run(self, **cfg):
|
||||
dm = self.RecordingDM()
|
||||
adapter = PluginAdapter(dm, VegasModeConfig(**cfg))
|
||||
plugin = self.PushyPlugin(dm, [canvas([(100, 300)])])
|
||||
adapter.get_content(plugin, 'pushy')
|
||||
return dm
|
||||
|
||||
def test_no_hardware_write_escapes_at_full_width(self):
|
||||
dm = self._run(render_width_pct=100)
|
||||
assert dm.hardware_writes_while_uncaptured == 0
|
||||
|
||||
def test_no_hardware_write_escapes_when_narrowing(self):
|
||||
dm = self._run(render_width_pct=50)
|
||||
assert dm.hardware_writes_while_uncaptured == 0
|
||||
|
||||
def test_capture_mode_is_released_afterwards(self):
|
||||
dm = self._run(render_width_pct=100)
|
||||
assert dm.capture_depth == 0
|
||||
|
||||
def test_capture_mode_released_even_when_the_plugin_raises(self):
|
||||
dm = self.RecordingDM()
|
||||
adapter = PluginAdapter(dm, VegasModeConfig())
|
||||
|
||||
class Boom:
|
||||
def __init__(self, d):
|
||||
self.display_manager = d
|
||||
self.config = {}
|
||||
|
||||
def get_vegas_content(self):
|
||||
raise ValueError("boom")
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestOverflowMode:
|
||||
"""
|
||||
Rotating a window through content only makes sense when the items are
|
||||
interchangeable. For ordered content it shows the middle of a ranked list —
|
||||
ranks 1-6, then 7 onwards two rotations later, which reads as out of order.
|
||||
"""
|
||||
|
||||
class Cfg:
|
||||
def __init__(self, cfg=None):
|
||||
self.config = cfg or {}
|
||||
|
||||
def get_vegas_content(self):
|
||||
return None
|
||||
|
||||
def test_default_is_rotate(self):
|
||||
adapter = adapter_with()
|
||||
assert adapter.resolve_overflow_mode(self.Cfg(), 'p') == 'rotate'
|
||||
|
||||
def test_global_mode_applies(self):
|
||||
adapter = adapter_with(overflow_mode='truncate')
|
||||
assert adapter.resolve_overflow_mode(self.Cfg(), 'p') == 'truncate'
|
||||
|
||||
def test_per_plugin_override_wins(self):
|
||||
adapter = adapter_with(overflow_mode='rotate')
|
||||
plugin = self.Cfg({'vegas_overflow': 'truncate'})
|
||||
assert adapter.resolve_overflow_mode(plugin, 'p') == 'truncate'
|
||||
|
||||
@pytest.mark.parametrize('bad', ['sideways', '', None, 5])
|
||||
def test_invalid_override_falls_back(self, bad):
|
||||
adapter = adapter_with(overflow_mode='rotate')
|
||||
plugin = self.Cfg({'vegas_overflow': bad})
|
||||
assert adapter.resolve_overflow_mode(plugin, 'p') == 'rotate'
|
||||
|
||||
def test_truncate_always_shows_the_same_opening_rows(self):
|
||||
# The reported problem: a ranked list should not resume from the middle.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
|
||||
intra_plugin_gap=0, min_content_separation=0,
|
||||
overflow_mode='truncate')
|
||||
rows = [canvas([(0, 200)], width=200) for _ in range(8)]
|
||||
plugin = NativePlugin(rows)
|
||||
|
||||
first = adapter.get_content(plugin, 'ranks')
|
||||
adapter.invalidate_cache('ranks')
|
||||
second = adapter.get_content(plugin, 'ranks')
|
||||
assert len(first) == len(second)
|
||||
assert 'ranks' not in adapter._item_offsets, "must not advance a window"
|
||||
|
||||
def test_rotate_still_advances(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
|
||||
intra_plugin_gap=0, min_content_separation=0,
|
||||
overflow_mode='rotate')
|
||||
rows = [canvas([(0, 200)], width=200) for _ in range(8)]
|
||||
adapter.get_content(NativePlugin(rows), 'ticker')
|
||||
assert adapter._item_offsets.get('ticker', 0) > 0
|
||||
|
||||
def test_truncate_on_a_single_wide_image_starts_at_zero(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
|
||||
overflow_mode='truncate')
|
||||
strip = canvas([(0, 5000)], width=5000)
|
||||
plugin = NativePlugin([strip])
|
||||
first = adapter.get_content(plugin, 'table')[0]
|
||||
adapter.invalidate_cache('table')
|
||||
second = adapter.get_content(plugin, 'table')[0]
|
||||
assert first.tobytes() == second.tobytes(), "window must not advance"
|
||||
|
||||
|
||||
class TestPerPluginWidthBudget:
|
||||
class Cfg:
|
||||
def __init__(self, cfg=None):
|
||||
self.config = cfg or {}
|
||||
|
||||
def get_vegas_content(self):
|
||||
return None
|
||||
|
||||
def test_global_ratio_by_default(self):
|
||||
adapter = adapter_with(max_plugin_width_ratio=3.0)
|
||||
assert adapter._width_budget(self.Cfg(), 'p') == DISPLAY_W * 3
|
||||
|
||||
def test_per_plugin_override_widens(self):
|
||||
adapter = adapter_with(max_plugin_width_ratio=3.0)
|
||||
plugin = self.Cfg({'vegas_max_width_screens': 8})
|
||||
assert adapter._width_budget(plugin, 'p') == DISPLAY_W * 8
|
||||
|
||||
def test_per_plugin_zero_means_uncapped(self):
|
||||
adapter = adapter_with(max_plugin_width_ratio=3.0)
|
||||
plugin = self.Cfg({'vegas_max_width_screens': 0})
|
||||
assert adapter._width_budget(plugin, 'p') == 0
|
||||
|
||||
def test_uncapped_plugin_keeps_all_its_content(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0)
|
||||
|
||||
class Wide(NativePlugin):
|
||||
def __init__(self, images):
|
||||
super().__init__(images)
|
||||
self.config = {'vegas_max_width_screens': 0}
|
||||
|
||||
strip = canvas([(0, 6000)], width=6000)
|
||||
assert adapter.get_content(Wide([strip]), 'whole')[0].width == 6000
|
||||
|
||||
@pytest.mark.parametrize('bad', ['wide', -1, None, ''])
|
||||
def test_invalid_override_falls_back_to_global(self, bad):
|
||||
adapter = adapter_with(max_plugin_width_ratio=2.0)
|
||||
plugin = self.Cfg({'vegas_max_width_screens': bad})
|
||||
assert adapter._width_budget(plugin, 'p') == DISPLAY_W * 2
|
||||
|
||||
def test_no_plugin_uses_the_global(self):
|
||||
adapter = adapter_with(max_plugin_width_ratio=2.0)
|
||||
assert adapter._width_budget() == DISPLAY_W * 2
|
||||
|
||||
def test_truncate_single_image_never_records_an_offset(self):
|
||||
# The behavioural guarantee behind the log message: no window state is
|
||||
# kept, so every pass starts at the top.
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
|
||||
overflow_mode='truncate')
|
||||
strip = canvas([(0, 5000)], width=5000)
|
||||
for _ in range(4):
|
||||
adapter.invalidate_cache('table')
|
||||
adapter.get_content(NativePlugin([strip]), 'table')
|
||||
assert 'table' not in adapter._item_offsets
|
||||
|
||||
def test_rotate_single_image_does_record_an_offset(self):
|
||||
adapter = adapter_with(content_padding=0, max_plugin_width_ratio=1.0,
|
||||
overflow_mode='rotate')
|
||||
strip = canvas([(0, 5000)], width=5000)
|
||||
adapter.get_content(NativePlugin([strip]), 'ticker')
|
||||
assert adapter._item_offsets.get('ticker', 0) > 0
|
||||
|
||||
@@ -924,7 +924,9 @@ 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_content_separation', 'vegas_min_cut_gap',
|
||||
'vegas_continuous_scroll', 'vegas_extend_threshold_screens',
|
||||
'vegas_smooth_scroll', 'vegas_overflow_mode']
|
||||
|
||||
if any(k in data for k in vegas_fields):
|
||||
if 'display' not in current_config:
|
||||
@@ -942,9 +944,40 @@ 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'))
|
||||
vegas_config['smooth_scroll'] = _coerce_to_bool(
|
||||
data.get('vegas_smooth_scroll'))
|
||||
|
||||
# max_plugin_width_ratio is the one fractional setting, so it is
|
||||
# handled outside the integer loop below.
|
||||
if data.get('vegas_overflow_mode') not in ('', None):
|
||||
mode = str(data['vegas_overflow_mode']).strip().lower()
|
||||
if mode not in ('rotate', 'truncate'):
|
||||
return jsonify({
|
||||
'status': 'error',
|
||||
'message': "Invalid value for vegas_overflow_mode: "
|
||||
"must be 'rotate' or 'truncate'"
|
||||
}), 400
|
||||
vegas_config['overflow_mode'] = mode
|
||||
|
||||
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'])
|
||||
@@ -977,6 +1010,7 @@ def save_main_config():
|
||||
'vegas_intra_plugin_gap': ('intra_plugin_gap', 0, 128),
|
||||
'vegas_render_width_pct': ('render_width_pct', 10, 100),
|
||||
'vegas_min_content_separation': ('min_content_separation', 0, 256),
|
||||
'vegas_min_cut_gap': ('min_cut_gap', 1, 128),
|
||||
'vegas_target_fps': ('target_fps', 30, 200),
|
||||
'vegas_buffer_ahead': ('buffer_ahead', 1, 5),
|
||||
'vegas_trim_threshold': ('trim_threshold', 0, 254),
|
||||
|
||||
@@ -499,6 +499,42 @@
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-3">Cycle Pacing</h4>
|
||||
<p class="text-sm text-gray-600 mb-3">How long one pass through the ticker lasts, and how many plugins it covers.</p>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-vegas_continuous_scroll" data-setting-key="display.vegas_scroll.continuous_scroll">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_continuous_scroll"
|
||||
name="vegas_continuous_scroll"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('continuous_scroll', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Scroll continuously between groups{{ ui.help_tip('Keep one endless strip, extending it with the next group of plugins as the scroll approaches the end, so they simply arrive from the right. Default: on.\nWith this off the ticker builds a fresh strip and swaps it in, which stops the motion, replaces everything at once and restarts with the screen already full — a freeze, a flash and a jump.', 'Continuous Scroll') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4" id="setting-display-vegas_smooth_scroll" data-setting-key="display.vegas_scroll.smooth_scroll">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox"
|
||||
id="vegas_smooth_scroll"
|
||||
name="vegas_smooth_scroll"
|
||||
{% if main_config.display.get('vegas_scroll', {}).get('smooth_scroll', True) %}checked{% endif %}
|
||||
class="form-checkbox">
|
||||
<span class="ml-2 text-sm text-gray-700">Smooth sub-pixel motion{{ ui.help_tip('Blend between neighbouring pixel positions so the ticker moves once per rendered frame instead of once per pixel. Default: on.\nWithout it, motion happens only as often as the scroll speed in pixels per second — at 50 px/s that is 50 steps a second however fast the display renders, which reads as a slight judder. The trade is that text softens very slightly horizontally, since each frame blends two positions. Turn it off if you prefer maximum crispness.', 'Smooth Scrolling') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-group" id="setting-display-vegas_extend_threshold_screens" data-setting-key="display.vegas_scroll.extend_threshold_screens">
|
||||
<label for="vegas_extend_threshold_screens" class="block text-sm font-medium text-gray-700">Extend When (screens left){{ ui.help_tip('How much unscrolled content triggers loading the next group, measured in screen widths (1.0–10.0).\nDefault: 2. Higher loads earlier and leaves more slack, at the cost of holding more content in memory. Only applies when Continuous Scroll is on.', 'Extend Threshold') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_extend_threshold_screens"
|
||||
name="vegas_extend_threshold_screens"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('extend_threshold_screens', 2.0) }}"
|
||||
min="1"
|
||||
max="10"
|
||||
step="0.5"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-group" id="setting-display-vegas_plugins_per_cycle" data-setting-key="display.vegas_scroll.plugins_per_cycle">
|
||||
<label for="vegas_plugins_per_cycle" class="block text-sm font-medium text-gray-700">Plugins Per Cycle{{ ui.help_tip('How many plugins are composed into one pass of the ticker (1–50).\nDefault: 6. Higher means more variety before the ticker restarts, and fewer recompose pauses. Lower means each plugin comes around sooner.', 'Plugins Per Cycle') }}</label>
|
||||
@@ -511,6 +547,14 @@
|
||||
class="form-control">
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_overflow_mode" data-setting-key="display.vegas_scroll.overflow_mode">
|
||||
<label for="vegas_overflow_mode" class="block text-sm font-medium text-gray-700">When A Plugin Is Too Wide{{ ui.help_tip('What to do when a plugin has more content than its width allowance.\nRotate through it: show a different slice each time round, so everything is seen eventually. Right for interchangeable items like news headlines, odds or stock prices.\nShow the start only: always display from the beginning and drop the rest. Right for ordered content — a league table that shows ranks 1-6 and then resumes at 7 two rotations later reads as out of order.\nDefault: rotate. Override for one plugin with vegas_overflow in its own settings.', 'Overflow Handling') }}</label>
|
||||
<select id="vegas_overflow_mode" name="vegas_overflow_mode" class="form-control">
|
||||
<option value="rotate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'rotate' %}selected{% endif %}>Rotate through it (default)</option>
|
||||
<option value="truncate" {% if main_config.display.get('vegas_scroll', {}).get('overflow_mode', 'rotate') == 'truncate' %}selected{% endif %}>Show the start only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="setting-display-vegas_max_plugin_width_ratio" data-setting-key="display.vegas_scroll.max_plugin_width_ratio">
|
||||
<label for="vegas_max_plugin_width_ratio" class="block text-sm font-medium text-gray-700">Max Plugin Width (screens){{ ui.help_tip('Caps how much of one cycle a single plugin may occupy, measured in screen widths (0–20).\nDefault: 3. A long ticker such as a news feed or leaderboard is trimmed to this and the remainder shown on later cycles, so one plugin cannot hold the display for minutes. Set 0 for no limit.', 'Max Plugin Width') }}</label>
|
||||
<input type="number"
|
||||
@@ -623,6 +667,19 @@
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div class="form-group" id="setting-display-vegas_min_cut_gap" data-setting-key="display.vegas_scroll.min_cut_gap">
|
||||
<label for="vegas_min_cut_gap" class="block text-sm font-medium text-gray-700">Min Cut Gap (pixels){{ ui.help_tip('When a plugin is too wide for its share of a cycle and has to be narrowed, the cut is only made where there is at least this much blank space (1–128 px).\nDefault: 6. The gaps between letters are about 1px wide, so a smaller value lets a cut land inside a word and orphan its last letter into the next cycle. Raise it if cuts still land awkwardly. Continuous images such as maps have no gaps and are cut to fit regardless.', 'Min Cut Gap') }}</label>
|
||||
<input type="number"
|
||||
id="vegas_min_cut_gap"
|
||||
name="vegas_min_cut_gap"
|
||||
value="{{ main_config.display.get('vegas_scroll', {}).get('min_cut_gap', 6) }}"
|
||||
min="1"
|
||||
max="128"
|
||||
class="form-control">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugin Order Section -->
|
||||
|
||||
Reference in New Issue
Block a user