/** * 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) => `${d}`; // ── 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 = '
'; 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 += `
${typeof value === 'boolean' ? ` ` : selectOptions ? ` ` : ` `}
`; }); gridHtml += '
'; 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 = `
${t('templates.test.results.duration')}: ${Number(duration).toFixed(2)}s
${t('templates.test.results.frame_count')}: ${frameCount}
`; if (frameCount > 1) { html += `
${t('templates.test.results.actual_fps')}: ${Number(fps).toFixed(1)}
${t('templates.test.results.avg_capture_time')}: ${Number(avgMs).toFixed(1)}ms
`; } html += `
${t('templates.test.results.resolution')} ${res}
`; 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'); } }