Add CSPT entity, processed CSS source type, reverse filter, and UI improvements

- Add Color Strip Processing Template (CSPT) entity: reusable filter chains
  for 1D LED strip postprocessing (backend, storage, API, frontend CRUD)
- Add "processed" color strip source type that wraps another CSS source and
  applies a CSPT filter chain (dataclass, stream, schema, modal, cards)
- Add Reverse filter for strip LED order reversal
- Add CSPT and processed CSS nodes/edges to visual graph editor
- Add CSPT test preview WS endpoint with input source selection
- Add device settings CSPT template selector (add + edit modals with hints)
- Use icon grids for palette quantization preset selector in filter lists
- Use EntitySelect for template references and test modal source selectors
- Fix filters.css_filter_template.desc missing localization
- Fix icon grid cell height inequality (grid-auto-rows: 1fr)
- Rename "Processed" subtab to "Processing Templates"
- Localize all new strings (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 02:16:59 +03:00
parent 7e78323c9c
commit 294d704eb0
72 changed files with 2992 additions and 1416 deletions

View File

@@ -23,6 +23,10 @@ import {
_cachedValueSources,
_cachedSyncClocks,
_cachedAudioTemplates,
_cachedCSPTemplates,
_csptModalFilters, set_csptModalFilters,
_csptNameManuallyEdited, set_csptNameManuallyEdited,
_stripFilters,
availableAudioEngines, setAvailableAudioEngines,
currentEditingAudioTemplateId, setCurrentEditingAudioTemplateId,
_audioTemplateNameManuallyEdited, set_audioTemplateNameManuallyEdited,
@@ -31,6 +35,7 @@ import {
streamsCache, ppTemplatesCache, captureTemplatesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
colorStripSourcesCache,
csptCache, stripFiltersCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
import { t } from '../core/i18n.js';
@@ -48,7 +53,7 @@ import {
ICON_TEMPLATE, ICON_CLONE, ICON_EDIT, ICON_TEST, ICON_LINK_SOURCE,
ICON_FPS, ICON_WEB, ICON_VALUE_SOURCE, ICON_CLOCK, ICON_AUDIO_LOOPBACK, ICON_AUDIO_INPUT,
ICON_AUDIO_TEMPLATE, ICON_MONITOR, ICON_WRENCH, ICON_RADIO,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_HELP,
ICON_CAPTURE_TEMPLATE, ICON_PP_TEMPLATE, ICON_CSPT, ICON_HELP,
} from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
@@ -61,6 +66,7 @@ let _captureTemplateTagsInput = null;
let _streamTagsInput = null;
let _ppTemplateTagsInput = null;
let _audioTemplateTagsInput = null;
let _csptTagsInput = null;
// ── Card section instances ──
const csRawStreams = new CardSection('raw-streams', { titleKey: 'streams.section.streams', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('raw')", keyAttr: 'data-stream-id' });
@@ -74,6 +80,7 @@ const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_t
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id' });
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' });
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id' });
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id' });
// Re-render picture sources when language changes
document.addEventListener('languageChanged', () => { if (apiKey) loadPictureSources(); });
@@ -170,13 +177,34 @@ class AudioTemplateModal extends Modal {
}
}
class CSPTEditorModal extends Modal {
constructor() { super('cspt-modal'); }
snapshotValues() {
return {
name: document.getElementById('cspt-name').value,
description: document.getElementById('cspt-description').value,
filters: JSON.stringify(_csptModalFilters.map(fi => ({ filter_id: fi.filter_id, options: fi.options }))),
tags: JSON.stringify(_csptTagsInput ? _csptTagsInput.getValue() : []),
};
}
onForceClose() {
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }
set_csptModalFilters([]);
set_csptNameManuallyEdited(false);
}
}
const templateModal = new CaptureTemplateModal();
const testTemplateModal = new Modal('test-template-modal');
const streamModal = new StreamEditorModal();
const testStreamModal = new Modal('test-stream-modal');
const ppTemplateModal = new PPTemplateEditorModal();
const testPPTemplateModal = new Modal('test-pp-template-modal');
let _ppTestSourceEntitySelect = null;
const audioTemplateModal = new AudioTemplateModal();
const csptModal = new CSPTEditorModal();
// ===== Capture Templates =====
@@ -1179,6 +1207,7 @@ export async function loadPictureSources() {
syncClocksCache.fetch(),
audioTemplatesCache.fetch(),
colorStripSourcesCache.fetch(),
csptCache.fetch(),
filtersCache.data.length === 0 ? filtersCache.fetch() : Promise.resolve(filtersCache.data),
]);
renderPictureSourcesList(streams);
@@ -1220,6 +1249,7 @@ const _streamSectionMap = {
raw: [csRawStreams, csRawTemplates],
static_image: [csStaticStreams],
processed: [csProcStreams, csProcTemplates],
css_processing: [csCSPTemplates],
color_strip: [csColorStrips],
audio: [csAudioMulti, csAudioMono, csAudioTemplates],
value: [csValueSources],
@@ -1365,6 +1395,32 @@ function renderPictureSourcesList(streams) {
});
};
const renderCSPTCard = (tmpl) => {
let filterChainHtml = '';
if (tmpl.filters && tmpl.filters.length > 0) {
const filterNames = tmpl.filters.map(fi => `<span class="filter-chain-item">${escapeHtml(_getStripFilterName(fi.filter_id))}</span>`);
filterChainHtml = `<div class="filter-chain">${filterNames.join('<span class="filter-chain-arrow">\u2192</span>')}</div>`;
}
return wrapCard({
type: 'template-card',
dataAttr: 'data-cspt-id',
id: tmpl.id,
removeOnclick: `deleteCSPT('${tmpl.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_CSPT} ${escapeHtml(tmpl.name)}</div>
</div>
${tmpl.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(tmpl.description)}</div>` : ''}
${filterChainHtml}
${renderTagChips(tmpl.tags)}`,
actions: `
<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testCSPT('${tmpl.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" onclick="cloneCSPT('${tmpl.id}')" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" onclick="editCSPT('${tmpl.id}')" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
};
const rawStreams = streams.filter(s => s.stream_type === 'raw');
const processedStreams = streams.filter(s => s.stream_type === 'processed');
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
@@ -1372,6 +1428,9 @@ function renderPictureSourcesList(streams) {
const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono');
// CSPT templates
const csptTemplates = csptCache.data;
// Color strip sources (maps needed for card rendering)
const colorStrips = colorStripSourcesCache.data;
const pictureSourceMap = {};
@@ -1383,6 +1442,7 @@ function renderPictureSourcesList(streams) {
{ key: 'raw', icon: getPictureSourceIcon('raw'), titleKey: 'streams.group.raw', count: rawStreams.length },
{ key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image', count: staticImageStreams.length },
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
{ key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip', count: colorStrips.length },
{ key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio', count: _cachedAudioSources.length },
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
@@ -1400,8 +1460,11 @@ function renderPictureSourcesList(streams) {
]
},
{
key: 'color_strip', icon: getColorStripIcon('static'), titleKey: 'streams.group.color_strip',
count: colorStrips.length,
key: 'strip_group', icon: getColorStripIcon('static'), titleKey: 'tree.group.strip',
children: [
{ key: 'color_strip', titleKey: 'streams.group.color_strip', icon: getColorStripIcon('static'), count: colorStrips.length },
{ key: 'css_processing', titleKey: 'streams.group.css_processing', icon: ICON_CSPT, count: csptTemplates.length },
]
},
{
key: 'audio', icon: getAudioSourceIcon('multichannel'), titleKey: 'streams.group.audio',
@@ -1515,6 +1578,7 @@ function renderPictureSourcesList(streams) {
const colorStripItems = csColorStrips.applySortOrder(colorStrips.map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })));
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
if (csRawStreams.isMounted()) {
// Incremental update: reconcile cards in-place
@@ -1522,6 +1586,7 @@ function renderPictureSourcesList(streams) {
raw: rawStreams.length,
static_image: staticImageStreams.length,
processed: processedStreams.length,
css_processing: csptTemplates.length,
color_strip: colorStrips.length,
audio: _cachedAudioSources.length + _cachedAudioTemplates.length,
value: _cachedValueSources.length,
@@ -1531,6 +1596,7 @@ function renderPictureSourcesList(streams) {
csRawTemplates.reconcile(rawTemplateItems);
csProcStreams.reconcile(procStreamItems);
csProcTemplates.reconcile(procTemplateItems);
csCSPTemplates.reconcile(csptItems);
csColorStrips.reconcile(colorStripItems);
csAudioMulti.reconcile(multiItems);
csAudioMono.reconcile(monoItems);
@@ -1544,6 +1610,7 @@ function renderPictureSourcesList(streams) {
let panelContent = '';
if (tab.key === 'raw') panelContent = csRawStreams.render(rawStreamItems) + csRawTemplates.render(rawTemplateItems);
else if (tab.key === 'processed') panelContent = csProcStreams.render(procStreamItems) + csProcTemplates.render(procTemplateItems);
else if (tab.key === 'css_processing') panelContent = csCSPTemplates.render(csptItems);
else if (tab.key === 'color_strip') panelContent = csColorStrips.render(colorStripItems);
else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
@@ -1553,7 +1620,7 @@ function renderPictureSourcesList(streams) {
}).join('');
container.innerHTML = panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources, csSyncClocks]);
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources, csSyncClocks]);
// Render tree sidebar with expand/collapse buttons
_streamsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllStreamSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
@@ -1562,6 +1629,7 @@ function renderPictureSourcesList(streams) {
'raw-streams': 'raw', 'raw-templates': 'raw',
'static-streams': 'static_image',
'proc-streams': 'processed', 'proc-templates': 'processed',
'css-proc-templates': 'css_processing',
'color-strips': 'color_strip',
'audio-multi': 'audio', 'audio-mono': 'audio', 'audio-templates': 'audio',
'value-sources': 'value',
@@ -2073,6 +2141,18 @@ export async function showTestPPTemplateModal(templateId) {
select.value = lastStream;
}
// EntitySelect for source stream picker
if (_ppTestSourceEntitySelect) _ppTestSourceEntitySelect.destroy();
_ppTestSourceEntitySelect = new EntitySelect({
target: select,
getItems: () => _cachedStreams.map(s => ({
value: s.id,
label: s.name,
icon: getPictureSourceIcon(s.stream_type),
})),
placeholder: t('palette.search'),
});
testPPTemplateModal.open();
}
@@ -2134,6 +2214,16 @@ function _getFilterName(filterId) {
return translated;
}
function _getStripFilterName(filterId) {
const key = 'filters.' + filterId;
const translated = t(key);
if (translated === key) {
const def = _stripFilters.find(f => f.filter_id === filterId);
return def ? def.filter_name : filterId;
}
return translated;
}
let _filterIconSelect = null;
const _FILTER_ICONS = {
@@ -2149,6 +2239,7 @@ const _FILTER_ICONS = {
frame_interpolation: P.fastForward,
noise_gate: P.volume2,
palette_quantization: P.sparkles,
css_filter_template: P.fileText,
};
function _populateFilterSelect() {
@@ -2179,17 +2270,32 @@ function _populateFilterSelect() {
}
}
export function renderModalFilterList() {
const container = document.getElementById('pp-filter-list');
if (_modalFilters.length === 0) {
/**
* Generic filter list renderer — shared by PP template and CSPT modals.
* @param {string} containerId - DOM container ID for filter cards
* @param {Array} filtersArr - mutable array of {filter_id, options, _expanded}
* @param {Array} filterDefs - available filter definitions (with options_schema)
* @param {string} prefix - handler prefix: '' for PP, 'cspt' for CSPT
* @param {string} editingIdInputId - ID of hidden input holding the editing template ID
* @param {string} selfRefFilterId - filter_id that should exclude self ('filter_template' or 'css_filter_template')
*/
function _renderFilterListGeneric(containerId, filtersArr, filterDefs, prefix, editingIdInputId, selfRefFilterId) {
const container = document.getElementById(containerId);
if (filtersArr.length === 0) {
container.innerHTML = `<div class="pp-filter-empty">${t('filters.empty')}</div>`;
return;
}
const toggleFn = prefix ? `${prefix}ToggleFilterExpand` : 'toggleFilterExpand';
const removeFn = prefix ? `${prefix}RemoveFilter` : 'removeFilter';
const updateFn = prefix ? `${prefix}UpdateFilterOption` : 'updateFilterOption';
const inputPrefix = prefix ? `${prefix}-filter` : 'filter';
const nameFn = prefix ? _getStripFilterName : _getFilterName;
let html = '';
_modalFilters.forEach((fi, index) => {
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
const filterName = _getFilterName(fi.filter_id);
filtersArr.forEach((fi, index) => {
const filterDef = filterDefs.find(f => f.filter_id === fi.filter_id);
const filterName = nameFn(fi.filter_id);
const isExpanded = fi._expanded === true;
let summary = '';
@@ -2201,13 +2307,13 @@ export function renderModalFilterList() {
}
html += `<div class="pp-filter-card${isExpanded ? ' expanded' : ''}" data-filter-index="${index}">
<div class="pp-filter-card-header" onclick="toggleFilterExpand(${index})">
<div class="pp-filter-card-header" onclick="${toggleFn}(${index})">
<span class="pp-filter-drag-handle" title="${t('filters.drag_to_reorder')}">&#x2807;</span>
<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 btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">&#x2715;</button>
<button type="button" class="btn-filter-action btn-filter-remove" onclick="${removeFn}(${index})" title="${t('filters.remove')}">&#x2715;</button>
</div>
</div>
<div class="pp-filter-card-options"${isExpanded ? '' : ' style="display:none"'}>`;
@@ -2215,35 +2321,37 @@ export function renderModalFilterList() {
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}`;
const inputId = `${inputPrefix}-${index}-${opt.key}`;
if (opt.type === 'bool') {
const checked = currentVal === true || currentVal === 'true';
html += `<div class="pp-filter-option pp-filter-option-bool">
<label for="${inputId}">
<span>${escapeHtml(opt.label)}</span>
<input type="checkbox" id="${inputId}" ${checked ? 'checked' : ''}
onchange="updateFilterOption(${index}, '${opt.key}', this.checked)">
onchange="${updateFn}(${index}, '${opt.key}', this.checked)">
</label>
</div>`;
} else if (opt.type === 'select' && Array.isArray(opt.choices)) {
// Exclude the template being edited from filter_template choices (prevent self-reference)
const editingId = document.getElementById('pp-template-id')?.value || '';
const filteredChoices = (fi.filter_id === 'filter_template' && opt.key === 'template_id' && editingId)
const editingId = document.getElementById(editingIdInputId)?.value || '';
const filteredChoices = (fi.filter_id === selfRefFilterId && opt.key === 'template_id' && editingId)
? opt.choices.filter(c => c.value !== editingId)
: opt.choices;
// Auto-correct if current value doesn't match any choice
let selectVal = currentVal;
if (filteredChoices.length > 0 && !filteredChoices.some(c => c.value === selectVal)) {
selectVal = filteredChoices[0].value;
fi.options[opt.key] = selectVal;
}
const hasPaletteColors = filteredChoices.some(c => c.colors);
const options = filteredChoices.map(c =>
`<option value="${escapeHtml(c.value)}"${c.value === selectVal ? ' selected' : ''}>${escapeHtml(c.label)}</option>`
).join('');
const gridAttr = hasPaletteColors ? ` data-palette-grid="${escapeHtml(JSON.stringify(filteredChoices))}"` : '';
const isTemplateRef = opt.key === 'template_id';
const entityAttr = isTemplateRef ? ' data-entity-select="template"' : '';
html += `<div class="pp-filter-option">
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
<select id="${inputId}"
onchange="updateFilterOption(${index}, '${opt.key}', this.value)">
<select id="${inputId}"${gridAttr}${entityAttr}
onchange="${updateFn}(${index}, '${opt.key}', this.value)">
${options}
</select>
</div>`;
@@ -2253,7 +2361,7 @@ export function renderModalFilterList() {
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
<input type="text" id="${inputId}" value="${escapeHtml(String(currentVal))}"
maxlength="${maxLen}" class="pp-filter-text-input"
onchange="updateFilterOption(${index}, '${opt.key}', this.value)">
onchange="${updateFn}(${index}, '${opt.key}', this.value)">
</div>`;
} else {
html += `<div class="pp-filter-option">
@@ -2263,7 +2371,7 @@ export function renderModalFilterList() {
</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;">
oninput="${updateFn}(${index}, '${opt.key}', this.value); document.getElementById('${inputId}-display').textContent = this.value;">
</div>`;
}
}
@@ -2273,18 +2381,72 @@ export function renderModalFilterList() {
});
container.innerHTML = html;
_initFilterDrag();
_initFilterDragForContainer(containerId, filtersArr, () => {
_renderFilterListGeneric(containerId, filtersArr, filterDefs, prefix, editingIdInputId, selfRefFilterId);
});
// Initialize palette icon grids on select elements
_initFilterPaletteGrids(container);
}
/* ── PP filter drag-and-drop reordering ── */
/** Stored IconSelect instances for filter option selects (keyed by select element id). */
const _filterOptionIconSelects = {};
function _paletteSwatchHTML(hexStr) {
const hexColors = hexStr.split(',').map(s => s.trim());
if (hexColors.length === 1) {
return `<span style="display:inline-block;width:60px;height:14px;border-radius:3px;background:${hexColors[0]}"></span>`;
}
const stops = hexColors.map((c, i) => `${c} ${(i / (hexColors.length - 1) * 100).toFixed(0)}%`).join(', ');
return `<span style="display:inline-block;width:60px;height:14px;border-radius:3px;background:linear-gradient(to right,${stops})"></span>`;
}
function _initFilterPaletteGrids(container) {
// Palette-colored grids (e.g. palette quantization preset)
container.querySelectorAll('select[data-palette-grid]').forEach(sel => {
if (_filterOptionIconSelects[sel.id]) _filterOptionIconSelects[sel.id].destroy();
try {
const choices = JSON.parse(sel.dataset.paletteGrid);
const items = choices.map(c => ({
value: c.value,
label: c.label,
icon: _paletteSwatchHTML(c.colors || ''),
}));
_filterOptionIconSelects[sel.id] = new IconSelect({ target: sel, items, columns: 2 });
} catch { /* ignore parse errors */ }
});
// Template reference selects → EntitySelect (searchable palette)
container.querySelectorAll('select[data-entity-select]').forEach(sel => {
if (_filterOptionIconSelects[sel.id]) _filterOptionIconSelects[sel.id].destroy();
const icon = sel.dataset.entitySelect === 'template' ? ICON_PP_TEMPLATE : ICON_CSPT;
_filterOptionIconSelects[sel.id] = new EntitySelect({
target: sel,
getItems: () => Array.from(sel.options).map(opt => ({
value: opt.value,
label: opt.textContent,
icon,
})),
placeholder: t('palette.search'),
});
});
}
export function renderModalFilterList() {
_renderFilterListGeneric('pp-filter-list', _modalFilters, _availableFilters, '', 'pp-template-id', 'filter_template');
}
export function renderCSPTModalFilterList() {
_renderFilterListGeneric('cspt-filter-list', _csptModalFilters, _stripFilters, 'cspt', 'cspt-id', 'css_filter_template');
}
/* ── Generic filter drag-and-drop reordering ── */
const _FILTER_DRAG_THRESHOLD = 5;
const _FILTER_SCROLL_EDGE = 60;
const _FILTER_SCROLL_SPEED = 12;
let _filterDragState = null;
function _initFilterDrag() {
const container = document.getElementById('pp-filter-list');
function _initFilterDragForContainer(containerId, filtersArr, rerenderFn) {
const container = document.getElementById(containerId);
if (!container) return;
container.addEventListener('pointerdown', (e) => {
@@ -2306,6 +2468,8 @@ function _initFilterDrag() {
offsetY: 0,
fromIndex,
scrollRaf: null,
filtersArr,
rerenderFn,
};
const onMove = (ev) => _onFilterDragMove(ev);
@@ -2402,12 +2566,11 @@ function _onFilterDragEnd() {
ds.clone.remove();
document.body.classList.remove('pp-filter-dragging');
// Reorder _modalFilters array
// Reorder filters array
if (toIndex !== ds.fromIndex) {
const [item] = _modalFilters.splice(ds.fromIndex, 1);
_modalFilters.splice(toIndex, 0, item);
renderModalFilterList();
_autoGeneratePPTemplateName();
const [item] = ds.filtersArr.splice(ds.fromIndex, 1);
ds.filtersArr.splice(toIndex, 0, item);
ds.rerenderFn();
}
}
@@ -2430,17 +2593,19 @@ function _filterAutoScroll(clientY, ds) {
ds.scrollRaf = requestAnimationFrame(scroll);
}
export function addFilterFromSelect() {
const select = document.getElementById('pp-add-filter-select');
/**
* Generic: add a filter from a select element into a filters array.
*/
function _addFilterGeneric(selectId, filtersArr, filterDefs, iconSelect, renderFn, autoNameFn) {
const select = document.getElementById(selectId);
const filterId = select.value;
if (!filterId) return;
const filterDef = _availableFilters.find(f => f.filter_id === filterId);
const filterDef = filterDefs.find(f => f.filter_id === filterId);
if (!filterDef) return;
const options = {};
for (const opt of filterDef.options_schema) {
// For select options with empty default, use the first choice's value
if (opt.type === 'select' && !opt.default && Array.isArray(opt.choices) && opt.choices.length > 0) {
options[opt.key] = opt.choices[0].value;
} else {
@@ -2448,40 +2613,17 @@ export function addFilterFromSelect() {
}
}
_modalFilters.push({ filter_id: filterId, options, _expanded: true });
filtersArr.push({ filter_id: filterId, options, _expanded: true });
select.value = '';
if (_filterIconSelect) _filterIconSelect.setValue('');
renderModalFilterList();
_autoGeneratePPTemplateName();
if (iconSelect) iconSelect.setValue('');
renderFn();
if (autoNameFn) autoNameFn();
}
export function toggleFilterExpand(index) {
if (_modalFilters[index]) {
_modalFilters[index]._expanded = !_modalFilters[index]._expanded;
renderModalFilterList();
}
}
export function removeFilter(index) {
_modalFilters.splice(index, 1);
renderModalFilterList();
_autoGeneratePPTemplateName();
}
export 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();
_autoGeneratePPTemplateName();
}
export function updateFilterOption(filterIndex, optionKey, value) {
if (_modalFilters[filterIndex]) {
const fi = _modalFilters[filterIndex];
const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id);
function _updateFilterOptionGeneric(filterIndex, optionKey, value, filtersArr, filterDefs) {
if (filtersArr[filterIndex]) {
const fi = filtersArr[filterIndex];
const filterDef = filterDefs.find(f => f.filter_id === fi.filter_id);
if (filterDef) {
const optDef = filterDef.options_schema.find(o => o.key === optionKey);
if (optDef && optDef.type === 'bool') {
@@ -2501,6 +2643,49 @@ export function updateFilterOption(filterIndex, optionKey, value) {
}
}
// ── PP filter actions ──
export function addFilterFromSelect() {
_addFilterGeneric('pp-add-filter-select', _modalFilters, _availableFilters, _filterIconSelect, renderModalFilterList, _autoGeneratePPTemplateName);
}
export function toggleFilterExpand(index) {
if (_modalFilters[index]) { _modalFilters[index]._expanded = !_modalFilters[index]._expanded; renderModalFilterList(); }
}
export function removeFilter(index) {
_modalFilters.splice(index, 1); renderModalFilterList(); _autoGeneratePPTemplateName();
}
export 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(); _autoGeneratePPTemplateName();
}
export function updateFilterOption(filterIndex, optionKey, value) {
_updateFilterOptionGeneric(filterIndex, optionKey, value, _modalFilters, _availableFilters);
}
// ── CSPT filter actions ──
export function csptAddFilterFromSelect() {
_addFilterGeneric('cspt-add-filter-select', _csptModalFilters, _stripFilters, _csptFilterIconSelect, renderCSPTModalFilterList, _autoGenerateCSPTName);
}
export function csptToggleFilterExpand(index) {
if (_csptModalFilters[index]) { _csptModalFilters[index]._expanded = !_csptModalFilters[index]._expanded; renderCSPTModalFilterList(); }
}
export function csptRemoveFilter(index) {
_csptModalFilters.splice(index, 1); renderCSPTModalFilterList(); _autoGenerateCSPTName();
}
export function csptUpdateFilterOption(filterIndex, optionKey, value) {
_updateFilterOptionGeneric(filterIndex, optionKey, value, _csptModalFilters, _stripFilters);
}
function collectFilters() {
return _modalFilters.map(fi => ({
filter_id: fi.filter_id,
@@ -2689,5 +2874,208 @@ export async function closePPTemplateModal() {
await ppTemplateModal.close();
}
// ===== Color Strip Processing Templates (CSPT) =====
let _csptFilterIconSelect = null;
async function loadStripFilters() {
await stripFiltersCache.fetch();
}
async function loadCSPTemplates() {
try {
if (_stripFilters.length === 0) await stripFiltersCache.fetch();
await csptCache.fetch();
renderPictureSourcesList(_cachedStreams);
} catch (error) {
console.error('Error loading CSPT:', error);
}
}
function _populateCSPTFilterSelect() {
const select = document.getElementById('cspt-add-filter-select');
select.innerHTML = `<option value="">${t('filters.select_type')}</option>`;
const items = [];
for (const f of _stripFilters) {
const name = _getStripFilterName(f.filter_id);
select.innerHTML += `<option value="${f.filter_id}">${name}</option>`;
const pathData = _FILTER_ICONS[f.filter_id] || P.wrench;
items.push({
value: f.filter_id,
icon: `<svg class="icon" viewBox="0 0 24 24">${pathData}</svg>`,
label: name,
desc: t(`filters.${f.filter_id}.desc`),
});
}
if (_csptFilterIconSelect) {
_csptFilterIconSelect.updateItems(items);
} else if (items.length > 0) {
_csptFilterIconSelect = new IconSelect({
target: select,
items,
columns: 3,
placeholder: t('filters.select_type'),
onChange: () => csptAddFilterFromSelect(),
});
}
}
function _autoGenerateCSPTName() {
if (_csptNameManuallyEdited) return;
if (document.getElementById('cspt-id').value) return;
const nameInput = document.getElementById('cspt-name');
if (_csptModalFilters.length > 0) {
const filterNames = _csptModalFilters.map(f => _getStripFilterName(f.filter_id)).join(' + ');
nameInput.value = filterNames;
} else {
nameInput.value = '';
}
}
function collectCSPTFilters() {
return _csptModalFilters.map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
}));
}
export async function showAddCSPTModal(cloneData = null) {
if (_stripFilters.length === 0) await loadStripFilters();
document.getElementById('cspt-modal-title').innerHTML = `${ICON_CSPT} ${t('css_processing.add')}`;
document.getElementById('cspt-form').reset();
document.getElementById('cspt-id').value = '';
document.getElementById('cspt-error').style.display = 'none';
if (cloneData) {
set_csptModalFilters((cloneData.filters || []).map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
})));
set_csptNameManuallyEdited(true);
} else {
set_csptModalFilters([]);
set_csptNameManuallyEdited(false);
}
document.getElementById('cspt-name').oninput = () => { set_csptNameManuallyEdited(true); };
_populateCSPTFilterSelect();
renderCSPTModalFilterList();
if (cloneData) {
document.getElementById('cspt-name').value = (cloneData.name || '') + ' (Copy)';
document.getElementById('cspt-description').value = cloneData.description || '';
}
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }
_csptTagsInput = new TagInput(document.getElementById('cspt-tags-container'), { placeholder: t('tags.placeholder') });
_csptTagsInput.setValue(cloneData ? (cloneData.tags || []) : []);
csptModal.open();
csptModal.snapshot();
}
export async function editCSPT(templateId) {
try {
if (_stripFilters.length === 0) await loadStripFilters();
const response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`);
if (!response.ok) throw new Error(`Failed to load template: ${response.status}`);
const tmpl = await response.json();
document.getElementById('cspt-modal-title').innerHTML = `${ICON_CSPT} ${t('css_processing.edit')}`;
document.getElementById('cspt-id').value = templateId;
document.getElementById('cspt-name').value = tmpl.name;
document.getElementById('cspt-description').value = tmpl.description || '';
document.getElementById('cspt-error').style.display = 'none';
set_csptModalFilters((tmpl.filters || []).map(fi => ({
filter_id: fi.filter_id,
options: { ...fi.options },
})));
_populateCSPTFilterSelect();
renderCSPTModalFilterList();
if (_csptTagsInput) { _csptTagsInput.destroy(); _csptTagsInput = null; }
_csptTagsInput = new TagInput(document.getElementById('cspt-tags-container'), { placeholder: t('tags.placeholder') });
_csptTagsInput.setValue(tmpl.tags || []);
csptModal.open();
csptModal.snapshot();
} catch (error) {
console.error('Error loading CSPT:', error);
showToast(t('css_processing.error.load') + ': ' + error.message, 'error');
}
}
export async function saveCSPT() {
const templateId = document.getElementById('cspt-id').value;
const name = document.getElementById('cspt-name').value.trim();
const description = document.getElementById('cspt-description').value.trim();
const errorEl = document.getElementById('cspt-error');
if (!name) { showToast(t('css_processing.error.required'), 'error'); return; }
const payload = { name, filters: collectCSPTFilters(), description: description || null, tags: _csptTagsInput ? _csptTagsInput.getValue() : [] };
try {
let response;
if (templateId) {
response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
response = await fetchWithAuth('/color-strip-processing-templates', { method: 'POST', body: JSON.stringify(payload) });
}
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to save template');
}
showToast(templateId ? t('css_processing.updated') : t('css_processing.created'), 'success');
csptModal.forceClose();
await loadCSPTemplates();
} catch (error) {
console.error('Error saving CSPT:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
export async function cloneCSPT(templateId) {
try {
const resp = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`);
if (!resp.ok) throw new Error('Failed to load template');
const tmpl = await resp.json();
showAddCSPTModal(tmpl);
} catch (error) {
if (error.isAuth) return;
console.error('Failed to clone CSPT:', error);
showToast(t('css_processing.error.clone_failed') + ': ' + error.message, 'error');
}
}
export async function deleteCSPT(templateId) {
const confirmed = await showConfirm(t('css_processing.delete.confirm'));
if (!confirmed) return;
try {
const response = await fetchWithAuth(`/color-strip-processing-templates/${templateId}`, { method: 'DELETE' });
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || error.message || 'Failed to delete template');
}
showToast(t('css_processing.deleted'), 'success');
await loadCSPTemplates();
} catch (error) {
console.error('Error deleting CSPT:', error);
showToast(t('css_processing.error.delete') + ': ' + error.message, 'error');
}
}
export async function closeCSPTModal() {
await csptModal.close();
}
// Exported helpers used by other modules
export { updateCaptureDuration, buildTestStatsHtml };