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 ? `