diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index 1ee964b..fedc0f9 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -59,7 +59,6 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe name=source.name, source_type=source.source_type, picture_source_id=getattr(source, "picture_source_id", None), - fps=getattr(source, "fps", None), brightness=getattr(source, "brightness", None), saturation=getattr(source, "saturation", None), gamma=getattr(source, "gamma", None), @@ -127,7 +126,6 @@ async def create_color_strip_source( name=data.name, source_type=data.source_type, picture_source_id=data.picture_source_id, - fps=data.fps, brightness=data.brightness, saturation=data.saturation, gamma=data.gamma, @@ -187,7 +185,6 @@ async def update_color_strip_source( source_id=source_id, name=data.name, picture_source_id=data.picture_source_id, - fps=data.fps, brightness=data.brightness, saturation=data.saturation, gamma=data.gamma, diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index a3ba232..ab40903 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -94,6 +94,7 @@ def _target_to_response(target) -> PictureTargetResponse: target_type=target.target_type, device_id=target.device_id, color_strip_source_id=target.color_strip_source_id, + fps=target.fps, standby_interval=target.standby_interval, state_check_interval=target.state_check_interval, description=target.description, @@ -148,6 +149,7 @@ async def create_target( target_type=data.target_type, device_id=data.device_id, color_strip_source_id=data.color_strip_source_id, + fps=data.fps, standby_interval=data.standby_interval, state_check_interval=data.state_check_interval, picture_source_id=data.picture_source_id, @@ -243,6 +245,7 @@ async def update_target( name=data.name, device_id=data.device_id, color_strip_source_id=data.color_strip_source_id, + fps=data.fps, standby_interval=data.standby_interval, state_check_interval=data.state_check_interval, picture_source_id=data.picture_source_id, @@ -254,7 +257,8 @@ async def update_target( try: target.sync_with_manager( manager, - settings_changed=(data.standby_interval is not None or + settings_changed=(data.fps is not None or + data.standby_interval is not None or data.state_check_interval is not None or data.key_colors_settings is not None), source_changed=data.color_strip_source_id is not None, diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index 6a1f6cd..42f7b08 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -34,7 +34,6 @@ class ColorStripSourceCreate(BaseModel): source_type: Literal["picture", "static", "gradient", "color_cycle"] = Field(default="picture", description="Source type") # picture-type fields picture_source_id: str = Field(default="", description="Picture source ID (for picture type)") - fps: int = Field(default=30, description="Target frames per second", ge=10, le=90) brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) saturation: float = Field(default=1.0, description="Saturation (0.0=grayscale, 1.0=unchanged, 2.0=double)", ge=0.0, le=2.0) gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0) @@ -61,7 +60,6 @@ class ColorStripSourceUpdate(BaseModel): name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) # picture-type fields picture_source_id: Optional[str] = Field(None, description="Picture source ID") - fps: Optional[int] = Field(None, description="Target FPS", ge=10, le=90) brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) saturation: Optional[float] = Field(None, description="Saturation (0.0-2.0)", ge=0.0, le=2.0) gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0) @@ -90,7 +88,6 @@ class ColorStripSourceResponse(BaseModel): source_type: str = Field(description="Source type") # picture-type fields picture_source_id: Optional[str] = Field(None, description="Picture source ID") - fps: Optional[int] = Field(None, description="Target FPS") brightness: Optional[float] = Field(None, description="Brightness multiplier") saturation: Optional[float] = Field(None, description="Saturation") gamma: Optional[float] = Field(None, description="Gamma correction") diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index 350d9d6..2b19c6f 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -53,6 +53,7 @@ class PictureTargetCreate(BaseModel): # LED target fields device_id: str = Field(default="", description="LED device ID") color_strip_source_id: str = Field(default="", description="Color strip source ID") + fps: int = Field(default=30, ge=10, le=90, description="Target send FPS (10-90)") standby_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0) state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600) # KC target fields @@ -68,6 +69,7 @@ class PictureTargetUpdate(BaseModel): # LED target fields device_id: Optional[str] = Field(None, description="LED device ID") color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") + fps: Optional[int] = Field(None, ge=10, le=90, description="Target send FPS (10-90)") standby_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0) state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600) # KC target fields @@ -85,6 +87,7 @@ class PictureTargetResponse(BaseModel): # LED target fields device_id: str = Field(default="", description="LED device ID") color_strip_source_id: str = Field(default="", description="Color strip source ID") + fps: Optional[int] = Field(None, description="Target send FPS") standby_interval: float = Field(default=1.0, description="Keepalive interval (s)") state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)") # KC target fields diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index 9f61984..c8d9b1a 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -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 diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index 29cce62..ec6c629 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -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()) diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index 79969ed..1820af4 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -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(), diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index d6954db..a40751b 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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 diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index ec9b1c0..72cd38f 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -18,7 +18,6 @@ class CSSEditorModal extends Modal { name: document.getElementById('css-editor-name').value, type, picture_source: document.getElementById('css-editor-picture-source').value, - fps: document.getElementById('css-editor-fps').value, interpolation: document.getElementById('css-editor-interpolation').value, smoothing: document.getElementById('css-editor-smoothing').value, brightness: document.getElementById('css-editor-brightness').value, @@ -26,7 +25,7 @@ class CSSEditorModal extends Modal { gamma: document.getElementById('css-editor-gamma').value, color: document.getElementById('css-editor-color').value, frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked, - led_count: (type === 'static' || type === 'gradient' || type === 'color_cycle') ? '0' : document.getElementById('css-editor-led-count').value, + led_count: document.getElementById('css-editor-led-count').value, gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]', animation_enabled: document.getElementById('css-editor-animation-enabled').checked, animation_type: document.getElementById('css-editor-animation-type').value, @@ -47,9 +46,6 @@ export function onCSSTypeChange() { document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none'; document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none'; document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none'; - // LED count is only meaningful for picture sources; static/gradient/color_cycle auto-size from device - document.getElementById('css-editor-led-count-group').style.display = - (type === 'static' || type === 'gradient' || type === 'color_cycle') ? 'none' : ''; // Animation section — shown for static/gradient only (color_cycle is always animating) const animSection = document.getElementById('css-editor-animation-section'); @@ -205,7 +201,7 @@ export function createColorStripCard(source, pictureSourceMap) { ).join(''); propsHtml = ` ${swatches} - 🔄 ${(source.cycle_speed || 1.0).toFixed(1)}× + ⏩ ${(source.cycle_speed || 1.0).toFixed(1)}× ${source.led_count ? `💡 ${source.led_count}` : ''} `; } else if (isGradient) { @@ -227,6 +223,7 @@ export function createColorStripCard(source, pictureSourceMap) { propsHtml = ` ${cssGradient ? `` : ''} 🎨 ${stops.length} ${t('color_strip.gradient.stops_count')} + ${source.led_count ? `💡 ${source.led_count}` : ''} ${animBadge} `; } else { @@ -237,9 +234,8 @@ export function createColorStripCard(source, pictureSourceMap) { const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0); const ledCount = (source.led_count > 0) ? source.led_count : calLeds; propsHtml = ` - ⚡ ${source.fps || 30} fps - ${ledCount ? `💡 ${ledCount}` : ''} 📺 ${escapeHtml(srcName)} + ${ledCount ? `💡 ${ledCount}` : ''} `; } @@ -311,10 +307,6 @@ export async function showCSSEditor(cssId = null) { } else { sourceSelect.value = css.picture_source_id || ''; - const fps = css.fps ?? 30; - document.getElementById('css-editor-fps').value = fps; - document.getElementById('css-editor-fps-value').textContent = fps; - document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average'; const smoothing = css.smoothing ?? 0.3; @@ -343,8 +335,6 @@ export async function showCSSEditor(cssId = null) { document.getElementById('css-editor-name').value = ''; document.getElementById('css-editor-type').value = 'picture'; onCSSTypeChange(); - document.getElementById('css-editor-fps').value = 30; - document.getElementById('css-editor-fps-value').textContent = '30'; document.getElementById('css-editor-interpolation').value = 'average'; document.getElementById('css-editor-smoothing').value = 0.3; document.getElementById('css-editor-smoothing-value').textContent = '0.30'; @@ -432,7 +422,6 @@ export async function saveCSSEditor() { payload = { name, picture_source_id: document.getElementById('css-editor-picture-source').value, - fps: parseInt(document.getElementById('css-editor-fps').value) || 30, interpolation_mode: document.getElementById('css-editor-interpolation').value, smoothing: parseFloat(document.getElementById('css-editor-smoothing').value), brightness: parseFloat(document.getElementById('css-editor-brightness').value), diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index e6a117e..c38d0bf 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -72,6 +72,7 @@ class TargetEditorModal extends Modal { name: document.getElementById('target-editor-name').value, device: document.getElementById('target-editor-device').value, css: document.getElementById('target-editor-css').value, + fps: document.getElementById('target-editor-fps').value, standby_interval: document.getElementById('target-editor-standby-interval').value, }; } @@ -146,6 +147,9 @@ export async function showTargetEditor(targetId = null) { document.getElementById('target-editor-name').value = target.name; deviceSelect.value = target.device_id || ''; cssSelect.value = target.color_strip_source_id || ''; + const fps = target.fps ?? 30; + document.getElementById('target-editor-fps').value = fps; + document.getElementById('target-editor-fps-value').textContent = fps; document.getElementById('target-editor-standby-interval').value = target.standby_interval ?? 1.0; document.getElementById('target-editor-standby-interval-value').textContent = target.standby_interval ?? 1.0; document.getElementById('target-editor-title').textContent = t('targets.edit'); @@ -153,6 +157,8 @@ export async function showTargetEditor(targetId = null) { // Creating new target — first option is selected by default document.getElementById('target-editor-id').value = ''; document.getElementById('target-editor-name').value = ''; + document.getElementById('target-editor-fps').value = 30; + document.getElementById('target-editor-fps-value').textContent = '30'; document.getElementById('target-editor-standby-interval').value = 1.0; document.getElementById('target-editor-standby-interval-value').textContent = '1.0'; document.getElementById('target-editor-title').textContent = t('targets.add'); @@ -203,10 +209,13 @@ export async function saveTargetEditor() { return; } + const fps = parseInt(document.getElementById('target-editor-fps').value) || 30; + const payload = { name, device_id: deviceId, color_strip_source_id: cssId, + fps, standby_interval: standbyInterval, }; @@ -508,6 +517,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {