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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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