Files
ledgrab/server/src/wled_controller/static/js/features/streams-capture-templates.ts
T
alexei.dolgolyov 6a6c8b2c52
Lint & Test / test (push) Failing after 19s
perf: reduce portable build size ~40MB — replace winsdk/wmi, migrate cv2 to Pillow
- Replace winsdk (~35MB) with winrt packages (~2.5MB) for OS notification
  listener. API is identical, 93% size reduction.
- Replace wmi (~3-5MB) with ctypes for monitor names (EnumDisplayDevicesW)
  and camera names (SetupAPI). Zero external dependency.
- Migrate cv2.resize/imencode/LUT to Pillow/numpy in 5 files (filters,
  preview helpers, kc_target_processor). OpenCV only needed for camera
  and video stream now.
- Fix DefWindowProcW ctypes overflow on 64-bit Python (pre-existing bug
  in platform_detector display power listener).
- Fix openLightbox import in streams-capture-templates.ts (was using
  broken window cast instead of direct import).
- Add mandatory data migration policy to CLAUDE.md after silent data
  loss incident from storage file rename without migration.
2026-03-22 13:35:01 +03:00

586 lines
24 KiB
TypeScript

/**
* Streams — Capture template CRUD, engine config, test modal.
* Extracted from streams.ts to reduce file size.
*/
import {
availableEngines, setAvailableEngines,
currentEditingTemplateId, setCurrentEditingTemplateId,
_templateNameManuallyEdited, set_templateNameManuallyEdited,
currentTestingTemplate, setCurrentTestingTemplate,
_cachedStreams, _cachedDisplays,
captureTemplatesCache, displaysCache,
apiKey,
} from '../core/state.ts';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm, openLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setupBackdropClose } from '../core/ui.ts';
import { openDisplayPicker, formatDisplayLabel } from './displays.ts';
import {
getEngineIcon,
ICON_CAPTURE_TEMPLATE,
} from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { TagInput } from '../core/tag-input.ts';
import { IconSelect } from '../core/icon-select.ts';
import { loadPictureSources } from './streams.ts';
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── TagInput instance for capture template modal ──
let _captureTemplateTagsInput: TagInput | null = null;
class CaptureTemplateModal extends Modal {
constructor() { super('template-modal'); }
snapshotValues() {
const vals: any = {
name: (document.getElementById('template-name') as HTMLInputElement).value,
description: (document.getElementById('template-description') as HTMLInputElement).value,
engine: (document.getElementById('template-engine') as HTMLSelectElement).value,
tags: JSON.stringify(_captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : []),
};
document.querySelectorAll('[data-config-key]').forEach((field: any) => {
vals['cfg_' + field.dataset.configKey] = field.value;
});
return vals;
}
onForceClose() {
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
setCurrentEditingTemplateId(null);
set_templateNameManuallyEdited(false);
}
}
const templateModal = new CaptureTemplateModal();
const testTemplateModal = new Modal('test-template-modal');
// ===== Capture Templates =====
async function loadCaptureTemplates() {
try {
await captureTemplatesCache.fetch();
await loadPictureSources();
} catch (error) {
if (error.isAuth) return;
console.error('Error loading capture templates:', error);
showToast(t('streams.error.load'), 'error');
}
}
export async function showAddTemplateModal(cloneData: any = null) {
setCurrentEditingTemplateId(null);
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.add')}`;
(document.getElementById('template-form') as HTMLFormElement).reset();
(document.getElementById('template-id') as HTMLInputElement).value = '';
document.getElementById('engine-config-section')!.style.display = 'none';
document.getElementById('template-error')!.style.display = 'none';
set_templateNameManuallyEdited(!!cloneData);
(document.getElementById('template-name') as HTMLInputElement).oninput = () => { set_templateNameManuallyEdited(true); };
await loadAvailableEngines();
// Pre-fill from clone data after engines are loaded
if (cloneData) {
(document.getElementById('template-name') as HTMLInputElement).value = (cloneData.name || '') + ' (Copy)';
(document.getElementById('template-description') as HTMLInputElement).value = cloneData.description || '';
(document.getElementById('template-engine') as HTMLSelectElement).value = cloneData.engine_type;
await onEngineChange();
populateEngineConfig(cloneData.engine_config);
}
// Tags
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
_captureTemplateTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
templateModal.open();
templateModal.snapshot();
}
export async function editTemplate(templateId: any) {
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const template = await response.json();
setCurrentEditingTemplateId(templateId);
document.getElementById('template-modal-title')!.innerHTML = `${ICON_CAPTURE_TEMPLATE} ${t('templates.edit')}`;
(document.getElementById('template-id') as HTMLInputElement).value = templateId;
(document.getElementById('template-name') as HTMLInputElement).value = template.name;
(document.getElementById('template-description') as HTMLInputElement).value = template.description || '';
await loadAvailableEngines();
(document.getElementById('template-engine') as HTMLSelectElement).value = template.engine_type;
await onEngineChange();
populateEngineConfig(template.engine_config);
await loadDisplaysForTest();
const testResults = document.getElementById('template-test-results');
if (testResults) testResults.style.display = 'none';
document.getElementById('template-error')!.style.display = 'none';
// Tags
if (_captureTemplateTagsInput) { _captureTemplateTagsInput.destroy(); _captureTemplateTagsInput = null; }
_captureTemplateTagsInput = new TagInput(document.getElementById('capture-template-tags-container'), { placeholder: t('tags.placeholder') });
_captureTemplateTagsInput.setValue(template.tags || []);
templateModal.open();
templateModal.snapshot();
} catch (error) {
console.error('Error loading template:', error);
showToast(t('templates.error.load') + ': ' + error.message, 'error');
}
}
export async function closeTemplateModal() {
await templateModal.close();
}
export function updateCaptureDuration(value: any) {
document.getElementById('test-template-duration-value')!.textContent = value;
localStorage.setItem('capture_duration', value);
}
function restoreCaptureDuration() {
const savedDuration = localStorage.getItem('capture_duration');
if (savedDuration) {
const durationInput = document.getElementById('test-template-duration') as HTMLInputElement;
const durationValue = document.getElementById('test-template-duration-value')!;
durationInput.value = savedDuration;
durationValue.textContent = savedDuration;
}
}
export async function showTestTemplateModal(templateId: any) {
try {
const templates = await captureTemplatesCache.fetch();
const template = templates.find(tp => tp.id === templateId);
if (!template) {
showToast(t('templates.error.load'), 'error');
return;
}
setCurrentTestingTemplate(template);
await loadDisplaysForTest();
restoreCaptureDuration();
testTemplateModal.open();
setupBackdropClose((testTemplateModal as any).el, () => closeTestTemplateModal());
} catch (error) {
if (error.isAuth) return;
showToast(t('templates.error.load'), 'error');
}
}
export function closeTestTemplateModal() {
testTemplateModal.forceClose();
setCurrentTestingTemplate(null);
}
async function loadAvailableEngines() {
try {
const response = await fetchWithAuth('/capture-engines');
if (!response.ok) throw new Error(`Failed to load engines: ${response.status}`);
const data = await response.json();
setAvailableEngines(data.engines || []);
const select = document.getElementById('template-engine') as HTMLSelectElement;
select.innerHTML = '';
availableEngines.forEach((engine: any) => {
const option = document.createElement('option');
option.value = engine.type;
option.textContent = engine.name;
if (!engine.available) {
option.disabled = true;
option.textContent += ` (${t('templates.engine.unavailable')})`;
}
select.appendChild(option);
});
if (!select.value) {
const firstAvailable = availableEngines.find(e => e.available);
if (firstAvailable) select.value = firstAvailable.type;
}
// Update icon-grid selector with dynamic engine list
const items = availableEngines
.filter(e => e.available)
.map(e => ({ value: e.type, icon: getEngineIcon(e.type), label: e.name, desc: t(`templates.engine.${e.type}.desc`) }));
if (_engineIconSelect) { _engineIconSelect.updateItems(items); }
else { _engineIconSelect = new IconSelect({ target: select, items, columns: 2 }); }
_engineIconSelect.setValue(select.value);
} catch (error) {
console.error('Error loading engines:', error);
showToast(t('templates.error.engines') + ': ' + error.message, 'error');
}
}
let _engineIconSelect: IconSelect | null = null;
export async function onEngineChange() {
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
if (_engineIconSelect) _engineIconSelect.setValue(engineType);
const configSection = document.getElementById('engine-config-section')!;
const configFields = document.getElementById('engine-config-fields')!;
if (!engineType) { configSection.style.display = 'none'; return; }
const engine = availableEngines.find((e: any) => e.type === engineType);
if (!engine) { configSection.style.display = 'none'; return; }
if (!_templateNameManuallyEdited && !(document.getElementById('template-id') as HTMLInputElement).value) {
(document.getElementById('template-name') as HTMLInputElement).value = engine.name || engineType;
}
const hint = document.getElementById('engine-availability-hint')!;
if (!engine.available) {
hint.textContent = t('templates.engine.unavailable.hint');
hint.style.display = 'block';
hint.style.color = 'var(--error-color)';
} else {
hint.style.display = 'none';
}
configFields.innerHTML = '';
const defaultConfig = engine.default_config || {};
// Known select options for specific config keys
const CONFIG_SELECT_OPTIONS = {
camera_backend: ['auto', 'dshow', 'msmf', 'v4l2'],
};
// IconSelect definitions for specific config keys
const CONFIG_ICON_SELECT = {
camera_backend: {
columns: 2,
items: [
{ value: 'auto', icon: _icon(P.refreshCw), label: 'Auto', desc: t('templates.config.camera_backend.auto') },
{ value: 'dshow', icon: _icon(P.camera), label: 'DShow', desc: t('templates.config.camera_backend.dshow') },
{ value: 'msmf', icon: _icon(P.film), label: 'MSMF', desc: t('templates.config.camera_backend.msmf') },
{ value: 'v4l2', icon: _icon(P.monitor), label: 'V4L2', desc: t('templates.config.camera_backend.v4l2') },
],
},
};
if (Object.keys(defaultConfig).length === 0) {
configSection.style.display = 'none';
return;
} else {
let gridHtml = '<div class="config-grid">';
Object.entries(defaultConfig).forEach(([key, value]) => {
const fieldType = typeof value === 'number' ? 'number' : 'text';
const fieldValue = typeof value === 'boolean' ? (value ? 'true' : 'false') : value;
const selectOptions = CONFIG_SELECT_OPTIONS[key];
gridHtml += `
<label class="config-grid-label" for="config-${key}">${key}</label>
<div class="config-grid-value">
${typeof value === 'boolean' ? `
<select id="config-${key}" data-config-key="${key}">
<option value="true" ${value ? 'selected' : ''}>true</option>
<option value="false" ${!value ? 'selected' : ''}>false</option>
</select>
` : selectOptions ? `
<select id="config-${key}" data-config-key="${key}">
${selectOptions.map(opt => `<option value="${opt}" ${opt === String(value) ? 'selected' : ''}>${opt}</option>`).join('')}
</select>
` : `
<input type="${fieldType}" id="config-${key}" data-config-key="${key}" value="${fieldValue}">
`}
</div>
`;
});
gridHtml += '</div>';
configFields.innerHTML = gridHtml;
// Apply IconSelect to known config selects
for (const [key, cfg] of Object.entries(CONFIG_ICON_SELECT)) {
const sel = document.getElementById(`config-${key}`);
if (sel) new IconSelect({ target: sel as HTMLSelectElement, items: cfg.items, columns: cfg.columns });
}
}
configSection.style.display = 'block';
}
function populateEngineConfig(config: any) {
Object.entries(config).forEach(([key, value]: [string, any]) => {
const field = document.getElementById(`config-${key}`) as HTMLInputElement | HTMLSelectElement | null;
if (field) {
if (field.tagName === 'SELECT') {
field.value = value.toString();
} else {
field.value = value;
}
}
});
}
function collectEngineConfig() {
const config: any = {};
const fields = document.querySelectorAll('[data-config-key]');
fields.forEach((field: any) => {
const key = field.dataset.configKey;
let value: any = field.value;
if (field.type === 'number') {
value = parseFloat(value);
} else if (field.tagName === 'SELECT' && (value === 'true' || value === 'false')) {
value = value === 'true';
}
config[key] = value;
});
return config;
}
async function loadDisplaysForTest() {
try {
// Use engine-specific display list for engines with own devices (camera, scrcpy)
const engineType = currentTestingTemplate?.engine_type;
const engineHasOwnDisplays = availableEngines.find(e => e.type === engineType)?.has_own_displays || false;
const url = engineHasOwnDisplays
? `/config/displays?engine_type=${engineType}`
: '/config/displays';
// Always refetch for engines with own displays (devices may change); use cache for desktop
if (!_cachedDisplays || engineHasOwnDisplays) {
const response = await fetchWithAuth(url);
if (!response.ok) throw new Error(`Failed to load displays: ${response.status}`);
const displaysData = await response.json();
displaysCache.update(displaysData.displays || []);
}
let selectedIndex: number | null = null;
const lastDisplay = localStorage.getItem('lastTestDisplayIndex');
if (lastDisplay !== null && _cachedDisplays) {
const found = _cachedDisplays.find(d => d.index === parseInt(lastDisplay));
if (found) selectedIndex = found.index;
}
if (selectedIndex === null && _cachedDisplays) {
const primary = _cachedDisplays.find(d => d.is_primary);
if (primary) selectedIndex = primary.index;
else if (_cachedDisplays.length > 0) selectedIndex = _cachedDisplays[0].index;
}
if (selectedIndex !== null && _cachedDisplays) {
const display = _cachedDisplays.find(d => d.index === selectedIndex);
(window as any).onTestDisplaySelected(selectedIndex, display);
}
} catch (error) {
console.error('Error loading displays:', error);
}
}
export function runTemplateTest() {
if (!currentTestingTemplate) {
showToast(t('templates.test.error.no_engine'), 'error');
return;
}
const displayIndex = (document.getElementById('test-template-display') as HTMLSelectElement).value;
const captureDuration = parseFloat((document.getElementById('test-template-duration') as HTMLInputElement).value);
if (displayIndex === '') {
showToast(t('templates.test.error.no_display'), 'error');
return;
}
const template = currentTestingTemplate;
localStorage.setItem('lastTestDisplayIndex', displayIndex);
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
_runTestViaWS(
'/capture-templates/test/ws',
{},
{
engine_type: template.engine_type,
engine_config: template.engine_config,
display_index: parseInt(displayIndex),
capture_duration: captureDuration,
preview_width: previewWidth,
},
captureDuration,
);
}
function buildTestStatsHtml(result: any) {
// Support both REST format (nested) and WS format (flat)
const p = result.performance || result;
const duration = p.capture_duration_s ?? p.elapsed_s ?? 0;
const frameCount = p.frame_count ?? 0;
const fps = p.actual_fps ?? p.fps ?? 0;
const avgMs = p.avg_capture_time_ms ?? p.avg_capture_ms ?? 0;
const w = result.full_capture?.width ?? result.width ?? 0;
const h = result.full_capture?.height ?? result.height ?? 0;
const res = `${w}x${h}`;
let html = `
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${Number(duration).toFixed(2)}s</strong></div>
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${frameCount}</strong></div>`;
if (frameCount > 1) {
html += `
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${Number(fps).toFixed(1)}</strong></div>
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${Number(avgMs).toFixed(1)}ms</strong></div>`;
}
html += `
<div class="stat-item"><span>${t('templates.test.results.resolution')}</span> <strong>${res}</strong></div>`;
return html;
}
// ===== Shared WebSocket test helper =====
/**
* Run a capture test via WebSocket, streaming intermediate previews into
* the overlay spinner and opening the lightbox with the final result.
*
* @param {string} wsPath Relative WS path (e.g. '/picture-sources/{id}/test/ws')
* @param {Object} queryParams Extra query params (duration, source_stream_id, etc.)
* @param {Object|null} firstMessage If non-null, sent as JSON after WS opens (for template test)
* @param {number} duration Test duration for overlay progress ring
*/
export function _runTestViaWS(wsPath: string, queryParams: any = {}, firstMessage: any = null, duration = 5) {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Dynamic preview resolution: 80% of viewport width, scaled by DPR, capped at 1920px
const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2));
const params = new URLSearchParams({ token: apiKey, preview_width: String(previewWidth), ...queryParams });
const wsUrl = `${protocol}//${window.location.host}${API_BASE}${wsPath}?${params}`;
showOverlaySpinner(t('streams.test.running'), duration);
let gotResult = false;
let ws;
try {
ws = new WebSocket(wsUrl);
} catch (e) {
hideOverlaySpinner();
showToast(t('streams.test.error.failed') + ': ' + e.message, 'error');
return;
}
// Close WS when user cancels overlay
const patchCloseBtn = () => {
const closeBtn = document.querySelector('.overlay-spinner-close') as HTMLElement | null;
if (closeBtn) {
const origHandler = closeBtn.onclick;
closeBtn.onclick = () => {
if (ws.readyState <= WebSocket.OPEN) ws.close();
if (origHandler) (origHandler as any)();
};
}
};
patchCloseBtn();
// Also close on ESC (overlay ESC handler calls hideOverlaySpinner which aborts)
const origAbort = window._overlayAbortController;
if (origAbort) {
origAbort.signal.addEventListener('abort', () => {
if (ws.readyState <= WebSocket.OPEN) ws.close();
}, { once: true });
}
ws.onopen = () => {
if (firstMessage) {
ws.send(JSON.stringify(firstMessage));
}
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'frame') {
updateOverlayPreview(msg.thumbnail, msg);
} else if (msg.type === 'result') {
gotResult = true;
hideOverlaySpinner();
openLightbox(msg.full_image, buildTestStatsHtml(msg));
ws.close();
} else if (msg.type === 'error') {
hideOverlaySpinner();
showToast(msg.detail || 'Test failed', 'error');
ws.close();
}
} catch (e) {
console.error('Error parsing test WS message:', e);
}
};
ws.onerror = () => {
if (!gotResult) {
hideOverlaySpinner();
showToast(t('streams.test.error.failed'), 'error');
}
};
ws.onclose = () => {
if (!gotResult) {
hideOverlaySpinner();
}
};
}
export async function saveTemplate() {
const templateId = (document.getElementById('template-id') as HTMLInputElement).value;
const name = (document.getElementById('template-name') as HTMLInputElement).value.trim();
const engineType = (document.getElementById('template-engine') as HTMLSelectElement).value;
if (!name || !engineType) {
showToast(t('templates.error.required'), 'error');
return;
}
const description = (document.getElementById('template-description') as HTMLInputElement).value.trim();
const engineConfig = collectEngineConfig();
const payload = { name, engine_type: engineType, engine_config: engineConfig, description: description || null, tags: _captureTemplateTagsInput ? _captureTemplateTagsInput.getValue() : [] };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/capture-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('templates.updated') : t('templates.created'), 'success');
templateModal.forceClose();
captureTemplatesCache.invalidate();
await loadCaptureTemplates();
} catch (error) {
console.error('Error saving template:', error);
document.getElementById('template-error')!.textContent = (error as any).message;
document.getElementById('template-error')!.style.display = 'block';
}
}
export async function deleteTemplate(templateId: any) {
const confirmed = await showConfirm(t('templates.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/capture-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('templates.deleted'), 'success');
captureTemplatesCache.invalidate();
await loadCaptureTemplates();
} catch (error) {
console.error('Error deleting template:', error);
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
}
}