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:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user