Port WLED optimizations to KC loop: fix FPS metrics, add keepalive and auto-refresh test

- Fix KC fps_actual to use frame-to-frame timestamps (was inflated by measuring before sleep)
- Add fps_potential, fps_current, frames_skipped, frames_keepalive metrics to KC loop
- Add keepalive broadcast for static frames so WS clients stay in sync
- Expose all KC metrics in get_kc_target_state() and update UI card to show 7 metrics
- Add auto-refresh play/pause button to KC test lightbox (polls every ~1s)
- Fix WebSocket color swatches computing hex from r,g,b when hex field is absent
- Fix WebSocket auth crash by using get_config() instead of module-level config variable
- Fix lightbox closing when clicking auto-refresh button (event bubbling)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 15:57:07 +03:00
parent 9383fb9a53
commit 398f090eca
5 changed files with 160 additions and 28 deletions

View File

@@ -33,7 +33,7 @@ from wled_controller.api.schemas.picture_targets import (
TargetMetricsResponse, TargetMetricsResponse,
TargetProcessingState, TargetProcessingState,
) )
from wled_controller.config import config from wled_controller.config import get_config
from wled_controller.core.capture_engines import EngineRegistry from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
@@ -763,8 +763,9 @@ async def target_colors_ws(
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>.""" """WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
# Authenticate # Authenticate
authenticated = False authenticated = False
if token and config.auth.api_keys: cfg = get_config()
for _label, api_key in config.auth.api_keys.items(): if token and cfg.auth.api_keys:
for _label, api_key in cfg.auth.api_keys.items():
if secrets.compare_digest(token, api_key): if secrets.compare_digest(token, api_key):
authenticated = True authenticated = True
break break

View File

@@ -1241,7 +1241,10 @@ class ProcessorManager:
frame_time = 1.0 / target_fps frame_time = 1.0 / target_fps
fps_samples: List[float] = [] fps_samples: List[float] = []
prev_frame_time_stamp = time.time()
prev_capture = None # Track previous ScreenCapture for change detection prev_capture = None # Track previous ScreenCapture for change detection
last_broadcast_time = 0.0 # Timestamp of last WS broadcast (for keepalive)
send_timestamps: collections.deque = collections.deque() # for fps_current
rectangles = state._resolved_rectangles rectangles = state._resolved_rectangles
@@ -1264,7 +1267,18 @@ class ProcessorManager:
# Skip processing if the frame hasn't changed # Skip processing if the frame hasn't changed
if capture is prev_capture: if capture is prev_capture:
# Keepalive: re-broadcast last colors so WS clients stay in sync
if state.latest_colors and (loop_start - last_broadcast_time) >= 1.0:
await self._broadcast_kc_colors(target_id, state.latest_colors)
last_broadcast_time = time.time()
send_timestamps.append(last_broadcast_time)
state.metrics.frames_keepalive += 1
state.metrics.frames_skipped += 1 state.metrics.frames_skipped += 1
# Update fps_current even on skip
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) await asyncio.sleep(frame_time)
continue continue
prev_capture = capture prev_capture = capture
@@ -1281,17 +1295,31 @@ class ProcessorManager:
# Broadcast to WebSocket clients # Broadcast to WebSocket clients
await self._broadcast_kc_colors(target_id, colors) await self._broadcast_kc_colors(target_id, colors)
last_broadcast_time = time.time()
send_timestamps.append(last_broadcast_time)
# Update metrics # Update metrics
state.metrics.frames_processed += 1 state.metrics.frames_processed += 1
state.metrics.last_update = datetime.utcnow() state.metrics.last_update = datetime.utcnow()
loop_time = time.time() - loop_start # Calculate actual FPS from frame-to-frame interval
fps_samples.append(1.0 / loop_time if loop_time > 0 else 0) now = time.time()
interval = now - prev_frame_time_stamp
prev_frame_time_stamp = now
fps_samples.append(1.0 / interval if interval > 0 else 0)
if len(fps_samples) > 10: if len(fps_samples) > 10:
fps_samples.pop(0) fps_samples.pop(0)
state.metrics.fps_actual = sum(fps_samples) / len(fps_samples) state.metrics.fps_actual = sum(fps_samples) / len(fps_samples)
# Potential FPS = how fast the pipeline could run without throttle
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: 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)
@@ -1357,13 +1385,18 @@ class ProcessorManager:
raise ValueError(f"KC target {target_id} not found") raise ValueError(f"KC target {target_id} not found")
state = self._kc_targets[target_id] state = self._kc_targets[target_id]
metrics = state.metrics
return { return {
"target_id": target_id, "target_id": target_id,
"processing": state.is_running, "processing": state.is_running,
"fps_actual": round(state.metrics.fps_actual, 1) if state.is_running else None, "fps_actual": round(metrics.fps_actual, 1) 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,
"last_update": state.metrics.last_update.isoformat() if state.metrics.last_update else None, "frames_skipped": metrics.frames_skipped if state.is_running else None,
"errors": [state.metrics.last_error] if state.metrics.last_error else [], "frames_keepalive": metrics.frames_keepalive if state.is_running else None,
"fps_current": metrics.fps_current if state.is_running else None,
"last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
} }
def get_kc_target_metrics(self, target_id: str) -> dict: def get_kc_target_metrics(self, target_id: str) -> dict:

View File

@@ -1,6 +1,8 @@
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
let refreshInterval = null; let refreshInterval = null;
let apiKey = null; let apiKey = null;
let kcTestAutoRefresh = null; // interval ID for KC test auto-refresh
let kcTestTargetId = null; // currently testing KC target
// Toggle hint description visibility next to a label // Toggle hint description visibility next to a label
function toggleHint(btn) { function toggleHint(btn) {
@@ -78,7 +80,9 @@ function openLightbox(imageSrc, statsHtml) {
} }
function closeLightbox(event) { function closeLightbox(event) {
if (event && event.target && event.target.closest('.lightbox-content')) return; if (event && event.target && (event.target.closest('.lightbox-content') || event.target.closest('.lightbox-refresh-btn'))) return;
// Stop KC test auto-refresh if running
stopKCTestAutoRefresh();
const lightbox = document.getElementById('image-lightbox'); const lightbox = document.getElementById('image-lightbox');
lightbox.classList.remove('active'); lightbox.classList.remove('active');
const img = document.getElementById('lightbox-image'); const img = document.getElementById('lightbox-image');
@@ -89,6 +93,9 @@ function closeLightbox(event) {
document.getElementById('lightbox-stats').style.display = 'none'; document.getElementById('lightbox-stats').style.display = 'none';
const spinner = lightbox.querySelector('.lightbox-spinner'); const spinner = lightbox.querySelector('.lightbox-spinner');
if (spinner) spinner.style.display = 'none'; if (spinner) spinner.style.display = 'none';
// Hide auto-refresh button
const refreshBtn = document.getElementById('lightbox-auto-refresh');
if (refreshBtn) { refreshBtn.style.display = 'none'; refreshBtn.classList.remove('active'); }
unlockBody(); unlockBody();
} }
@@ -4341,20 +4348,32 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) {
${isProcessing ? ` ${isProcessing ? `
<div class="metrics-grid"> <div class="metrics-grid">
<div class="metric"> <div class="metric">
<div class="metric-label">${t('device.metrics.actual_fps')}</div>
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div> <div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
<div class="metric-label">${t('targets.metrics.actual_fps')}</div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="metric-label">${t('device.metrics.current_fps')}</div>
<div class="metric-value">${state.fps_current ?? '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.target_fps')}</div>
<div class="metric-value">${state.fps_target || 0}</div> <div class="metric-value">${state.fps_target || 0}</div>
<div class="metric-label">${t('targets.metrics.target_fps')}</div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="metric-label">${t('device.metrics.potential_fps')}</div>
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value">${metrics.frames_processed || 0}</div> <div class="metric-value">${metrics.frames_processed || 0}</div>
<div class="metric-label">${t('targets.metrics.frames')}</div>
</div> </div>
<div class="metric"> <div class="metric">
<div class="metric-label">${t('device.metrics.keepalive')}</div>
<div class="metric-value">${state.frames_keepalive ?? '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.errors')}</div>
<div class="metric-value">${metrics.errors_count || 0}</div> <div class="metric-value">${metrics.errors_count || 0}</div>
<div class="metric-label">${t('targets.metrics.errors')}</div>
</div> </div>
</div> </div>
` : ''} ` : ''}
@@ -4382,7 +4401,21 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) {
// ===== KEY COLORS TEST ===== // ===== KEY COLORS TEST =====
async function fetchKCTest(targetId) {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/test`, {
method: 'POST',
headers: getHeaders(),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || response.statusText);
}
return response.json();
}
async function testKCTarget(targetId) { async function testKCTarget(targetId) {
kcTestTargetId = targetId;
// Show lightbox immediately with a spinner // Show lightbox immediately with a spinner
const lightbox = document.getElementById('image-lightbox'); const lightbox = document.getElementById('image-lightbox');
const lbImg = document.getElementById('lightbox-image'); const lbImg = document.getElementById('lightbox-image');
@@ -4400,19 +4433,15 @@ async function testKCTarget(targetId) {
} }
spinner.style.display = ''; spinner.style.display = '';
// Show auto-refresh button
const refreshBtn = document.getElementById('lightbox-auto-refresh');
if (refreshBtn) refreshBtn.style.display = '';
lightbox.classList.add('active'); lightbox.classList.add('active');
lockBody(); lockBody();
try { try {
const response = await fetch(`${API_BASE}/picture-targets/${targetId}/test`, { const result = await fetchKCTest(targetId);
method: 'POST',
headers: getHeaders(),
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
throw new Error(err.detail || response.statusText);
}
const result = await response.json();
displayKCTestResults(result); displayKCTestResults(result);
} catch (e) { } catch (e) {
closeLightbox(); closeLightbox();
@@ -4420,6 +4449,44 @@ async function testKCTarget(targetId) {
} }
} }
function toggleKCTestAutoRefresh() {
if (kcTestAutoRefresh) {
stopKCTestAutoRefresh();
} else {
kcTestAutoRefresh = setInterval(async () => {
if (!kcTestTargetId) return;
try {
const result = await fetchKCTest(kcTestTargetId);
displayKCTestResults(result);
} catch (e) {
stopKCTestAutoRefresh();
}
}, 1000);
updateAutoRefreshButton(true);
}
}
function stopKCTestAutoRefresh() {
if (kcTestAutoRefresh) {
clearInterval(kcTestAutoRefresh);
kcTestAutoRefresh = null;
}
kcTestTargetId = null;
updateAutoRefreshButton(false);
}
function updateAutoRefreshButton(active) {
const btn = document.getElementById('lightbox-auto-refresh');
if (!btn) return;
if (active) {
btn.classList.add('active');
btn.innerHTML = '&#x23F8;'; // pause symbol
} else {
btn.classList.remove('active');
btn.innerHTML = '&#x25B6;'; // play symbol
}
}
function displayKCTestResults(result) { function displayKCTestResults(result) {
const srcImg = new window.Image(); const srcImg = new window.Image();
srcImg.onload = () => { srcImg.onload = () => {
@@ -4794,12 +4861,15 @@ function updateKCColorSwatches(targetId, colors) {
return; return;
} }
container.innerHTML = entries.map(([name, color]) => ` container.innerHTML = entries.map(([name, color]) => {
<div class="kc-swatch"> const hex = color.hex || `#${(color.r || 0).toString(16).padStart(2, '0')}${(color.g || 0).toString(16).padStart(2, '0')}${(color.b || 0).toString(16).padStart(2, '0')}`;
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div> return `
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span> <div class="kc-swatch">
</div> <div class="kc-swatch-color" style="background-color: ${hex}" title="${hex}"></div>
`).join(''); <span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`;
}).join('');
} }
// ===== PATTERN TEMPLATES ===== // ===== PATTERN TEMPLATES =====

View File

@@ -867,6 +867,7 @@
<!-- Image Lightbox --> <!-- Image Lightbox -->
<div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)"> <div id="image-lightbox" class="lightbox" onclick="closeLightbox(event)">
<button class="lightbox-close" onclick="closeLightbox()" title="Close">&#x2715;</button> <button class="lightbox-close" onclick="closeLightbox()" title="Close">&#x2715;</button>
<button id="lightbox-auto-refresh" class="lightbox-refresh-btn" onclick="toggleKCTestAutoRefresh()" title="Auto-refresh" style="display:none">&#x25B6;</button>
<div class="lightbox-content"> <div class="lightbox-content">
<img id="lightbox-image" src="" alt="Full size preview"> <img id="lightbox-image" src="" alt="Full size preview">
<div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div> <div id="lightbox-stats" class="lightbox-stats" style="display: none;"></div>

View File

@@ -2440,6 +2440,33 @@ input:-webkit-autofill:focus {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
.lightbox-refresh-btn {
position: absolute;
top: 16px;
right: 64px;
background: rgba(255, 255, 255, 0.15);
border: none;
color: white;
font-size: 1.2rem;
width: 40px;
height: 40px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
z-index: 1;
}
.lightbox-refresh-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.lightbox-refresh-btn.active {
background: var(--primary-color);
}
.lightbox-stats { .lightbox-stats {
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;