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 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 13:35:26 +03:00
parent 9ee6dcf94a
commit ddfa7637d6
7 changed files with 76 additions and 4 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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();
}

View File

@@ -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",

View File

@@ -334,6 +334,7 @@
"streams.updated": "Источник успешно обновлён",
"streams.deleted": "Источник успешно удалён",
"streams.delete.confirm": "Вы уверены, что хотите удалить этот источник?",
"streams.modal.loading": "Загрузка...",
"streams.error.load": "Не удалось загрузить источники",
"streams.error.required": "Пожалуйста, заполните все обязательные поля",
"streams.error.delete": "Не удалось удалить источник",

View File

@@ -334,6 +334,7 @@
"streams.updated": "源更新成功",
"streams.deleted": "源删除成功",
"streams.delete.confirm": "确定要删除此源吗?",
"streams.modal.loading": "加载中...",
"streams.error.load": "加载源失败",
"streams.error.required": "请填写所有必填项",
"streams.error.delete": "删除源失败",

View File

@@ -6,6 +6,11 @@
<button class="modal-close-btn" onclick="closeStreamModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body">
<!-- Loading overlay shown while dropdowns are being populated -->
<div id="stream-modal-loading" class="modal-body-loading" style="display:none">
<div class="loading-spinner"></div>
<div class="modal-body-loading-text" data-i18n="streams.modal.loading">Loading...</div>
</div>
<input type="hidden" id="stream-id">
<form id="stream-form">
<div class="form-group">