Add Picture Streams architecture with postprocessing templates and stream test UI
Introduce Picture Stream abstraction that separates the capture pipeline into composable layers: raw streams (display + capture engine + FPS) and processed streams (source stream + postprocessing template). Devices reference a picture stream instead of managing individual capture settings. - Add PictureStream and PostprocessingTemplate data models and stores - Add CRUD API endpoints for picture streams and postprocessing templates - Add stream chain resolution in ProcessorManager for start_processing - Add picture stream test endpoint with postprocessing preview support - Add Stream Settings modal with border_width and interpolation_mode controls - Add stream test modal with capture preview and performance metrics - Add full frontend: Picture Streams tab, Processing Templates tab, stream selector on device cards, test buttons on stream cards - Add localization keys for all new features (en, ru) - Migrate existing devices to picture streams on startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -347,6 +347,12 @@ function switchTab(name) {
|
||||
if (name === 'templates') {
|
||||
loadCaptureTemplates();
|
||||
}
|
||||
if (name === 'streams') {
|
||||
loadPictureStreams();
|
||||
}
|
||||
if (name === 'pp-templates') {
|
||||
loadPPTemplates();
|
||||
}
|
||||
}
|
||||
|
||||
function initTabs() {
|
||||
@@ -625,8 +631,8 @@ function createDeviceCard(device) {
|
||||
<button class="btn btn-icon btn-secondary" onclick="showSettings('${device.id}')" title="${t('device.button.settings')}">
|
||||
⚙️
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showCaptureSettings('${device.id}')" title="${t('device.button.capture_settings')}">
|
||||
🎬
|
||||
<button class="btn btn-icon btn-secondary" onclick="showStreamSelector('${device.id}')" title="${t('device.button.stream_selector')}">
|
||||
📺
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="showCalibration('${device.id}')" title="${t('device.button.calibrate')}">
|
||||
📐
|
||||
@@ -930,20 +936,14 @@ async function showCaptureSettings(deviceId) {
|
||||
templateSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
if (templateSelect.options.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = 'tpl_mss_default';
|
||||
opt.textContent = 'MSS (Default)';
|
||||
templateSelect.appendChild(opt);
|
||||
}
|
||||
templateSelect.value = device.capture_template_id || 'tpl_mss_default';
|
||||
templateSelect.value = device.capture_template_id || '';
|
||||
|
||||
// Store device ID, current settings snapshot, and initial values for dirty check
|
||||
document.getElementById('capture-settings-device-id').value = device.id;
|
||||
captureSettingsInitialValues = {
|
||||
display_index: String(device.settings.display_index ?? 0),
|
||||
fps: String(currentSettings.fps ?? 30),
|
||||
capture_template_id: device.capture_template_id || 'tpl_mss_default',
|
||||
capture_template_id: device.capture_template_id || '',
|
||||
_currentSettings: currentSettings,
|
||||
};
|
||||
|
||||
@@ -2359,25 +2359,16 @@ function renderTemplatesList(templates) {
|
||||
return;
|
||||
}
|
||||
|
||||
const defaultTemplates = templates.filter(t => t.is_default);
|
||||
const customTemplates = templates.filter(t => !t.is_default);
|
||||
|
||||
const renderCard = (template) => {
|
||||
const engineIcon = getEngineIcon(template.engine_type);
|
||||
const defaultBadge = template.is_default
|
||||
? `<span class="badge badge-default">${t('templates.default')}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="template-card" data-template-id="${template.id}">
|
||||
${!template.is_default ? `
|
||||
<button class="card-remove-btn" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">✕</button>
|
||||
` : ''}
|
||||
<button class="card-remove-btn" onclick="deleteTemplate('${template.id}')" title="${t('common.delete')}">✕</button>
|
||||
<div class="template-card-header">
|
||||
<div class="template-name">
|
||||
${engineIcon} ${escapeHtml(template.name)}
|
||||
</div>
|
||||
${defaultBadge}
|
||||
</div>
|
||||
<div class="template-config">
|
||||
<strong>${t('templates.engine')}</strong> ${template.engine_type.toUpperCase()}
|
||||
@@ -2401,22 +2392,15 @@ function renderTemplatesList(templates) {
|
||||
<button class="btn btn-icon btn-secondary" onclick="showTestTemplateModal('${template.id}')" title="${t('templates.test.title')}">
|
||||
🧪
|
||||
</button>
|
||||
${!template.is_default ? `
|
||||
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">
|
||||
✏️
|
||||
</button>
|
||||
` : ''}
|
||||
<button class="btn btn-icon btn-secondary" onclick="editTemplate('${template.id}')" title="${t('common.edit')}">
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
let html = defaultTemplates.map(renderCard).join('');
|
||||
|
||||
if (customTemplates.length > 0) {
|
||||
html += `<div class="templates-separator"><span>${t('templates.custom')}</span></div>`;
|
||||
html += customTemplates.map(renderCard).join('');
|
||||
}
|
||||
let html = templates.map(renderCard).join('');
|
||||
|
||||
html += `<div class="template-card add-template-card" onclick="showAddTemplateModal()">
|
||||
<div class="add-template-icon">+</div>
|
||||
@@ -2978,3 +2962,773 @@ async function deleteTemplate(templateId) {
|
||||
showToast(t('templates.error.delete') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Picture Streams =====
|
||||
|
||||
let _cachedStreams = [];
|
||||
let _cachedPPTemplates = [];
|
||||
|
||||
async function loadPictureStreams() {
|
||||
try {
|
||||
const response = await fetchWithAuth('/picture-streams');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load streams: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
_cachedStreams = data.streams || [];
|
||||
renderPictureStreamsList(_cachedStreams);
|
||||
} catch (error) {
|
||||
console.error('Error loading picture streams:', error);
|
||||
document.getElementById('streams-list').innerHTML = `
|
||||
<div class="error-message">${t('streams.error.load')}: ${error.message}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPictureStreamsList(streams) {
|
||||
const container = document.getElementById('streams-list');
|
||||
|
||||
if (streams.length === 0) {
|
||||
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddStreamModal()">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add')}</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
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>`;
|
||||
|
||||
let detailsHtml = '';
|
||||
if (stream.stream_type === 'raw') {
|
||||
detailsHtml = `
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}
|
||||
</div>
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// 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 || '-');
|
||||
detailsHtml = `
|
||||
<div class="template-config">
|
||||
<strong>${t('streams.source')}</strong> ${sourceName}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="template-card" data-stream-id="${stream.id}">
|
||||
<button class="card-remove-btn" onclick="deleteStream('${stream.id}')" title="${t('common.delete')}">✕</button>
|
||||
<div class="template-card-header">
|
||||
<div class="template-name">
|
||||
${typeIcon} ${escapeHtml(stream.name)}
|
||||
</div>
|
||||
${typeBadge}
|
||||
</div>
|
||||
${detailsHtml}
|
||||
${stream.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(stream.description)}</div>` : ''}
|
||||
<div class="template-card-actions">
|
||||
<button class="btn btn-icon btn-secondary" onclick="showTestStreamModal('${stream.id}')" title="${t('streams.test.title')}">
|
||||
🧪
|
||||
</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="editStream('${stream.id}')" title="${t('common.edit')}">
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
let html = streams.map(renderCard).join('');
|
||||
html += `<div class="template-card add-template-card" onclick="showAddStreamModal()">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('streams.add')}</div>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
async function showAddStreamModal() {
|
||||
document.getElementById('stream-modal-title').textContent = t('streams.add');
|
||||
document.getElementById('stream-form').reset();
|
||||
document.getElementById('stream-id').value = '';
|
||||
document.getElementById('stream-error').style.display = 'none';
|
||||
document.getElementById('stream-type').disabled = false;
|
||||
|
||||
// Reset to raw type
|
||||
document.getElementById('stream-type').value = 'raw';
|
||||
onStreamTypeChange();
|
||||
|
||||
// Populate dropdowns
|
||||
await populateStreamModalDropdowns();
|
||||
|
||||
const modal = document.getElementById('stream-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); };
|
||||
}
|
||||
|
||||
async function editStream(streamId) {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/picture-streams/${streamId}`);
|
||||
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
|
||||
const stream = await response.json();
|
||||
|
||||
document.getElementById('stream-modal-title').textContent = t('streams.edit');
|
||||
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';
|
||||
|
||||
// Set type and disable changing it for existing streams
|
||||
document.getElementById('stream-type').value = stream.stream_type;
|
||||
document.getElementById('stream-type').disabled = true;
|
||||
onStreamTypeChange();
|
||||
|
||||
// Populate dropdowns before setting values
|
||||
await populateStreamModalDropdowns();
|
||||
|
||||
if (stream.stream_type === 'raw') {
|
||||
document.getElementById('stream-display-index').value = String(stream.display_index ?? 0);
|
||||
document.getElementById('stream-capture-template').value = stream.capture_template_id || '';
|
||||
const fps = stream.target_fps ?? 30;
|
||||
document.getElementById('stream-target-fps').value = fps;
|
||||
document.getElementById('stream-target-fps-value').textContent = fps;
|
||||
} else {
|
||||
document.getElementById('stream-source').value = stream.source_stream_id || '';
|
||||
document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || '';
|
||||
}
|
||||
|
||||
const modal = document.getElementById('stream-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closeStreamModal(); };
|
||||
} catch (error) {
|
||||
console.error('Error loading stream:', error);
|
||||
showToast(t('streams.error.load') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function populateStreamModalDropdowns() {
|
||||
// Load displays, capture templates, streams, and PP templates in parallel
|
||||
const [displaysRes, captureTemplatesRes, streamsRes, ppTemplatesRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/config/displays`, { headers: getHeaders() }),
|
||||
fetchWithAuth('/capture-templates'),
|
||||
fetchWithAuth('/picture-streams'),
|
||||
fetchWithAuth('/postprocessing-templates'),
|
||||
]);
|
||||
|
||||
// Displays
|
||||
const displaySelect = document.getElementById('stream-display-index');
|
||||
displaySelect.innerHTML = '';
|
||||
if (displaysRes.ok) {
|
||||
const displaysData = await displaysRes.json();
|
||||
(displaysData.displays || []).forEach(d => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = d.index;
|
||||
opt.textContent = `${d.index}: ${d.width}x${d.height}${d.is_primary ? ` (${t('displays.badge.primary')})` : ''}`;
|
||||
displaySelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
if (displaySelect.options.length === 0) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '0';
|
||||
opt.textContent = '0';
|
||||
displaySelect.appendChild(opt);
|
||||
}
|
||||
|
||||
// Capture templates
|
||||
const templateSelect = document.getElementById('stream-capture-template');
|
||||
templateSelect.innerHTML = '';
|
||||
if (captureTemplatesRes.ok) {
|
||||
const data = await captureTemplatesRes.json();
|
||||
(data.templates || []).forEach(tmpl => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = tmpl.id;
|
||||
opt.textContent = `${getEngineIcon(tmpl.engine_type)} ${tmpl.name}`;
|
||||
templateSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// Source streams (all existing streams)
|
||||
const sourceSelect = document.getElementById('stream-source');
|
||||
sourceSelect.innerHTML = '';
|
||||
if (streamsRes.ok) {
|
||||
const data = await streamsRes.json();
|
||||
const editingId = document.getElementById('stream-id').value;
|
||||
(data.streams || []).forEach(s => {
|
||||
// Don't show the current stream as a possible source
|
||||
if (s.id === editingId) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
const typeLabel = s.stream_type === 'raw' ? '📷' : '🎨';
|
||||
opt.textContent = `${typeLabel} ${s.name}`;
|
||||
sourceSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// PP templates
|
||||
const ppSelect = document.getElementById('stream-pp-template');
|
||||
ppSelect.innerHTML = '';
|
||||
if (ppTemplatesRes.ok) {
|
||||
const data = await ppTemplatesRes.json();
|
||||
(data.templates || []).forEach(tmpl => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = tmpl.id;
|
||||
opt.textContent = tmpl.name;
|
||||
ppSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStream() {
|
||||
const streamId = document.getElementById('stream-id').value;
|
||||
const name = document.getElementById('stream-name').value.trim();
|
||||
const streamType = document.getElementById('stream-type').value;
|
||||
const description = document.getElementById('stream-description').value.trim();
|
||||
const errorEl = document.getElementById('stream-error');
|
||||
|
||||
if (!name) {
|
||||
showToast(t('streams.error.required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { name, description: description || null };
|
||||
|
||||
if (!streamId) {
|
||||
// Creating - include stream_type
|
||||
payload.stream_type = streamType;
|
||||
}
|
||||
|
||||
if (streamType === 'raw') {
|
||||
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 {
|
||||
payload.source_stream_id = document.getElementById('stream-source').value;
|
||||
payload.postprocessing_template_id = document.getElementById('stream-pp-template').value;
|
||||
}
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (streamId) {
|
||||
response = await fetchWithAuth(`/picture-streams/${streamId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} else {
|
||||
response = await fetchWithAuth('/picture-streams', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to save stream');
|
||||
}
|
||||
|
||||
showToast(streamId ? t('streams.updated') : t('streams.created'), 'success');
|
||||
closeStreamModal();
|
||||
await loadPictureStreams();
|
||||
} catch (error) {
|
||||
console.error('Error saving stream:', error);
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStream(streamId) {
|
||||
const confirmed = await showConfirm(t('streams.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/picture-streams/${streamId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to delete stream');
|
||||
}
|
||||
|
||||
showToast(t('streams.deleted'), 'success');
|
||||
await loadPictureStreams();
|
||||
} catch (error) {
|
||||
console.error('Error deleting stream:', error);
|
||||
showToast(t('streams.error.delete') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closeStreamModal() {
|
||||
document.getElementById('stream-modal').style.display = 'none';
|
||||
document.getElementById('stream-type').disabled = false;
|
||||
unlockBody();
|
||||
}
|
||||
|
||||
// ===== Picture Stream Test =====
|
||||
|
||||
let _currentTestStreamId = null;
|
||||
|
||||
async function showTestStreamModal(streamId) {
|
||||
_currentTestStreamId = streamId;
|
||||
restoreStreamTestDuration();
|
||||
document.getElementById('test-stream-results').style.display = 'none';
|
||||
|
||||
const modal = document.getElementById('test-stream-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closeTestStreamModal(); };
|
||||
}
|
||||
|
||||
function closeTestStreamModal() {
|
||||
document.getElementById('test-stream-modal').style.display = 'none';
|
||||
unlockBody();
|
||||
_currentTestStreamId = null;
|
||||
}
|
||||
|
||||
function updateStreamTestDuration(value) {
|
||||
document.getElementById('test-stream-duration-value').textContent = value;
|
||||
localStorage.setItem('lastStreamTestDuration', value);
|
||||
}
|
||||
|
||||
function restoreStreamTestDuration() {
|
||||
const saved = localStorage.getItem('lastStreamTestDuration') || '5';
|
||||
document.getElementById('test-stream-duration').value = saved;
|
||||
document.getElementById('test-stream-duration-value').textContent = saved;
|
||||
}
|
||||
|
||||
async function runStreamTest() {
|
||||
if (!_currentTestStreamId) return;
|
||||
|
||||
const captureDuration = parseFloat(document.getElementById('test-stream-duration').value);
|
||||
|
||||
showOverlaySpinner(t('streams.test.running'), captureDuration);
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/picture-streams/${_currentTestStreamId}/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ capture_duration: captureDuration })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Test failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
displayStreamTestResults(result);
|
||||
} catch (error) {
|
||||
console.error('Error running stream test:', error);
|
||||
hideOverlaySpinner();
|
||||
showToast(t('streams.test.error.failed') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function displayStreamTestResults(result) {
|
||||
hideOverlaySpinner();
|
||||
|
||||
const previewImg = document.getElementById('test-stream-preview-image');
|
||||
previewImg.innerHTML = `<img src="${result.full_capture.image}" alt="Stream preview" style="max-width: 100%; border-radius: 4px;">`;
|
||||
|
||||
document.getElementById('test-stream-actual-duration').textContent = `${result.performance.capture_duration_s.toFixed(2)}s`;
|
||||
document.getElementById('test-stream-frame-count').textContent = result.performance.frame_count;
|
||||
document.getElementById('test-stream-actual-fps').textContent = `${result.performance.actual_fps.toFixed(1)} FPS`;
|
||||
document.getElementById('test-stream-avg-capture-time').textContent = `${result.performance.avg_capture_time_ms.toFixed(1)}ms`;
|
||||
|
||||
document.getElementById('test-stream-results').style.display = 'block';
|
||||
}
|
||||
|
||||
// ===== Processing Templates =====
|
||||
|
||||
async function loadPPTemplates() {
|
||||
try {
|
||||
const response = await fetchWithAuth('/postprocessing-templates');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load templates: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
_cachedPPTemplates = data.templates || [];
|
||||
renderPPTemplatesList(_cachedPPTemplates);
|
||||
} catch (error) {
|
||||
console.error('Error loading PP templates:', error);
|
||||
document.getElementById('pp-templates-list').innerHTML = `
|
||||
<div class="error-message">${t('postprocessing.error.load')}: ${error.message}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPPTemplatesList(templates) {
|
||||
const container = document.getElementById('pp-templates-list');
|
||||
|
||||
if (templates.length === 0) {
|
||||
container.innerHTML = `<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('postprocessing.add')}</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const renderCard = (tmpl) => {
|
||||
const configEntries = {
|
||||
[t('postprocessing.gamma')]: tmpl.gamma,
|
||||
[t('postprocessing.saturation')]: tmpl.saturation,
|
||||
[t('postprocessing.brightness')]: tmpl.brightness,
|
||||
[t('postprocessing.smoothing')]: tmpl.smoothing,
|
||||
};
|
||||
|
||||
return `
|
||||
<div class="template-card" data-pp-template-id="${tmpl.id}">
|
||||
<button class="card-remove-btn" onclick="deletePPTemplate('${tmpl.id}')" title="${t('common.delete')}">✕</button>
|
||||
<div class="template-card-header">
|
||||
<div class="template-name">
|
||||
🎨 ${escapeHtml(tmpl.name)}
|
||||
</div>
|
||||
</div>
|
||||
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
|
||||
<details class="template-config-details">
|
||||
<summary>${t('postprocessing.config.show')}</summary>
|
||||
<table class="config-table">
|
||||
${Object.entries(configEntries).map(([key, val]) => `
|
||||
<tr>
|
||||
<td class="config-key">${escapeHtml(key)}</td>
|
||||
<td class="config-value">${escapeHtml(String(val))}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
</details>
|
||||
<div class="template-card-actions">
|
||||
<button class="btn btn-icon btn-secondary" onclick="editPPTemplate('${tmpl.id}')" title="${t('common.edit')}">
|
||||
✏️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
let html = templates.map(renderCard).join('');
|
||||
html += `<div class="template-card add-template-card" onclick="showAddPPTemplateModal()">
|
||||
<div class="add-template-icon">+</div>
|
||||
<div class="add-template-label">${t('postprocessing.add')}</div>
|
||||
</div>`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function showAddPPTemplateModal() {
|
||||
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.add');
|
||||
document.getElementById('pp-template-form').reset();
|
||||
document.getElementById('pp-template-id').value = '';
|
||||
document.getElementById('pp-template-error').style.display = 'none';
|
||||
|
||||
// Reset slider displays to defaults
|
||||
document.getElementById('pp-template-gamma').value = '2.2';
|
||||
document.getElementById('pp-template-gamma-value').textContent = '2.2';
|
||||
document.getElementById('pp-template-saturation').value = '1.0';
|
||||
document.getElementById('pp-template-saturation-value').textContent = '1.0';
|
||||
document.getElementById('pp-template-brightness').value = '1.0';
|
||||
document.getElementById('pp-template-brightness-value').textContent = '1.0';
|
||||
document.getElementById('pp-template-smoothing').value = '0.3';
|
||||
document.getElementById('pp-template-smoothing-value').textContent = '0.3';
|
||||
|
||||
const modal = document.getElementById('pp-template-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); };
|
||||
}
|
||||
|
||||
async function editPPTemplate(templateId) {
|
||||
try {
|
||||
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
|
||||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||||
const tmpl = await response.json();
|
||||
|
||||
document.getElementById('pp-template-modal-title').textContent = t('postprocessing.edit');
|
||||
document.getElementById('pp-template-id').value = templateId;
|
||||
document.getElementById('pp-template-name').value = tmpl.name;
|
||||
document.getElementById('pp-template-description').value = tmpl.description || '';
|
||||
document.getElementById('pp-template-error').style.display = 'none';
|
||||
|
||||
// Set sliders
|
||||
document.getElementById('pp-template-gamma').value = tmpl.gamma;
|
||||
document.getElementById('pp-template-gamma-value').textContent = tmpl.gamma;
|
||||
document.getElementById('pp-template-saturation').value = tmpl.saturation;
|
||||
document.getElementById('pp-template-saturation-value').textContent = tmpl.saturation;
|
||||
document.getElementById('pp-template-brightness').value = tmpl.brightness;
|
||||
document.getElementById('pp-template-brightness-value').textContent = tmpl.brightness;
|
||||
document.getElementById('pp-template-smoothing').value = tmpl.smoothing;
|
||||
document.getElementById('pp-template-smoothing-value').textContent = tmpl.smoothing;
|
||||
|
||||
const modal = document.getElementById('pp-template-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closePPTemplateModal(); };
|
||||
} catch (error) {
|
||||
console.error('Error loading PP template:', error);
|
||||
showToast(t('postprocessing.error.load') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function savePPTemplate() {
|
||||
const templateId = document.getElementById('pp-template-id').value;
|
||||
const name = document.getElementById('pp-template-name').value.trim();
|
||||
const description = document.getElementById('pp-template-description').value.trim();
|
||||
const errorEl = document.getElementById('pp-template-error');
|
||||
|
||||
if (!name) {
|
||||
showToast(t('postprocessing.error.required'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
gamma: parseFloat(document.getElementById('pp-template-gamma').value),
|
||||
saturation: parseFloat(document.getElementById('pp-template-saturation').value),
|
||||
brightness: parseFloat(document.getElementById('pp-template-brightness').value),
|
||||
smoothing: parseFloat(document.getElementById('pp-template-smoothing').value),
|
||||
description: description || null,
|
||||
};
|
||||
|
||||
try {
|
||||
let response;
|
||||
if (templateId) {
|
||||
response = await fetchWithAuth(`/postprocessing-templates/${templateId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} else {
|
||||
response = await fetchWithAuth('/postprocessing-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('postprocessing.updated') : t('postprocessing.created'), 'success');
|
||||
closePPTemplateModal();
|
||||
await loadPPTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error saving PP template:', error);
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePPTemplate(templateId) {
|
||||
const confirmed = await showConfirm(t('postprocessing.delete.confirm'));
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/postprocessing-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('postprocessing.deleted'), 'success');
|
||||
await loadPPTemplates();
|
||||
} catch (error) {
|
||||
console.error('Error deleting PP template:', error);
|
||||
showToast(t('postprocessing.error.delete') + ': ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function closePPTemplateModal() {
|
||||
document.getElementById('pp-template-modal').style.display = 'none';
|
||||
unlockBody();
|
||||
}
|
||||
|
||||
// ===== Device Stream Selector =====
|
||||
|
||||
let streamSelectorInitialValues = {};
|
||||
|
||||
async function showStreamSelector(deviceId) {
|
||||
try {
|
||||
const [deviceResponse, streamsResponse, settingsResponse] = await Promise.all([
|
||||
fetch(`${API_BASE}/devices/${deviceId}`, { headers: getHeaders() }),
|
||||
fetchWithAuth('/picture-streams'),
|
||||
fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() }),
|
||||
]);
|
||||
|
||||
if (deviceResponse.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!deviceResponse.ok) {
|
||||
showToast('Failed to load device', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const device = await deviceResponse.json();
|
||||
const settings = settingsResponse.ok ? await settingsResponse.json() : {};
|
||||
|
||||
// Populate stream select
|
||||
const streamSelect = document.getElementById('stream-selector-stream');
|
||||
streamSelect.innerHTML = '';
|
||||
|
||||
if (streamsResponse.ok) {
|
||||
const data = await streamsResponse.json();
|
||||
(data.streams || []).forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
const typeIcon = s.stream_type === 'raw' ? '📷' : '🎨';
|
||||
opt.textContent = `${typeIcon} ${s.name}`;
|
||||
streamSelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
const currentStreamId = device.picture_stream_id || '';
|
||||
streamSelect.value = currentStreamId;
|
||||
|
||||
// Populate LED projection fields
|
||||
const borderWidth = settings.border_width ?? device.settings?.border_width ?? 10;
|
||||
document.getElementById('stream-selector-border-width').value = borderWidth;
|
||||
document.getElementById('stream-selector-interpolation').value = device.settings?.interpolation_mode || 'average';
|
||||
|
||||
streamSelectorInitialValues = {
|
||||
stream: currentStreamId,
|
||||
border_width: String(borderWidth),
|
||||
interpolation: device.settings?.interpolation_mode || 'average',
|
||||
};
|
||||
|
||||
document.getElementById('stream-selector-device-id').value = deviceId;
|
||||
document.getElementById('stream-selector-error').style.display = 'none';
|
||||
|
||||
// Show info about selected stream
|
||||
updateStreamSelectorInfo(streamSelect.value);
|
||||
streamSelect.onchange = () => updateStreamSelectorInfo(streamSelect.value);
|
||||
|
||||
const modal = document.getElementById('stream-selector-modal');
|
||||
modal.style.display = 'flex';
|
||||
lockBody();
|
||||
modal.onclick = (e) => { if (e.target === modal) closeStreamSelectorModal(); };
|
||||
} catch (error) {
|
||||
console.error('Failed to load stream settings:', error);
|
||||
showToast('Failed to load stream settings', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStreamSelectorInfo(streamId) {
|
||||
const infoPanel = document.getElementById('stream-selector-info');
|
||||
if (!streamId) {
|
||||
infoPanel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`/picture-streams/${streamId}`);
|
||||
if (!response.ok) {
|
||||
infoPanel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
const stream = await response.json();
|
||||
|
||||
let infoHtml = `<div class="stream-info-type"><strong>${t('streams.type')}</strong> ${stream.stream_type === 'raw' ? t('streams.type.raw') : t('streams.type.processed')}</div>`;
|
||||
|
||||
if (stream.stream_type === 'raw') {
|
||||
infoHtml += `<div><strong>${t('streams.display')}</strong> ${stream.display_index ?? 0}</div>`;
|
||||
infoHtml += `<div><strong>${t('streams.target_fps')}</strong> ${stream.target_fps ?? 30}</div>`;
|
||||
} else {
|
||||
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
||||
infoHtml += `<div><strong>${t('streams.source')}</strong> ${sourceStream ? escapeHtml(sourceStream.name) : stream.source_stream_id}</div>`;
|
||||
}
|
||||
|
||||
infoPanel.innerHTML = infoHtml;
|
||||
infoPanel.style.display = '';
|
||||
} catch {
|
||||
infoPanel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStreamSelector() {
|
||||
const deviceId = document.getElementById('stream-selector-device-id').value;
|
||||
const pictureStreamId = document.getElementById('stream-selector-stream').value;
|
||||
const borderWidth = parseInt(document.getElementById('stream-selector-border-width').value) || 10;
|
||||
const interpolation = document.getElementById('stream-selector-interpolation').value;
|
||||
const errorEl = document.getElementById('stream-selector-error');
|
||||
|
||||
try {
|
||||
// Save picture stream assignment
|
||||
const response = await fetch(`${API_BASE}/devices/${deviceId}`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ picture_stream_id: pictureStreamId })
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
handle401Error();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to save');
|
||||
}
|
||||
|
||||
// Save LED projection settings — merge with existing to avoid overwriting other fields
|
||||
const currentSettingsRes = await fetch(`${API_BASE}/devices/${deviceId}/settings`, { headers: getHeaders() });
|
||||
const currentSettings = currentSettingsRes.ok ? await currentSettingsRes.json() : {};
|
||||
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ ...currentSettings, border_width: borderWidth, interpolation_mode: interpolation })
|
||||
});
|
||||
|
||||
if (!settingsResponse.ok) {
|
||||
const error = await settingsResponse.json();
|
||||
throw new Error(error.detail || error.message || 'Failed to save settings');
|
||||
}
|
||||
|
||||
showToast(t('device.stream_selector.saved'), 'success');
|
||||
forceCloseStreamSelectorModal();
|
||||
await loadDevices();
|
||||
} catch (error) {
|
||||
console.error('Error saving stream settings:', error);
|
||||
errorEl.textContent = error.message;
|
||||
errorEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function isStreamSettingsDirty() {
|
||||
return (
|
||||
document.getElementById('stream-selector-stream').value !== streamSelectorInitialValues.stream ||
|
||||
document.getElementById('stream-selector-border-width').value !== streamSelectorInitialValues.border_width ||
|
||||
document.getElementById('stream-selector-interpolation').value !== streamSelectorInitialValues.interpolation
|
||||
);
|
||||
}
|
||||
|
||||
async function closeStreamSelectorModal() {
|
||||
if (isStreamSettingsDirty()) {
|
||||
const confirmed = await showConfirm(t('modal.discard_changes'));
|
||||
if (!confirmed) return;
|
||||
}
|
||||
forceCloseStreamSelectorModal();
|
||||
}
|
||||
|
||||
function forceCloseStreamSelectorModal() {
|
||||
document.getElementById('stream-selector-modal').style.display = 'none';
|
||||
document.getElementById('stream-selector-error').style.display = 'none';
|
||||
unlockBody();
|
||||
streamSelectorInitialValues = {};
|
||||
}
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
<div class="tab-bar">
|
||||
<button class="tab-btn active" data-tab="devices" onclick="switchTab('devices')"><span data-i18n="devices.title">💡 Devices</span></button>
|
||||
<button class="tab-btn" data-tab="displays" onclick="switchTab('displays')"><span data-i18n="displays.layout">🖥️ Displays</span></button>
|
||||
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Picture Streams</span></button>
|
||||
<button class="tab-btn" data-tab="templates" onclick="switchTab('templates')"><span data-i18n="templates.title">🎯 Capture Templates</span></button>
|
||||
<button class="tab-btn" data-tab="pp-templates" onclick="switchTab('pp-templates')"><span data-i18n="postprocessing.title">🎨 Processing Templates</span></button>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel active" id="tab-devices">
|
||||
@@ -62,6 +64,17 @@
|
||||
<div id="displays-list" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-streams">
|
||||
<p class="section-tip">
|
||||
<span data-i18n="streams.description">
|
||||
Picture streams define the capture pipeline. A raw stream captures from a display using a capture template. A processed stream applies postprocessing to another stream. Assign streams to devices.
|
||||
</span>
|
||||
</p>
|
||||
<div id="streams-list" class="templates-grid">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-templates">
|
||||
<p class="section-tip">
|
||||
<span data-i18n="templates.description">
|
||||
@@ -72,6 +85,17 @@
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-panel" id="tab-pp-templates">
|
||||
<p class="section-tip">
|
||||
<span data-i18n="postprocessing.description">
|
||||
Processing templates define color correction and smoothing settings. Assign them to processed picture streams for consistent postprocessing across devices.
|
||||
</span>
|
||||
</p>
|
||||
<div id="pp-templates-list" class="templates-grid">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="app-footer">
|
||||
@@ -244,44 +268,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Capture Settings Modal -->
|
||||
<div id="capture-settings-modal" class="modal">
|
||||
<!-- Stream Settings Modal (picture stream + LED projection settings) -->
|
||||
<div id="stream-selector-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 data-i18n="settings.capture.title">🎬 Capture Settings</h2>
|
||||
<button class="modal-close-btn" onclick="closeCaptureSettingsModal()" title="Close">✕</button>
|
||||
<h2 data-i18n="device.stream_settings.title">📺 Stream Settings</h2>
|
||||
<button class="modal-close-btn" onclick="closeStreamSelectorModal()" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="capture-settings-form">
|
||||
<input type="hidden" id="capture-settings-device-id">
|
||||
<form id="stream-selector-form">
|
||||
<input type="hidden" id="stream-selector-device-id">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="capture-settings-display-index" data-i18n="settings.display_index">Display:</label>
|
||||
<select id="capture-settings-display-index"></select>
|
||||
<small class="input-hint" data-i18n="settings.display_index.hint">Which screen to capture for this device</small>
|
||||
<label for="stream-selector-stream" data-i18n="device.stream_selector.label">Assigned Picture Stream:</label>
|
||||
<select id="stream-selector-stream"></select>
|
||||
<small class="input-hint" data-i18n="device.stream_selector.hint">Select a picture stream that defines what this device captures and processes</small>
|
||||
</div>
|
||||
|
||||
<div id="stream-selector-info" class="stream-info-panel" style="display: none;"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="stream-selector-border-width" data-i18n="device.stream_settings.border_width">Border Width (px):</label>
|
||||
<input type="number" id="stream-selector-border-width" min="1" max="100" value="10">
|
||||
<small class="input-hint" data-i18n="device.stream_settings.border_width_hint">How many pixels from the screen edge to sample for LED colors (1-100)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="capture-settings-fps" data-i18n="settings.fps">Target FPS:</label>
|
||||
<div class="slider-row">
|
||||
<input type="range" id="capture-settings-fps" min="10" max="90" value="30" oninput="document.getElementById('capture-settings-fps-value').textContent = this.value">
|
||||
<span id="capture-settings-fps-value" class="slider-value">30</span>
|
||||
</div>
|
||||
<small class="input-hint" data-i18n="settings.fps.hint">Target frames per second (10-90)</small>
|
||||
<label for="stream-selector-interpolation" data-i18n="device.stream_settings.interpolation">Interpolation Mode:</label>
|
||||
<select id="stream-selector-interpolation">
|
||||
<option value="average" data-i18n="device.stream_settings.interpolation.average">Average</option>
|
||||
<option value="median" data-i18n="device.stream_settings.interpolation.median">Median</option>
|
||||
<option value="dominant" data-i18n="device.stream_settings.interpolation.dominant">Dominant</option>
|
||||
</select>
|
||||
<small class="input-hint" data-i18n="device.stream_settings.interpolation_hint">How to calculate LED color from sampled pixels</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="capture-settings-template" data-i18n="settings.capture_template">Capture Template:</label>
|
||||
<select id="capture-settings-template"></select>
|
||||
<small class="input-hint" data-i18n="settings.capture_template.hint">Screen capture engine and configuration for this device</small>
|
||||
</div>
|
||||
|
||||
<div id="capture-settings-error" class="error-message" style="display: none;"></div>
|
||||
<div id="stream-selector-error" class="error-message" style="display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeCaptureSettingsModal()" title="Cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveCaptureSettings()" title="Save">✓</button>
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeStreamSelectorModal()" title="Cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveStreamSelector()" title="Save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -465,6 +492,189 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Stream Modal -->
|
||||
<div id="test-stream-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 data-i18n="streams.test.title">Test Picture Stream</h2>
|
||||
<button class="modal-close-btn" onclick="closeTestStreamModal()" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label for="test-stream-duration">
|
||||
<span data-i18n="streams.test.duration">Capture Duration (s):</span>
|
||||
<span id="test-stream-duration-value">5</span>
|
||||
</label>
|
||||
<input type="range" id="test-stream-duration" min="1" max="10" step="1" value="5" oninput="updateStreamTestDuration(this.value)" />
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-primary" onclick="runStreamTest()" style="margin-top: 16px;">
|
||||
<span data-i18n="streams.test.run">🧪 Run Test</span>
|
||||
</button>
|
||||
|
||||
<div id="test-stream-results" style="display: none; margin-top: 16px;">
|
||||
<div class="test-results-container">
|
||||
<div class="test-preview-section">
|
||||
<div id="test-stream-preview-image" class="test-preview-image"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-performance-section">
|
||||
<div class="test-performance-stats">
|
||||
<div class="stat-item">
|
||||
<span data-i18n="templates.test.results.duration">Duration:</span>
|
||||
<strong id="test-stream-actual-duration">-</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span data-i18n="templates.test.results.frame_count">Frames:</span>
|
||||
<strong id="test-stream-frame-count">-</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span data-i18n="templates.test.results.actual_fps">Actual FPS:</span>
|
||||
<strong id="test-stream-actual-fps">-</strong>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span data-i18n="templates.test.results.avg_capture_time">Avg Capture:</span>
|
||||
<strong id="test-stream-avg-capture-time">-</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Picture Stream Modal -->
|
||||
<div id="stream-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="stream-modal-title" data-i18n="streams.add">Add Picture Stream</h2>
|
||||
<button class="modal-close-btn" onclick="closeStreamModal()" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="stream-id">
|
||||
<form id="stream-form">
|
||||
<div class="form-group">
|
||||
<label for="stream-name" data-i18n="streams.name">Stream Name:</label>
|
||||
<input type="text" id="stream-name" data-i18n-placeholder="streams.name.placeholder" placeholder="My Stream" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="stream-type" data-i18n="streams.type">Stream Type:</label>
|
||||
<select id="stream-type" onchange="onStreamTypeChange()">
|
||||
<option value="raw" data-i18n="streams.type.raw">Screen Capture</option>
|
||||
<option value="processed" data-i18n="streams.type.processed">Processed</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Raw stream fields -->
|
||||
<div id="stream-raw-fields">
|
||||
<div class="form-group">
|
||||
<label for="stream-display-index" data-i18n="streams.display">Display:</label>
|
||||
<select id="stream-display-index"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stream-capture-template" data-i18n="streams.capture_template">Capture Template:</label>
|
||||
<select id="stream-capture-template"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stream-target-fps" data-i18n="streams.target_fps">Target FPS:</label>
|
||||
<div class="slider-row">
|
||||
<input type="range" id="stream-target-fps" min="10" max="90" value="30" oninput="document.getElementById('stream-target-fps-value').textContent = this.value">
|
||||
<span id="stream-target-fps-value" class="slider-value">30</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processed stream fields -->
|
||||
<div id="stream-processed-fields" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label for="stream-source" data-i18n="streams.source">Source Stream:</label>
|
||||
<select id="stream-source"></select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="stream-pp-template" data-i18n="streams.pp_template">Processing Template:</label>
|
||||
<select id="stream-pp-template"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="stream-description" data-i18n="streams.description_label">Description (optional):</label>
|
||||
<input type="text" id="stream-description" data-i18n-placeholder="streams.description_placeholder" placeholder="Describe this stream...">
|
||||
</div>
|
||||
|
||||
<div id="stream-error" class="error-message" style="display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closeStreamModal()" title="Cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveStream()" title="Save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Template Modal -->
|
||||
<div id="pp-template-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2 id="pp-template-modal-title" data-i18n="postprocessing.add">Add Processing Template</h2>
|
||||
<button class="modal-close-btn" onclick="closePPTemplateModal()" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="pp-template-id">
|
||||
<form id="pp-template-form">
|
||||
<div class="form-group">
|
||||
<label for="pp-template-name" data-i18n="postprocessing.name">Template Name:</label>
|
||||
<input type="text" id="pp-template-name" data-i18n-placeholder="postprocessing.name.placeholder" placeholder="My Processing Template" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="pp-template-gamma">
|
||||
<span data-i18n="postprocessing.gamma">Gamma:</span>
|
||||
<span id="pp-template-gamma-value">2.2</span>
|
||||
</label>
|
||||
<input type="range" id="pp-template-gamma" min="0.1" max="5.0" step="0.1" value="2.2" oninput="document.getElementById('pp-template-gamma-value').textContent = this.value">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="pp-template-saturation">
|
||||
<span data-i18n="postprocessing.saturation">Saturation:</span>
|
||||
<span id="pp-template-saturation-value">1.0</span>
|
||||
</label>
|
||||
<input type="range" id="pp-template-saturation" min="0.0" max="2.0" step="0.1" value="1.0" oninput="document.getElementById('pp-template-saturation-value').textContent = this.value">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="pp-template-brightness">
|
||||
<span data-i18n="postprocessing.brightness">Brightness:</span>
|
||||
<span id="pp-template-brightness-value">1.0</span>
|
||||
</label>
|
||||
<input type="range" id="pp-template-brightness" min="0.0" max="1.0" step="0.05" value="1.0" oninput="document.getElementById('pp-template-brightness-value').textContent = this.value">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="pp-template-smoothing">
|
||||
<span data-i18n="postprocessing.smoothing">Smoothing:</span>
|
||||
<span id="pp-template-smoothing-value">0.3</span>
|
||||
</label>
|
||||
<input type="range" id="pp-template-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('pp-template-smoothing-value').textContent = this.value">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="pp-template-description" data-i18n="postprocessing.description_label">Description (optional):</label>
|
||||
<input type="text" id="pp-template-description" data-i18n-placeholder="postprocessing.description_placeholder" placeholder="Describe this template...">
|
||||
</div>
|
||||
|
||||
<div id="pp-template-error" class="error-message" style="display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-icon btn-secondary" onclick="closePPTemplateModal()" title="Cancel">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="savePPTemplate()" title="Save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device Tutorial Overlay (viewport-level) -->
|
||||
<div id="device-tutorial-overlay" class="tutorial-overlay tutorial-overlay-fixed">
|
||||
<div class="tutorial-backdrop"></div>
|
||||
|
||||
@@ -50,9 +50,6 @@
|
||||
"templates.config.show": "Show configuration",
|
||||
"templates.config.none": "No additional configuration",
|
||||
"templates.config.default": "Default",
|
||||
"templates.default": "Default",
|
||||
"templates.custom": "Custom Templates",
|
||||
"templates.default.locked": "Default template (cannot edit/delete)",
|
||||
"templates.created": "Template created successfully",
|
||||
"templates.updated": "Template updated successfully",
|
||||
"templates.deleted": "Template deleted successfully",
|
||||
@@ -195,5 +192,69 @@
|
||||
"modal.discard_changes": "You have unsaved changes. Discard them?",
|
||||
"confirm.title": "Confirm Action",
|
||||
"confirm.yes": "Yes",
|
||||
"confirm.no": "No"
|
||||
"confirm.no": "No",
|
||||
"common.delete": "Delete",
|
||||
"common.edit": "Edit",
|
||||
"streams.title": "\uD83D\uDCFA Picture Streams",
|
||||
"streams.description": "Picture streams define the capture pipeline. A raw stream captures from a display using a capture template. A processed stream applies postprocessing to another stream. Assign streams to devices.",
|
||||
"streams.add": "Add Picture Stream",
|
||||
"streams.edit": "Edit Picture Stream",
|
||||
"streams.name": "Stream Name:",
|
||||
"streams.name.placeholder": "My Stream",
|
||||
"streams.type": "Type:",
|
||||
"streams.type.raw": "Screen Capture",
|
||||
"streams.type.processed": "Processed",
|
||||
"streams.display": "Display:",
|
||||
"streams.capture_template": "Capture Template:",
|
||||
"streams.target_fps": "Target FPS:",
|
||||
"streams.source": "Source Stream:",
|
||||
"streams.pp_template": "Processing Template:",
|
||||
"streams.description_label": "Description (optional):",
|
||||
"streams.description_placeholder": "Describe this stream...",
|
||||
"streams.created": "Stream created successfully",
|
||||
"streams.updated": "Stream updated successfully",
|
||||
"streams.deleted": "Stream deleted successfully",
|
||||
"streams.delete.confirm": "Are you sure you want to delete this stream?",
|
||||
"streams.error.load": "Failed to load streams",
|
||||
"streams.error.required": "Please fill in all required fields",
|
||||
"streams.error.delete": "Failed to delete stream",
|
||||
"streams.test.title": "Test Picture Stream",
|
||||
"streams.test.run": "🧪 Run Test",
|
||||
"streams.test.running": "Testing stream...",
|
||||
"streams.test.duration": "Capture Duration (s):",
|
||||
"streams.test.error.failed": "Stream test failed",
|
||||
"postprocessing.title": "\uD83C\uDFA8 Processing Templates",
|
||||
"postprocessing.description": "Processing templates define color correction and smoothing settings. Assign them to processed picture streams for consistent postprocessing across devices.",
|
||||
"postprocessing.add": "Add Processing Template",
|
||||
"postprocessing.edit": "Edit Processing Template",
|
||||
"postprocessing.name": "Template Name:",
|
||||
"postprocessing.name.placeholder": "My Processing Template",
|
||||
"postprocessing.gamma": "Gamma:",
|
||||
"postprocessing.saturation": "Saturation:",
|
||||
"postprocessing.brightness": "Brightness:",
|
||||
"postprocessing.smoothing": "Smoothing:",
|
||||
"postprocessing.description_label": "Description (optional):",
|
||||
"postprocessing.description_placeholder": "Describe this template...",
|
||||
"postprocessing.created": "Template created successfully",
|
||||
"postprocessing.updated": "Template updated successfully",
|
||||
"postprocessing.deleted": "Template deleted successfully",
|
||||
"postprocessing.delete.confirm": "Are you sure you want to delete this processing template?",
|
||||
"postprocessing.error.load": "Failed to load processing templates",
|
||||
"postprocessing.error.required": "Please fill in all required fields",
|
||||
"postprocessing.error.delete": "Failed to delete processing template",
|
||||
"postprocessing.config.show": "Show settings",
|
||||
"device.button.stream_selector": "Stream Settings",
|
||||
"device.stream_settings.title": "📺 Stream Settings",
|
||||
"device.stream_selector.label": "Assigned Picture Stream:",
|
||||
"device.stream_selector.hint": "Select a picture stream that defines what this device captures and processes",
|
||||
"device.stream_selector.none": "-- No stream assigned --",
|
||||
"device.stream_selector.saved": "Stream settings updated",
|
||||
"device.stream_settings.border_width": "Border Width (px):",
|
||||
"device.stream_settings.border_width_hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
|
||||
"device.stream_settings.interpolation": "Interpolation Mode:",
|
||||
"device.stream_settings.interpolation.average": "Average",
|
||||
"device.stream_settings.interpolation.median": "Median",
|
||||
"device.stream_settings.interpolation.dominant": "Dominant",
|
||||
"device.stream_settings.interpolation_hint": "How to calculate LED color from sampled pixels",
|
||||
"device.tip.stream_selector": "Configure picture stream and LED projection settings for this device"
|
||||
}
|
||||
|
||||
@@ -50,9 +50,6 @@
|
||||
"templates.config.show": "Показать конфигурацию",
|
||||
"templates.config.none": "Нет дополнительных настроек",
|
||||
"templates.config.default": "По умолчанию",
|
||||
"templates.default": "По умолчанию",
|
||||
"templates.custom": "Пользовательские шаблоны",
|
||||
"templates.default.locked": "Системный шаблон (нельзя редактировать/удалить)",
|
||||
"templates.created": "Шаблон успешно создан",
|
||||
"templates.updated": "Шаблон успешно обновлён",
|
||||
"templates.deleted": "Шаблон успешно удалён",
|
||||
@@ -195,5 +192,69 @@
|
||||
"modal.discard_changes": "У вас есть несохранённые изменения. Отменить их?",
|
||||
"confirm.title": "Подтверждение Действия",
|
||||
"confirm.yes": "Да",
|
||||
"confirm.no": "Нет"
|
||||
"confirm.no": "Нет",
|
||||
"common.delete": "Удалить",
|
||||
"common.edit": "Редактировать",
|
||||
"streams.title": "\uD83D\uDCFA Видеопотоки",
|
||||
"streams.description": "Видеопотоки определяют конвейер захвата. Сырой поток захватывает экран с помощью шаблона захвата. Обработанный поток применяет постобработку к другому потоку. Назначайте потоки устройствам.",
|
||||
"streams.add": "Добавить Видеопоток",
|
||||
"streams.edit": "Редактировать Видеопоток",
|
||||
"streams.name": "Имя Потока:",
|
||||
"streams.name.placeholder": "Мой Поток",
|
||||
"streams.type": "Тип:",
|
||||
"streams.type.raw": "Захват экрана",
|
||||
"streams.type.processed": "Обработанный",
|
||||
"streams.display": "Дисплей:",
|
||||
"streams.capture_template": "Шаблон Захвата:",
|
||||
"streams.target_fps": "Целевой FPS:",
|
||||
"streams.source": "Исходный Поток:",
|
||||
"streams.pp_template": "Шаблон Обработки:",
|
||||
"streams.description_label": "Описание (необязательно):",
|
||||
"streams.description_placeholder": "Опишите этот поток...",
|
||||
"streams.created": "Поток успешно создан",
|
||||
"streams.updated": "Поток успешно обновлён",
|
||||
"streams.deleted": "Поток успешно удалён",
|
||||
"streams.delete.confirm": "Вы уверены, что хотите удалить этот поток?",
|
||||
"streams.error.load": "Не удалось загрузить потоки",
|
||||
"streams.error.required": "Пожалуйста, заполните все обязательные поля",
|
||||
"streams.error.delete": "Не удалось удалить поток",
|
||||
"streams.test.title": "Тест Видеопотока",
|
||||
"streams.test.run": "🧪 Запустить Тест",
|
||||
"streams.test.running": "Тестирование потока...",
|
||||
"streams.test.duration": "Длительность Захвата (с):",
|
||||
"streams.test.error.failed": "Тест потока не удался",
|
||||
"postprocessing.title": "\uD83C\uDFA8 Шаблоны Обработки",
|
||||
"postprocessing.description": "Шаблоны обработки определяют настройки цветокоррекции и сглаживания. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.",
|
||||
"postprocessing.add": "Добавить Шаблон Обработки",
|
||||
"postprocessing.edit": "Редактировать Шаблон Обработки",
|
||||
"postprocessing.name": "Имя Шаблона:",
|
||||
"postprocessing.name.placeholder": "Мой Шаблон Обработки",
|
||||
"postprocessing.gamma": "Гамма:",
|
||||
"postprocessing.saturation": "Насыщенность:",
|
||||
"postprocessing.brightness": "Яркость:",
|
||||
"postprocessing.smoothing": "Сглаживание:",
|
||||
"postprocessing.description_label": "Описание (необязательно):",
|
||||
"postprocessing.description_placeholder": "Опишите этот шаблон...",
|
||||
"postprocessing.created": "Шаблон успешно создан",
|
||||
"postprocessing.updated": "Шаблон успешно обновлён",
|
||||
"postprocessing.deleted": "Шаблон успешно удалён",
|
||||
"postprocessing.delete.confirm": "Вы уверены, что хотите удалить этот шаблон обработки?",
|
||||
"postprocessing.error.load": "Не удалось загрузить шаблоны обработки",
|
||||
"postprocessing.error.required": "Пожалуйста, заполните все обязательные поля",
|
||||
"postprocessing.error.delete": "Не удалось удалить шаблон обработки",
|
||||
"postprocessing.config.show": "Показать настройки",
|
||||
"device.button.stream_selector": "Настройки потока",
|
||||
"device.stream_settings.title": "📺 Настройки потока",
|
||||
"device.stream_selector.label": "Назначенный Видеопоток:",
|
||||
"device.stream_selector.hint": "Выберите видеопоток, определяющий что это устройство захватывает и обрабатывает",
|
||||
"device.stream_selector.none": "-- Поток не назначен --",
|
||||
"device.stream_selector.saved": "Настройки потока обновлены",
|
||||
"device.stream_settings.border_width": "Ширина границы (px):",
|
||||
"device.stream_settings.border_width_hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
|
||||
"device.stream_settings.interpolation": "Режим интерполяции:",
|
||||
"device.stream_settings.interpolation.average": "Среднее",
|
||||
"device.stream_settings.interpolation.median": "Медиана",
|
||||
"device.stream_settings.interpolation.dominant": "Доминантный",
|
||||
"device.stream_settings.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей",
|
||||
"device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства"
|
||||
}
|
||||
|
||||
@@ -1762,24 +1762,6 @@ input:-webkit-autofill:focus {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.templates-separator {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.templates-separator::before,
|
||||
.templates-separator::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.template-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -1839,7 +1821,7 @@ input:-webkit-autofill:focus {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.template-card:has(.card-remove-btn) .template-card-header {
|
||||
.template-card .template-card-header {
|
||||
padding-right: 24px;
|
||||
}
|
||||
|
||||
@@ -1857,11 +1839,6 @@ input:-webkit-autofill:focus {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-default {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.template-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
@@ -2038,6 +2015,36 @@ input:-webkit-autofill:focus {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Stream type badges */
|
||||
.badge-raw {
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-processed {
|
||||
background: #7b1fa2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Stream info panel in stream selector modal */
|
||||
.stream-info-panel {
|
||||
background: var(--bg-secondary, #2a2a2a);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.stream-info-panel div {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stream-info-panel strong {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.templates-grid {
|
||||
|
||||
Reference in New Issue
Block a user