mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 15:25:08 +00:00
* perf(timing): say which render-thread work a late frame followed The soak already says how often a moving frame reached the panel late, but not what the render thread was doing just before it. Vegas does two kinds of work there between frames -- building its strip (compose, extend) and, with live elements, patching changed pixels into it -- and deciding whether either is affordable needs their own numbers. - FrameTimingRecorder.note_op(kind, nbytes) tags the next presented frame. Totals gain op_frames, late_op_frames, op_freezes and op_bytes per kind; aggregate() still takes frames without ops. The file schema is unchanged. - Vegas tags compose and every strip extension (with the bytes it copied). - frame_soak prints an "after work" table: frames, late %, freezes and MB moved per kind, only when something tagged its work. - render_bench gains --strip-screens (Vegas-sized strips), --patch-bytes / --patch-every / --patch-where (in-place column writes, as a live element update does) and --extend-every-screens / --extend-width (append + trim on a fixed cadence that holds the strip's width). No runtime behaviour changes: this is the measurement gate for live Vegas elements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): note the frame-op attribution and bench modes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(scroll): build the strip's PIL image only when something reads it Every Vegas strip extension rebuilt ScrollHelper.cached_image from cached_array in full, twice (append, then trim), on the render thread: Image.fromarray is 1.7ms for an 8,000px strip and 3.8ms for 20,000px on a Pi 4 (measured on ledpi), about two thirds of an extension's render-thread cost. Nothing on the frame path reads the image's pixels; every frame is cut from the array. cached_image is now a property. append_content and drop_scrolled_prefix defer it; the first read builds it from the array it started with and keeps it only if the strip has not changed meanwhile, so a sync push racing an extension cannot leave a stale image cached. Assigning cached_image stores exactly what was assigned, as before. has_strip() says whether there is a strip without building its image; the helper's frame path, Vegas and the adapter's scroll-cache invalidation use it. The strip is also no longer held in memory twice. In Vegas the image is now built only by a multi-display sync push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(vegas): live elements -- a plugin API for content that changes while it scrolls Vegas bakes each plugin's pictures into one strip, so a card already on its way across the panel keeps what it showed when it was drawn. This adds the API and bookkeeping for content that can be updated in place; the worker that redraws and swaps it follows separately. No shipped plugin implements the hook yet, so nothing changes for users. Plugin API (core 3.8.0), all no-ops by default: - BasePlugin.get_vegas_elements() -> [VegasElement(key, image, version, live, refresh_hz)]: named, fixed-width pieces of Vegas content. - BasePlugin.redraw_vegas_element(key, width, height, at): a lock-free redraw for content that changes with time. - BasePlugin.notify_vegas_data_changed(): data that lands outside update(). - src/plugin_system/vegas_elements.py (VegasElement, re-exported from base_plugin). Core: - PluginAdapter asks a plugin that implements the hook for elements on the background fetch only (under its lock, on its own canvas); every other path keeps get_vegas_content(). Live elements are pinned (padded with content_padding, never trimmed), tagged with their key, digest and data epoch in Image.info so the existing cache and group plumbing carry them unchanged, and untagged if a width budget crops them. - RenderPipeline records where each live element lands (ElementRecord), in absolute strip columns a trim does not move; the block-start arithmetic is shared with the STATIC markers. - PluginManager update listeners (add/remove_update_listener, notify_data_changed): told the moment update() completes, not at the next ~4s Vegas poll. The coordinator uses one to move each plugin's data epoch on. - vegas_scroll.live_refresh (kill switch), live_max_hz, live_min_interval, live_lead_screens; per-plugin core-owned vegas_live. Live elements are off under multi-display sync, in swap mode and with offscreen_prefetch off. - scripts/check_plugin.py checks the element contract (src/plugin_system/testing/vegas.py); test/fixtures/plugins/vegas-live-stub is a working example. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(vegas): live elements update in place while they scroll One background worker (src/vegas_mode/live_worker.py) redraws a plugin's live elements when its data epoch moves on (update listener) or on their refresh_hz, nearest the screen first, and hands changed pixels lock-free to the render thread, which copies them into the strip between frames (RenderPipeline.apply_live_patches, ScrollHelper.patch_columns): at most four patches or two screens of bytes a frame, no drawing or locks there. The worker takes over group prefetch once a live element is placed, runs inside the render gate, and is supervised. Update tick 1s while live elements exist. Web UI switch for live_refresh. OFFSCREEN_RENDERING.md describes what was built and why SegmentStrip was not needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(sports): live Vegas cards for the scoreboards (shared layer) One live element per game, drawn only when what the card shows changes, so a score changes on a card already crossing the panel. The shared part, so each scoreboard adopts it in a few lines: - src/common/sports_vegas.py: game_key, game_fingerprint (the whole game dict, frozen: no drawn field can be missed), dedupe_games, VegasCardCache, StickyOdds (odds a live poll left out stay drawn), finished_games / with_finished_games (a game that just went final keeps its card, after its league's live games; one a heuristic only judged over keeps its live state, so a tied end of regulation never shows FINAL early). - SportsScrollDisplay.make_vegas_renderer() is the override point; build_vegas_elements() and SportsScrollDisplayManager .get_vegas_elements_for() do the rest. A card's version includes its teams' ranks, which the renderer draws from the rankings cache. - SportsLiveSharedMixin._record_finished_game() / finished_games_snapshot(): held for FINISHED_GAME_TTL after it leaves the live list. A sport that does not implement make_vegas_renderer keeps its ordinary Vegas content, so no scoreboard changes until it opts in. scripts/render_plugin.py --vegas renders a plugin's Vegas block as the ticker lays it out, and --timeline stacks it at successive moments as the ticker would update it in place; the join is now render_pipeline.join_plugin_rows(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(vegas): keep live games in the ticker by default display.vegas_scroll.live_in_ticker now defaults to true: through a live game the marquee keeps running and the live scoreboard takes extra turns in it -- its cards updating in place while they scroll -- instead of the ticker giving way to the full-screen scoreboard. The new default would reach nobody on its own: every existing config holds an explicit false copied from the template (there was no control for it), and the template merge only adds missing keys. ConfigManager therefore turns a stored false on once, with a backup, and records live_in_ticker_migrated so a false chosen afterwards stays. The marker is never in the template. A "Keep live games in the ticker" checkbox under Vegas mode sets it. Tests that pin the full-screen takeover now say live_in_ticker=false. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(sports): a default _determine_game_type on SportsScrollDisplay render_vegas_card looked the method up with getattr and a None default, which static analysis (Codacy) reports as calling something that may not be callable. The base class now has the default -- the card type from the game's state -- and the plugins that define their own override it as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: review follow-ups on the shared live-card layer - The reused Vegas renderer always gets the current rankings, empty included, so ranks cleared since are not kept drawn. - render_plugin.py: --timeline refuses --no-live (a timeline shows live elements changing), --timeline/--no-live need --vegas, and the Vegas paths create the output's directory like the display path does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
476 lines
23 KiB
Python
476 lines
23 KiB
Python
"""
|
|
Vegas Mode Configuration
|
|
|
|
Handles configuration for Vegas-style continuous scroll mode including
|
|
plugin ordering, exclusions, scroll speed, and display settings.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Dict, Any, List, Set
|
|
from dataclasses import dataclass, field
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class VegasModeConfig:
|
|
"""Configuration for Vegas scroll mode."""
|
|
|
|
# Core settings
|
|
enabled: bool = False
|
|
scroll_speed: float = 50.0 # Pixels per second
|
|
separator_width: int = 32 # Gap between plugins (pixels)
|
|
|
|
# Fraction of the panel width a plugin is told it has while rendering for
|
|
# the ticker, as a percentage. Trimming can only remove blank margins; it
|
|
# cannot compact a layout that genuinely spans the display — a five-column
|
|
# forecast, a full-width progress bar, a centred stat block with the panel's
|
|
# whole width between its elements. Rendering at a narrower size makes the
|
|
# plugin choose a tighter layout instead. 100 disables it.
|
|
render_width_pct: int = 100
|
|
|
|
# Minimum blank columns guaranteed between adjacent content, measured from
|
|
# actual ink rather than added blindly. A flat additive gap leaves
|
|
# card-style content nearly touching when the cards are drawn flush to their
|
|
# own edges, while padding out content that already has wide margins.
|
|
min_content_separation: int = 24
|
|
|
|
# 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
|
|
|
|
# Lock motion to the panel: a whole number of pixels per presented frame,
|
|
# each frame held for a whole number of refreshes, with SwapOnVSync as the
|
|
# clock (see src/common/scroll_config.py). scroll_speed is snapped to the
|
|
# nearest speed the panel can show that way. Off falls back to advancing by
|
|
# elapsed time, which drifts against the refresh and judders.
|
|
smooth_scroll: bool = True
|
|
|
|
# The older way of smoothing: advance by elapsed time and blend the two
|
|
# neighbouring pixel positions each frame. It looks anti-aliased in the web
|
|
# preview, but on the panel the blended columns shimmer (the library's
|
|
# brightness curve makes a 50% blend far dimmer than half), text softens,
|
|
# and the loop is not tied to the refresh, so it still misses frames.
|
|
# Measured on a 512x64 chain at 95Hz: 73-89fps, p99 20-28ms. Takes
|
|
# precedence over smooth_scroll's whole-pixel pacing when on.
|
|
sub_pixel_blend: bool = False
|
|
|
|
# Render every plugin's ticker content on the background prefetch thread,
|
|
# each on a canvas of its own (DisplayManager.offscreen), instead of
|
|
# handing plugins that draw on the display canvas to the render thread one
|
|
# at a time. Each of those cost the scroll a 40-600ms pause. False restores
|
|
# that path; it is kept for one release in case a plugin misbehaves when
|
|
# drawn off the render thread. See docs/OFFSCREEN_RENDERING.md.
|
|
offscreen_prefetch: bool = True
|
|
|
|
# How long another thread may hold the GIL before the render thread's
|
|
# request forces it to yield, in ms, while Vegas runs. CPython's default is
|
|
# 5ms. Plugin rendering on the prefetch thread and plugin updates hold the
|
|
# GIL in Pillow and Python code, and a frame waiting its turn for 5ms at a
|
|
# time misses its refresh. 0 leaves the interpreter default alone.
|
|
# Experimental. On hdpi it did less than prefetch_gate (0.90% -> 0.78% late
|
|
# against 0.60%; see docs/OFFSCREEN_RENDERING.md), so it stays off.
|
|
switch_interval_ms: float = 0.0
|
|
|
|
# Let the prefetch thread run Python only while the render thread is
|
|
# blocked waiting for vsync, and park it the rest of the time, so the
|
|
# render thread never waits for the GIL when its refresh comes round. Needs
|
|
# a binding that releases the GIL in SwapOnVSync; off otherwise. On hdpi
|
|
# it cut frames two or more refreshes late eightfold, and late frames
|
|
# overall from 0.90% to 0.60%. See src/common/render_gate.py.
|
|
prefetch_gate: bool = True
|
|
|
|
# Live elements (src/plugin_system/vegas_elements.py): a plugin that hands
|
|
# the ticker named, fixed-width elements has them redrawn when its data
|
|
# changes, and the changed pixels are swapped into the strip in place --
|
|
# on screen included -- instead of waiting for the plugin's next turn.
|
|
# False restores the frozen-segment behaviour exactly. Also off, whatever
|
|
# this says, under multi-display sync, in swap mode (continuous_scroll
|
|
# false) and with offscreen_prefetch false.
|
|
live_refresh: bool = True
|
|
# Ceiling on how often an element that animates (refresh_hz) is redrawn,
|
|
# in Hz. 0 turns animation off and keeps data-driven updates.
|
|
live_max_hz: float = 5.0
|
|
# Shortest time between two data redraws of one plugin, in seconds. A
|
|
# plugin updating faster is redrawn at this rate, never skipped: the
|
|
# latest data is always drawn eventually.
|
|
live_min_interval: float = 2.0
|
|
# How far ahead of the right edge, in screens, an animated element starts
|
|
# being redrawn, so it is already moving when it scrolls in.
|
|
live_lead_screens: float = 1.0
|
|
|
|
# Keep one continuous strip, extending it with the next group of plugins as
|
|
# the scroll approaches the end, instead of composing a fresh strip and
|
|
# swapping it in. A swap stops the motion, substitutes every pixel at once
|
|
# and restarts with the viewport already full — read as a freeze, a flash
|
|
# and a jump. Extending means the next group simply scrolls in from the
|
|
# right. Set false to restore the swap behaviour.
|
|
continuous_scroll: bool = True
|
|
|
|
# Extend once the unscrolled remainder falls below this many screen widths.
|
|
# Needs to be more than one so the join is prepared before it is on screen.
|
|
extend_threshold_screens: float = 2.0
|
|
|
|
# How many plugins are composed into one scroll cycle. Kept separate from
|
|
# buffer_ahead (which is only a prefetch low-water mark) because the two
|
|
# were previously the same number: a buffer_ahead of 2 meant just 3 plugins
|
|
# 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.
|
|
# 0 (the default) disables the cap, so every plugin contributes all of its
|
|
# content and is always entered at its beginning.
|
|
#
|
|
# Capping was the default until it proved to cost more than it bought.
|
|
# Measured over a 17-plugin fleet on a 512px panel, only four plugins were
|
|
# ever wide enough to hit a 3.0 cap; for those four it produced two visible
|
|
# faults. Content resumed mid-item on each appearance (a news ticker entered
|
|
# at column 6027 of its own strip), and the final window of a rotation was
|
|
# whatever happened to be left — 348px of a 1840px stocks ticker, seven
|
|
# seconds of panel time. Both read as the display being broken rather than
|
|
# as deferral working.
|
|
#
|
|
# A wide plugin does hold the panel for a long time uncapped: set the cap
|
|
# per plugin with vegas_max_width_screens where that matters, rather than
|
|
# globally where it mostly hurts plugins that were never the problem.
|
|
max_plugin_width_ratio: float = 0.0
|
|
|
|
# Plugin management
|
|
plugin_order: List[str] = field(default_factory=list)
|
|
excluded_plugins: Set[str] = field(default_factory=set)
|
|
|
|
# --- Live content in the ticker -------------------------------------
|
|
#
|
|
# By default live content stays in the marquee and takes extra turns
|
|
# within it, its cards updating while they scroll (live elements, below).
|
|
# With live_in_ticker false a live game preempts Vegas entirely: the
|
|
# display controller refuses to run the ticker while any plugin reports
|
|
# live priority, and you get the full-screen scoreboard instead. (False
|
|
# was the default until 3.8.0; ConfigManager turns it on once for configs
|
|
# that still hold the old default.)
|
|
#
|
|
# The rotation is otherwise a strict round robin -- every plugin appears
|
|
# exactly once per cycle -- so with a dozen plugins enabled a live score
|
|
# comes round once a lap and can be minutes old on screen. Weighting lets a
|
|
# plugin claim several slots per cycle instead.
|
|
#
|
|
# Weights are per plugin, not per game: a scoreboard showing four live
|
|
# games still occupies one slot at a time, and rotates its own games within
|
|
# that slot using its own favorite_live_boost.
|
|
live_in_ticker: bool = True
|
|
|
|
# Slots per cycle for a plugin reporting live content. 1 disables the boost
|
|
# and restores the plain round robin.
|
|
live_weight: int = 3
|
|
|
|
# Slots per cycle for a plugin whose live content involves a favorite team.
|
|
# Only plugins implementing get_vegas_priority_weight() can claim this --
|
|
# the core cannot tell whose game is on, so the plugin reports it.
|
|
favorite_live_weight: int = 5
|
|
|
|
# Performance settings
|
|
target_fps: int = 125 # Target frame rate
|
|
buffer_ahead: int = 2 # Number of plugins to buffer ahead
|
|
|
|
# Scroll behavior. Neither key steps the scroll or sets a frame rate:
|
|
# motion is always by elapsed time at scroll_speed px/s. With
|
|
# frame_based_scrolling the speed is first converted to px per
|
|
# scroll_delay and clamped to 0.1-5 (ScrollHelper.set_scroll_speed), so
|
|
# the speed actually applied is clamp(scroll_speed * scroll_delay, 0.1, 5)
|
|
# / scroll_delay -- at the 0.02 default, speeds under 5 px/s run at 5.
|
|
frame_based_scrolling: bool = True
|
|
scroll_delay: float = 0.02 # only feeds the clamp above; not a frame period
|
|
|
|
# Dynamic duration
|
|
dynamic_duration_enabled: bool = True
|
|
min_cycle_duration: int = 60 # Minimum seconds per full cycle
|
|
max_cycle_duration: int = 240 # Maximum seconds per full cycle
|
|
|
|
@classmethod
|
|
def from_config(cls, config: Dict[str, Any]) -> 'VegasModeConfig':
|
|
"""
|
|
Create VegasModeConfig from main configuration dictionary.
|
|
|
|
Args:
|
|
config: Main config dict (expects config['display']['vegas_scroll'])
|
|
|
|
Returns:
|
|
VegasModeConfig instance
|
|
"""
|
|
vegas_config = config.get('display', {}).get('vegas_scroll', {})
|
|
# Missing keys fall back to the field defaults above, so each default
|
|
# is written once and the two cannot drift apart.
|
|
d = cls()
|
|
get = vegas_config.get
|
|
|
|
return cls(
|
|
enabled=get('enabled', d.enabled),
|
|
scroll_speed=float(get('scroll_speed', d.scroll_speed)),
|
|
separator_width=int(get('separator_width', d.separator_width)),
|
|
intra_plugin_gap=int(get('intra_plugin_gap', d.intra_plugin_gap)),
|
|
render_width_pct=int(get('render_width_pct', d.render_width_pct)),
|
|
min_content_separation=int(
|
|
get('min_content_separation', d.min_content_separation)),
|
|
min_cut_gap=int(get('min_cut_gap', d.min_cut_gap)),
|
|
smooth_scroll=get('smooth_scroll', d.smooth_scroll),
|
|
sub_pixel_blend=bool(get('sub_pixel_blend', d.sub_pixel_blend)),
|
|
continuous_scroll=get('continuous_scroll', d.continuous_scroll),
|
|
offscreen_prefetch=bool(get('offscreen_prefetch', d.offscreen_prefetch)),
|
|
switch_interval_ms=float(get('switch_interval_ms', d.switch_interval_ms) or 0.0),
|
|
prefetch_gate=bool(get('prefetch_gate', d.prefetch_gate)),
|
|
live_refresh=bool(get('live_refresh', d.live_refresh)),
|
|
live_max_hz=float(get('live_max_hz', d.live_max_hz)),
|
|
live_min_interval=float(get('live_min_interval', d.live_min_interval)),
|
|
live_lead_screens=float(get('live_lead_screens', d.live_lead_screens)),
|
|
extend_threshold_screens=float(
|
|
get('extend_threshold_screens', d.extend_threshold_screens)),
|
|
auto_trim=get('auto_trim', d.auto_trim),
|
|
trim_threshold=int(get('trim_threshold', d.trim_threshold)),
|
|
content_padding=int(get('content_padding', d.content_padding)),
|
|
min_plugin_width=int(get('min_plugin_width', d.min_plugin_width)),
|
|
lead_in_width=int(get('lead_in_width', d.lead_in_width)),
|
|
plugins_per_cycle=int(get('plugins_per_cycle', d.plugins_per_cycle)),
|
|
max_plugin_width_ratio=float(
|
|
get('max_plugin_width_ratio', d.max_plugin_width_ratio)),
|
|
overflow_mode=str(get('overflow_mode', d.overflow_mode)),
|
|
plugin_order=list(get('plugin_order', d.plugin_order)),
|
|
excluded_plugins=set(get('excluded_plugins', d.excluded_plugins)),
|
|
live_in_ticker=bool(get('live_in_ticker', d.live_in_ticker)),
|
|
# Clamped: a weight below 1 would drop the plugin from the rotation
|
|
# entirely, and a very large one starves everything else.
|
|
live_weight=max(1, min(10, int(get('live_weight', d.live_weight)))),
|
|
favorite_live_weight=max(1, min(10, int(
|
|
get('favorite_live_weight', d.favorite_live_weight)))),
|
|
target_fps=int(get('target_fps', d.target_fps)),
|
|
buffer_ahead=int(get('buffer_ahead', d.buffer_ahead)),
|
|
frame_based_scrolling=get(
|
|
'frame_based_scrolling', d.frame_based_scrolling),
|
|
scroll_delay=float(get('scroll_delay', d.scroll_delay)),
|
|
dynamic_duration_enabled=get(
|
|
'dynamic_duration_enabled', d.dynamic_duration_enabled),
|
|
min_cycle_duration=int(get('min_cycle_duration', d.min_cycle_duration)),
|
|
max_cycle_duration=int(get('max_cycle_duration', d.max_cycle_duration)),
|
|
)
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
"""Convert config to dictionary for serialization."""
|
|
return {
|
|
'enabled': self.enabled,
|
|
'scroll_speed': self.scroll_speed,
|
|
'separator_width': self.separator_width,
|
|
'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,
|
|
'sub_pixel_blend': self.sub_pixel_blend,
|
|
'continuous_scroll': self.continuous_scroll,
|
|
'offscreen_prefetch': self.offscreen_prefetch,
|
|
'switch_interval_ms': self.switch_interval_ms,
|
|
'prefetch_gate': self.prefetch_gate,
|
|
'live_refresh': self.live_refresh,
|
|
'live_max_hz': self.live_max_hz,
|
|
'live_min_interval': self.live_min_interval,
|
|
'live_lead_screens': self.live_lead_screens,
|
|
'extend_threshold_screens': self.extend_threshold_screens,
|
|
'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,
|
|
'live_in_ticker': self.live_in_ticker,
|
|
'live_weight': self.live_weight,
|
|
'favorite_live_weight': self.favorite_live_weight,
|
|
'overflow_mode': self.overflow_mode,
|
|
'plugin_order': self.plugin_order,
|
|
'excluded_plugins': list(self.excluded_plugins),
|
|
'target_fps': self.target_fps,
|
|
'buffer_ahead': self.buffer_ahead,
|
|
'frame_based_scrolling': self.frame_based_scrolling,
|
|
'scroll_delay': self.scroll_delay,
|
|
'dynamic_duration_enabled': self.dynamic_duration_enabled,
|
|
'min_cycle_duration': self.min_cycle_duration,
|
|
'max_cycle_duration': self.max_cycle_duration,
|
|
}
|
|
|
|
def get_frame_interval(self) -> float:
|
|
"""Get the frame interval in seconds for target FPS."""
|
|
return 1.0 / max(1, self.target_fps)
|
|
|
|
def get_ordered_plugins(self, available_plugins: List[str]) -> List[str]:
|
|
"""
|
|
Get plugins in configured order, filtering excluded ones.
|
|
|
|
Args:
|
|
available_plugins: List of all available plugin IDs
|
|
|
|
Returns:
|
|
Ordered list of plugin IDs to include in Vegas scroll
|
|
"""
|
|
if self.plugin_order:
|
|
# Use explicit order, filter to only available and non-excluded
|
|
ordered = [
|
|
p for p in self.plugin_order
|
|
if p in available_plugins and p not in self.excluded_plugins
|
|
]
|
|
# Add any available plugins not in the order list (at the end)
|
|
for p in available_plugins:
|
|
if p not in ordered and p not in self.excluded_plugins:
|
|
ordered.append(p)
|
|
return ordered
|
|
else:
|
|
# Use natural order, filter excluded
|
|
return [p for p in available_plugins if p not in self.excluded_plugins]
|
|
|
|
def validate(self) -> List[str]:
|
|
"""
|
|
Validate configuration values.
|
|
|
|
Returns:
|
|
List of validation error messages (empty if valid)
|
|
"""
|
|
errors = []
|
|
|
|
if self.scroll_speed < 1.0:
|
|
errors.append(f"scroll_speed must be >= 1.0, got {self.scroll_speed}")
|
|
if self.scroll_speed > 200.0:
|
|
errors.append(f"scroll_speed must be <= 200.0, got {self.scroll_speed}")
|
|
|
|
if self.separator_width < 0:
|
|
errors.append(f"separator_width must be >= 0, got {self.separator_width}")
|
|
if self.separator_width > 128:
|
|
errors.append(f"separator_width must be <= 128, got {self.separator_width}")
|
|
|
|
if self.target_fps < 30:
|
|
errors.append(f"target_fps must be >= 30, got {self.target_fps}")
|
|
if self.target_fps > 200:
|
|
errors.append(f"target_fps must be <= 200, got {self.target_fps}")
|
|
|
|
if self.buffer_ahead < 1:
|
|
errors.append(f"buffer_ahead must be >= 1, got {self.buffer_ahead}")
|
|
if self.buffer_ahead > 5:
|
|
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
|
|
|
|
if not 10 <= self.render_width_pct <= 100:
|
|
errors.append(
|
|
"render_width_pct must be between 10 and 100, "
|
|
f"got {self.render_width_pct}")
|
|
|
|
if not 0 <= self.min_content_separation <= 256:
|
|
errors.append(
|
|
"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 0.0 <= self.live_max_hz <= 10.0:
|
|
errors.append(
|
|
f"live_max_hz must be between 0 and 10, got {self.live_max_hz}")
|
|
if not 0.5 <= self.live_min_interval <= 60.0:
|
|
errors.append(
|
|
"live_min_interval must be between 0.5 and 60, "
|
|
f"got {self.live_min_interval}")
|
|
if not 0.0 <= self.live_lead_screens <= 5.0:
|
|
errors.append(
|
|
"live_lead_screens must be between 0 and 5, "
|
|
f"got {self.live_lead_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}")
|
|
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}")
|
|
# Bounded because every segment narrower than this is dropped — an
|
|
# unbounded value would discard every plugin and leave a blank ticker.
|
|
if self.min_plugin_width > 512:
|
|
errors.append(
|
|
f"min_plugin_width must be <= 512, 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.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 "
|
|
f"(0 disables the cap), got {self.max_plugin_width_ratio}")
|
|
|
|
return errors
|