Move FPS from color strip source to target; dynamic capture rate

FPS is a consumption property (how fast to send to a device), not a
production property. Two targets sharing the same source may need
different FPS. This moves the fps field from PictureColorStripSource
to WledPictureTarget across the full stack.

The capture stream now auto-adjusts its rate to max(all connected
target FPS values) via ColorStripStreamManager tracking per-consumer
FPS. UI updates: FPS slider in target editor, FPS badge on target
cards, LED count repositioned in CSS editor, consistent speed icons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 03:46:08 +03:00
parent 1204676c30
commit 1f6c913343
14 changed files with 126 additions and 57 deletions

View File

@@ -135,7 +135,7 @@ class PictureColorStripStream(ColorStripStream):
from wled_controller.storage.color_strip_source import PictureColorStripSource
self._live_stream = live_stream
self._fps: int = source.fps
self._fps: int = 30 # internal capture rate (send FPS is on the target)
self._smoothing: float = source.smoothing
self._brightness: float = source.brightness
self._saturation: float = source.saturation
@@ -217,6 +217,14 @@ class PictureColorStripStream(ColorStripStream):
def get_last_timing(self) -> dict:
return dict(self._last_timing)
def set_capture_fps(self, fps: int) -> None:
"""Update the internal capture rate. Thread-safe (read atomically by the loop)."""
fps = max(10, min(90, fps))
if fps != self._fps:
self._fps = fps
self._interp_duration = 1.0 / fps
logger.info(f"PictureColorStripStream capture FPS set to {fps}")
def update_source(self, source) -> None:
"""Hot-update processing parameters. Thread-safe for scalar params.
@@ -227,7 +235,6 @@ class PictureColorStripStream(ColorStripStream):
if not isinstance(source, PictureColorStripSource):
return
self._fps = source.fps
self._smoothing = source.smoothing
self._brightness = source.brightness
self._saturation = source.saturation

View File

@@ -32,6 +32,12 @@ class _ColorStripEntry:
ref_count: int
# ID of the picture source whose LiveStream we acquired (for release)
picture_source_id: str
# Per-consumer target FPS values (target_id → fps)
target_fps: Dict[str, int] = None
def __post_init__(self):
if self.target_fps is None:
self.target_fps = {}
class ColorStripStreamManager:
@@ -213,6 +219,36 @@ class ColorStripStreamManager:
logger.info(f"Updated running color strip stream {css_id}")
def notify_target_fps(self, css_id: str, target_id: str, fps: int) -> None:
"""Register or update a consumer's target FPS.
Recalculates the capture rate for PictureColorStripStreams as
max(all consumer FPS values). Non-picture streams are unaffected.
"""
entry = self._streams.get(css_id)
if not entry:
return
entry.target_fps[target_id] = fps
self._recalc_capture_fps(entry)
def remove_target_fps(self, css_id: str, target_id: str) -> None:
"""Unregister a consumer's target FPS (e.g. on stop)."""
entry = self._streams.get(css_id)
if not entry:
return
entry.target_fps.pop(target_id, None)
self._recalc_capture_fps(entry)
def _recalc_capture_fps(self, entry: _ColorStripEntry) -> None:
"""Push max(consumer FPS) to the stream if it supports set_capture_fps."""
if not hasattr(entry.stream, "set_capture_fps"):
return
if entry.target_fps:
new_fps = max(entry.target_fps.values())
else:
new_fps = 30 # default when no consumers
entry.stream.set_capture_fps(new_fps)
def release_all(self) -> None:
"""Stop and remove all managed color strip streams. Called on shutdown."""
css_ids = list(self._streams.keys())

View File

@@ -272,6 +272,7 @@ class ProcessorManager:
target_id: str,
device_id: str,
color_strip_source_id: str = "",
fps: int = 30,
standby_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
):
@@ -285,6 +286,7 @@ class ProcessorManager:
target_id=target_id,
device_id=device_id,
color_strip_source_id=color_strip_source_id,
fps=fps,
standby_interval=standby_interval,
state_check_interval=state_check_interval,
ctx=self._build_context(),

View File

@@ -40,6 +40,7 @@ class WledTargetProcessor(TargetProcessor):
target_id: str,
device_id: str,
color_strip_source_id: str,
fps: int,
standby_interval: float,
state_check_interval: int,
ctx: TargetContext,
@@ -47,6 +48,7 @@ class WledTargetProcessor(TargetProcessor):
super().__init__(target_id, ctx)
self._device_id = device_id
self._color_strip_source_id = color_strip_source_id
self._target_fps = fps if fps > 0 else 30
self._standby_interval = standby_interval
self._state_check_interval = state_check_interval
@@ -58,7 +60,6 @@ class WledTargetProcessor(TargetProcessor):
# Resolved stream metadata (set once stream is acquired)
self._resolved_display_index: Optional[int] = None
self._resolved_target_fps: Optional[int] = None
# ----- Properties -----
@@ -114,7 +115,6 @@ class WledTargetProcessor(TargetProcessor):
stream = await asyncio.to_thread(css_manager.acquire, self._color_strip_source_id)
self._color_strip_stream = stream
self._resolved_display_index = stream.display_index
self._resolved_target_fps = stream.target_fps
# For auto-sized static/gradient/color_cycle streams (led_count == 0), size to device LED count
from wled_controller.core.processing.color_strip_stream import (
@@ -125,10 +125,15 @@ class WledTargetProcessor(TargetProcessor):
if isinstance(stream, (StaticColorStripStream, GradientColorStripStream, ColorCycleColorStripStream)) and device_info.led_count > 0:
stream.configure(device_info.led_count)
# Notify stream manager of our target FPS so it can adjust capture rate
css_manager.notify_target_fps(
self._color_strip_source_id, self._target_id, self._target_fps
)
logger.info(
f"Acquired color strip stream for target {self._target_id} "
f"(css={self._color_strip_source_id}, display={self._resolved_display_index}, "
f"fps={self._resolved_target_fps})"
f"fps={self._target_fps})"
)
except Exception as e:
logger.error(f"Failed to acquire color strip stream for target {self._target_id}: {e}")
@@ -176,6 +181,7 @@ class WledTargetProcessor(TargetProcessor):
css_manager = self._ctx.color_strip_stream_manager
if css_manager and self._color_strip_source_id:
try:
css_manager.remove_target_fps(self._color_strip_source_id, self._target_id)
await asyncio.to_thread(css_manager.release, self._color_strip_source_id)
except Exception as e:
logger.warning(f"Error releasing color strip stream for {self._target_id}: {e}")
@@ -189,6 +195,14 @@ class WledTargetProcessor(TargetProcessor):
def update_settings(self, settings: dict) -> None:
"""Update target-specific timing settings."""
if isinstance(settings, dict):
if "fps" in settings:
self._target_fps = settings["fps"] if settings["fps"] > 0 else 30
# Notify stream manager so capture rate adjusts to max of all consumers
css_manager = self._ctx.color_strip_stream_manager
if css_manager and self._color_strip_source_id and self._is_running:
css_manager.notify_target_fps(
self._color_strip_source_id, self._target_id, self._target_fps
)
if "standby_interval" in settings:
self._standby_interval = settings["standby_interval"]
if "state_check_interval" in settings:
@@ -213,11 +227,12 @@ class WledTargetProcessor(TargetProcessor):
old_id = self._color_strip_source_id
try:
new_stream = css_manager.acquire(color_strip_source_id)
css_manager.remove_target_fps(old_id, self._target_id)
css_manager.release(old_id)
self._color_strip_stream = new_stream
self._resolved_display_index = new_stream.display_index
self._resolved_target_fps = new_stream.target_fps
self._color_strip_source_id = color_strip_source_id
css_manager.notify_target_fps(color_strip_source_id, self._target_id, self._target_fps)
logger.info(f"Swapped color strip source for {self._target_id}: {old_id}{color_strip_source_id}")
except Exception as e:
logger.error(f"Failed to swap color strip source for {self._target_id}: {e}")
@@ -234,7 +249,7 @@ class WledTargetProcessor(TargetProcessor):
def get_state(self) -> dict:
metrics = self._metrics
fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None
fps_target = self._target_fps
# Pull per-stage timing from the CSS stream (runs in a background thread)
css_timing: dict = {}
@@ -277,7 +292,7 @@ class WledTargetProcessor(TargetProcessor):
def get_metrics(self) -> dict:
metrics = self._metrics
fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None
fps_target = self._target_fps
uptime_seconds = 0.0
if metrics.start_time and self._is_running:
uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds()
@@ -406,15 +421,15 @@ class WledTargetProcessor(TargetProcessor):
logger.info(
f"Processing loop started for target {self._target_id} "
f"(display={self._resolved_display_index}, fps={self._resolved_target_fps})"
f"(display={self._resolved_display_index}, fps={self._target_fps})"
)
try:
with high_resolution_timer():
while self._is_running:
loop_start = now = time.perf_counter()
# Re-read target_fps each tick so hot-updates to the CSS source take effect
target_fps = stream.target_fps if stream.target_fps > 0 else 30
# Re-read target_fps each tick so hot-updates take effect immediately
target_fps = self._target_fps if self._target_fps > 0 else 30
frame_time = 1.0 / target_fps
# Re-fetch device info every ~30 iterations instead of every