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
);
}

View File

@@ -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">

View File

@@ -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"
}

View File

@@ -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 для этого устройства"
}

View File

@@ -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);