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

@@ -65,6 +65,9 @@ import {
addFilterFromSelect, toggleFilterExpand, removeFilter, moveFilter, updateFilterOption,
renderModalFilterList, updateCaptureDuration,
cloneStream, cloneCaptureTemplate, clonePPTemplate,
showAddCSPTModal, editCSPT, closeCSPTModal, saveCSPT, deleteCSPT, cloneCSPT,
csptAddFilterFromSelect, csptToggleFilterExpand, csptRemoveFilter, csptUpdateFilterOption,
renderCSPTModalFilterList,
expandAllStreamSections, collapseAllStreamSections,
} from './features/streams.js';
import {
@@ -125,7 +128,7 @@ import {
onNotificationFilterModeChange,
notificationAddAppColor, notificationRemoveAppColor,
testNotification,
testColorStrip, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
} from './features/color-strips.js';
// Layer 5: audio sources
@@ -295,6 +298,17 @@ Object.assign(window, {
cloneStream,
cloneCaptureTemplate,
clonePPTemplate,
showAddCSPTModal,
editCSPT,
closeCSPTModal,
saveCSPT,
deleteCSPT,
cloneCSPT,
csptAddFilterFromSelect,
csptToggleFilterExpand,
csptRemoveFilter,
csptUpdateFilterOption,
renderCSPTModalFilterList,
showAddAudioTemplateModal,
editAudioTemplate,
closeAudioTemplateModal,
@@ -413,7 +427,7 @@ Object.assign(window, {
onNotificationFilterModeChange,
notificationAddAppColor, notificationRemoveAppColor,
testNotification,
testColorStrip, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
testColorStrip, testCSPT, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
// audio sources
showAudioSourceModal,

View File

@@ -116,6 +116,7 @@ export const ENTITY_COLORS = {
scene_preset: '#CE93D8',
automation: '#A5D6A7',
pattern_template: '#BCAAA4',
cspt: '#7E57C2',
};
export const ENTITY_LABELS = {
@@ -132,6 +133,7 @@ export const ENTITY_LABELS = {
scene_preset: 'Scene Preset',
automation: 'Automation',
pattern_template: 'Pattern Template',
cspt: 'Strip Processing',
};
/* ── Edge type (for CSS class) ── */
@@ -145,6 +147,7 @@ function edgeType(fromKind, toKind, field) {
if (fromKind === 'audio_source' || fromKind === 'audio_template') return 'audio';
if (fromKind === 'capture_template' || fromKind === 'pp_template' || fromKind === 'pattern_template') return 'template';
if (fromKind === 'scene_preset') return 'scene';
if (fromKind === 'cspt') return 'template';
return 'default';
}
@@ -238,6 +241,11 @@ function buildGraph(e) {
addNode(a.id, 'automation', a.name, '', { running: a.enabled || false });
}
// 14. Color strip processing templates (CSPT)
for (const t of e.csptTemplates || []) {
addNode(t.id, 'cspt', t.name, '');
}
// ── Edges ──
// Picture source edges
@@ -265,6 +273,10 @@ function buildGraph(e) {
if (s.audio_source_id) addEdge(s.audio_source_id, s.id, 'audio_source_id');
if (s.clock_id) addEdge(s.clock_id, s.id, 'clock_id');
// Processed type: input source and processing template edges
if (s.input_source_id) addEdge(s.input_source_id, s.id, 'input_source_id');
if (s.processing_template_id) addEdge(s.processing_template_id, s.id, 'processing_template_id');
// Composite layers
if (s.layers) {
for (const layer of s.layers) {
@@ -316,6 +328,20 @@ function buildGraph(e) {
if (a.deactivation_scene_preset_id) addEdge(a.deactivation_scene_preset_id, a.id, 'deactivation_scene_preset_id');
}
// Composite layer → CSPT edges
for (const s of e.colorStripSources || []) {
if (s.layers) {
for (const layer of s.layers) {
if (layer.processing_template_id) addEdge(layer.processing_template_id, s.id, 'layer.processing_template_id');
}
}
}
// Device → CSPT default template edges
for (const d of e.devices || []) {
if (d.default_css_processing_template_id) addEdge(d.default_css_processing_template_id, d.id, 'default_css_processing_template_id');
}
return { nodes, edges };
}

View File

@@ -24,6 +24,7 @@ const KIND_ICONS = {
output_target: P.zap,
scene_preset: P.sparkles,
automation: P.clipboardList,
cspt: P.wrench,
};
// ── Subtype-specific icon overrides ──
@@ -34,6 +35,7 @@ const SUBTYPE_ICONS = {
mapped: P.mapPin, mapped_zones: P.mapPin,
audio: P.music, audio_visualization: P.music,
api_input: P.send, notification: P.bellRing, daylight: P.sun, candlelight: P.flame,
processed: P.sparkles,
},
picture_source: { raw: P.monitor, processed: P.palette, static_image: P.image },
value_source: {

View File

@@ -26,6 +26,7 @@ const _colorStripTypeIcons = {
notification: _svg(P.bellRing),
daylight: _svg(P.sun),
candlelight: _svg(P.flame),
processed: _svg(P.sparkles),
};
const _valueSourceTypeIcons = {
static: _svg(P.layoutDashboard), animated: _svg(P.refreshCw), audio: _svg(P.music),
@@ -102,6 +103,7 @@ export const ICON_CAPTURE_TEMPLATE = _svg(P.camera);
export const ICON_PP_TEMPLATE = _svg(P.wrench);
export const ICON_PATTERN_TEMPLATE = _svg(P.fileText);
export const ICON_AUDIO_TEMPLATE = _svg(P.music);
export const ICON_CSPT = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>';
// ── Action constants ────────────────────────────────────────

View File

@@ -83,6 +83,17 @@ export function set_modalFilters(v) { _modalFilters = v; }
export let _ppTemplateNameManuallyEdited = false;
export function set_ppTemplateNameManuallyEdited(v) { _ppTemplateNameManuallyEdited = v; }
// CSPT (Color Strip Processing Template) state
export let _csptModalFilters = [];
export function set_csptModalFilters(v) { _csptModalFilters = v; }
export let _csptNameManuallyEdited = false;
export function set_csptNameManuallyEdited(v) { _csptNameManuallyEdited = v; }
export let _stripFilters = [];
export let _cachedCSPTemplates = [];
// Stream test state
export let _currentTestStreamId = null;
export function set_currentTestStreamId(v) { _currentTestStreamId = v; }
@@ -260,6 +271,18 @@ export const colorStripSourcesCache = new DataCache({
extractData: json => json.sources || [],
});
export const csptCache = new DataCache({
endpoint: '/color-strip-processing-templates',
extractData: json => json.templates || [],
});
csptCache.subscribe(v => { _cachedCSPTemplates = v; });
export const stripFiltersCache = new DataCache({
endpoint: '/strip-filters',
extractData: json => json.filters || [],
});
stripFiltersCache.subscribe(v => { _stripFilters = v; });
export const devicesCache = new DataCache({
endpoint: '/devices',
extractData: json => json.devices || [],

View File

@@ -3,7 +3,7 @@
*/
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { _cachedSyncClocks, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache } from '../core/state.js';
import { _cachedSyncClocks, _cachedValueSources, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache } from '../core/state.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
import { Modal } from '../core/modal.js';
@@ -48,11 +48,7 @@ class CSSEditorModal extends Modal {
picture_source: document.getElementById('css-editor-picture-source').value,
interpolation: document.getElementById('css-editor-interpolation').value,
smoothing: document.getElementById('css-editor-smoothing').value,
brightness: document.getElementById('css-editor-brightness').value,
saturation: document.getElementById('css-editor-saturation').value,
gamma: document.getElementById('css-editor-gamma').value,
color: document.getElementById('css-editor-color').value,
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
led_count: document.getElementById('css-editor-led-count').value,
gradient_stops: type === 'gradient' ? JSON.stringify(getGradientStops()) : '[]',
animation_type: document.getElementById('css-editor-animation-type').value,
@@ -89,6 +85,8 @@ class CSSEditorModal extends Modal {
candlelight_intensity: document.getElementById('css-editor-candlelight-intensity').value,
candlelight_num_candles: document.getElementById('css-editor-candlelight-num-candles').value,
candlelight_speed: document.getElementById('css-editor-candlelight-speed').value,
processed_input: document.getElementById('css-editor-processed-input').value,
processed_template: document.getElementById('css-editor-processed-template').value,
tags: JSON.stringify(_cssTagsInput ? _cssTagsInput.getValue() : []),
};
}
@@ -102,13 +100,15 @@ let _cssTagsInput = null;
let _cssPictureSourceEntitySelect = null;
let _cssAudioSourceEntitySelect = null;
let _cssClockEntitySelect = null;
let _processedInputEntitySelect = null;
let _processedTemplateEntitySelect = null;
/* ── Icon-grid type selector ──────────────────────────────────── */
const CSS_TYPE_KEYS = [
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification', 'daylight', 'candlelight',
'api_input', 'notification', 'daylight', 'candlelight', 'processed',
];
function _buildCSSTypeItems() {
@@ -159,8 +159,10 @@ export function onCSSTypeChange() {
document.getElementById('css-editor-notification-section').style.display = type === 'notification' ? '' : 'none';
document.getElementById('css-editor-daylight-section').style.display = type === 'daylight' ? '' : 'none';
document.getElementById('css-editor-candlelight-section').style.display = type === 'candlelight' ? '' : 'none';
document.getElementById('css-editor-processed-section').style.display = type === 'processed' ? '' : 'none';
if (isPictureType) _ensureInterpolationIconSelect();
if (type === 'processed') _populateProcessedSelectors();
if (type === 'effect') {
_ensureEffectTypeIconSelect();
_ensureEffectPaletteIconSelect();
@@ -244,6 +246,45 @@ export function onCSSClockChange() {
// No-op: speed sliders removed; speed is now clock-only
}
function _populateProcessedSelectors() {
const editingId = document.getElementById('css-editor-id').value;
const allSources = colorStripSourcesCache.data || [];
// Exclude self and other processed sources to prevent cycles
const inputSources = allSources.filter(s => s.id !== editingId && s.source_type !== 'processed');
const inputSel = document.getElementById('css-editor-processed-input');
const prevInput = inputSel.value;
inputSel.innerHTML = '<option value="">—</option>' +
inputSources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
inputSel.value = prevInput || '';
if (_processedInputEntitySelect) _processedInputEntitySelect.destroy();
if (inputSources.length > 0) {
_processedInputEntitySelect = new EntitySelect({
target: inputSel,
getItems: () => (colorStripSourcesCache.data || [])
.filter(s => s.id !== editingId && s.source_type !== 'processed')
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
placeholder: t('palette.search'),
});
}
const templates = csptCache.data || [];
const tplSel = document.getElementById('css-editor-processed-template');
const prevTpl = tplSel.value;
tplSel.innerHTML = '<option value="">—</option>' +
templates.map(tp => `<option value="${tp.id}">${escapeHtml(tp.name)}</option>`).join('');
tplSel.value = prevTpl || '';
if (_processedTemplateEntitySelect) _processedTemplateEntitySelect.destroy();
if (templates.length > 0) {
_processedTemplateEntitySelect = new EntitySelect({
target: tplSel,
getItems: () => (csptCache.data || []).map(tp => ({
value: tp.id, label: tp.name, icon: ICON_SPARKLES,
})),
placeholder: t('palette.search'),
});
}
}
function _getAnimationPayload() {
const type = document.getElementById('css-editor-animation-type').value;
return {
@@ -543,6 +584,7 @@ let _compositeAvailableSources = []; // non-composite sources for layer dropdow
let _compositeSourceEntitySelects = [];
let _compositeBrightnessEntitySelects = [];
let _compositeBlendIconSelects = [];
let _compositeCSPTEntitySelects = [];
function _compositeDestroyEntitySelects() {
_compositeSourceEntitySelects.forEach(es => es.destroy());
@@ -551,6 +593,8 @@ function _compositeDestroyEntitySelects() {
_compositeBrightnessEntitySelects = [];
_compositeBlendIconSelects.forEach(is => is.destroy());
_compositeBlendIconSelects = [];
_compositeCSPTEntitySelects.forEach(es => es.destroy());
_compositeCSPTEntitySelects = [];
}
function _getCompositeBlendItems() {
@@ -578,6 +622,14 @@ function _getCompositeBrightnessItems() {
}));
}
function _getCompositeCSPTItems() {
return (_cachedCSPTemplates || []).map(tmpl => ({
value: tmpl.id,
label: tmpl.name,
icon: ICON_SPARKLES,
}));
}
function _compositeRenderList() {
const list = document.getElementById('composite-layers-list');
if (!list) return;
@@ -591,6 +643,11 @@ function _compositeRenderList() {
vsList.map(v =>
`<option value="${v.id}"${layer.brightness_source_id === v.id ? ' selected' : ''}>${escapeHtml(v.name)}</option>`
).join('');
const csptList = _cachedCSPTemplates || [];
const csptOptions = `<option value="">${t('common.none')}</option>` +
csptList.map(tmpl =>
`<option value="${tmpl.id}"${layer.processing_template_id === tmpl.id ? ' selected' : ''}>${escapeHtml(tmpl.name)}</option>`
).join('');
const canRemove = _compositeLayers.length > 1;
return `
<div class="composite-layer-item">
@@ -625,6 +682,12 @@ function _compositeRenderList() {
</label>
<select class="composite-layer-brightness" data-idx="${i}">${vsOptions}</select>
</div>
<div class="composite-layer-row">
<label class="composite-layer-brightness-label">
<span>${t('color_strip.composite.processing')}:</span>
</label>
<select class="composite-layer-cspt" data-idx="${i}">${csptOptions}</select>
</div>
</div>
`;
}).join('');
@@ -663,6 +726,17 @@ function _compositeRenderList() {
noneLabel: t('color_strip.composite.brightness.none'),
}));
});
// Attach EntitySelect to each layer's CSPT dropdown
list.querySelectorAll('.composite-layer-cspt').forEach(sel => {
_compositeCSPTEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getCompositeCSPTItems,
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('common.none'),
}));
});
}
export function compositeAddLayer() {
@@ -673,6 +747,7 @@ export function compositeAddLayer() {
opacity: 1.0,
enabled: true,
brightness_source_id: null,
processing_template_id: null,
});
_compositeRenderList();
}
@@ -692,6 +767,7 @@ function _compositeLayersSyncFromDom() {
const opacities = list.querySelectorAll('.composite-layer-opacity');
const enableds = list.querySelectorAll('.composite-layer-enabled');
const briSrcs = list.querySelectorAll('.composite-layer-brightness');
const csptSels = list.querySelectorAll('.composite-layer-cspt');
if (srcs.length === _compositeLayers.length) {
for (let i = 0; i < srcs.length; i++) {
_compositeLayers[i].source_id = srcs[i].value;
@@ -699,6 +775,7 @@ function _compositeLayersSyncFromDom() {
_compositeLayers[i].opacity = parseFloat(opacities[i].value);
_compositeLayers[i].enabled = enableds[i].checked;
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
_compositeLayers[i].processing_template_id = csptSels[i] ? (csptSels[i].value || null) : null;
}
}
}
@@ -713,6 +790,7 @@ function _compositeGetLayers() {
enabled: l.enabled,
};
if (l.brightness_source_id) layer.brightness_source_id = l.brightness_source_id;
if (l.processing_template_id) layer.processing_template_id = l.processing_template_id;
return layer;
});
}
@@ -726,8 +804,9 @@ function _loadCompositeState(css) {
opacity: l.opacity != null ? l.opacity : 1.0,
enabled: l.enabled != null ? l.enabled : true,
brightness_source_id: l.brightness_source_id || null,
processing_template_id: l.processing_template_id || null,
}))
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null }];
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null, processing_template_id: null }];
_compositeRenderList();
}
@@ -1217,6 +1296,16 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
<span class="stream-card-prop">${numCandles} ${t('color_strip.candlelight.num_candles')}</span>
${clockBadge}
`;
} else if (source.source_type === 'processed') {
const inputSrc = (colorStripSourcesCache.data || []).find(s => s.id === source.input_source_id);
const inputName = inputSrc?.name || source.input_source_id || '—';
const tplName = source.processing_template_id
? (_cachedCSPTemplates.find(t => t.id === source.processing_template_id)?.name || source.processing_template_id)
: '—';
propsHtml = `
<span class="stream-card-prop" title="${t('color_strip.processed.input')}">${ICON_LINK_SOURCE} ${escapeHtml(inputName)}</span>
<span class="stream-card-prop" title="${t('color_strip.processed.template')}">${ICON_SPARKLES} ${escapeHtml(tplName)}</span>
`;
} else if (isPictureAdvanced) {
const cal = source.calibration || {};
const lines = cal.lines || [];
@@ -1253,7 +1342,8 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) {
const icon = getColorStripIcon(source.source_type);
const isDaylight = source.source_type === 'daylight';
const isCandlelight = source.source_type === 'candlelight';
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification && !isDaylight && !isCandlelight);
const isProcessed = source.source_type === 'processed';
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification && !isDaylight && !isCandlelight && !isProcessed);
const calibrationBtn = isPictureKind
? `<button class="btn btn-icon btn-secondary" onclick="${isPictureAdvanced ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
: '';
@@ -1427,6 +1517,12 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType =
document.getElementById('css-editor-candlelight-num-candles').value = css.num_candles ?? 3;
document.getElementById('css-editor-candlelight-speed').value = css.speed ?? 1.0;
document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
} else if (sourceType === 'processed') {
await csptCache.fetch();
await colorStripSourcesCache.fetch();
_populateProcessedSelectors();
document.getElementById('css-editor-processed-input').value = css.input_source_id || '';
document.getElementById('css-editor-processed-template').value = css.processing_template_id || '';
} else {
if (sourceType === 'picture') sourceSelect.value = css.picture_source_id || '';
@@ -1437,19 +1533,6 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType =
document.getElementById('css-editor-smoothing').value = smoothing;
document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2);
const brightness = css.brightness ?? 1.0;
document.getElementById('css-editor-brightness').value = brightness;
document.getElementById('css-editor-brightness-value').textContent = parseFloat(brightness).toFixed(2);
const saturation = css.saturation ?? 1.0;
document.getElementById('css-editor-saturation').value = saturation;
document.getElementById('css-editor-saturation-value').textContent = parseFloat(saturation).toFixed(2);
const gamma = css.gamma ?? 1.0;
document.getElementById('css-editor-gamma').value = gamma;
document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2);
document.getElementById('css-editor-frame-interpolation').checked = css.frame_interpolation || false;
}
document.getElementById('css-editor-led-count').value = css.led_count ?? 0;
@@ -1496,13 +1579,6 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType =
if (_interpolationIconSelect) _interpolationIconSelect.setValue('average');
document.getElementById('css-editor-smoothing').value = 0.3;
document.getElementById('css-editor-smoothing-value').textContent = '0.30';
document.getElementById('css-editor-brightness').value = 1.0;
document.getElementById('css-editor-brightness-value').textContent = '1.00';
document.getElementById('css-editor-saturation').value = 1.0;
document.getElementById('css-editor-saturation-value').textContent = '1.00';
document.getElementById('css-editor-gamma').value = 1.0;
document.getElementById('css-editor-gamma-value').textContent = '1.00';
document.getElementById('css-editor-frame-interpolation').checked = false;
document.getElementById('css-editor-color').value = '#ffffff';
document.getElementById('css-editor-led-count').value = 0;
_loadAnimationState(null);
@@ -1536,6 +1612,12 @@ export async function showCSSEditor(cssId = null, cloneData = null, presetType =
document.getElementById('css-editor-candlelight-num-candles').value = 3;
document.getElementById('css-editor-candlelight-speed').value = 1.0;
document.getElementById('css-editor-candlelight-speed-val').textContent = '1.0';
// Processed defaults
if (presetType === 'processed') {
await csptCache.fetch();
await colorStripSourcesCache.fetch();
_populateProcessedSelectors();
}
const typeIcon = getColorStripIcon(presetType || 'picture');
document.getElementById('css-editor-title').innerHTML = `${typeIcon} ${t('color_strip.add')}: ${t(`color_strip.type.${presetType || 'picture'}`)}`;
document.getElementById('css-editor-gradient-preset').value = '';
@@ -1714,15 +1796,24 @@ export async function saveCSSEditor() {
speed: parseFloat(document.getElementById('css-editor-candlelight-speed').value),
};
if (!cssId) payload.source_type = 'candlelight';
} else if (sourceType === 'processed') {
const inputId = document.getElementById('css-editor-processed-input').value;
const templateId = document.getElementById('css-editor-processed-template').value;
if (!inputId) {
cssEditorModal.showError(t('color_strip.processed.error.no_input'));
return;
}
payload = {
name,
input_source_id: inputId,
processing_template_id: templateId || null,
};
if (!cssId) payload.source_type = 'processed';
} else if (sourceType === 'picture_advanced') {
payload = {
name,
interpolation_mode: document.getElementById('css-editor-interpolation').value,
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
saturation: parseFloat(document.getElementById('css-editor-saturation').value),
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
if (!cssId) payload.source_type = 'picture_advanced';
@@ -1732,10 +1823,6 @@ export async function saveCSSEditor() {
picture_source_id: document.getElementById('css-editor-picture-source').value,
interpolation_mode: document.getElementById('css-editor-interpolation').value,
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
saturation: parseFloat(document.getElementById('css-editor-saturation').value),
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
if (!cssId) payload.source_type = 'picture';
@@ -1906,6 +1993,9 @@ let _cssTestIsComposite = false;
let _cssTestLayerData = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array }
let _cssTestGeneration = 0; // bumped on each connect to ignore stale WS messages
let _cssTestNotificationIds = []; // notification source IDs to fire (self or composite layers)
let _cssTestCSPTMode = false; // true when testing a CSPT template
let _cssTestCSPTId = null; // CSPT template ID when in CSPT mode
let _csptTestInputEntitySelect = null;
function _getCssTestLedCount() {
const stored = parseInt(localStorage.getItem(_CSS_TEST_LED_KEY), 10);
@@ -1918,6 +2008,48 @@ function _getCssTestFps() {
}
export function testColorStrip(sourceId) {
_cssTestCSPTMode = false;
_cssTestCSPTId = null;
// Hide CSPT input selector
const csptGroup = document.getElementById('css-test-cspt-input-group');
if (csptGroup) csptGroup.style.display = 'none';
_openTestModal(sourceId);
}
export async function testCSPT(templateId) {
_cssTestCSPTMode = true;
_cssTestCSPTId = templateId;
// Populate input source selector
await colorStripSourcesCache.fetch();
const sources = colorStripSourcesCache.data || [];
const nonProcessed = sources.filter(s => s.source_type !== 'processed');
const sel = document.getElementById('css-test-cspt-input-select');
sel.innerHTML = nonProcessed.map(s =>
`<option value="${s.id}">${escapeHtml(s.name)}</option>`
).join('');
// EntitySelect for input source picker
if (_csptTestInputEntitySelect) _csptTestInputEntitySelect.destroy();
_csptTestInputEntitySelect = new EntitySelect({
target: sel,
getItems: () => (colorStripSourcesCache.data || [])
.filter(s => s.source_type !== 'processed')
.map(s => ({ value: s.id, label: s.name, icon: getColorStripIcon(s.source_type) })),
placeholder: t('palette.search'),
});
// Show CSPT input selector
const csptGroup = document.getElementById('css-test-cspt-input-group');
if (csptGroup) csptGroup.style.display = '';
const inputId = sel.value;
if (!inputId) {
showToast(t('color_strip.processed.error.no_input'), 'error');
return;
}
_openTestModal(inputId);
}
function _openTestModal(sourceId) {
// Clean up any previous session fully
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; }
@@ -1966,7 +2098,12 @@ function _cssTestConnect(sourceId, ledCount, fps) {
if (!fps) fps = _getCssTestFps();
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const apiKey = localStorage.getItem('wled_api_key') || '';
const wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
let wsUrl;
if (_cssTestCSPTMode && _cssTestCSPTId) {
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-processing-templates/${_cssTestCSPTId}/test/ws?token=${encodeURIComponent(apiKey)}&input_source_id=${encodeURIComponent(sourceId)}&led_count=${ledCount}&fps=${fps}`;
} else {
wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`;
}
_cssTestWs = new WebSocket(wsUrl);
_cssTestWs.binaryType = 'arraybuffer';
@@ -2171,6 +2308,14 @@ export function applyCssTestSettings() {
_cssTestMeta = null;
_cssTestLayerData = null;
// In CSPT mode, read selected input source
if (_cssTestCSPTMode) {
const inputSel = document.getElementById('css-test-cspt-input-select');
if (inputSel && inputSel.value) {
_cssTestSourceId = inputSel.value;
}
}
// Reconnect (generation counter ignores stale frames from old WS)
_cssTestConnect(_cssTestSourceId, leds, fps);
}

View File

@@ -5,6 +5,7 @@
import {
_discoveryScanRunning, set_discoveryScanRunning,
_discoveryCache, set_discoveryCache,
csptCache,
} from '../core/state.js';
import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isEspnowDevice, isHueDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, escapeHtml } from '../core/api.js';
import { devicesCache } from '../core/state.js';
@@ -12,7 +13,8 @@ import { t } from '../core/i18n.js';
import { showToast, desktopFocus } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { _computeMaxFps, _renderFpsHint } from './devices.js';
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY } from '../core/icons.js';
import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE } from '../core/icons.js';
import { EntitySelect } from '../core/entity-palette.js';
import { IconSelect, showTypePicker } from '../core/icon-select.js';
class AddDeviceModal extends Modal {
@@ -30,6 +32,7 @@ class AddDeviceModal extends Modal {
sendLatency: document.getElementById('device-send-latency')?.value || '0',
zones: JSON.stringify(_getCheckedZones('device-zone-list')),
zoneMode: _getZoneMode(),
csptId: document.getElementById('device-css-processing-template')?.value || '',
dmxProtocol: document.getElementById('device-dmx-protocol')?.value || 'artnet',
dmxStartUniverse: document.getElementById('device-dmx-start-universe')?.value || '0',
dmxStartChannel: document.getElementById('device-dmx-start-channel')?.value || '1',
@@ -53,6 +56,7 @@ function _buildDeviceTypeItems() {
}
let _deviceTypeIconSelect = null;
let _csptEntitySelect = null;
function _ensureDeviceTypeIconSelect() {
const sel = document.getElementById('device-type');
@@ -61,6 +65,30 @@ function _ensureDeviceTypeIconSelect() {
_deviceTypeIconSelect = new IconSelect({ target: sel, items: _buildDeviceTypeItems(), columns: 3 });
}
function _ensureCsptEntitySelect() {
const sel = document.getElementById('device-css-processing-template');
if (!sel) return;
const templates = csptCache.data || [];
// Populate native <select> options
sel.innerHTML = `<option value="">—</option>` +
templates.map(tp => `<option value="${tp.id}">${tp.name}</option>`).join('');
if (_csptEntitySelect) _csptEntitySelect.destroy();
if (templates.length > 0) {
_csptEntitySelect = new EntitySelect({
target: sel,
getItems: () => (csptCache.data || []).map(tp => ({
value: tp.id,
label: tp.name,
icon: ICON_TEMPLATE,
desc: '',
})),
placeholder: t('palette.search'),
allowNone: true,
noneLabel: '—',
});
}
}
/* ── Icon-grid DMX protocol selector ─────────────────────────── */
function _buildDmxProtocolItems() {
@@ -583,6 +611,8 @@ export function showAddDevice(presetType = null) {
const scanBtn = document.getElementById('scan-network-btn');
if (scanBtn) scanBtn.disabled = false;
_ensureDeviceTypeIconSelect();
// Populate CSPT template selector
csptCache.fetch().then(() => _ensureCsptEntitySelect());
// Pre-select type and hide the type selector (already chosen)
document.getElementById('device-type').value = presetType;
@@ -775,6 +805,8 @@ export async function handleAddDevice(event) {
if (isGameSenseDevice(deviceType)) {
body.gamesense_device_type = document.getElementById('device-gamesense-device-type')?.value || 'keyboard';
}
const csptId = document.getElementById('device-css-processing-template')?.value;
if (csptId) body.default_css_processing_template_id = csptId;
if (lastTemplateId) body.capture_template_id = lastTemplateId;
const response = await fetchWithAuth('/devices', {

View File

@@ -4,6 +4,7 @@
import {
_deviceBrightnessCache, updateDeviceBrightness,
csptCache,
} from '../core/state.js';
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice } from '../core/api.js';
import { devicesCache } from '../core/state.js';
@@ -11,11 +12,36 @@ import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode,
import { t } from '../core/i18n.js';
import { showToast, showConfirm, desktopFocus } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH } from '../core/icons.js';
import { ICON_SETTINGS, ICON_STOP_PLAIN, ICON_LED, ICON_WEB, ICON_PLUG, ICON_REFRESH, ICON_TEMPLATE } from '../core/icons.js';
import { wrapCard } from '../core/card-colors.js';
import { TagInput, renderTagChips } from '../core/tag-input.js';
import { EntitySelect } from '../core/entity-palette.js';
let _deviceTagsInput = null;
let _settingsCsptEntitySelect = null;
function _ensureSettingsCsptSelect() {
const sel = document.getElementById('settings-css-processing-template');
if (!sel) return;
const templates = csptCache.data || [];
sel.innerHTML = `<option value="">—</option>` +
templates.map(tp => `<option value="${tp.id}">${tp.name}</option>`).join('');
if (_settingsCsptEntitySelect) _settingsCsptEntitySelect.destroy();
if (templates.length > 0) {
_settingsCsptEntitySelect = new EntitySelect({
target: sel,
getItems: () => (csptCache.data || []).map(tp => ({
value: tp.id,
label: tp.name,
icon: ICON_TEMPLATE,
desc: '',
})),
placeholder: window.t ? t('palette.search') : 'Search...',
allowNone: true,
noneLabel: '—',
});
}
}
class DeviceSettingsModal extends Modal {
constructor() { super('device-settings-modal'); }
@@ -38,6 +64,7 @@ class DeviceSettingsModal extends Modal {
dmxProtocol: document.getElementById('settings-dmx-protocol')?.value || 'artnet',
dmxStartUniverse: document.getElementById('settings-dmx-start-universe')?.value || '0',
dmxStartChannel: document.getElementById('settings-dmx-start-channel')?.value || '1',
csptId: document.getElementById('settings-css-processing-template')?.value || '',
};
}
@@ -394,6 +421,12 @@ export async function showSettings(deviceId) {
});
_deviceTagsInput.setValue(device.tags || []);
// CSPT template selector
await csptCache.fetch();
_ensureSettingsCsptSelect();
const csptSel = document.getElementById('settings-css-processing-template');
if (csptSel) csptSel.value = device.default_css_processing_template_id || '';
settingsModal.snapshot();
settingsModal.open();
@@ -407,7 +440,7 @@ export async function showSettings(deviceId) {
}
export function isSettingsDirty() { return settingsModal.isDirty(); }
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } settingsModal.forceClose(); }
export function forceCloseDeviceSettingsModal() { if (_deviceTagsInput) { _deviceTagsInput.destroy(); _deviceTagsInput = null; } if (_settingsCsptEntitySelect) { _settingsCsptEntitySelect.destroy(); _settingsCsptEntitySelect = null; } settingsModal.forceClose(); }
export function closeDeviceSettingsModal() { settingsModal.close(); }
export async function saveDeviceSettings() {
@@ -449,6 +482,8 @@ export async function saveDeviceSettings() {
body.dmx_start_universe = parseInt(document.getElementById('settings-dmx-start-universe')?.value || '0', 10);
body.dmx_start_channel = parseInt(document.getElementById('settings-dmx-start-channel')?.value || '1', 10);
}
const csptId = document.getElementById('settings-css-processing-template')?.value || '';
body.default_css_processing_template_id = csptId;
const deviceResponse = await fetchWithAuth(`/devices/${deviceId}`, {
method: 'PUT',
body: JSON.stringify(body)

View File

@@ -11,7 +11,7 @@ import {
streamsCache, audioSourcesCache, audioTemplatesCache,
valueSourcesCache, colorStripSourcesCache, syncClocksCache,
outputTargetsCache, patternTemplatesCache, scenePresetsCache,
automationsCacheObj,
automationsCacheObj, csptCache,
} from '../core/state.js';
import { fetchWithAuth } from '../core/api.js';
import { showToast, showConfirm } from '../core/ui.js';
@@ -515,13 +515,13 @@ async function _fetchAllEntities() {
devices, captureTemplates, ppTemplates, pictureSources,
audioSources, audioTemplates, valueSources, colorStripSources,
syncClocks, outputTargets, patternTemplates, scenePresets, automations,
batchStatesResp,
csptTemplates, batchStatesResp,
] = await Promise.all([
devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(),
streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(),
valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(),
outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(),
automationsCacheObj.fetch(),
automationsCacheObj.fetch(), csptCache.fetch(),
fetchWithAuth('/output-targets/batch/states').catch(() => null),
]);
@@ -540,6 +540,7 @@ async function _fetchAllEntities() {
devices, captureTemplates, ppTemplates, pictureSources,
audioSources, audioTemplates, valueSources, colorStripSources,
syncClocks, outputTargets: enrichedTargets, patternTemplates, scenePresets, automations,
csptTemplates,
};
}

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