Add static image picture stream type with auto-validating UI
Introduces a new "static_image" stream type that loads a frame from a URL or local file path, enabling LED testing with known images or displaying static content. Includes validate-image API endpoint, auto-validation on blur/enter/paste with caching, capture template names on stream cards, and conditional test stats display for single-frame results. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2953,13 +2953,17 @@ async function runTemplateTest() {
|
||||
function buildTestStatsHtml(result) {
|
||||
const p = result.performance;
|
||||
const res = `${result.full_capture.width}x${result.full_capture.height}`;
|
||||
return `
|
||||
let html = `
|
||||
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${p.capture_duration_s.toFixed(2)}s</strong></div>
|
||||
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${p.frame_count}</strong></div>
|
||||
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${p.frame_count}</strong></div>`;
|
||||
if (p.frame_count > 1) {
|
||||
html += `
|
||||
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${p.actual_fps.toFixed(1)}</strong></div>
|
||||
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${p.avg_capture_time_ms.toFixed(1)}ms</strong></div>
|
||||
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>
|
||||
`;
|
||||
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${p.avg_capture_time_ms.toFixed(1)}ms</strong></div>`;
|
||||
}
|
||||
html += `
|
||||
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// Display test results — opens lightbox with stats overlay
|
||||
@@ -3046,20 +3050,27 @@ async function deleteTemplate(templateId) {
|
||||
|
||||
let _cachedStreams = [];
|
||||
let _cachedPPTemplates = [];
|
||||
let _cachedCaptureTemplates = [];
|
||||
let _availableFilters = []; // Loaded from GET /filters
|
||||
|
||||
async function loadPictureStreams() {
|
||||
try {
|
||||
// Ensure PP templates are cached so processed stream cards can show filter info
|
||||
if (_cachedPPTemplates.length === 0) {
|
||||
// Ensure PP templates and capture templates are cached for stream card display
|
||||
if (_cachedPPTemplates.length === 0 || _cachedCaptureTemplates.length === 0) {
|
||||
try {
|
||||
if (_availableFilters.length === 0) {
|
||||
const fr = await fetchWithAuth('/filters');
|
||||
if (fr.ok) { const fd = await fr.json(); _availableFilters = fd.filters || []; }
|
||||
}
|
||||
const pr = await fetchWithAuth('/postprocessing-templates');
|
||||
if (pr.ok) { const pd = await pr.json(); _cachedPPTemplates = pd.templates || []; }
|
||||
} catch (e) { console.warn('Could not pre-load PP templates for streams:', e); }
|
||||
if (_cachedPPTemplates.length === 0) {
|
||||
const pr = await fetchWithAuth('/postprocessing-templates');
|
||||
if (pr.ok) { const pd = await pr.json(); _cachedPPTemplates = pd.templates || []; }
|
||||
}
|
||||
if (_cachedCaptureTemplates.length === 0) {
|
||||
const cr = await fetchWithAuth('/capture-templates');
|
||||
if (cr.ok) { const cd = await cr.json(); _cachedCaptureTemplates = cd.templates || []; }
|
||||
}
|
||||
} catch (e) { console.warn('Could not pre-load templates for streams:', e); }
|
||||
}
|
||||
const response = await fetchWithAuth('/picture-streams');
|
||||
if (!response.ok) {
|
||||
@@ -3094,6 +3105,19 @@ function renderPictureStreamsList(streams) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">🖼️</span>
|
||||
<span class="stream-group-title">${t('streams.group.static_image')}</span>
|
||||
<span class="stream-group-count">0</span>
|
||||
</div>
|
||||
<div class="templates-grid">
|
||||
<div class="template-card add-template-card" onclick="showAddStreamModal('static_image')">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add.static_image')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">🎨</span>
|
||||
@@ -3111,13 +3135,24 @@ function renderPictureStreamsList(streams) {
|
||||
}
|
||||
|
||||
const renderCard = (stream) => {
|
||||
const typeIcon = stream.stream_type === 'raw' ? '🖥️' : '🎨';
|
||||
const typeBadge = stream.stream_type === 'raw'
|
||||
? `<span class="badge badge-raw">${t('streams.type.raw')}</span>`
|
||||
: `<span class="badge badge-processed">${t('streams.type.processed')}</span>`;
|
||||
const typeIcons = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
|
||||
const typeIcon = typeIcons[stream.stream_type] || '📺';
|
||||
const typeBadges = {
|
||||
raw: `<span class="badge badge-raw">${t('streams.type.raw')}</span>`,
|
||||
processed: `<span class="badge badge-processed">${t('streams.type.processed')}</span>`,
|
||||
static_image: `<span class="badge badge-processed">${t('streams.type.static_image')}</span>`,
|
||||
};
|
||||
const typeBadge = typeBadges[stream.stream_type] || '';
|
||||
|
||||
let detailsHtml = '';
|
||||
if (stream.stream_type === 'raw') {
|
||||
let captureTemplateHtml = '';
|
||||
if (stream.capture_template_id) {
|
||||
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
|
||||
if (capTmpl) {
|
||||
captureTemplateHtml = `<div class="template-config"><strong>${t('streams.capture_template')}</strong> ${escapeHtml(capTmpl.name)}</div>`;
|
||||
}
|
||||
}
|
||||
detailsHtml = `
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}
|
||||
@@ -3125,8 +3160,9 @@ function renderPictureStreamsList(streams) {
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}
|
||||
</div>
|
||||
${captureTemplateHtml}
|
||||
`;
|
||||
} else {
|
||||
} else if (stream.stream_type === 'processed') {
|
||||
// Find source stream name and PP template name
|
||||
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
||||
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
|
||||
@@ -3144,6 +3180,15 @@ function renderPictureStreamsList(streams) {
|
||||
</div>
|
||||
${ppTemplateHtml}
|
||||
`;
|
||||
} else if (stream.stream_type === 'static_image') {
|
||||
const src = stream.image_source || '';
|
||||
const truncated = src.length > 50 ? src.substring(0, 47) + '...' : src;
|
||||
detailsHtml = `
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.image_source')}</strong>
|
||||
</div>
|
||||
<div class="stream-card-image-source" title="${escapeHtml(src)}">${escapeHtml(truncated)}</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
@@ -3171,6 +3216,7 @@ function renderPictureStreamsList(streams) {
|
||||
|
||||
const rawStreams = streams.filter(s => s.stream_type === 'raw');
|
||||
const processedStreams = streams.filter(s => s.stream_type === 'processed');
|
||||
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
|
||||
|
||||
let html = '';
|
||||
|
||||
@@ -3190,6 +3236,22 @@ function renderPictureStreamsList(streams) {
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Static Image streams section
|
||||
html += `<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">🖼️</span>
|
||||
<span class="stream-group-title">${t('streams.group.static_image')}</span>
|
||||
<span class="stream-group-count">${staticImageStreams.length}</span>
|
||||
</div>
|
||||
<div class="templates-grid">
|
||||
${staticImageStreams.map(renderCard).join('')}
|
||||
<div class="template-card add-template-card" onclick="showAddStreamModal('static_image')">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add.static_image')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Processed streams section
|
||||
html += `<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
@@ -3213,18 +3275,28 @@ function onStreamTypeChange() {
|
||||
const streamType = document.getElementById('stream-type').value;
|
||||
document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none';
|
||||
document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
|
||||
document.getElementById('stream-static-image-fields').style.display = streamType === 'static_image' ? '' : 'none';
|
||||
}
|
||||
|
||||
async function showAddStreamModal(presetType) {
|
||||
const streamType = presetType || 'raw';
|
||||
const titleKey = streamType === 'raw' ? 'streams.add.raw' : 'streams.add.processed';
|
||||
document.getElementById('stream-modal-title').textContent = t(titleKey);
|
||||
const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' };
|
||||
document.getElementById('stream-modal-title').textContent = t(titleKeys[streamType] || 'streams.add');
|
||||
document.getElementById('stream-form').reset();
|
||||
document.getElementById('stream-id').value = '';
|
||||
document.getElementById('stream-display-index').value = '';
|
||||
document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select');
|
||||
document.getElementById('stream-error').style.display = 'none';
|
||||
document.getElementById('stream-type').value = streamType;
|
||||
// Clear static image preview and wire up auto-validation
|
||||
_lastValidatedImageSource = '';
|
||||
const imgSrcInput = document.getElementById('stream-image-source');
|
||||
imgSrcInput.value = '';
|
||||
document.getElementById('stream-image-preview-container').style.display = 'none';
|
||||
document.getElementById('stream-image-validation-status').style.display = 'none';
|
||||
imgSrcInput.onblur = () => validateStaticImage();
|
||||
imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } };
|
||||
imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0);
|
||||
onStreamTypeChange();
|
||||
|
||||
// Populate dropdowns
|
||||
@@ -3242,8 +3314,8 @@ async function editStream(streamId) {
|
||||
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
|
||||
const stream = await response.json();
|
||||
|
||||
const editTitleKey = stream.stream_type === 'raw' ? 'streams.edit.raw' : 'streams.edit.processed';
|
||||
document.getElementById('stream-modal-title').textContent = t(editTitleKey);
|
||||
const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image' };
|
||||
document.getElementById('stream-modal-title').textContent = t(editTitleKeys[stream.stream_type] || 'streams.edit');
|
||||
document.getElementById('stream-id').value = streamId;
|
||||
document.getElementById('stream-name').value = stream.name;
|
||||
document.getElementById('stream-description').value = stream.description || '';
|
||||
@@ -3251,6 +3323,9 @@ async function editStream(streamId) {
|
||||
|
||||
// Set type (hidden input)
|
||||
document.getElementById('stream-type').value = stream.stream_type;
|
||||
// Clear static image preview
|
||||
document.getElementById('stream-image-preview-container').style.display = 'none';
|
||||
document.getElementById('stream-image-validation-status').style.display = 'none';
|
||||
onStreamTypeChange();
|
||||
|
||||
// Populate dropdowns before setting values
|
||||
@@ -3264,9 +3339,15 @@ async function editStream(streamId) {
|
||||
const fps = stream.target_fps ?? 30;
|
||||
document.getElementById('stream-target-fps').value = fps;
|
||||
document.getElementById('stream-target-fps-value').textContent = fps;
|
||||
} else {
|
||||
} else if (stream.stream_type === 'processed') {
|
||||
document.getElementById('stream-source').value = stream.source_stream_id || '';
|
||||
document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || '';
|
||||
} else if (stream.stream_type === 'static_image') {
|
||||
document.getElementById('stream-image-source').value = stream.image_source || '';
|
||||
// Auto-validate to show preview
|
||||
if (stream.image_source) {
|
||||
validateStaticImage();
|
||||
}
|
||||
}
|
||||
|
||||
const modal = document.getElementById('stream-modal');
|
||||
@@ -3324,7 +3405,8 @@ async function populateStreamModalDropdowns() {
|
||||
if (s.id === editingId) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
const typeLabel = s.stream_type === 'raw' ? '🖥️' : '🎨';
|
||||
const typeLabels = { raw: '🖥️', processed: '🎨', static_image: '🖼️' };
|
||||
const typeLabel = typeLabels[s.stream_type] || '📺';
|
||||
opt.textContent = `${typeLabel} ${s.name}`;
|
||||
sourceSelect.appendChild(opt);
|
||||
});
|
||||
@@ -3367,9 +3449,16 @@ async function saveStream() {
|
||||
payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0;
|
||||
payload.capture_template_id = document.getElementById('stream-capture-template').value;
|
||||
payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30;
|
||||
} else {
|
||||
} else if (streamType === 'processed') {
|
||||
payload.source_stream_id = document.getElementById('stream-source').value;
|
||||
payload.postprocessing_template_id = document.getElementById('stream-pp-template').value;
|
||||
} else if (streamType === 'static_image') {
|
||||
const imageSource = document.getElementById('stream-image-source').value.trim();
|
||||
if (!imageSource) {
|
||||
showToast(t('streams.error.required'), 'error');
|
||||
return;
|
||||
}
|
||||
payload.image_source = imageSource;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -3429,6 +3518,56 @@ function closeStreamModal() {
|
||||
unlockBody();
|
||||
}
|
||||
|
||||
let _lastValidatedImageSource = '';
|
||||
|
||||
async function validateStaticImage() {
|
||||
const source = document.getElementById('stream-image-source').value.trim();
|
||||
const previewContainer = document.getElementById('stream-image-preview-container');
|
||||
const previewImg = document.getElementById('stream-image-preview');
|
||||
const infoEl = document.getElementById('stream-image-info');
|
||||
const statusEl = document.getElementById('stream-image-validation-status');
|
||||
|
||||
if (!source) {
|
||||
_lastValidatedImageSource = '';
|
||||
previewContainer.style.display = 'none';
|
||||
statusEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (source === _lastValidatedImageSource) return;
|
||||
|
||||
// Show loading state
|
||||
statusEl.textContent = t('streams.validate_image.validating');
|
||||
statusEl.className = 'validation-status loading';
|
||||
statusEl.style.display = 'block';
|
||||
previewContainer.style.display = 'none';
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth('/picture-streams/validate-image', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ image_source: source }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
_lastValidatedImageSource = source;
|
||||
if (data.valid) {
|
||||
previewImg.src = data.preview;
|
||||
infoEl.textContent = `${data.width} × ${data.height} px`;
|
||||
previewContainer.style.display = '';
|
||||
statusEl.textContent = t('streams.validate_image.valid');
|
||||
statusEl.className = 'validation-status success';
|
||||
} else {
|
||||
previewContainer.style.display = 'none';
|
||||
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`;
|
||||
statusEl.className = 'validation-status error';
|
||||
}
|
||||
} catch (err) {
|
||||
previewContainer.style.display = 'none';
|
||||
statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`;
|
||||
statusEl.className = 'validation-status error';
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Picture Stream Test =====
|
||||
|
||||
let _currentTestStreamId = null;
|
||||
|
||||
Reference in New Issue
Block a user