From ddfa7637d6bf2b18763f30ca8f016c186bcd2291 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 1 Mar 2026 13:35:26 +0300 Subject: [PATCH] Speed up camera source modal with cached enumeration and instant open Cache camera enumeration results for 30s and limit probe range using WMI camera count on Windows. Open source modal instantly with a loading spinner while dropdowns are populated asynchronously. Co-Authored-By: Claude Opus 4.6 --- .../core/capture_engines/camera_engine.py | 26 +++++++++++++++++- .../wled_controller/static/css/components.css | 19 +++++++++++++ .../static/js/features/streams.js | 27 ++++++++++++++++--- .../wled_controller/static/locales/en.json | 1 + .../wled_controller/static/locales/ru.json | 1 + .../wled_controller/static/locales/zh.json | 1 + .../templates/modals/stream.html | 5 ++++ 7 files changed, 76 insertions(+), 4 deletions(-) diff --git a/server/src/wled_controller/core/capture_engines/camera_engine.py b/server/src/wled_controller/core/capture_engines/camera_engine.py index 56582ef..301d316 100644 --- a/server/src/wled_controller/core/capture_engines/camera_engine.py +++ b/server/src/wled_controller/core/capture_engines/camera_engine.py @@ -10,6 +10,7 @@ Prerequisites (optional dependency): import platform import sys +import time from typing import Any, Dict, List, Optional import numpy as np @@ -66,23 +67,43 @@ def _get_camera_friendly_names() -> Dict[int, str]: return {} +_camera_cache: Optional[List[Dict[str, Any]]] = None +_camera_cache_time: float = 0 +_CAMERA_CACHE_TTL = 30.0 # seconds + + def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]: """Probe camera indices and return metadata for each available camera. + Results are cached for 30 seconds to avoid repeated slow probes. Returns a list of dicts: {cv2_index, name, width, height, fps}. """ + global _camera_cache, _camera_cache_time + + now = time.monotonic() + if _camera_cache is not None and (now - _camera_cache_time) < _CAMERA_CACHE_TTL: + return _camera_cache + try: import cv2 except ImportError: + _camera_cache = [] + _camera_cache_time = now return [] backend_id = _cv2_backend_id(backend_name) friendly_names = _get_camera_friendly_names() + # On Windows, WMI tells us how many cameras exist — skip probing + # indices beyond that count to avoid slow timeouts. + max_probe = _MAX_CAMERA_INDEX + if friendly_names: + max_probe = min(len(friendly_names) + 1, _MAX_CAMERA_INDEX) + cameras: List[Dict[str, Any]] = [] sequential_idx = 0 - for i in range(_MAX_CAMERA_INDEX): + for i in range(max_probe): if backend_id is not None: cap = cv2.VideoCapture(i, backend_id) else: @@ -108,6 +129,9 @@ def _enumerate_cameras(backend_name: str = "auto") -> List[Dict[str, Any]]: }) sequential_idx += 1 + _camera_cache = cameras + _camera_cache_time = now + logger.debug(f"Camera enumeration: found {len(cameras)} camera(s) (probed {max_probe} indices)") return cameras diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index 29f57c2..6c10ef0 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -246,6 +246,25 @@ input:-webkit-autofill:focus { to { transform: rotate(360deg); } } +/* Modal body loading overlay — hides form while data loads */ +.modal-body-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 20px; + gap: 12px; +} + +.modal-body-loading .loading-spinner { + padding: 0; +} + +.modal-body-loading-text { + color: var(--text-secondary); + font-size: 0.9rem; +} + /* Full-page overlay spinner */ .overlay-spinner { position: fixed; diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 8e8e8fd..539e8d1 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -1494,6 +1494,10 @@ export async function showAddStreamModal(presetType, cloneData = null) { document.getElementById('stream-source').onchange = () => _autoGenerateStreamName(); document.getElementById('stream-pp-template').onchange = () => _autoGenerateStreamName(); + // Open modal instantly with loading indicator + _showStreamModalLoading(true); + streamModal.open(); + await populateStreamModalDropdowns(); // Pre-fill from clone data after dropdowns are populated @@ -1518,12 +1522,19 @@ export async function showAddStreamModal(presetType, cloneData = null) { } } - streamModal.open(); + _showStreamModalLoading(false); streamModal.snapshot(); } export async function editStream(streamId) { try { + // Open modal instantly with loading indicator + document.getElementById('stream-modal-title').innerHTML = t('streams.edit'); + document.getElementById('stream-form').reset(); + document.getElementById('stream-error').style.display = 'none'; + _showStreamModalLoading(true); + streamModal.open(); + const response = await fetchWithAuth(`/picture-sources/${streamId}`); if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`); const stream = await response.json(); @@ -1533,7 +1544,6 @@ export async function editStream(streamId) { document.getElementById('stream-id').value = streamId; document.getElementById('stream-name').value = stream.name; document.getElementById('stream-description').value = stream.description || ''; - document.getElementById('stream-error').style.display = 'none'; document.getElementById('stream-type').value = stream.stream_type; set_lastValidatedImageSource(''); @@ -1565,10 +1575,11 @@ export async function editStream(streamId) { if (stream.image_source) validateStaticImage(); } - streamModal.open(); + _showStreamModalLoading(false); streamModal.snapshot(); } catch (error) { console.error('Error loading stream:', error); + streamModal.forceClose(); showToast(t('streams.error.load') + ': ' + error.message, 'error'); } } @@ -1751,6 +1762,16 @@ export async function deleteStream(streamId) { } } +/** Toggle loading overlay in stream modal — hides form while data loads. */ +function _showStreamModalLoading(show) { + const loading = document.getElementById('stream-modal-loading'); + const form = document.getElementById('stream-form'); + const footer = document.querySelector('#stream-modal .modal-footer'); + if (loading) loading.style.display = show ? '' : 'none'; + if (form) form.style.display = show ? 'none' : ''; + if (footer) footer.style.visibility = show ? 'hidden' : ''; +} + export async function closeStreamModal() { await streamModal.close(); } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 2e94620..4f14816 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -334,6 +334,7 @@ "streams.updated": "Source updated successfully", "streams.deleted": "Source deleted successfully", "streams.delete.confirm": "Are you sure you want to delete this source?", + "streams.modal.loading": "Loading...", "streams.error.load": "Failed to load sources", "streams.error.required": "Please fill in all required fields", "streams.error.delete": "Failed to delete source", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 9772950..45347b3 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -334,6 +334,7 @@ "streams.updated": "Источник успешно обновлён", "streams.deleted": "Источник успешно удалён", "streams.delete.confirm": "Вы уверены, что хотите удалить этот источник?", + "streams.modal.loading": "Загрузка...", "streams.error.load": "Не удалось загрузить источники", "streams.error.required": "Пожалуйста, заполните все обязательные поля", "streams.error.delete": "Не удалось удалить источник", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 272663c..620d94a 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -334,6 +334,7 @@ "streams.updated": "源更新成功", "streams.deleted": "源删除成功", "streams.delete.confirm": "确定要删除此源吗?", + "streams.modal.loading": "加载中...", "streams.error.load": "加载源失败", "streams.error.required": "请填写所有必填项", "streams.error.delete": "删除源失败", diff --git a/server/src/wled_controller/templates/modals/stream.html b/server/src/wled_controller/templates/modals/stream.html index 95c162d..d8c1e38 100644 --- a/server/src/wled_controller/templates/modals/stream.html +++ b/server/src/wled_controller/templates/modals/stream.html @@ -6,6 +6,11 @@