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:
@@ -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')}">⠇</span>
|
||||
<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 btn-filter-remove" onclick="removeFilter(${index})" title="${t('filters.remove')}">✕</button>
|
||||
<button type="button" class="btn-filter-action btn-filter-remove" onclick="${removeFn}(${index})" title="${t('filters.remove')}">✕</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 };
|
||||
|
||||
Reference in New Issue
Block a user