From 55e25b8860ce9d8a7cbcf7a1645dc87d921ec0a4 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 20 Feb 2026 20:29:22 +0300 Subject: [PATCH] Frame interpolation, FPS hot-update, timing metrics, KC brightness fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CSS: add frame interpolation option — blends between consecutive captured frames on idle ticks so LED output runs at full target FPS even when capture rate is lower (e.g. capture 30fps, output 60fps) - WledTargetProcessor: re-read stream.target_fps each loop tick so FPS changes to the CSS source take effect without restarting the target - WledTargetProcessor: restore per-stage timing metrics on target card by pulling extract/map/smooth/total from CSS stream get_last_timing() - TargetProcessingState schema: add missing timing_extract_ms, timing_map_leds_ms, timing_smooth_ms, timing_total_ms fields - KC targets: add extraction FPS badge to target card props row - KC targets: fix 500 error when changing brightness — update_fields now accepts (and ignores) WLED-specific kwargs - KC targets: fix partial key_colors_settings update wiping pattern_template_id — update route merges only explicitly-set fields using model_dump(exclude_unset=True) Co-Authored-By: Claude Sonnet 4.6 --- .../api/routes/color_strip_sources.py | 3 ++ .../api/routes/picture_targets.py | 23 +++++++- .../api/schemas/color_strip_sources.py | 3 ++ .../api/schemas/picture_targets.py | 4 ++ .../core/processing/color_strip_stream.py | 52 +++++++++++++++++++ .../core/processing/wled_target_processor.py | 27 ++++++++-- .../static/js/features/color-strips.js | 5 ++ .../static/js/features/kc-targets.js | 1 + .../wled_controller/static/locales/en.json | 2 + .../wled_controller/static/locales/ru.json | 2 + .../storage/color_strip_source.py | 3 ++ .../storage/color_strip_store.py | 5 ++ .../storage/key_colors_picture_target.py | 5 +- .../templates/modals/css-editor.html | 9 ++++ 14 files changed, 138 insertions(+), 6 deletions(-) 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 586b2db..a8ed6e2 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -70,6 +70,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe color=getattr(source, "color", None), stops=stops, description=source.description, + frame_interpolation=getattr(source, "frame_interpolation", None), overlay_active=overlay_active, created_at=source.created_at, updated_at=source.updated_at, @@ -134,6 +135,7 @@ async def create_color_strip_source( color=data.color, stops=stops, description=data.description, + frame_interpolation=data.frame_interpolation, ) return _css_to_response(source) @@ -190,6 +192,7 @@ async def update_color_strip_source( color=data.color, stops=stops, description=data.description, + frame_interpolation=data.frame_interpolation, ) # Hot-reload running stream (no restart needed for in-place param changes) diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index e989d1e..a3ba232 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -214,7 +214,28 @@ async def update_target( if not device: raise HTTPException(status_code=422, detail=f"Device {data.device_id} not found") - kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None + # Build KC settings with partial-update support: only apply fields that were + # explicitly provided in the request body, merging with the existing settings. + kc_settings = None + if data.key_colors_settings is not None: + incoming = data.key_colors_settings.model_dump(exclude_unset=True) + try: + existing_target = target_store.get_target(target_id) + except ValueError: + existing_target = None + + if isinstance(existing_target, KeyColorsPictureTarget): + ex = existing_target.settings + merged = KeyColorsSettingsSchema( + fps=incoming.get("fps", ex.fps), + interpolation_mode=incoming.get("interpolation_mode", ex.interpolation_mode), + smoothing=incoming.get("smoothing", ex.smoothing), + pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id), + brightness=incoming.get("brightness", ex.brightness), + ) + kc_settings = _kc_schema_to_settings(merged) + else: + kc_settings = _kc_schema_to_settings(data.key_colors_settings) # Update in store target = target_store.update_target( 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 cfc7852..df796cb 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -40,6 +40,7 @@ class ColorStripSourceCreate(BaseModel): # shared led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0) description: Optional[str] = Field(None, description="Optional description", max_length=500) + frame_interpolation: bool = Field(default=False, description="Blend between consecutive captured frames for smoother output") class ColorStripSourceUpdate(BaseModel): @@ -62,6 +63,7 @@ class ColorStripSourceUpdate(BaseModel): # shared led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0) description: Optional[str] = Field(None, description="Optional description", max_length=500) + frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames") class ColorStripSourceResponse(BaseModel): @@ -86,6 +88,7 @@ class ColorStripSourceResponse(BaseModel): # shared led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)") description: Optional[str] = Field(None, description="Description") + frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames") overlay_active: bool = Field(False, description="Whether the screen overlay is currently active") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index 223f2e8..350d9d6 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -116,6 +116,10 @@ class TargetProcessingState(BaseModel): frames_keepalive: Optional[int] = Field(None, description="Keepalive frames sent during standby") fps_current: Optional[int] = Field(None, description="Frames sent in the last second") timing_send_ms: Optional[float] = Field(None, description="DDP send time (ms)") + timing_extract_ms: Optional[float] = Field(None, description="Border pixel extraction time (ms)") + timing_map_leds_ms: Optional[float] = Field(None, description="LED color mapping time (ms)") + timing_smooth_ms: Optional[float] = Field(None, description="Temporal smoothing time (ms)") + timing_total_ms: Optional[float] = Field(None, description="Total processing time per frame (ms)") timing_calc_colors_ms: Optional[float] = Field(None, description="Color calculation time (ms, KC targets)") timing_broadcast_ms: Optional[float] = Field(None, description="WebSocket broadcast time (ms, KC targets)") display_index: Optional[int] = Field(None, description="Current display index") 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 d7c5a1c..74c1e8f 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -151,6 +151,14 @@ class PictureColorStripStream(ColorStripStream): self._colors_lock = threading.Lock() self._previous_colors: Optional[np.ndarray] = None + # Frame interpolation state + self._frame_interpolation: bool = source.frame_interpolation + self._interp_from: Optional[np.ndarray] = None + self._interp_to: Optional[np.ndarray] = None + self._interp_start: float = 0.0 + self._interp_duration: float = 1.0 / self._fps if self._fps > 0 else 1.0 + self._last_capture_time: float = 0.0 + self._running = False self._thread: Optional[threading.Thread] = None self._last_timing: dict = {} @@ -194,6 +202,9 @@ class PictureColorStripStream(ColorStripStream): self._thread = None self._latest_colors = None self._previous_colors = None + self._interp_from = None + self._interp_to = None + self._last_capture_time = 0.0 logger.info("PictureColorStripStream stopped") def get_latest_colors(self) -> Optional[np.ndarray]: @@ -236,6 +247,11 @@ class PictureColorStripStream(ColorStripStream): ) self._previous_colors = None # Reset smoothing history on calibration change + if source.frame_interpolation != self._frame_interpolation: + self._frame_interpolation = source.frame_interpolation + self._interp_from = None + self._interp_to = None + logger.info("PictureColorStripStream params updated in-place") def _processing_loop(self) -> None: @@ -251,10 +267,39 @@ class PictureColorStripStream(ColorStripStream): frame = self._live_stream.get_latest_frame() if frame is None or frame is cached_frame: + if ( + frame is not None + and self._frame_interpolation + and self._interp_from is not None + and self._interp_to is not None + ): + t = min(1.0, (loop_start - self._interp_start) / self._interp_duration) + alpha = int(t * 256) + led_colors = ( + (256 - alpha) * self._interp_from.astype(np.uint16) + + alpha * self._interp_to.astype(np.uint16) + ) >> 8 + led_colors = led_colors.astype(np.uint8) + if self._saturation != 1.0: + led_colors = _apply_saturation(led_colors, self._saturation) + if self._gamma != 1.0: + led_colors = self._gamma_lut[led_colors] + if self._brightness != 1.0: + led_colors = np.clip( + led_colors.astype(np.float32) * self._brightness, 0, 255 + ).astype(np.uint8) + with self._colors_lock: + self._latest_colors = led_colors elapsed = time.perf_counter() - loop_start time.sleep(max(frame_time - elapsed, 0.001)) continue + interval = ( + loop_start - self._last_capture_time + if self._last_capture_time > 0 + else frame_time + ) + self._last_capture_time = loop_start cached_frame = frame t0 = time.perf_counter() @@ -275,6 +320,13 @@ class PictureColorStripStream(ColorStripStream): else: led_colors = led_colors[:target_count] + # Update interpolation buffers (raw colors, before corrections) + if self._frame_interpolation: + self._interp_from = self._interp_to + self._interp_to = led_colors.copy() + self._interp_start = loop_start + self._interp_duration = max(interval, 0.001) + # Temporal smoothing smoothing = self._smoothing if ( 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 e34d63a..7e6bb84 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -234,6 +234,20 @@ class WledTargetProcessor(TargetProcessor): metrics = self._metrics fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None + # Pull per-stage timing from the CSS stream (runs in a background thread) + css_timing: dict = {} + if self._is_running and self._color_strip_stream is not None: + css_timing = self._color_strip_stream.get_last_timing() + + send_ms = round(metrics.timing_send_ms, 1) if self._is_running else None + extract_ms = round(css_timing.get("extract_ms", 0), 1) if css_timing else None + map_ms = round(css_timing.get("map_leds_ms", 0), 1) if css_timing else None + smooth_ms = round(css_timing.get("smooth_ms", 0), 1) if css_timing else None + total_ms = ( + round(css_timing.get("total_ms", 0) + metrics.timing_send_ms, 1) + if css_timing else None + ) + return { "target_id": self._target_id, "device_id": self._device_id, @@ -245,7 +259,11 @@ class WledTargetProcessor(TargetProcessor): "frames_skipped": metrics.frames_skipped if self._is_running else None, "frames_keepalive": metrics.frames_keepalive if self._is_running else None, "fps_current": metrics.fps_current if self._is_running else None, - "timing_send_ms": round(metrics.timing_send_ms, 1) if self._is_running else None, + "timing_send_ms": send_ms, + "timing_extract_ms": extract_ms, + "timing_map_leds_ms": map_ms, + "timing_smooth_ms": smooth_ms, + "timing_total_ms": total_ms, "display_index": self._resolved_display_index, "overlay_active": self._overlay_active, "last_update": metrics.last_update, @@ -342,8 +360,6 @@ class WledTargetProcessor(TargetProcessor): async def _processing_loop(self) -> None: """Main processing loop — poll ColorStripStream → apply brightness → send.""" stream = self._color_strip_stream - target_fps = self._resolved_target_fps or 30 - frame_time = 1.0 / target_fps standby_interval = self._standby_interval fps_samples: collections.deque = collections.deque(maxlen=10) @@ -355,12 +371,15 @@ class WledTargetProcessor(TargetProcessor): logger.info( f"Processing loop started for target {self._target_id} " - f"(display={self._resolved_display_index}, fps={target_fps})" + f"(display={self._resolved_display_index}, fps={self._resolved_target_fps})" ) try: while self._is_running: loop_start = now = time.time() + # 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 + frame_time = 1.0 / target_fps # Re-fetch device info for runtime changes (test mode, brightness) device_info = self._ctx.get_device_info(self._device_id) 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 eb42528..445f63b 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -25,6 +25,7 @@ class CSSEditorModal extends Modal { saturation: document.getElementById('css-editor-saturation').value, 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') ? '0' : document.getElementById('css-editor-led-count').value, gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]', }; @@ -194,6 +195,8 @@ export async function showCSSEditor(cssId = null) { const gamma = css.gamma ?? 1.0; document.getElementById('css-editor-gamma').value = gamma; document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2); + + document.getElementById('css-editor-frame-interpolation').checked = css.frame_interpolation || false; } document.getElementById('css-editor-led-count').value = css.led_count ?? 0; @@ -214,6 +217,7 @@ export async function showCSSEditor(cssId = null) { document.getElementById('css-editor-saturation-value').textContent = '1.00'; document.getElementById('css-editor-gamma').value = 1.0; document.getElementById('css-editor-gamma-value').textContent = '1.00'; + document.getElementById('css-editor-frame-interpolation').checked = false; document.getElementById('css-editor-color').value = '#ffffff'; document.getElementById('css-editor-led-count').value = 0; document.getElementById('css-editor-title').textContent = t('color_strip.add'); @@ -281,6 +285,7 @@ export async function saveCSSEditor() { brightness: parseFloat(document.getElementById('css-editor-brightness').value), saturation: parseFloat(document.getElementById('css-editor-saturation').value), gamma: parseFloat(document.getElementById('css-editor-gamma').value), + frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked, led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0, }; if (!cssId) payload.source_type = 'picture'; diff --git a/server/src/wled_controller/static/js/features/kc-targets.js b/server/src/wled_controller/static/js/features/kc-targets.js index 14f15e7..e439bb3 100644 --- a/server/src/wled_controller/static/js/features/kc-targets.js +++ b/server/src/wled_controller/static/js/features/kc-targets.js @@ -75,6 +75,7 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap) { 📺 ${escapeHtml(sourceName)} 📄 ${escapeHtml(patternName)} ▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''} + ⚡ ${kcSettings.fps ?? 10} fps
dict: d = super().to_dict() @@ -155,6 +157,7 @@ class PictureColorStripSource(ColorStripSource): d["interpolation_mode"] = self.interpolation_mode d["calibration"] = calibration_to_dict(self.calibration) d["led_count"] = self.led_count + d["frame_interpolation"] = self.frame_interpolation return d diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index 0217dfa..b0e8b34 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -104,6 +104,7 @@ class ColorStripStore: color: Optional[list] = None, stops: Optional[list] = None, description: Optional[str] = None, + frame_interpolation: bool = False, ) -> ColorStripSource: """Create a new color strip source. @@ -165,6 +166,7 @@ class ColorStripStore: interpolation_mode=interpolation_mode, calibration=calibration, led_count=led_count, + frame_interpolation=frame_interpolation, ) self._sources[source_id] = source @@ -189,6 +191,7 @@ class ColorStripStore: color: Optional[list] = None, stops: Optional[list] = None, description: Optional[str] = None, + frame_interpolation: Optional[bool] = None, ) -> ColorStripSource: """Update an existing color strip source. @@ -228,6 +231,8 @@ class ColorStripStore: source.calibration = calibration if led_count is not None: source.led_count = led_count + if frame_interpolation is not None: + source.frame_interpolation = frame_interpolation elif isinstance(source, StaticColorStripSource): if color is not None: if isinstance(color, list) and len(color) == 3: diff --git a/server/src/wled_controller/storage/key_colors_picture_target.py b/server/src/wled_controller/storage/key_colors_picture_target.py index 36ee8f0..bd82ea8 100644 --- a/server/src/wled_controller/storage/key_colors_picture_target.py +++ b/server/src/wled_controller/storage/key_colors_picture_target.py @@ -90,7 +90,10 @@ class KeyColorsPictureTarget(PictureTarget): manager.update_target_source(self.id, self.picture_source_id) def update_fields(self, *, name=None, device_id=None, picture_source_id=None, - settings=None, key_colors_settings=None, description=None) -> None: + settings=None, key_colors_settings=None, description=None, + # WledPictureTarget-specific params — accepted but ignored: + color_strip_source_id=None, standby_interval=None, + state_check_interval=None) -> None: """Apply mutable field updates for KC targets.""" super().update_fields(name=name, description=description) if picture_source_id is not None: diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index fce64a2..1a0fc5a 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -78,6 +78,15 @@
+
+
+ + +
+ + +
+
Color Corrections