Add pluggable postprocessing filter system with collapsible UI
Replace hardcoded gamma/saturation/brightness fields with a flexible filter pipeline architecture. Templates now contain an ordered list of filter instances, each with its own options schema. Filters operate on full images before border extraction. - Add filter framework: base class, registry, image pool, filter instance - Implement 6 built-in filters: brightness, saturation, gamma, downscaler, pixelate, auto crop - Move smoothing from PP templates to device stream settings (temporal, not spatial) - Add GET /api/v1/filters endpoint for available filter types - Dynamic filter UI in template modal with add/remove/reorder/collapse - Replace camera icon with display icon for screen capture streams - Legacy migration: existing templates auto-convert flat fields to filter list Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2992,6 +2992,7 @@ async function deleteTemplate(templateId) {
|
||||
|
||||
let _cachedStreams = [];
|
||||
let _cachedPPTemplates = [];
|
||||
let _availableFilters = []; // Loaded from GET /filters
|
||||
|
||||
async function loadPictureStreams() {
|
||||
try {
|
||||
@@ -3017,7 +3018,7 @@ function renderPictureStreamsList(streams) {
|
||||
container.innerHTML = `
|
||||
<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">📷</span>
|
||||
<span class="stream-group-icon">🖥️</span>
|
||||
<span class="stream-group-title">${t('streams.group.raw')}</span>
|
||||
<span class="stream-group-count">0</span>
|
||||
</div>
|
||||
@@ -3045,7 +3046,7 @@ function renderPictureStreamsList(streams) {
|
||||
}
|
||||
|
||||
const renderCard = (stream) => {
|
||||
const typeIcon = stream.stream_type === 'raw' ? '📷' : '🎨';
|
||||
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>`;
|
||||
@@ -3102,7 +3103,7 @@ function renderPictureStreamsList(streams) {
|
||||
// Screen Capture streams section
|
||||
html += `<div class="stream-group">
|
||||
<div class="stream-group-header">
|
||||
<span class="stream-group-icon">📷</span>
|
||||
<span class="stream-group-icon">🖥️</span>
|
||||
<span class="stream-group-title">${t('streams.group.raw')}</span>
|
||||
<span class="stream-group-count">${rawStreams.length}</span>
|
||||
</div>
|
||||
@@ -3252,7 +3253,7 @@ async function populateStreamModalDropdowns() {
|
||||
if (s.id === editingId) return;
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
const typeLabel = s.stream_type === 'raw' ? '📷' : '🎨';
|
||||
const typeLabel = s.stream_type === 'raw' ? '🖥️' : '🎨';
|
||||
opt.textContent = `${typeLabel} ${s.name}`;
|
||||
sourceSelect.appendChild(opt);
|
||||
});
|
||||
@@ -3423,8 +3424,24 @@ function displayStreamTestResults(result) {
|
||||
|
||||
// ===== Processing Templates =====
|
||||
|
||||
async function loadAvailableFilters() {
|
||||
try {
|
||||
const response = await fetchWithAuth('/filters');
|
||||
if (!response.ok) throw new Error(`Failed to load filters: ${response.status}`);
|
||||
const data = await response.json();
|
||||
_availableFilters = data.filters || [];
|
||||
} catch (error) {
|
||||
console.error('Error loading available filters:', error);
|
||||
_availableFilters = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPPTemplates() {
|
||||
try {
|
||||
// Ensure filters are loaded for rendering
|
||||
if (_availableFilters.length === 0) {
|
||||
await loadAvailableFilters();
|
||||
}
|
||||
const response = await fetchWithAuth('/postprocessing-templates');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load templates: ${response.status}`);
|
||||
@@ -3440,6 +3457,17 @@ async function loadPPTemplates() {
|
||||
}
|
||||
}
|
||||
|
||||
function _getFilterName(filterId) {
|
||||
const key = 'filters.' + filterId;
|
||||
const translated = t(key);
|
||||
// Fallback to filter_name from registry if no localization
|
||||
if (translated === key) {
|
||||
const def = _availableFilters.find(f => f.filter_id === filterId);
|
||||
return def ? def.filter_name : filterId;
|
||||
}
|
||||
return translated;
|
||||
}
|
||||
|
||||
function renderPPTemplatesList(templates) {
|
||||
const container = document.getElementById('pp-templates-list');
|
||||
|
||||
@@ -3452,12 +3480,12 @@ function renderPPTemplatesList(templates) {
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
// Build config entries from filter list
|
||||
const filterRows = (tmpl.filters || []).map(fi => {
|
||||
const filterName = _getFilterName(fi.filter_id);
|
||||
const optStr = Object.entries(fi.options || {}).map(([k, v]) => `${v}`).join(', ');
|
||||
return `<tr><td class="config-key">${escapeHtml(filterName)}</td><td class="config-value">${escapeHtml(optStr)}</td></tr>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div class="template-card" data-pp-template-id="${tmpl.id}">
|
||||
@@ -3471,12 +3499,7 @@ function renderPPTemplatesList(templates) {
|
||||
<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('')}
|
||||
${filterRows}
|
||||
</table>
|
||||
</details>
|
||||
<div class="template-card-actions">
|
||||
@@ -3497,21 +3520,154 @@ function renderPPTemplatesList(templates) {
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
// --- Filter list management in PP template modal ---
|
||||
|
||||
let _modalFilters = []; // Current filter list being edited in modal
|
||||
|
||||
function _populateFilterSelect() {
|
||||
const select = document.getElementById('pp-add-filter-select');
|
||||
// Keep first option (placeholder)
|
||||
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
|
||||
for (const f of _availableFilters) {
|
||||
const name = _getFilterName(f.filter_id);
|
||||
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderModalFilterList() {
|
||||
const container = document.getElementById('pp-filter-list');
|
||||
if (_modalFilters.length === 0) {
|
||||
container.innerHTML = `<div class="pp-filter-empty">${t('filters.empty')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
_modalFilters.forEach((fi, index) => {
|
||||
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
|
||||
const filterName = _getFilterName(fi.filter_id);
|
||||
const isExpanded = fi._expanded === true;
|
||||
|
||||
// Build compact summary of current option values for collapsed state
|
||||
let summary = '';
|
||||
if (filterDef && !isExpanded) {
|
||||
summary = filterDef.options_schema.map(opt => {
|
||||
const val = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
|
||||
return val;
|
||||
}).join(', ');
|
||||
}
|
||||
|
||||
html += `<div class="pp-filter-card${isExpanded ? ' expanded' : ''}" data-filter-index="${index}">
|
||||
<div class="pp-filter-card-header" onclick="toggleFilterExpand(${index})">
|
||||
<span class="pp-filter-card-chevron">${isExpanded ? '▼' : '▶'}</span>
|
||||
<span class="pp-filter-card-name">${escapeHtml(filterName)}</span>
|
||||
${summary ? `<span class="pp-filter-card-summary">${escapeHtml(summary)}</span>` : ''}
|
||||
<div class="pp-filter-card-actions" onclick="event.stopPropagation()">
|
||||
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, -1)" title="${t('filters.move_up')}" ${index === 0 ? 'disabled' : ''}>▲</button>
|
||||
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, 1)" title="${t('filters.move_down')}" ${index === _modalFilters.length - 1 ? 'disabled' : ''}>▼</button>
|
||||
<button type="button" class="btn-filter-action btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-filter-card-options"${isExpanded ? '' : ' style="display:none"'}>`;
|
||||
|
||||
if (filterDef) {
|
||||
for (const opt of filterDef.options_schema) {
|
||||
const currentVal = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default;
|
||||
const inputId = `filter-${index}-${opt.key}`;
|
||||
html += `<div class="pp-filter-option">
|
||||
<label for="${inputId}">
|
||||
<span>${escapeHtml(opt.label)}:</span>
|
||||
<span id="${inputId}-display">${currentVal}</span>
|
||||
</label>
|
||||
<input type="range" id="${inputId}"
|
||||
min="${opt.min_value}" max="${opt.max_value}" step="${opt.step}" value="${currentVal}"
|
||||
oninput="updateFilterOption(${index}, '${opt.key}', this.value); document.getElementById('${inputId}-display').textContent = this.value;">
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += `</div></div>`;
|
||||
});
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function addFilterFromSelect() {
|
||||
const select = document.getElementById('pp-add-filter-select');
|
||||
const filterId = select.value;
|
||||
if (!filterId) return;
|
||||
|
||||
const filterDef = _availableFilters.find(f => f.filter_id === filterId);
|
||||
if (!filterDef) return;
|
||||
|
||||
// Build default options
|
||||
const options = {};
|
||||
for (const opt of filterDef.options_schema) {
|
||||
options[opt.key] = opt.default;
|
||||
}
|
||||
|
||||
_modalFilters.push({ filter_id: filterId, options, _expanded: true });
|
||||
select.value = '';
|
||||
renderModalFilterList();
|
||||
}
|
||||
|
||||
function toggleFilterExpand(index) {
|
||||
if (_modalFilters[index]) {
|
||||
_modalFilters[index]._expanded = !_modalFilters[index]._expanded;
|
||||
renderModalFilterList();
|
||||
}
|
||||
}
|
||||
|
||||
function removeFilter(index) {
|
||||
_modalFilters.splice(index, 1);
|
||||
renderModalFilterList();
|
||||
}
|
||||
|
||||
function moveFilter(index, direction) {
|
||||
const newIndex = index + direction;
|
||||
if (newIndex < 0 || newIndex >= _modalFilters.length) return;
|
||||
const tmp = _modalFilters[index];
|
||||
_modalFilters[index] = _modalFilters[newIndex];
|
||||
_modalFilters[newIndex] = tmp;
|
||||
renderModalFilterList();
|
||||
}
|
||||
|
||||
function updateFilterOption(filterIndex, optionKey, value) {
|
||||
if (_modalFilters[filterIndex]) {
|
||||
// Determine type from schema
|
||||
const fi = _modalFilters[filterIndex];
|
||||
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
|
||||
if (filterDef) {
|
||||
const optDef = filterDef.options_schema.find(o => o.key === optionKey);
|
||||
if (optDef && optDef.type === 'int') {
|
||||
fi.options[optionKey] = parseInt(value);
|
||||
} else {
|
||||
fi.options[optionKey] = parseFloat(value);
|
||||
}
|
||||
} else {
|
||||
fi.options[optionKey] = parseFloat(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectFilters() {
|
||||
return _modalFilters.map(fi => ({
|
||||
filter_id: fi.filter_id,
|
||||
options: { ...fi.options },
|
||||
}));
|
||||
}
|
||||
|
||||
async function showAddPPTemplateModal() {
|
||||
if (_availableFilters.length === 0) await loadAvailableFilters();
|
||||
|
||||
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';
|
||||
_modalFilters = [];
|
||||
|
||||
_populateFilterSelect();
|
||||
renderModalFilterList();
|
||||
|
||||
const modal = document.getElementById('pp-template-modal');
|
||||
modal.style.display = 'flex';
|
||||
@@ -3521,6 +3677,8 @@ async function showAddPPTemplateModal() {
|
||||
|
||||
async function editPPTemplate(templateId) {
|
||||
try {
|
||||
if (_availableFilters.length === 0) await loadAvailableFilters();
|
||||
|
||||
const response = await fetchWithAuth(`/postprocessing-templates/${templateId}`);
|
||||
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
|
||||
const tmpl = await response.json();
|
||||
@@ -3531,15 +3689,14 @@ async function editPPTemplate(templateId) {
|
||||
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;
|
||||
// Load filters from template
|
||||
_modalFilters = (tmpl.filters || []).map(fi => ({
|
||||
filter_id: fi.filter_id,
|
||||
options: { ...fi.options },
|
||||
}));
|
||||
|
||||
_populateFilterSelect();
|
||||
renderModalFilterList();
|
||||
|
||||
const modal = document.getElementById('pp-template-modal');
|
||||
modal.style.display = 'flex';
|
||||
@@ -3564,10 +3721,7 @@ async function savePPTemplate() {
|
||||
|
||||
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),
|
||||
filters: collectFilters(),
|
||||
description: description || null,
|
||||
};
|
||||
|
||||
@@ -3624,6 +3778,7 @@ async function deletePPTemplate(templateId) {
|
||||
|
||||
function closePPTemplateModal() {
|
||||
document.getElementById('pp-template-modal').style.display = 'none';
|
||||
_modalFilters = [];
|
||||
unlockBody();
|
||||
}
|
||||
|
||||
@@ -3661,7 +3816,7 @@ async function showStreamSelector(deviceId) {
|
||||
(data.streams || []).forEach(s => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
const typeIcon = s.stream_type === 'raw' ? '📷' : '🎨';
|
||||
const typeIcon = s.stream_type === 'raw' ? '🖥️' : '🎨';
|
||||
opt.textContent = `${typeIcon} ${s.name}`;
|
||||
streamSelect.appendChild(opt);
|
||||
});
|
||||
@@ -3672,13 +3827,17 @@ async function showStreamSelector(deviceId) {
|
||||
|
||||
// Populate LED projection fields
|
||||
const borderWidth = settings.border_width ?? device.settings?.border_width ?? 10;
|
||||
const smoothing = settings.smoothing ?? device.settings?.smoothing ?? 0.3;
|
||||
document.getElementById('stream-selector-border-width').value = borderWidth;
|
||||
document.getElementById('stream-selector-interpolation').value = device.settings?.interpolation_mode || 'average';
|
||||
document.getElementById('stream-selector-smoothing').value = smoothing;
|
||||
document.getElementById('stream-selector-smoothing-value').textContent = smoothing;
|
||||
|
||||
streamSelectorInitialValues = {
|
||||
stream: currentStreamId,
|
||||
border_width: String(borderWidth),
|
||||
interpolation: device.settings?.interpolation_mode || 'average',
|
||||
smoothing: String(smoothing),
|
||||
};
|
||||
|
||||
document.getElementById('stream-selector-device-id').value = deviceId;
|
||||
@@ -3735,6 +3894,7 @@ async function saveStreamSelector() {
|
||||
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 smoothing = parseFloat(document.getElementById('stream-selector-smoothing').value);
|
||||
const errorEl = document.getElementById('stream-selector-error');
|
||||
|
||||
try {
|
||||
@@ -3761,7 +3921,7 @@ async function saveStreamSelector() {
|
||||
const settingsResponse = await fetch(`${API_BASE}/devices/${deviceId}/settings`, {
|
||||
method: 'PUT',
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({ ...currentSettings, border_width: borderWidth, interpolation_mode: interpolation })
|
||||
body: JSON.stringify({ ...currentSettings, border_width: borderWidth, interpolation_mode: interpolation, smoothing: smoothing })
|
||||
});
|
||||
|
||||
if (!settingsResponse.ok) {
|
||||
@@ -3783,7 +3943,8 @@ 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
|
||||
document.getElementById('stream-selector-interpolation').value !== streamSelectorInitialValues.interpolation ||
|
||||
document.getElementById('stream-selector-smoothing').value !== streamSelectorInitialValues.smoothing
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user