mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Vegas mode: reclaim dead space and pace the rotation
On a wide panel Vegas mode spent much of its time showing black. At 50px/s on a 512px display, one display width of blank is 10.2 seconds, which makes several long-standing behaviours expensive: - ScrollHelper prepended a full display width of black as an "initial gap", charged once per cycle — 10.2s of black at the start of every rotation. - Plugins without get_vegas_content() are captured off a full-display canvas, so their blank margins entered the ticker too. Measured: of-the-day drew 35px of "No Data" on a 512px canvas (92% blank), youtube-stats 142px of content with 185px of black either side. Only the scroll_helper path had any trimming. - Cycle transitions deliberately pushed a blank frame and then recomposed synchronously: 84ms at best, 4.8s at worst, every millisecond of it black. - buffer_ahead doubled as the cycle size, so a 21-plugin install showed 3 plugins per cycle and took ~7 cycles to come around. - separator_width was applied between every image rather than at plugin boundaries, so a per-row ticker like the F1 scoreboard (116 images, which it renders 4px apart internally) got a 32px chasm between each row — and the width budget didn't count those gaps, so the plugin quietly occupied far more of the panel than intended. Changes: - src/vegas_mode/geometry.py: numpy column-ink primitives shared by the trimmer and the audit tool, so the number reported is the number acted on. A Python per-column loop over a 17,000px strip is far too slow for the render path. - PluginAdapter trims every content path, not just scroll_helper. Only outer edges are cropped: interior blank columns are the plugin's own layout (logo left, score right) and closing them would corrupt the design. A plugin on a non-black background is inherently unaffected. - ScrollHelper.create_scrolling_image takes an explicit lead_gap, still defaulting to display_width so the many standalone-ticker callers are unchanged. Vegas passes lead_in_width (default 0). - Cycle end holds the last rendered frame instead of blanking, turning the recompose into a brief freeze rather than the panel switching off. - plugins_per_cycle (default 6) is split from buffer_ahead, which goes back to being only a prefetch low-water mark. - max_plugin_width_ratio (default 3x display width) caps one plugin's share of a cycle. Overflow is deferred, not discarded: a rotation offset advances each fetch so later rows appear on subsequent cycles. Single oversized images are cropped at a blank column so the cut misses glyphs. - Composition groups images by plugin: rows are joined by intra_plugin_gap (default 8) and separator_width applies only between plugins. The width budget now counts those gaps. - Plugin data updates no longer run on the Vegas render path. All new settings are user-configurable in Display -> Vegas Scroll, including min/max cycle duration and dynamic duration, which previously existed in code but were reachable only by hand-editing config.json. Measured with scripts/dev/vegas_audit.py on a 512x64 panel: mean ink coverage 42.7% -> 69.4% fully blank 5.9% -> 0% reads as empty 13.6% -> 0% worst blank stretch 4.8s -> 0s full rotation 414s -> 123s plugins per cycle 3 -> 6 Note the metric choice: a "fully blank" scan (>=95% black viewport) reported only 0.4% and badly understated the problem, because two full-width segments with mid-canvas content never fully blank the viewport — they hold it at ~28%. window_coverage_stats grades every viewport position by how much ink it carries, which is what tracks perceived dead time. Known remaining: cycle transitions still freeze ~3.5s while the next cycle is fetched. Fixing that needs background prefetch, which is deferred because the fallback-capture path mutates the shared display_manager.image and racing it against the render loop risks torn frames. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
This commit is contained in:
@@ -110,20 +110,30 @@ class ScrollHelper:
|
||||
self.is_scrolling = False
|
||||
self.scroll_complete = False
|
||||
|
||||
def create_scrolling_image(self, content_items: list,
|
||||
def create_scrolling_image(self, content_items: list,
|
||||
item_gap: int = 32,
|
||||
element_gap: int = 16) -> Image.Image:
|
||||
element_gap: int = 16,
|
||||
lead_gap: Optional[int] = None) -> Image.Image:
|
||||
"""
|
||||
Create a wide image containing all content items for scrolling.
|
||||
|
||||
|
||||
Args:
|
||||
content_items: List of PIL Images to include in scroll
|
||||
item_gap: Gap between different items
|
||||
element_gap: Gap between elements within an item
|
||||
|
||||
lead_gap: Blank columns before the first item. Defaults to a full
|
||||
display width, which makes a standalone ticker scroll in from
|
||||
off-screen. Callers that loop many plugins back-to-back (Vegas
|
||||
mode) pass a smaller value, since a full display width of black
|
||||
reads as the panel being switched off at the start of every
|
||||
cycle.
|
||||
|
||||
Returns:
|
||||
PIL Image containing all content arranged horizontally
|
||||
"""
|
||||
if lead_gap is None:
|
||||
lead_gap = self.display_width
|
||||
lead_gap = max(0, int(lead_gap))
|
||||
if not content_items:
|
||||
# Create empty image if no content
|
||||
# Still set total_scroll_width to 0 to indicate no scrollable content
|
||||
@@ -144,13 +154,13 @@ class ScrollHelper:
|
||||
total_width += element_gap * len(content_items)
|
||||
|
||||
# Add initial gap before first item
|
||||
total_width += self.display_width
|
||||
|
||||
total_width += lead_gap
|
||||
|
||||
# Create the full scrolling image
|
||||
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
|
||||
|
||||
|
||||
# Position items
|
||||
current_x = self.display_width # Start with initial gap
|
||||
current_x = lead_gap # Start with initial gap
|
||||
|
||||
for i, img in enumerate(content_items):
|
||||
# Paste the item image
|
||||
|
||||
@@ -15,6 +15,7 @@ PIL Image canvas and draws text using the actual project fonts.
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
@@ -62,6 +63,9 @@ class VisualTestDisplayManager:
|
||||
# Matrix proxy (plugins access display_manager.matrix.width/height)
|
||||
self.matrix = _MatrixProxy(width, height)
|
||||
|
||||
# Set while inside capture_mode(); mirrors DisplayManager's flag.
|
||||
self._capture_mode_active = False
|
||||
|
||||
# Scrolling state (interface compat, no-op)
|
||||
self._scrolling_state = {
|
||||
'is_scrolling': False,
|
||||
@@ -174,6 +178,21 @@ class VisualTestDisplayManager:
|
||||
"""No-op for hardware; marks that display was updated."""
|
||||
self.update_called = True
|
||||
|
||||
@contextmanager
|
||||
def capture_mode(self):
|
||||
"""
|
||||
Interface parity with DisplayManager.capture_mode().
|
||||
|
||||
There is no hardware to suppress here, but Vegas mode's PluginAdapter
|
||||
wraps every off-screen content fetch in this context, so the harness
|
||||
must provide it for that code path to be exercisable in tests.
|
||||
"""
|
||||
self._capture_mode_active = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._capture_mode_active = False
|
||||
|
||||
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
|
||||
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
|
||||
font: Optional[Any] = None, centered: bool = False) -> None:
|
||||
|
||||
@@ -21,6 +21,41 @@ class VegasModeConfig:
|
||||
scroll_speed: float = 50.0 # Pixels per second
|
||||
separator_width: int = 32 # Gap between plugins (pixels)
|
||||
|
||||
# Gap between rows contributed by the *same* plugin. separator_width marks
|
||||
# the handoff from one plugin to the next; applying it between every image
|
||||
# forced a 32px chasm between each row of a per-row ticker (the F1
|
||||
# scoreboard renders its own rows 4px apart), which both looked wrong and
|
||||
# silently inflated the width that plugin occupied.
|
||||
intra_plugin_gap: int = 8
|
||||
|
||||
# Content density
|
||||
#
|
||||
# Plugins that render onto a full-display canvas contribute that whole
|
||||
# canvas to the ticker, blank margins included. On a wide panel that is the
|
||||
# dominant source of dead air: a plugin drawing 35px of text on a 512px
|
||||
# canvas otherwise buys 9.5s of black at 50px/s. Trimming reclaims it.
|
||||
auto_trim: bool = True
|
||||
trim_threshold: int = 10 # Per-channel value a pixel must exceed to be "ink"
|
||||
content_padding: int = 8 # Blank columns kept either side of trimmed content
|
||||
min_plugin_width: int = 8 # Segments narrower than this after trim are dropped
|
||||
|
||||
# Columns of blank lead-in before the first item of a cycle. ScrollHelper
|
||||
# defaults this to a full display width, which reads as the display being
|
||||
# switched off at the start of every cycle.
|
||||
lead_in_width: int = 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
|
||||
|
||||
# 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.
|
||||
# 0 disables the cap.
|
||||
max_plugin_width_ratio: float = 3.0
|
||||
|
||||
# Plugin management
|
||||
plugin_order: List[str] = field(default_factory=list)
|
||||
excluded_plugins: Set[str] = field(default_factory=set)
|
||||
@@ -55,6 +90,15 @@ class VegasModeConfig:
|
||||
enabled=vegas_config.get('enabled', False),
|
||||
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
|
||||
separator_width=int(vegas_config.get('separator_width', 32)),
|
||||
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
|
||||
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)),
|
||||
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)),
|
||||
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
|
||||
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)),
|
||||
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)),
|
||||
@@ -72,6 +116,14 @@ class VegasModeConfig:
|
||||
'enabled': self.enabled,
|
||||
'scroll_speed': self.scroll_speed,
|
||||
'separator_width': self.separator_width,
|
||||
'intra_plugin_gap': self.intra_plugin_gap,
|
||||
'auto_trim': self.auto_trim,
|
||||
'trim_threshold': self.trim_threshold,
|
||||
'content_padding': self.content_padding,
|
||||
'min_plugin_width': self.min_plugin_width,
|
||||
'lead_in_width': self.lead_in_width,
|
||||
'plugins_per_cycle': self.plugins_per_cycle,
|
||||
'max_plugin_width_ratio': self.max_plugin_width_ratio,
|
||||
'plugin_order': self.plugin_order,
|
||||
'excluded_plugins': list(self.excluded_plugins),
|
||||
'target_fps': self.target_fps,
|
||||
@@ -157,6 +209,44 @@ class VegasModeConfig:
|
||||
if self.buffer_ahead > 5:
|
||||
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
|
||||
|
||||
if self.intra_plugin_gap < 0:
|
||||
errors.append(
|
||||
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
|
||||
if self.intra_plugin_gap > 128:
|
||||
errors.append(
|
||||
f"intra_plugin_gap must be <= 128, got {self.intra_plugin_gap}")
|
||||
|
||||
if not 0 <= self.trim_threshold <= 254:
|
||||
errors.append(
|
||||
f"trim_threshold must be between 0 and 254, got {self.trim_threshold}")
|
||||
|
||||
if self.content_padding < 0:
|
||||
errors.append(
|
||||
f"content_padding must be >= 0, got {self.content_padding}")
|
||||
if self.content_padding > 128:
|
||||
errors.append(
|
||||
f"content_padding must be <= 128, got {self.content_padding}")
|
||||
|
||||
if self.min_plugin_width < 0:
|
||||
errors.append(
|
||||
f"min_plugin_width must be >= 0, got {self.min_plugin_width}")
|
||||
|
||||
if self.lead_in_width < 0:
|
||||
errors.append(
|
||||
f"lead_in_width must be >= 0, got {self.lead_in_width}")
|
||||
|
||||
if self.plugins_per_cycle < 1:
|
||||
errors.append(
|
||||
f"plugins_per_cycle must be >= 1, got {self.plugins_per_cycle}")
|
||||
if self.plugins_per_cycle > 50:
|
||||
errors.append(
|
||||
f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}")
|
||||
|
||||
if self.max_plugin_width_ratio < 0:
|
||||
errors.append(
|
||||
"max_plugin_width_ratio must be >= 0 "
|
||||
f"(0 disables the cap), got {self.max_plugin_width_ratio}")
|
||||
|
||||
return errors
|
||||
|
||||
def update(self, new_config: Dict[str, Any]) -> None:
|
||||
@@ -174,6 +264,23 @@ class VegasModeConfig:
|
||||
self.scroll_speed = float(vegas_config['scroll_speed'])
|
||||
if 'separator_width' in vegas_config:
|
||||
self.separator_width = int(vegas_config['separator_width'])
|
||||
if 'intra_plugin_gap' in vegas_config:
|
||||
self.intra_plugin_gap = int(vegas_config['intra_plugin_gap'])
|
||||
if 'auto_trim' in vegas_config:
|
||||
self.auto_trim = vegas_config['auto_trim']
|
||||
if 'trim_threshold' in vegas_config:
|
||||
self.trim_threshold = int(vegas_config['trim_threshold'])
|
||||
if 'content_padding' in vegas_config:
|
||||
self.content_padding = int(vegas_config['content_padding'])
|
||||
if 'min_plugin_width' in vegas_config:
|
||||
self.min_plugin_width = int(vegas_config['min_plugin_width'])
|
||||
if 'lead_in_width' in vegas_config:
|
||||
self.lead_in_width = int(vegas_config['lead_in_width'])
|
||||
if 'plugins_per_cycle' in vegas_config:
|
||||
self.plugins_per_cycle = int(vegas_config['plugins_per_cycle'])
|
||||
if 'max_plugin_width_ratio' in vegas_config:
|
||||
self.max_plugin_width_ratio = float(
|
||||
vegas_config['max_plugin_width_ratio'])
|
||||
if 'plugin_order' in vegas_config:
|
||||
self.plugin_order = list(vegas_config['plugin_order'])
|
||||
if 'excluded_plugins' in vegas_config:
|
||||
|
||||
@@ -64,7 +64,7 @@ class VegasModeCoordinator:
|
||||
self.plugin_manager = plugin_manager
|
||||
|
||||
# Initialize components
|
||||
self.plugin_adapter = PluginAdapter(display_manager)
|
||||
self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
|
||||
self.stream_manager = StreamManager(
|
||||
self.vegas_config,
|
||||
plugin_manager,
|
||||
@@ -505,6 +505,10 @@ class VegasModeCoordinator:
|
||||
# Update components
|
||||
self.render_pipeline.update_config(new_vegas_config)
|
||||
self.stream_manager.config = new_vegas_config
|
||||
self.plugin_adapter.config = new_vegas_config
|
||||
# Cached segments were trimmed under the old settings, so drop them
|
||||
# or a changed trim/padding value would not visibly take effect.
|
||||
self.plugin_adapter.invalidate_cache()
|
||||
|
||||
# Force refresh of stream manager to pick up plugin_order/buffer changes
|
||||
self.stream_manager._last_refresh = 0
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Geometry primitives for Vegas Mode.
|
||||
|
||||
Pure, side-effect-free measurements over PIL images. Two consumers:
|
||||
|
||||
- ``PluginAdapter`` trims the blank margins plugins bake into their content
|
||||
before it enters the ticker (see ``trim_to_content``).
|
||||
- ``scripts/dev/vegas_audit.py`` reports how much of the composed ticker is
|
||||
dead space (see ``dead_window_stats``).
|
||||
|
||||
Keeping both on the same primitives means the number the audit reports is the
|
||||
number the trimmer acted on.
|
||||
|
||||
All column scans go through numpy: a Python-level per-column loop over a
|
||||
17,000px-wide ticker image takes seconds, which is far too slow for the render
|
||||
path.
|
||||
"""
|
||||
|
||||
from typing import NamedTuple, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# A pixel counts as "ink" when any channel exceeds this. Chosen to ignore the
|
||||
# 1-2/255 noise that JPEG-sourced logos and alpha compositing leave behind in
|
||||
# nominally black areas, while still treating any deliberately drawn dark grey
|
||||
# as real content.
|
||||
DEFAULT_INK_THRESHOLD = 10
|
||||
|
||||
# A window counts as "dead" when this fraction of its columns carry no ink.
|
||||
DEFAULT_DEAD_WINDOW_RATIO = 0.95
|
||||
|
||||
|
||||
def column_has_ink(img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD) -> np.ndarray:
|
||||
"""
|
||||
Return a boolean array, one entry per image column, True where the column
|
||||
contains at least one pixel brighter than ``threshold`` in any channel.
|
||||
|
||||
Args:
|
||||
img: Image to scan (converted to RGB internally)
|
||||
threshold: Per-channel value a pixel must exceed to count as ink
|
||||
|
||||
Returns:
|
||||
Bool array of shape (width,)
|
||||
"""
|
||||
arr = np.asarray(img if img.mode == 'RGB' else img.convert('RGB'))
|
||||
if arr.ndim != 3:
|
||||
# Degenerate/empty image — treat every column as blank.
|
||||
return np.zeros(img.width, dtype=bool)
|
||||
# Collapse rows and channels: a column is ink if any pixel in it is bright.
|
||||
return arr.max(axis=(0, 2)) > threshold
|
||||
|
||||
|
||||
def content_bounds(
|
||||
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
|
||||
) -> Optional[Tuple[int, int]]:
|
||||
"""
|
||||
Find the first and last columns containing ink.
|
||||
|
||||
Args:
|
||||
img: Image to measure
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
(first_col, last_col) inclusive, or None if the image is entirely blank
|
||||
"""
|
||||
ink = column_has_ink(img, threshold)
|
||||
if not ink.any():
|
||||
return None
|
||||
first = int(ink.argmax())
|
||||
last = len(ink) - 1 - int(ink[::-1].argmax())
|
||||
return first, last
|
||||
|
||||
|
||||
class TrimResult(NamedTuple):
|
||||
"""Outcome of a ``trim_to_content`` call."""
|
||||
|
||||
image: Optional[Image.Image] # None when the source was entirely blank
|
||||
original_width: int
|
||||
trimmed_left: int
|
||||
trimmed_right: int
|
||||
|
||||
@property
|
||||
def is_blank(self) -> bool:
|
||||
"""True when the source image carried no ink at all."""
|
||||
return self.image is None
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
"""Width after trimming (0 for a blank source)."""
|
||||
return 0 if self.image is None else self.image.width
|
||||
|
||||
@property
|
||||
def removed(self) -> int:
|
||||
"""Total columns removed."""
|
||||
return self.trimmed_left + self.trimmed_right
|
||||
|
||||
|
||||
def trim_to_content(
|
||||
img: Image.Image,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
padding: int = 0,
|
||||
) -> TrimResult:
|
||||
"""
|
||||
Crop blank columns off the left and right edges of an image.
|
||||
|
||||
Only the outer edges are considered. Blank columns *between* two pieces of
|
||||
content are deliberately preserved — those are the plugin's own layout
|
||||
(e.g. a logo on the left and a score on the right), and closing them up
|
||||
would corrupt the design rather than reclaim dead space.
|
||||
|
||||
A plugin drawing on a non-black background is unaffected: every column of a
|
||||
filled background carries ink, so there is nothing to trim.
|
||||
|
||||
Args:
|
||||
img: Image to trim
|
||||
threshold: Ink threshold
|
||||
padding: Columns of the original blank margin to keep on each side, as
|
||||
breathing room. Capped at what the margin actually contains, so
|
||||
this never widens the image beyond its original bounds.
|
||||
|
||||
Returns:
|
||||
TrimResult. When the image is entirely blank, ``image`` is None and the
|
||||
caller decides whether to skip the plugin.
|
||||
"""
|
||||
bounds = content_bounds(img, threshold)
|
||||
if bounds is None:
|
||||
return TrimResult(None, img.width, 0, 0)
|
||||
|
||||
first, last = bounds
|
||||
pad = max(0, padding)
|
||||
left = max(0, first - pad)
|
||||
right = min(img.width, last + 1 + pad)
|
||||
|
||||
if left == 0 and right == img.width:
|
||||
return TrimResult(img, img.width, 0, 0)
|
||||
|
||||
cropped = img.crop((left, 0, right, img.height))
|
||||
return TrimResult(cropped, img.width, left, img.width - right)
|
||||
|
||||
|
||||
def find_blank_cut(
|
||||
img: Image.Image,
|
||||
target: int,
|
||||
search_radius: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
) -> int:
|
||||
"""
|
||||
Find a column near ``target`` that carries no ink, so an image can be cut
|
||||
there without slicing through a glyph or logo.
|
||||
|
||||
Used when a single oversized segment has to be narrowed to fit a width
|
||||
budget. Cutting at an arbitrary column would leave half a character
|
||||
hanging at the panel edge; snapping to the nearest gap hides the cut.
|
||||
|
||||
Args:
|
||||
img: Image to cut
|
||||
target: Preferred cut column
|
||||
search_radius: How far either side of ``target`` to look
|
||||
threshold: Ink threshold
|
||||
|
||||
Returns:
|
||||
A blank column within the search window, or ``target`` clamped to the
|
||||
image bounds when the window contains no blank column at all.
|
||||
"""
|
||||
width = img.width
|
||||
target = max(0, min(target, width))
|
||||
if search_radius <= 0 or width == 0:
|
||||
return target
|
||||
|
||||
ink = column_has_ink(img, threshold)
|
||||
lo = max(0, target - search_radius)
|
||||
hi = min(width - 1, target + search_radius)
|
||||
|
||||
# Walk outwards from target so the nearest gap wins.
|
||||
for offset in range(0, search_radius + 1):
|
||||
right = target + offset
|
||||
if right <= hi and not ink[right]:
|
||||
return right
|
||||
left = target - offset
|
||||
if left >= lo and not ink[left]:
|
||||
return left
|
||||
|
||||
return target
|
||||
|
||||
|
||||
class DeadWindowStats(NamedTuple):
|
||||
"""How much of a composed ticker reads as blank to a viewer."""
|
||||
|
||||
total_windows: int
|
||||
dead_windows: int
|
||||
longest_dead_run: int # consecutive dead windows (i.e. scroll steps)
|
||||
|
||||
@property
|
||||
def dead_ratio(self) -> float:
|
||||
"""Fraction of viewport positions that are effectively blank."""
|
||||
if self.total_windows <= 0:
|
||||
return 0.0
|
||||
return self.dead_windows / self.total_windows
|
||||
|
||||
|
||||
def dead_window_stats(
|
||||
img: Image.Image,
|
||||
viewport_width: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
dead_ratio: float = DEFAULT_DEAD_WINDOW_RATIO,
|
||||
step: int = 1,
|
||||
) -> DeadWindowStats:
|
||||
"""
|
||||
Slide a viewport across a composed ticker image and count how many
|
||||
positions are effectively blank.
|
||||
|
||||
This models what the viewer actually experiences: the ticker is only ever
|
||||
seen ``viewport_width`` columns at a time, so a stretch of blank wider than
|
||||
the viewport becomes a period where the panel looks switched off. Measuring
|
||||
per-window rather than per-column is what makes the result correspond to
|
||||
perceived dead time.
|
||||
|
||||
Args:
|
||||
img: Composed ticker image
|
||||
viewport_width: Display width in pixels
|
||||
threshold: Ink threshold
|
||||
dead_ratio: Fraction of blank columns for a window to count as dead
|
||||
step: Column stride between sampled windows. 1 is exact; larger values
|
||||
trade precision for speed on very wide images.
|
||||
|
||||
Returns:
|
||||
DeadWindowStats. ``longest_dead_run`` is in units of ``step`` columns,
|
||||
so multiply by ``step`` for pixels.
|
||||
"""
|
||||
if viewport_width <= 0 or img.width <= 0:
|
||||
return DeadWindowStats(0, 0, 0)
|
||||
|
||||
ink = column_has_ink(img, threshold)
|
||||
step = max(1, step)
|
||||
|
||||
# Prefix sum of ink counts lets each window be evaluated in constant time,
|
||||
# instead of re-summing viewport_width columns per position.
|
||||
prefix = np.concatenate(([0], np.cumsum(ink)))
|
||||
|
||||
# Only whole windows are sampled; a partial tail window would report
|
||||
# artificially dead because it has fewer columns to draw ink from.
|
||||
last_start = img.width - viewport_width
|
||||
if last_start < 0:
|
||||
# Image narrower than the viewport — evaluate it as a single window.
|
||||
blank_cols = len(ink) - int(prefix[-1])
|
||||
is_dead = blank_cols >= dead_ratio * len(ink)
|
||||
return DeadWindowStats(1, 1 if is_dead else 0, 1 if is_dead else 0)
|
||||
|
||||
starts = np.arange(0, last_start + 1, step)
|
||||
ink_counts = prefix[starts + viewport_width] - prefix[starts]
|
||||
blank_counts = viewport_width - ink_counts
|
||||
dead = blank_counts >= dead_ratio * viewport_width
|
||||
|
||||
longest = _longest_true_run(dead)
|
||||
return DeadWindowStats(len(starts), int(dead.sum()), longest)
|
||||
|
||||
|
||||
class CoverageStats(NamedTuple):
|
||||
"""How well-filled the viewport stays as the ticker scrolls past."""
|
||||
|
||||
total_windows: int
|
||||
mean_ink_ratio: float # average fraction of the viewport carrying ink
|
||||
min_ink_ratio: float # worst viewport position in the cycle
|
||||
sparse_windows: int # positions below the "looks empty" threshold
|
||||
longest_sparse_run: int # consecutive sparse positions, in steps
|
||||
|
||||
@property
|
||||
def sparse_ratio(self) -> float:
|
||||
"""Fraction of viewport positions that read as near-empty."""
|
||||
if self.total_windows <= 0:
|
||||
return 0.0
|
||||
return self.sparse_windows / self.total_windows
|
||||
|
||||
|
||||
def window_coverage_stats(
|
||||
img: Image.Image,
|
||||
viewport_width: int,
|
||||
threshold: int = DEFAULT_INK_THRESHOLD,
|
||||
sparse_ink_ratio: float = 0.10,
|
||||
step: int = 1,
|
||||
) -> CoverageStats:
|
||||
"""
|
||||
Measure how full the viewport stays across a whole scroll cycle.
|
||||
|
||||
``dead_window_stats`` only catches viewport positions that are *entirely*
|
||||
blank. That misses the more common complaint: a position holding one narrow
|
||||
sliver of content at the very edge, with the other 90% black. Such a
|
||||
position is not "dead" by that definition but still looks switched off.
|
||||
This function grades every position by how much ink it carries, so
|
||||
"there is always something to see" becomes measurable.
|
||||
|
||||
Args:
|
||||
img: Composed ticker image
|
||||
viewport_width: Display width in pixels
|
||||
threshold: Ink threshold
|
||||
sparse_ink_ratio: A position with less than this fraction of inked
|
||||
columns counts as reading near-empty
|
||||
step: Column stride between sampled positions
|
||||
|
||||
Returns:
|
||||
CoverageStats
|
||||
"""
|
||||
if viewport_width <= 0 or img.width <= 0:
|
||||
return CoverageStats(0, 0.0, 0.0, 0, 0)
|
||||
|
||||
ink = column_has_ink(img, threshold)
|
||||
step = max(1, step)
|
||||
prefix = np.concatenate(([0], np.cumsum(ink)))
|
||||
|
||||
last_start = img.width - viewport_width
|
||||
if last_start < 0:
|
||||
ratio = float(prefix[-1]) / viewport_width
|
||||
sparse = ratio < sparse_ink_ratio
|
||||
return CoverageStats(1, ratio, ratio, 1 if sparse else 0, 1 if sparse else 0)
|
||||
|
||||
starts = np.arange(0, last_start + 1, step)
|
||||
ratios = (prefix[starts + viewport_width] - prefix[starts]) / viewport_width
|
||||
sparse_flags = ratios < sparse_ink_ratio
|
||||
|
||||
return CoverageStats(
|
||||
total_windows=len(starts),
|
||||
mean_ink_ratio=float(ratios.mean()),
|
||||
min_ink_ratio=float(ratios.min()),
|
||||
sparse_windows=int(sparse_flags.sum()),
|
||||
longest_sparse_run=_longest_true_run(sparse_flags),
|
||||
)
|
||||
|
||||
|
||||
def _longest_true_run(flags: np.ndarray) -> int:
|
||||
"""Length of the longest consecutive run of True in a boolean array."""
|
||||
if flags.size == 0 or not flags.any():
|
||||
return 0
|
||||
# Reset a running counter at every False by subtracting the cumulative max
|
||||
# of the counter's value at the preceding False positions.
|
||||
idx = np.arange(len(flags))
|
||||
not_flag = ~flags
|
||||
# For each position, the index of the most recent False at or before it.
|
||||
last_false = np.maximum.accumulate(np.where(not_flag, idx, -1))
|
||||
run_lengths = idx - last_false
|
||||
return int(run_lengths[flags].max())
|
||||
@@ -11,6 +11,8 @@ import time
|
||||
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
|
||||
from PIL import Image
|
||||
|
||||
from src.vegas_mode.geometry import find_blank_cut, trim_to_content
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
@@ -26,14 +28,21 @@ class PluginAdapter:
|
||||
2. Fallback: Capture display_manager.image after calling plugin.display()
|
||||
"""
|
||||
|
||||
def __init__(self, display_manager: Any):
|
||||
def __init__(self, display_manager: Any, config: Optional[Any] = None):
|
||||
"""
|
||||
Initialize the plugin adapter.
|
||||
|
||||
Args:
|
||||
display_manager: DisplayManager instance for fallback capture
|
||||
config: VegasModeConfig controlling trim behaviour. When omitted,
|
||||
trimming runs with the dataclass defaults, so existing callers
|
||||
and tests keep working unchanged.
|
||||
"""
|
||||
self.display_manager = display_manager
|
||||
if config is None:
|
||||
from src.vegas_mode.config import VegasModeConfig
|
||||
config = VegasModeConfig()
|
||||
self.config = config
|
||||
# Handle both property and method access patterns
|
||||
self.display_width = (
|
||||
display_manager.width() if callable(display_manager.width)
|
||||
@@ -49,6 +58,11 @@ class PluginAdapter:
|
||||
self._cache_lock = threading.Lock()
|
||||
self._cache_ttl = 5.0 # Cache for 5 seconds
|
||||
|
||||
# Per-plugin rotation offset, so a plugin whose content exceeds its
|
||||
# width budget shows a different slice on each cycle rather than
|
||||
# always the same opening items.
|
||||
self._item_offsets: dict = {}
|
||||
|
||||
logger.info(
|
||||
"PluginAdapter initialized: display=%dx%d",
|
||||
self.display_width, self.display_height
|
||||
@@ -93,8 +107,7 @@ class PluginAdapter:
|
||||
"[%s] Native content SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
return self._finalize(content, plugin_id, 'native')
|
||||
logger.info("[%s] Native content returned None", plugin_id)
|
||||
|
||||
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
|
||||
@@ -107,8 +120,7 @@ class PluginAdapter:
|
||||
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
return self._finalize(content, plugin_id, 'scroll_helper')
|
||||
if has_scroll_helper:
|
||||
logger.info("[%s] ScrollHelper content returned None", plugin_id)
|
||||
|
||||
@@ -121,8 +133,7 @@ class PluginAdapter:
|
||||
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
|
||||
plugin_id, len(content), total_width
|
||||
)
|
||||
self._cache_content(plugin_id, content)
|
||||
return content
|
||||
return self._finalize(content, plugin_id, 'fallback')
|
||||
|
||||
logger.warning(
|
||||
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
|
||||
@@ -130,6 +141,181 @@ class PluginAdapter:
|
||||
)
|
||||
return None
|
||||
|
||||
def _finalize(
|
||||
self, images: List[Image.Image], plugin_id: str, source: str
|
||||
) -> Optional[List[Image.Image]]:
|
||||
"""
|
||||
Trim dead space off a segment, then cache it.
|
||||
|
||||
Every content path funnels through here so trimming is applied
|
||||
uniformly. Previously only the scroll_helper path had its margins
|
||||
stripped, which left plugins that render onto a full-display canvas
|
||||
contributing their entire blank canvas to the ticker.
|
||||
|
||||
Each image is trimmed independently because compose_scroll_content()
|
||||
treats every image as its own item and inserts separator_width between
|
||||
them — so a per-image trim is what makes that separator the real gap.
|
||||
|
||||
Args:
|
||||
images: Raw content from one of the fetch paths
|
||||
plugin_id: Plugin identifier for logging
|
||||
source: Which path produced the content, for logging
|
||||
|
||||
Returns:
|
||||
Trimmed image list, or None if nothing worth showing remains
|
||||
"""
|
||||
if not self.config.auto_trim:
|
||||
self._cache_content(plugin_id, images)
|
||||
return images
|
||||
|
||||
original_width = sum(img.width for img in images)
|
||||
kept: List[Image.Image] = []
|
||||
dropped_blank = 0
|
||||
|
||||
for img in images:
|
||||
result = trim_to_content(
|
||||
img,
|
||||
threshold=self.config.trim_threshold,
|
||||
padding=self.config.content_padding,
|
||||
)
|
||||
if result.is_blank:
|
||||
dropped_blank += 1
|
||||
continue
|
||||
kept.append(result.image)
|
||||
|
||||
if not kept:
|
||||
logger.info(
|
||||
"[%s] All %d image(s) from %s were blank — contributing nothing",
|
||||
plugin_id, len(images), source
|
||||
)
|
||||
return None
|
||||
|
||||
trimmed_width = sum(img.width for img in kept)
|
||||
|
||||
if trimmed_width < self.config.min_plugin_width:
|
||||
logger.info(
|
||||
"[%s] Trimmed content %dpx is below min_plugin_width %dpx — skipping",
|
||||
plugin_id, trimmed_width, self.config.min_plugin_width
|
||||
)
|
||||
return None
|
||||
|
||||
if trimmed_width != original_width or dropped_blank:
|
||||
logger.info(
|
||||
"[%s] Trimmed %s content: %dpx -> %dpx (%.0f%% reclaimed), "
|
||||
"%d image(s) kept, %d blank dropped",
|
||||
plugin_id, source, original_width, trimmed_width,
|
||||
100.0 * (original_width - trimmed_width) / original_width
|
||||
if original_width else 0.0,
|
||||
len(kept), dropped_blank
|
||||
)
|
||||
|
||||
kept = self._apply_width_budget(kept, plugin_id)
|
||||
|
||||
self._cache_content(plugin_id, kept)
|
||||
return kept
|
||||
|
||||
def _width_budget(self) -> int:
|
||||
"""Maximum columns one plugin may occupy in a cycle. 0 means unlimited."""
|
||||
ratio = self.config.max_plugin_width_ratio
|
||||
if ratio <= 0:
|
||||
return 0
|
||||
return int(self.display_width * ratio)
|
||||
|
||||
def _apply_width_budget(
|
||||
self, images: List[Image.Image], plugin_id: str
|
||||
) -> List[Image.Image]:
|
||||
"""
|
||||
Hold one plugin to its share of a cycle.
|
||||
|
||||
A ticker returning 7,000px would otherwise own the panel for over two
|
||||
minutes, which defeats the point of a rotation. Overflow is deferred
|
||||
rather than discarded: the starting offset advances each time this
|
||||
plugin is fetched, so later items appear on subsequent cycles instead
|
||||
of never being seen.
|
||||
|
||||
Args:
|
||||
images: Trimmed images for this plugin
|
||||
plugin_id: Plugin identifier, used to track its rotation offset
|
||||
|
||||
Returns:
|
||||
Images that fit the budget, starting from the plugin's current
|
||||
rotation offset.
|
||||
"""
|
||||
budget = self._width_budget()
|
||||
|
||||
# Count the gaps the compositor will insert between these rows, not
|
||||
# just the pixels of the rows themselves — otherwise a plugin with many
|
||||
# rows quietly occupies far more of the panel than its budget allows.
|
||||
gap = max(0, self.config.intra_plugin_gap)
|
||||
total = sum(img.width for img in images) + gap * (len(images) - 1)
|
||||
|
||||
if not budget or total <= budget:
|
||||
# Fits, so reset rotation — the whole segment is being shown.
|
||||
self._item_offsets.pop(plugin_id, None)
|
||||
return images
|
||||
|
||||
if len(images) == 1:
|
||||
return [self._crop_to_budget(images[0], budget, plugin_id)]
|
||||
|
||||
start = self._item_offsets.get(plugin_id, 0) % len(images)
|
||||
selected: List[Image.Image] = []
|
||||
used = 0
|
||||
consumed = 0
|
||||
|
||||
# Walk forward from the rotation offset, taking whole items only, so a
|
||||
# cut never lands in the middle of one.
|
||||
for step in range(len(images)):
|
||||
img = images[(start + step) % len(images)]
|
||||
cost = img.width + (gap if selected else 0)
|
||||
if selected and used + cost > budget:
|
||||
break
|
||||
selected.append(img)
|
||||
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
|
||||
)
|
||||
return selected
|
||||
|
||||
def _crop_to_budget(
|
||||
self, img: Image.Image, budget: int, plugin_id: str
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Narrow a single oversized image to the budget, advancing a window
|
||||
through it across cycles.
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
# 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
|
||||
)
|
||||
return img.crop((start, 0, end, img.height))
|
||||
|
||||
def _get_native_content(
|
||||
self, plugin: 'BasePlugin', plugin_id: str
|
||||
) -> Optional[List[Image.Image]]:
|
||||
|
||||
@@ -66,10 +66,6 @@ class RenderPipeline:
|
||||
else display_manager.height
|
||||
)
|
||||
|
||||
# Reusable blank frame for cycle-end pushes (allocated lazily,
|
||||
# re-blacked before each reuse)
|
||||
self._blank_frame = None
|
||||
|
||||
# ScrollHelper for optimized scrolling
|
||||
self.scroll_helper = ScrollHelper(
|
||||
self.display_width,
|
||||
@@ -141,23 +137,37 @@ class RenderPipeline:
|
||||
True if composition successful
|
||||
"""
|
||||
try:
|
||||
# Get all buffered content
|
||||
images = self.stream_manager.get_all_content_for_composition()
|
||||
# Content grouped by plugin, so a separator can be placed at the
|
||||
# plugin boundaries only.
|
||||
grouped = self.stream_manager.get_grouped_content_for_composition()
|
||||
|
||||
if not images:
|
||||
if not grouped:
|
||||
logger.warning("No content available for composition")
|
||||
return False
|
||||
|
||||
# Add separator gaps between images
|
||||
content_with_gaps = []
|
||||
for i, img in enumerate(images):
|
||||
content_with_gaps.append(img)
|
||||
# Collapse each plugin's rows into a single block, joined by
|
||||
# intra_plugin_gap. ScrollHelper applies one uniform gap between the
|
||||
# items it is given, so handing it one item per plugin is what makes
|
||||
# separator_width mean "between plugins" instead of "between every
|
||||
# row". Without this, a per-row ticker such as the F1 scoreboard got
|
||||
# the full separator between each of its ~116 rows.
|
||||
blocks = []
|
||||
total_rows = 0
|
||||
for plugin_id, images in grouped:
|
||||
total_rows += len(images)
|
||||
blocks.append(self._join_plugin_rows(images))
|
||||
|
||||
# Create scrolling image via ScrollHelper
|
||||
# Create scrolling image via ScrollHelper.
|
||||
#
|
||||
# lead_gap is explicit because ScrollHelper otherwise prepends a
|
||||
# full display width of black — appropriate for a standalone ticker
|
||||
# scrolling in from off-screen, but in Vegas mode it is charged
|
||||
# once per cycle and reads as the panel switching off.
|
||||
self.scroll_helper.create_scrolling_image(
|
||||
content_items=content_with_gaps,
|
||||
content_items=blocks,
|
||||
item_gap=self.config.separator_width,
|
||||
element_gap=0
|
||||
element_gap=0,
|
||||
lead_gap=self.config.lead_in_width
|
||||
)
|
||||
|
||||
# Verify scroll image was created successfully
|
||||
@@ -177,11 +187,14 @@ class RenderPipeline:
|
||||
self._cycle_complete = False
|
||||
|
||||
logger.info(
|
||||
"Composed scroll image: %dx%d, %d plugins, %d items",
|
||||
"Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
|
||||
"separator=%dpx between plugins / %dpx within",
|
||||
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
|
||||
self.display_height,
|
||||
len(self._segments_in_scroll),
|
||||
len(images)
|
||||
len(blocks),
|
||||
total_rows,
|
||||
self.config.separator_width,
|
||||
self.config.intra_plugin_gap,
|
||||
)
|
||||
|
||||
return True
|
||||
@@ -191,6 +204,32 @@ class RenderPipeline:
|
||||
logger.exception("Error composing scroll content")
|
||||
return False
|
||||
|
||||
def _join_plugin_rows(self, images: List[Image.Image]) -> Image.Image:
|
||||
"""
|
||||
Concatenate one plugin's images into a single block.
|
||||
|
||||
Args:
|
||||
images: That plugin's content, in order
|
||||
|
||||
Returns:
|
||||
A single image with the rows laid out left to right, separated by
|
||||
``intra_plugin_gap``. Returned unchanged when there is only one row,
|
||||
which is the common case and avoids a pointless copy.
|
||||
"""
|
||||
if len(images) == 1:
|
||||
return images[0]
|
||||
|
||||
gap = max(0, self.config.intra_plugin_gap)
|
||||
width = sum(img.width for img in images) + gap * (len(images) - 1)
|
||||
height = max(img.height for img in images)
|
||||
|
||||
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||
x = 0
|
||||
for img in images:
|
||||
block.paste(img, (x, 0))
|
||||
x += img.width + gap
|
||||
return block
|
||||
|
||||
def render_frame(self) -> bool:
|
||||
"""
|
||||
Render a single frame to the display.
|
||||
@@ -236,24 +275,17 @@ class RenderPipeline:
|
||||
"Scroll cycle complete after %.1fs",
|
||||
time.time() - self._cycle_start_time
|
||||
)
|
||||
# Push blank immediately so the hardware never shows any
|
||||
# post-wrap content while the coordinator recomposes the
|
||||
# next cycle (~100 ms). The blank is allocated once and
|
||||
# reused across cycle wraps (fresh paste each time in case
|
||||
# a consumer drew on the previous one).
|
||||
try:
|
||||
if self._blank_frame is None or self._blank_frame.size != (
|
||||
self.display_width, self.display_height):
|
||||
self._blank_frame = Image.new(
|
||||
'RGB', (self.display_width, self.display_height))
|
||||
else:
|
||||
self._blank_frame.paste(
|
||||
(0, 0, 0),
|
||||
(0, 0, self.display_width, self.display_height))
|
||||
self.display_manager.image = self._blank_frame
|
||||
self.display_manager.update_display()
|
||||
except Exception:
|
||||
logger.exception("Failed to write blank frame to display at cycle end")
|
||||
# Deliberately leave the last rendered frame on the panel.
|
||||
#
|
||||
# This used to push a blank frame so no post-wrap content
|
||||
# could be seen while the next cycle was composed. But
|
||||
# recomposing is synchronous and fetches plugin content:
|
||||
# measured 84ms at best and 4.8s at worst on a 512px panel,
|
||||
# and every millisecond of it was black. Holding the last
|
||||
# frame instead turns that into a brief freeze, which reads
|
||||
# as far less broken than the display switching off. The
|
||||
# frame is already past the end of the content, so there is
|
||||
# no second-pass content to leak.
|
||||
return True # Cycle done; coordinator starts new cycle next frame
|
||||
|
||||
# Get visible portion
|
||||
@@ -415,11 +447,12 @@ class RenderPipeline:
|
||||
result = self.compose_scroll_content()
|
||||
|
||||
if result and self.sync_manager:
|
||||
# When sync is active, start the leader at display_width instead of 0.
|
||||
# This skips the initial black gap so the leader immediately shows content.
|
||||
# The follower starts at position 0 (the gap) which looks like a clean
|
||||
# blank transition rather than near-end content wrapping around.
|
||||
self.scroll_helper.scroll_position = float(self.display_width)
|
||||
# When sync is active, start the leader past the lead-in gap so it
|
||||
# immediately shows content, leaving the follower on the blank gap
|
||||
# for a clean transition rather than near-end content wrapping
|
||||
# around. This tracks lead_in_width rather than assuming a full
|
||||
# display width of gap, which is no longer the default.
|
||||
self.scroll_helper.scroll_position = float(self.config.lead_in_width)
|
||||
|
||||
if result and self.sync_manager:
|
||||
# Signal follower that a new cycle started (triggers its own rebuild)
|
||||
|
||||
@@ -14,7 +14,7 @@ Supports three display modes:
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, List, Dict, Any, Deque, TYPE_CHECKING
|
||||
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from PIL import Image
|
||||
@@ -116,8 +116,11 @@ class StreamManager:
|
||||
logger.warning("No plugins available for Vegas scroll")
|
||||
return False
|
||||
|
||||
# Prefetch initial content
|
||||
self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins)))
|
||||
# Fill the buffer to a whole cycle's worth of plugins. This used to be
|
||||
# buffer_ahead + 1, which conflated prefetch depth with cycle size and
|
||||
# meant a 20-plugin install only showed 3 plugins before recomposing.
|
||||
self._prefetch_content(
|
||||
count=min(self.config.plugins_per_cycle, len(self._ordered_plugins)))
|
||||
|
||||
logger.info(
|
||||
"StreamManager initialized with %d plugins, %d segments buffered",
|
||||
@@ -385,7 +388,7 @@ class StreamManager:
|
||||
return
|
||||
|
||||
for _ in range(count):
|
||||
if len(self._active_buffer) >= self.config.buffer_ahead + 1:
|
||||
if len(self._active_buffer) >= self.config.plugins_per_cycle:
|
||||
break
|
||||
|
||||
# Ensure index is valid (guard against empty list)
|
||||
@@ -521,28 +524,61 @@ class StreamManager:
|
||||
logger.debug("Refreshed content for %s in staging buffer", plugin_id)
|
||||
|
||||
def _ensure_buffer_filled(self) -> None:
|
||||
"""Ensure buffer has enough content prefetched."""
|
||||
if len(self._active_buffer) < self.config.buffer_ahead:
|
||||
needed = self.config.buffer_ahead - len(self._active_buffer)
|
||||
self._prefetch_content(count=needed)
|
||||
"""
|
||||
Top the buffer back up after segments have been served.
|
||||
|
||||
buffer_ahead is the low-water mark only; plugins_per_cycle is the
|
||||
ceiling and is enforced inside _prefetch_content.
|
||||
"""
|
||||
low_water = min(self.config.buffer_ahead, self.config.plugins_per_cycle)
|
||||
if len(self._active_buffer) < low_water:
|
||||
self._prefetch_content(count=low_water - len(self._active_buffer))
|
||||
|
||||
def get_all_content_for_composition(self) -> List[Image.Image]:
|
||||
"""
|
||||
Get all buffered content as a flat list of images.
|
||||
|
||||
Used when composing the full scroll image.
|
||||
Skips STATIC segments as they don't have images to compose.
|
||||
|
||||
Prefer get_grouped_content_for_composition(): flattening loses the
|
||||
plugin boundaries, which is what tells the compositor where a
|
||||
separator belongs and where it does not.
|
||||
|
||||
Returns:
|
||||
List of all images in buffer order
|
||||
"""
|
||||
all_images = []
|
||||
for _plugin_id, images in self.get_grouped_content_for_composition():
|
||||
all_images.extend(images)
|
||||
return all_images
|
||||
|
||||
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
|
||||
"""
|
||||
Get buffered content grouped by the plugin that produced it.
|
||||
|
||||
The grouping matters: separator_width is meant to mark the handoff from
|
||||
one plugin to the next, not to sit between every row a single plugin
|
||||
contributes. A per-row ticker like the F1 scoreboard returns over a
|
||||
hundred images that it renders 4px apart internally, so flattening them
|
||||
into one list and applying a uniform gap forced 32px between each of
|
||||
its rows — both inconsistent with how the plugin looks standalone, and
|
||||
a large hidden addition to the width it occupies.
|
||||
|
||||
Skips STATIC segments, which trigger a pause rather than contributing
|
||||
scroll content, and segments left with no images.
|
||||
|
||||
Returns:
|
||||
List of (plugin_id, images) in buffer order
|
||||
"""
|
||||
grouped: List[Tuple[str, List[Image.Image]]] = []
|
||||
with self._buffer_lock:
|
||||
for segment in self._active_buffer:
|
||||
# Skip STATIC segments - they trigger pauses, not scroll content
|
||||
if segment.display_mode != VegasDisplayMode.STATIC:
|
||||
all_images.extend(segment.images)
|
||||
return all_images
|
||||
if segment.display_mode == VegasDisplayMode.STATIC:
|
||||
continue
|
||||
if not segment.images:
|
||||
continue
|
||||
grouped.append((segment.plugin_id, list(segment.images)))
|
||||
return grouped
|
||||
|
||||
def advance_cycle(self) -> None:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user