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 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 15:17:14 +03:00
parent 8d5ebc92ee
commit 3100b0d979
9 changed files with 154 additions and 50 deletions

View File

@@ -67,6 +67,7 @@ def _settings_to_core(schema: ProcessingSettingsSchema) -> ProcessingSettings:
interpolation_mode=schema.interpolation_mode, interpolation_mode=schema.interpolation_mode,
brightness=schema.brightness, brightness=schema.brightness,
smoothing=schema.smoothing, smoothing=schema.smoothing,
standby_interval=schema.standby_interval,
state_check_interval=schema.state_check_interval, state_check_interval=schema.state_check_interval,
) )
if schema.color_correction: if schema.color_correction:
@@ -87,6 +88,7 @@ def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchem
interpolation_mode=settings.interpolation_mode, interpolation_mode=settings.interpolation_mode,
brightness=settings.brightness, brightness=settings.brightness,
smoothing=settings.smoothing, smoothing=settings.smoothing,
standby_interval=settings.standby_interval,
state_check_interval=settings.state_check_interval, state_check_interval=settings.state_check_interval,
color_correction=ColorCorrection( color_correction=ColorCorrection(
gamma=settings.gamma, gamma=settings.gamma,
@@ -470,6 +472,7 @@ async def update_target_settings(
gamma=existing.gamma, gamma=existing.gamma,
saturation=existing.saturation, saturation=existing.saturation,
smoothing=settings.smoothing if 'smoothing' in sent else existing.smoothing, 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, state_check_interval=settings.state_check_interval if 'state_check_interval' in sent else existing.state_check_interval,
) )

View File

@@ -25,6 +25,7 @@ class ProcessingSettings(BaseModel):
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") 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) 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) 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( state_check_interval: int = Field(
default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600, default=DEFAULT_STATE_CHECK_INTERVAL, ge=5, le=600,
description="Seconds between WLED health checks" description="Seconds between WLED health checks"
@@ -125,6 +126,9 @@ class TargetProcessingState(BaseModel):
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved") fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)") fps_potential: Optional[float] = Field(None, description="Potential FPS (processing speed without throttle)")
fps_target: int = Field(default=0, description="Target FPS") 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") display_index: int = Field(default=0, description="Current display index")
last_update: Optional[datetime] = Field(None, description="Last successful update") last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors") errors: List[str] = Field(default_factory=list, description="Recent errors")

View File

@@ -1,6 +1,7 @@
"""Processing manager for coordinating screen capture and WLED updates.""" """Processing manager for coordinating screen capture and WLED updates."""
import asyncio import asyncio
import collections
import json import json
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
@@ -33,15 +34,16 @@ logger = get_logger(__name__)
DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds between health checks 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). """All CPU-bound work for one WLED frame (runs in thread pool).
Includes get_latest_frame() because ProcessedLiveStream may apply Args:
filters (image copy + processing) which should not block the event loop. 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) border_pixels = extract_border_pixels(capture, border_width)
led_colors = pixel_mapper.map_border_to_leds(border_pixels) led_colors = pixel_mapper.map_border_to_leds(border_pixels)
if previous_colors and smoothing > 0: 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 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). """All CPU-bound work for one KC frame (runs in thread pool).
Includes get_latest_frame() because ProcessedLiveStream may apply Args:
filters which should not block the event loop. 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 img = capture.image
h, w = img.shape[:2] h, w = img.shape[:2]
colors = {} colors = {}
@@ -110,6 +113,7 @@ class ProcessingSettings:
saturation: float = 1.0 saturation: float = 1.0
smoothing: float = 0.3 smoothing: float = 0.3
interpolation_mode: str = "average" 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 state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
@@ -133,12 +137,15 @@ class ProcessingMetrics:
"""Metrics for processing performance.""" """Metrics for processing performance."""
frames_processed: int = 0 frames_processed: int = 0
frames_skipped: int = 0
frames_keepalive: int = 0
errors_count: int = 0 errors_count: int = 0
last_error: Optional[str] = None last_error: Optional[str] = None
last_update: Optional[datetime] = None last_update: Optional[datetime] = None
start_time: Optional[datetime] = None start_time: Optional[datetime] = None
fps_actual: float = 0.0 fps_actual: float = 0.0
fps_potential: float = 0.0 fps_potential: float = 0.0
fps_current: int = 0
@dataclass @dataclass
@@ -558,13 +565,10 @@ class ProcessorManager:
logger.warning(f"Could not snapshot WLED state: {e}") logger.warning(f"Could not snapshot WLED state: {e}")
state.wled_state_before = None state.wled_state_before = None
# Connect to WLED device # Connect to WLED device (always use DDP for low-latency UDP streaming)
try: try:
use_ddp = state.led_count > 500 state.wled_client = WLEDClient(state.device_url, use_ddp=True)
state.wled_client = WLEDClient(state.device_url, use_ddp=use_ddp)
await state.wled_client.connect() 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: except Exception as e:
logger.error(f"Failed to connect to WLED device for target {target_id}: {e}") logger.error(f"Failed to connect to WLED device for target {target_id}: {e}")
@@ -672,8 +676,12 @@ class ProcessorManager:
) )
frame_time = 1.0 / target_fps frame_time = 1.0 / target_fps
standby_interval = settings.standby_interval
fps_samples = [] fps_samples = []
prev_frame_time_stamp = time.time() 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 # Check if the device has test mode active — skip capture while in test mode
device_state = self._devices.get(state.device_id) device_state = self._devices.get(state.device_id)
@@ -688,19 +696,47 @@ class ProcessorManager:
continue continue
try: try:
# Batch all CPU work (frame read + processing) in a single thread call. # get_latest_frame() is a fast lock read (ProcessedLiveStream
led_colors = await asyncio.to_thread( # pre-computes in a background thread). Safe on asyncio thread.
_process_frame, capture = state.live_stream.get_latest_frame()
state.live_stream, border_width,
state.pixel_mapper, state.previous_colors, smoothing,
)
if led_colors is None: if capture is None:
if state.metrics.frames_processed == 0: if state.metrics.frames_processed == 0:
logger.info(f"Capture returned None for target {target_id} (no new frame yet)") logger.info(f"Capture returned None for target {target_id} (no new frame yet)")
await asyncio.sleep(frame_time) await asyncio.sleep(frame_time)
continue 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 # Send to WLED with device brightness
if not state.is_running or state.wled_client is None: if not state.is_running or state.wled_client is None:
break break
@@ -709,6 +745,8 @@ class ProcessorManager:
state.wled_client.send_pixels_fast(led_colors, brightness=brightness_value) state.wled_client.send_pixels_fast(led_colors, brightness=brightness_value)
else: else:
await state.wled_client.send_pixels(led_colors, brightness=brightness_value) await state.wled_client.send_pixels(led_colors, brightness=brightness_value)
last_send_time = time.time()
send_timestamps.append(last_send_time)
# Update metrics # Update metrics
state.metrics.frames_processed += 1 state.metrics.frames_processed += 1
@@ -730,6 +768,11 @@ class ProcessorManager:
processing_time = now - loop_start processing_time = now - loop_start
state.metrics.fps_potential = 1.0 / processing_time if processing_time > 0 else 0 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: except Exception as e:
state.metrics.errors_count += 1 state.metrics.errors_count += 1
state.metrics.last_error = str(e) state.metrics.last_error = str(e)
@@ -782,6 +825,9 @@ class ProcessorManager:
"fps_actual": metrics.fps_actual if state.is_running else None, "fps_actual": metrics.fps_actual if state.is_running else None,
"fps_potential": metrics.fps_potential if state.is_running else None, "fps_potential": metrics.fps_potential if state.is_running else None,
"fps_target": state.settings.fps, "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, "display_index": state.resolved_display_index if state.resolved_display_index is not None else state.settings.display_index,
"last_update": metrics.last_update, "last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [], "errors": [metrics.last_error] if metrics.last_error else [],
@@ -877,8 +923,7 @@ class ProcessorManager:
if active_client: if active_client:
await active_client.send_pixels(pixels) await active_client.send_pixels(pixels)
else: else:
use_ddp = ds.led_count > WLEDClient.HTTP_MAX_LEDS async with WLEDClient(ds.device_url, use_ddp=True) as wled:
async with WLEDClient(ds.device_url, use_ddp=use_ddp) as wled:
await wled.send_pixels(pixels) await wled.send_pixels(pixels)
except Exception as e: except Exception as e:
logger.error(f"Failed to send test pixels for {device_id}: {e}") logger.error(f"Failed to send test pixels for {device_id}: {e}")
@@ -898,8 +943,7 @@ class ProcessorManager:
if active_client: if active_client:
await active_client.send_pixels(pixels) await active_client.send_pixels(pixels)
else: else:
use_ddp = ds.led_count > WLEDClient.HTTP_MAX_LEDS async with WLEDClient(ds.device_url, use_ddp=True) as wled:
async with WLEDClient(ds.device_url, use_ddp=use_ddp) as wled:
await wled.send_pixels(pixels) await wled.send_pixels(pixels)
except Exception as e: except Exception as e:
logger.error(f"Failed to clear pixels for {device_id}: {e}") logger.error(f"Failed to clear pixels for {device_id}: {e}")
@@ -1197,6 +1241,7 @@ class ProcessorManager:
frame_time = 1.0 / target_fps frame_time = 1.0 / target_fps
fps_samples: List[float] = [] fps_samples: List[float] = []
prev_capture = None # Track previous ScreenCapture for change detection
rectangles = state._resolved_rectangles rectangles = state._resolved_rectangles
@@ -1210,16 +1255,27 @@ class ProcessorManager:
loop_start = time.time() loop_start = time.time()
try: try:
# Batch all CPU work in a single thread call # get_latest_frame() is a fast lock read — safe on asyncio thread
colors = await asyncio.to_thread( capture = state.live_stream.get_latest_frame()
_process_kc_frame,
state.live_stream, rectangles, calc_fn, if capture is None:
state.previous_colors, smoothing,
)
if colors is None:
await asyncio.sleep(frame_time) await asyncio.sleep(frame_time)
continue 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.previous_colors = dict(colors)
state.latest_colors = dict(colors) state.latest_colors = dict(colors)

View File

@@ -3821,6 +3821,8 @@ async function showTargetEditor(targetId = null) {
document.getElementById('target-editor-interpolation').value = target.settings?.interpolation_mode ?? 'average'; 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 = target.settings?.smoothing ?? 0.3;
document.getElementById('target-editor-smoothing-value').textContent = 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'); document.getElementById('target-editor-title').textContent = t('targets.edit');
} else { } else {
// Creating new target // Creating new target
@@ -3834,6 +3836,8 @@ async function showTargetEditor(targetId = null) {
document.getElementById('target-editor-interpolation').value = 'average'; document.getElementById('target-editor-interpolation').value = 'average';
document.getElementById('target-editor-smoothing').value = 0.3; document.getElementById('target-editor-smoothing').value = 0.3;
document.getElementById('target-editor-smoothing-value').textContent = '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'); 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, border_width: document.getElementById('target-editor-border-width').value,
interpolation: document.getElementById('target-editor-interpolation').value, interpolation: document.getElementById('target-editor-interpolation').value,
smoothing: document.getElementById('target-editor-smoothing').value, smoothing: document.getElementById('target-editor-smoothing').value,
standby_interval: document.getElementById('target-editor-standby-interval').value,
}; };
const modal = document.getElementById('target-editor-modal'); 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-fps').value !== targetEditorInitialValues.fps ||
document.getElementById('target-editor-border-width').value !== targetEditorInitialValues.border_width || document.getElementById('target-editor-border-width').value !== targetEditorInitialValues.border_width ||
document.getElementById('target-editor-interpolation').value !== targetEditorInitialValues.interpolation || 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 borderWidth = parseInt(document.getElementById('target-editor-border-width').value) || 10;
const interpolation = document.getElementById('target-editor-interpolation').value; const interpolation = document.getElementById('target-editor-interpolation').value;
const smoothing = parseFloat(document.getElementById('target-editor-smoothing').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'); const errorEl = document.getElementById('target-editor-error');
if (!name) { if (!name) {
@@ -3913,6 +3920,7 @@ async function saveTargetEditor() {
border_width: borderWidth, border_width: borderWidth,
interpolation_mode: interpolation, interpolation_mode: interpolation,
smoothing: smoothing, smoothing: smoothing,
standby_interval: standbyInterval,
}, },
}; };
@@ -4175,24 +4183,32 @@ function createTargetCard(target, deviceMap, sourceMap) {
${isProcessing ? ` ${isProcessing ? `
<div class="metrics-grid"> <div class="metrics-grid">
<div class="metric"> <div class="metric">
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
<div class="metric-label">${t('device.metrics.actual_fps')}</div> <div class="metric-label">${t('device.metrics.actual_fps')}</div>
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.current_fps')}</div>
<div class="metric-value">${state.fps_current ?? '-'}</div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="metric-value">${state.fps_target || 0}</div>
<div class="metric-label">${t('device.metrics.target_fps')}</div> <div class="metric-label">${t('device.metrics.target_fps')}</div>
<div class="metric-value">${state.fps_target || 0}</div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</div>
<div class="metric-label">${t('device.metrics.potential_fps')}</div> <div class="metric-label">${t('device.metrics.potential_fps')}</div>
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="metric-value">${metrics.frames_processed || 0}</div>
<div class="metric-label">${t('device.metrics.frames')}</div> <div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value">${metrics.frames_processed || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.keepalive')}</div>
<div class="metric-value">${state.frames_keepalive ?? '-'}</div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="metric-value">${metrics.errors_count || 0}</div>
<div class="metric-label">${t('device.metrics.errors')}</div> <div class="metric-label">${t('device.metrics.errors')}</div>
<div class="metric-value">${metrics.errors_count || 0}</div>
</div> </div>
</div> </div>
` : ''} ` : ''}

View File

@@ -317,6 +317,18 @@
<input type="range" id="target-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('target-editor-smoothing-value').textContent = this.value"> <input type="range" id="target-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('target-editor-smoothing-value').textContent = this.value">
</div> </div>
<div class="form-group">
<div class="label-row">
<label for="target-editor-standby-interval">
<span data-i18n="targets.standby_interval">Standby Interval:</span>
<span id="target-editor-standby-interval-value">1.0</span><span>s</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.standby_interval.hint">How often to resend the last frame when the screen is static, to keep WLED in live mode (0.5-5.0s)</small>
<input type="range" id="target-editor-standby-interval" min="0.5" max="5.0" step="0.5" value="1.0" oninput="document.getElementById('target-editor-standby-interval-value').textContent = this.value">
</div>
<div id="target-editor-error" class="error-message" style="display: none;"></div> <div id="target-editor-error" class="error-message" style="display: none;"></div>
</form> </form>
</div> </div>

View File

@@ -129,9 +129,12 @@
"device.started": "Processing started", "device.started": "Processing started",
"device.stopped": "Processing stopped", "device.stopped": "Processing stopped",
"device.metrics.actual_fps": "Actual FPS", "device.metrics.actual_fps": "Actual FPS",
"device.metrics.current_fps": "Current FPS",
"device.metrics.target_fps": "Target FPS", "device.metrics.target_fps": "Target FPS",
"device.metrics.potential_fps": "Potential FPS", "device.metrics.potential_fps": "Potential FPS",
"device.metrics.frames": "Frames", "device.metrics.frames": "Frames",
"device.metrics.frames_skipped": "Skipped",
"device.metrics.keepalive": "Keepalive",
"device.metrics.errors": "Errors", "device.metrics.errors": "Errors",
"device.health.online": "WLED Online", "device.health.online": "WLED Online",
"device.health.offline": "WLED Offline", "device.health.offline": "WLED Offline",
@@ -330,6 +333,8 @@
"targets.interpolation.dominant": "Dominant", "targets.interpolation.dominant": "Dominant",
"targets.smoothing": "Smoothing:", "targets.smoothing": "Smoothing:",
"targets.smoothing.hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", "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.created": "Target created successfully",
"targets.updated": "Target updated successfully", "targets.updated": "Target updated successfully",
"targets.deleted": "Target deleted successfully", "targets.deleted": "Target deleted successfully",

View File

@@ -129,9 +129,12 @@
"device.started": "Обработка запущена", "device.started": "Обработка запущена",
"device.stopped": "Обработка остановлена", "device.stopped": "Обработка остановлена",
"device.metrics.actual_fps": "Факт. FPS", "device.metrics.actual_fps": "Факт. FPS",
"device.metrics.current_fps": "Текущ. FPS",
"device.metrics.target_fps": "Целев. FPS", "device.metrics.target_fps": "Целев. FPS",
"device.metrics.potential_fps": "Потенц. FPS", "device.metrics.potential_fps": "Потенц. FPS",
"device.metrics.frames": "Кадры", "device.metrics.frames": "Кадры",
"device.metrics.frames_skipped": "Пропущено",
"device.metrics.keepalive": "Keepalive",
"device.metrics.errors": "Ошибки", "device.metrics.errors": "Ошибки",
"device.health.online": "WLED Онлайн", "device.health.online": "WLED Онлайн",
"device.health.offline": "WLED Недоступен", "device.health.offline": "WLED Недоступен",
@@ -330,6 +333,8 @@
"targets.interpolation.dominant": "Доминантный", "targets.interpolation.dominant": "Доминантный",
"targets.smoothing": "Сглаживание:", "targets.smoothing": "Сглаживание:",
"targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.", "targets.smoothing.hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
"targets.standby_interval": "Интервал ожидания:",
"targets.standby_interval.hint": "Как часто повторно отправлять последний кадр при статичном экране для удержания WLED в режиме live (0.5-5.0с)",
"targets.created": "Цель успешно создана", "targets.created": "Цель успешно создана",
"targets.updated": "Цель успешно обновлена", "targets.updated": "Цель успешно обновлена",
"targets.deleted": "Цель успешно удалена", "targets.deleted": "Цель успешно удалена",

View File

@@ -844,28 +844,29 @@ input:-webkit-autofill:focus {
.metrics-grid { .metrics-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: 1fr 1fr;
gap: 10px; gap: 4px 12px;
margin-top: 10px; margin-top: 8px;
} }
.metric { .metric {
text-align: center; display: flex;
padding: 10px; justify-content: space-between;
align-items: center;
padding: 3px 8px;
background: var(--bg-color); background: var(--bg-color);
border-radius: 4px; border-radius: 4px;
} }
.metric-value { .metric-value {
font-size: 1.5rem; font-size: 0.9rem;
font-weight: 700; font-weight: 700;
color: var(--primary-color); color: var(--primary-color);
} }
.metric-label { .metric-label {
font-size: 0.85rem; font-size: 0.8rem;
color: #999; color: #999;
margin-top: 5px;
} }
/* Modal Styles */ /* Modal Styles */

View File

@@ -30,6 +30,7 @@ class WledPictureTarget(PictureTarget):
"saturation": self.settings.saturation, "saturation": self.settings.saturation,
"smoothing": self.settings.smoothing, "smoothing": self.settings.smoothing,
"interpolation_mode": self.settings.interpolation_mode, "interpolation_mode": self.settings.interpolation_mode,
"standby_interval": self.settings.standby_interval,
"state_check_interval": self.settings.state_check_interval, "state_check_interval": self.settings.state_check_interval,
} }
return d return d
@@ -49,6 +50,7 @@ class WledPictureTarget(PictureTarget):
saturation=settings_data.get("saturation", 1.0), saturation=settings_data.get("saturation", 1.0),
smoothing=settings_data.get("smoothing", 0.3), smoothing=settings_data.get("smoothing", 0.3),
interpolation_mode=settings_data.get("interpolation_mode", "average"), 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), state_check_interval=settings_data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
) )