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:
2026-02-11 11:57:19 +03:00
parent e8cbc73161
commit ebd6cc7d7d
16 changed files with 1115 additions and 192 deletions

View File

@@ -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 ? '&#x25BC;' : '&#x25B6;'}</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' : ''}>&#x25B2;</button>
<button type="button" class="btn-filter-action" onclick="moveFilter(${index}, 1)" title="${t('filters.move_down')}" ${index === _modalFilters.length - 1 ? 'disabled' : ''}>&#x25BC;</button>
<button type="button" class="btn-filter-action btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">&#x2715;</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
);
}