From 3100b0d97982c5ea1a5569278c49a73265da0990 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 13 Feb 2026 15:17:14 +0300 Subject: [PATCH] Add frame-change detection, keepalive, current FPS, and compact metrics UI Skip redundant processing/DDP sends when screen is static using object identity comparison. Add configurable standby interval to periodically resend last frame keeping WLED in live mode. Track frames skipped, keepalive count, and current FPS (rolling 1-second send count). Always use DDP regardless of LED count. Compact metrics grid with label-value rows and remove Skipped from UI display. Co-Authored-By: Claude Opus 4.6 --- .../api/routes/picture_targets.py | 3 + .../api/schemas/picture_targets.py | 4 + .../wled_controller/core/processor_manager.py | 128 +++++++++++++----- server/src/wled_controller/static/app.js | 28 +++- server/src/wled_controller/static/index.html | 12 ++ .../wled_controller/static/locales/en.json | 5 + .../wled_controller/static/locales/ru.json | 5 + server/src/wled_controller/static/style.css | 17 +-- .../storage/wled_picture_target.py | 2 + 9 files changed, 154 insertions(+), 50 deletions(-) diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index ddee169..0946846 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -67,6 +67,7 @@ def _settings_to_core(schema: ProcessingSettingsSchema) -> ProcessingSettings: interpolation_mode=schema.interpolation_mode, brightness=schema.brightness, smoothing=schema.smoothing, + standby_interval=schema.standby_interval, state_check_interval=schema.state_check_interval, ) if schema.color_correction: @@ -87,6 +88,7 @@ def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchem interpolation_mode=settings.interpolation_mode, brightness=settings.brightness, smoothing=settings.smoothing, + standby_interval=settings.standby_interval, state_check_interval=settings.state_check_interval, color_correction=ColorCorrection( gamma=settings.gamma, @@ -470,6 +472,7 @@ async def update_target_settings( gamma=existing.gamma, saturation=existing.saturation, smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing, + standby_interval=settings.standby_interval if 'standby_interval' in sent else existing.standby_interval, state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval, ) diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index c094e32..b2da08b 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -25,6 +25,7 @@ class ProcessingSettings(BaseModel): interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") brightness: float = Field(default=1.0, description="Global brightness (0.0-1.0)", ge=0.0, le=1.0) smoothing: float = Field(default=0.3, description="Temporal smoothing factor (0.0=none, 1.0=full)", ge=0.0, le=1.0) + standby_interval: float = Field(default=1.0, description="Seconds between keepalive sends when screen is static (0.5-5.0)", ge=0.5, le=5.0) state_check_interval: int = Field( default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600, description="Seconds between WLED health checks" @@ -125,6 +126,9 @@ class TargetProcessingState(BaseModel): fps_actual: Optional[float] = Field(None, description="Actual FPS achieved") fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)") fps_target: int = Field(default=0, description="Target FPS") + frames_skipped: Optional[int] = Field(None, description="Frames skipped (no screen change)") + 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") display_index: int = Field(default=0, description="Current display index") last_update: Optional[datetime] = Field(None, description="Last successful update") errors: List[str] = Field(default_factory=list, description="Recent errors") diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index a075bd9..41d4124 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -1,6 +1,7 @@ """Processing manager for coordinating screen capture and WLED updates.""" import asyncio +import collections import json import time from dataclasses import dataclass, field @@ -33,15 +34,16 @@ logger = get_logger(__name__) DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks -def _process_frame(live_stream, border_width, pixel_mapper, previous_colors, smoothing): +def _process_frame(capture, border_width, pixel_mapper, previous_colors, smoothing): """All CPU-bound work for one WLED frame (runs in thread pool). - Includes get_latest_frame() because ProcessedLiveStream may apply - filters (image copy + processing) which should not block the event loop. + Args: + capture: ScreenCapture from live_stream.get_latest_frame() + border_width: Border pixel width for extraction + pixel_mapper: PixelMapper for LED mapping + previous_colors: Previous frame colors for smoothing + smoothing: Smoothing factor (0-1) """ - capture = live_stream.get_latest_frame() - if capture is None: - return None border_pixels = extract_border_pixels(capture, border_width) led_colors = pixel_mapper.map_border_to_leds(border_pixels) if previous_colors and smoothing > 0: @@ -49,15 +51,16 @@ def _process_frame(live_stream, border_width, pixel_mapper, previous_colors, smo return led_colors -def _process_kc_frame(live_stream, rectangles, calc_fn, previous_colors, smoothing): +def _process_kc_frame(capture, rectangles, calc_fn, previous_colors, smoothing): """All CPU-bound work for one KC frame (runs in thread pool). - Includes get_latest_frame() because ProcessedLiveStream may apply - filters which should not block the event loop. + Args: + capture: ScreenCapture from live_stream.get_latest_frame() + rectangles: List of pattern rectangles to extract colors from + calc_fn: Color calculation function (average/median/dominant) + previous_colors: Previous frame colors for smoothing + smoothing: Smoothing factor (0-1) """ - capture = live_stream.get_latest_frame() - if capture is None: - return None img = capture.image h, w = img.shape[:2] colors = {} @@ -110,6 +113,7 @@ class ProcessingSettings: saturation: float = 1.0 smoothing: float = 0.3 interpolation_mode: str = "average" + standby_interval: float = 1.0 # seconds between keepalive sends when screen is static state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL @@ -133,12 +137,15 @@ class ProcessingMetrics: """Metrics for processing performance.""" frames_processed: int = 0 + frames_skipped: int = 0 + frames_keepalive: int = 0 errors_count: int = 0 last_error: Optional[str] = None last_update: Optional[datetime] = None start_time: Optional[datetime] = None fps_actual: float = 0.0 fps_potential: float = 0.0 + fps_current: int = 0 @dataclass @@ -558,14 +565,11 @@ class ProcessorManager: logger.warning(f"Could not snapshot WLED state: {e}") state.wled_state_before = None - # Connect to WLED device + # Connect to WLED device (always use DDP for low-latency UDP streaming) try: - use_ddp = state.led_count > 500 - state.wled_client = WLEDClient(state.device_url, use_ddp=use_ddp) + state.wled_client = WLEDClient(state.device_url, use_ddp=True) await state.wled_client.connect() - - if use_ddp: - logger.info(f"Target {target_id} using DDP protocol ({state.led_count} LEDs)") + logger.info(f"Target {target_id} using DDP protocol ({state.led_count} LEDs)") except Exception as e: logger.error(f"Failed to connect to WLED device for target {target_id}: {e}") raise RuntimeError(f"Failed to connect to WLED device: {e}") @@ -672,8 +676,12 @@ class ProcessorManager: ) frame_time = 1.0 / target_fps + standby_interval = settings.standby_interval fps_samples = [] prev_frame_time_stamp = time.time() + prev_capture = None # Track previous ScreenCapture for change detection + last_send_time = 0.0 # Timestamp of last DDP send (for keepalive) + send_timestamps: collections.deque = collections.deque() # for fps_current # Check if the device has test mode active — skip capture while in test mode device_state = self._devices.get(state.device_id) @@ -688,19 +696,47 @@ class ProcessorManager: continue try: - # Batch all CPU work (frame read + processing) in a single thread call. - led_colors = await asyncio.to_thread( - _process_frame, - state.live_stream, border_width, - state.pixel_mapper, state.previous_colors, smoothing, - ) + # get_latest_frame() is a fast lock read (ProcessedLiveStream + # pre-computes in a background thread). Safe on asyncio thread. + capture = state.live_stream.get_latest_frame() - if led_colors is None: + if capture is None: if state.metrics.frames_processed == 0: logger.info(f"Capture returned None for target {target_id} (no new frame yet)") await asyncio.sleep(frame_time) continue + # Skip processing + send if the frame hasn't changed + if capture is prev_capture: + # Keepalive: resend last colors to prevent WLED exiting live mode + if state.previous_colors and (loop_start - last_send_time) >= standby_interval: + if not state.is_running or state.wled_client is None: + break + brightness_value = int(wled_brightness * 255) + if state.wled_client.use_ddp: + state.wled_client.send_pixels_fast(state.previous_colors, brightness=brightness_value) + else: + await state.wled_client.send_pixels(state.previous_colors, brightness=brightness_value) + last_send_time = time.time() + send_timestamps.append(last_send_time) + state.metrics.frames_keepalive += 1 + state.metrics.frames_skipped += 1 + # Update fps_current: count sends in last 1 second + now_ts = time.time() + while send_timestamps and send_timestamps[0] < now_ts - 1.0: + send_timestamps.popleft() + state.metrics.fps_current = len(send_timestamps) + await asyncio.sleep(frame_time) + continue + prev_capture = capture + + # CPU-bound work in thread pool + led_colors = await asyncio.to_thread( + _process_frame, + capture, border_width, + state.pixel_mapper, state.previous_colors, smoothing, + ) + # Send to WLED with device brightness if not state.is_running or state.wled_client is None: break @@ -709,6 +745,8 @@ class ProcessorManager: state.wled_client.send_pixels_fast(led_colors, brightness=brightness_value) else: await state.wled_client.send_pixels(led_colors, brightness=brightness_value) + last_send_time = time.time() + send_timestamps.append(last_send_time) # Update metrics state.metrics.frames_processed += 1 @@ -730,6 +768,11 @@ class ProcessorManager: processing_time = now - loop_start state.metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0 + # Update fps_current: count sends in last 1 second + while send_timestamps and send_timestamps[0] < now - 1.0: + send_timestamps.popleft() + state.metrics.fps_current = len(send_timestamps) + except Exception as e: state.metrics.errors_count += 1 state.metrics.last_error = str(e) @@ -782,6 +825,9 @@ class ProcessorManager: "fps_actual": metrics.fps_actual if state.is_running else None, "fps_potential": metrics.fps_potential if state.is_running else None, "fps_target": state.settings.fps, + "frames_skipped": metrics.frames_skipped if state.is_running else None, + "frames_keepalive": metrics.frames_keepalive if state.is_running else None, + "fps_current": metrics.fps_current if state.is_running else None, "display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index, "last_update": metrics.last_update, "errors": [metrics.last_error] if metrics.last_error else [], @@ -877,8 +923,7 @@ class ProcessorManager: if active_client: await active_client.send_pixels(pixels) else: - use_ddp = ds.led_count > WLEDClient.HTTP_MAX_LEDS - async with WLEDClient(ds.device_url, use_ddp=use_ddp) as wled: + async with WLEDClient(ds.device_url, use_ddp=True) as wled: await wled.send_pixels(pixels) except Exception as e: logger.error(f"Failed to send test pixels for {device_id}: {e}") @@ -898,8 +943,7 @@ class ProcessorManager: if active_client: await active_client.send_pixels(pixels) else: - use_ddp = ds.led_count > WLEDClient.HTTP_MAX_LEDS - async with WLEDClient(ds.device_url, use_ddp=use_ddp) as wled: + async with WLEDClient(ds.device_url, use_ddp=True) as wled: await wled.send_pixels(pixels) except Exception as e: logger.error(f"Failed to clear pixels for {device_id}: {e}") @@ -1197,6 +1241,7 @@ class ProcessorManager: frame_time = 1.0 / target_fps fps_samples: List[float] = [] + prev_capture = None # Track previous ScreenCapture for change detection rectangles = state._resolved_rectangles @@ -1210,16 +1255,27 @@ class ProcessorManager: loop_start = time.time() try: - # Batch all CPU work in a single thread call - colors = await asyncio.to_thread( - _process_kc_frame, - state.live_stream, rectangles, calc_fn, - state.previous_colors, smoothing, - ) - if colors is None: + # get_latest_frame() is a fast lock read — safe on asyncio thread + capture = state.live_stream.get_latest_frame() + + if capture is None: await asyncio.sleep(frame_time) continue + # Skip processing if the frame hasn't changed + if capture is prev_capture: + state.metrics.frames_skipped += 1 + await asyncio.sleep(frame_time) + continue + prev_capture = capture + + # CPU-bound work in thread pool + colors = await asyncio.to_thread( + _process_kc_frame, + capture, rectangles, calc_fn, + state.previous_colors, smoothing, + ) + state.previous_colors = dict(colors) state.latest_colors = dict(colors) diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 23e0b1e..7e4442c 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -3821,6 +3821,8 @@ async function showTargetEditor(targetId = null) { document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average'; document.getElementById('target-editor-smoothing').value = target.settings?.smoothing ?? 0.3; document.getElementById('target-editor-smoothing-value').textContent = target.settings?.smoothing ?? 0.3; + document.getElementById('target-editor-standby-interval').value = target.settings?.standby_interval ?? 1.0; + document.getElementById('target-editor-standby-interval-value').textContent = target.settings?.standby_interval ?? 1.0; document.getElementById('target-editor-title').textContent = t('targets.edit'); } else { // Creating new target @@ -3834,6 +3836,8 @@ async function showTargetEditor(targetId = null) { document.getElementById('target-editor-interpolation').value = 'average'; document.getElementById('target-editor-smoothing').value = 0.3; document.getElementById('target-editor-smoothing-value').textContent = '0.3'; + 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'); } @@ -3845,6 +3849,7 @@ async function showTargetEditor(targetId = null) { border_width: document.getElementById('target-editor-border-width').value, interpolation: document.getElementById('target-editor-interpolation').value, smoothing: document.getElementById('target-editor-smoothing').value, + standby_interval: document.getElementById('target-editor-standby-interval').value, }; const modal = document.getElementById('target-editor-modal'); @@ -3868,7 +3873,8 @@ function isTargetEditorDirty() { document.getElementById('target-editor-fps').value !== targetEditorInitialValues.fps || document.getElementById('target-editor-border-width').value !== targetEditorInitialValues.border_width || document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation || - document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing + document.getElementById('target-editor-smoothing').value !== targetEditorInitialValues.smoothing || + document.getElementById('target-editor-standby-interval').value !== targetEditorInitialValues.standby_interval ); } @@ -3896,6 +3902,7 @@ async function saveTargetEditor() { const borderWidth = parseInt(document.getElementById('target-editor-border-width').value) || 10; const interpolation = document.getElementById('target-editor-interpolation').value; const smoothing = parseFloat(document.getElementById('target-editor-smoothing').value); + const standbyInterval = parseFloat(document.getElementById('target-editor-standby-interval').value); const errorEl = document.getElementById('target-editor-error'); if (!name) { @@ -3913,6 +3920,7 @@ async function saveTargetEditor() { border_width: borderWidth, interpolation_mode: interpolation, smoothing: smoothing, + standby_interval: standbyInterval, }, }; @@ -4175,24 +4183,32 @@ function createTargetCard(target, deviceMap, sourceMap) { ${isProcessing ? `
-
${state.fps_actual?.toFixed(1) || '0.0'}
${t('device.metrics.actual_fps')}
+
${state.fps_actual?.toFixed(1) || '0.0'}
+
+
+
${t('device.metrics.current_fps')}
+
${state.fps_current ?? '-'}
-
${state.fps_target || 0}
${t('device.metrics.target_fps')}
+
${state.fps_target || 0}
-
${state.fps_potential?.toFixed(0) || '-'}
${t('device.metrics.potential_fps')}
+
${state.fps_potential?.toFixed(0) || '-'}
-
${metrics.frames_processed || 0}
${t('device.metrics.frames')}
+
${metrics.frames_processed || 0}
+
+
+
${t('device.metrics.keepalive')}
+
${state.frames_keepalive ?? '-'}
-
${metrics.errors_count || 0}
${t('device.metrics.errors')}
+
${metrics.errors_count || 0}
` : ''} diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 251373a..6370324 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -317,6 +317,18 @@ +
+
+ + +
+ + +
+ diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 95861c4..3583799 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -129,9 +129,12 @@ "device.started": "Processing started", "device.stopped": "Processing stopped", "device.metrics.actual_fps": "Actual FPS", + "device.metrics.current_fps": "Current FPS", "device.metrics.target_fps": "Target FPS", "device.metrics.potential_fps": "Potential FPS", "device.metrics.frames": "Frames", + "device.metrics.frames_skipped": "Skipped", + "device.metrics.keepalive": "Keepalive", "device.metrics.errors": "Errors", "device.health.online": "WLED Online", "device.health.offline": "WLED Offline", @@ -330,6 +333,8 @@ "targets.interpolation.dominant": "Dominant", "targets.smoothing": "Smoothing:", "targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", + "targets.standby_interval": "Standby Interval:", + "targets.standby_interval.hint": "How often to resend the last frame when the screen is static, keeping WLED in live mode (0.5-5.0s)", "targets.created": "Target created successfully", "targets.updated": "Target updated successfully", "targets.deleted": "Target deleted successfully", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 1078d99..f1117a5 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -129,9 +129,12 @@ "device.started": "Обработка запущена", "device.stopped": "Обработка остановлена", "device.metrics.actual_fps": "Факт. FPS", + "device.metrics.current_fps": "Текущ. FPS", "device.metrics.target_fps": "Целев. FPS", "device.metrics.potential_fps": "Потенц. FPS", "device.metrics.frames": "Кадры", + "device.metrics.frames_skipped": "Пропущено", + "device.metrics.keepalive": "Keepalive", "device.metrics.errors": "Ошибки", "device.health.online": "WLED Онлайн", "device.health.offline": "WLED Недоступен", @@ -330,6 +333,8 @@ "targets.interpolation.dominant": "Доминантный", "targets.smoothing": "Сглаживание:", "targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.", + "targets.standby_interval": "Интервал ожидания:", + "targets.standby_interval.hint": "Как часто повторно отправлять последний кадр при статичном экране для удержания WLED в режиме live (0.5-5.0с)", "targets.created": "Цель успешно создана", "targets.updated": "Цель успешно обновлена", "targets.deleted": "Цель успешно удалена", diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 444600b..0ea9d0c 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -844,28 +844,29 @@ input:-webkit-autofill:focus { .metrics-grid { display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 10px; - margin-top: 10px; + grid-template-columns: 1fr 1fr; + gap: 4px 12px; + margin-top: 8px; } .metric { - text-align: center; - padding: 10px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 3px 8px; background: var(--bg-color); border-radius: 4px; } .metric-value { - font-size: 1.5rem; + font-size: 0.9rem; font-weight: 700; color: var(--primary-color); } .metric-label { - font-size: 0.85rem; + font-size: 0.8rem; color: #999; - margin-top: 5px; } /* Modal Styles */ diff --git a/server/src/wled_controller/storage/wled_picture_target.py b/server/src/wled_controller/storage/wled_picture_target.py index 3e52fbc..28a40de 100644 --- a/server/src/wled_controller/storage/wled_picture_target.py +++ b/server/src/wled_controller/storage/wled_picture_target.py @@ -30,6 +30,7 @@ class WledPictureTarget(PictureTarget): "saturation": self.settings.saturation, "smoothing": self.settings.smoothing, "interpolation_mode": self.settings.interpolation_mode, + "standby_interval": self.settings.standby_interval, "state_check_interval": self.settings.state_check_interval, } return d @@ -49,6 +50,7 @@ class WledPictureTarget(PictureTarget): saturation=settings_data.get("saturation", 1.0), smoothing=settings_data.get("smoothing", 0.3), interpolation_mode=settings_data.get("interpolation_mode", "average"), + standby_interval=settings_data.get("standby_interval", 1.0), state_check_interval=settings_data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL), )