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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
<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.
|
||||
Processing templates define image filters and color correction. Assign them to processed picture streams for consistent postprocessing across devices.
|
||||
</span>
|
||||
</p>
|
||||
<div id="pp-templates-list" class="templates-grid">
|
||||
@@ -303,6 +303,15 @@
|
||||
<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="stream-selector-smoothing">
|
||||
<span data-i18n="device.stream_settings.smoothing">Smoothing:</span>
|
||||
<span id="stream-selector-smoothing-value">0.3</span>
|
||||
</label>
|
||||
<input type="range" id="stream-selector-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('stream-selector-smoothing-value').textContent = this.value">
|
||||
<small class="input-hint" data-i18n="device.stream_settings.smoothing_hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
|
||||
</div>
|
||||
|
||||
<div id="stream-selector-error" class="error-message" style="display: none;"></div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -566,36 +575,15 @@
|
||||
<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>
|
||||
<!-- Dynamic filter list -->
|
||||
<div id="pp-filter-list" class="pp-filter-list"></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">
|
||||
<!-- Add filter control -->
|
||||
<div class="pp-add-filter-row">
|
||||
<select id="pp-add-filter-select" class="pp-add-filter-select">
|
||||
<option value="" data-i18n="filters.select_type">Select filter type...</option>
|
||||
</select>
|
||||
<button type="button" class="pp-add-filter-btn" onclick="addFilterFromSelect()" title="Add Filter">+</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -230,15 +230,23 @@
|
||||
"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.description": "Processing templates define image filters and color correction. 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:",
|
||||
"filters.select_type": "Select filter type...",
|
||||
"filters.add": "Add Filter",
|
||||
"filters.remove": "Remove",
|
||||
"filters.move_up": "Move Up",
|
||||
"filters.move_down": "Move Down",
|
||||
"filters.empty": "No filters added. Use the selector below to add filters.",
|
||||
"filters.brightness": "Brightness",
|
||||
"filters.saturation": "Saturation",
|
||||
"filters.gamma": "Gamma",
|
||||
"filters.downscaler": "Downscaler",
|
||||
"filters.pixelate": "Pixelate",
|
||||
"filters.auto_crop": "Auto Crop",
|
||||
"postprocessing.description_label": "Description (optional):",
|
||||
"postprocessing.description_placeholder": "Describe this template...",
|
||||
"postprocessing.created": "Template created successfully",
|
||||
@@ -262,5 +270,7 @@
|
||||
"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.stream_settings.smoothing": "Smoothing:",
|
||||
"device.stream_settings.smoothing_hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.",
|
||||
"device.tip.stream_selector": "Configure picture stream and LED projection settings for this device"
|
||||
}
|
||||
|
||||
@@ -230,15 +230,23 @@
|
||||
"streams.test.duration": "Длительность Захвата (с):",
|
||||
"streams.test.error.failed": "Тест потока не удался",
|
||||
"postprocessing.title": "\uD83C\uDFA8 Шаблоны Обработки",
|
||||
"postprocessing.description": "Шаблоны обработки определяют настройки цветокоррекции и сглаживания. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.",
|
||||
"postprocessing.description": "Шаблоны обработки определяют фильтры изображений и цветокоррекцию. Назначайте их обработанным видеопотокам для единообразной постобработки на всех устройствах.",
|
||||
"postprocessing.add": "Добавить Шаблон Обработки",
|
||||
"postprocessing.edit": "Редактировать Шаблон Обработки",
|
||||
"postprocessing.name": "Имя Шаблона:",
|
||||
"postprocessing.name.placeholder": "Мой Шаблон Обработки",
|
||||
"postprocessing.gamma": "Гамма:",
|
||||
"postprocessing.saturation": "Насыщенность:",
|
||||
"postprocessing.brightness": "Яркость:",
|
||||
"postprocessing.smoothing": "Сглаживание:",
|
||||
"filters.select_type": "Выберите тип фильтра...",
|
||||
"filters.add": "Добавить фильтр",
|
||||
"filters.remove": "Удалить",
|
||||
"filters.move_up": "Вверх",
|
||||
"filters.move_down": "Вниз",
|
||||
"filters.empty": "Фильтры не добавлены. Используйте селектор ниже для добавления.",
|
||||
"filters.brightness": "Яркость",
|
||||
"filters.saturation": "Насыщенность",
|
||||
"filters.gamma": "Гамма",
|
||||
"filters.downscaler": "Уменьшение",
|
||||
"filters.pixelate": "Пикселизация",
|
||||
"filters.auto_crop": "Авто Обрезка",
|
||||
"postprocessing.description_label": "Описание (необязательно):",
|
||||
"postprocessing.description_placeholder": "Опишите этот шаблон...",
|
||||
"postprocessing.created": "Шаблон успешно создан",
|
||||
@@ -262,5 +270,7 @@
|
||||
"device.stream_settings.interpolation.median": "Медиана",
|
||||
"device.stream_settings.interpolation.dominant": "Доминантный",
|
||||
"device.stream_settings.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей",
|
||||
"device.stream_settings.smoothing": "Сглаживание:",
|
||||
"device.stream_settings.smoothing_hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.",
|
||||
"device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства"
|
||||
}
|
||||
|
||||
@@ -1926,6 +1926,163 @@ input:-webkit-autofill:focus {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* PP Filter List in Template Modal */
|
||||
.pp-filter-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pp-filter-empty {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
border: 1px dashed var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pp-filter-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.pp-filter-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pp-filter-card.expanded .pp-filter-card-header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.pp-filter-card-chevron {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.pp-filter-card-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pp-filter-card-summary {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
margin-right: 8px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pp-filter-card-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-filter-action {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-filter-action:hover:not(:disabled) {
|
||||
background: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-filter-action:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-filter-remove:hover:not(:disabled) {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.pp-filter-card-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pp-filter-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.pp-filter-option label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pp-filter-option input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pp-add-filter-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pp-add-filter-select {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pp-add-filter-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--card-bg);
|
||||
color: var(--text-primary);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pp-add-filter-btn:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
/* Template Test Section */
|
||||
.template-test-section {
|
||||
background: var(--bg-secondary);
|
||||
|
||||
Reference in New Issue
Block a user