Add composite color strip source type with layer blending
Composite sources stack multiple existing color strip sources as layers with configurable blend modes (Normal, Add, Multiply, Screen) and per-layer opacity. Includes full CRUD, hot-reload, delete protection for referenced layers, and pre-allocated integer blend math at 30 FPS. Also eliminates per-frame numpy allocations in color_strip_stream, effect_stream, and wled_target_processor (buffer pre-allocation). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,7 @@ class CSSEditorModal extends Modal {
|
||||
effect_intensity: document.getElementById('css-editor-effect-intensity').value,
|
||||
effect_scale: document.getElementById('css-editor-effect-scale').value,
|
||||
effect_mirror: document.getElementById('css-editor-effect-mirror').checked,
|
||||
composite_layers: JSON.stringify(_compositeLayers),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -53,6 +54,7 @@ export function onCSSTypeChange() {
|
||||
document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none';
|
||||
document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none';
|
||||
document.getElementById('css-editor-effect-section').style.display = type === 'effect' ? '' : 'none';
|
||||
document.getElementById('css-editor-composite-section').style.display = type === 'composite' ? '' : 'none';
|
||||
|
||||
if (type === 'effect') onEffectTypeChange();
|
||||
|
||||
@@ -85,7 +87,12 @@ export function onCSSTypeChange() {
|
||||
}
|
||||
_syncAnimationSpeedState();
|
||||
|
||||
if (type === 'gradient') {
|
||||
// LED count — not needed for composite (uses device count)
|
||||
document.getElementById('css-editor-led-count-group').style.display = type === 'composite' ? 'none' : '';
|
||||
|
||||
if (type === 'composite') {
|
||||
_compositeRenderList();
|
||||
} else if (type === 'gradient') {
|
||||
requestAnimationFrame(() => gradientRenderAll());
|
||||
}
|
||||
}
|
||||
@@ -260,6 +267,117 @@ function hexToRgbArray(hex) {
|
||||
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255];
|
||||
}
|
||||
|
||||
/* ── Composite layer helpers ──────────────────────────────────── */
|
||||
|
||||
let _compositeLayers = [];
|
||||
let _compositeAvailableSources = []; // non-composite sources for layer dropdowns
|
||||
|
||||
function _compositeRenderList() {
|
||||
const list = document.getElementById('composite-layers-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = _compositeLayers.map((layer, i) => {
|
||||
const srcOptions = _compositeAvailableSources.map(s =>
|
||||
`<option value="${s.id}"${layer.source_id === s.id ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
|
||||
).join('');
|
||||
const canRemove = _compositeLayers.length > 1;
|
||||
return `
|
||||
<div class="composite-layer-item">
|
||||
<div class="composite-layer-row">
|
||||
<select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
|
||||
<select class="composite-layer-blend" data-idx="${i}">
|
||||
<option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option>
|
||||
<option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option>
|
||||
<option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option>
|
||||
<option value="screen"${layer.blend_mode === 'screen' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.screen')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="composite-layer-row">
|
||||
<label class="composite-layer-opacity-label">
|
||||
<span>${t('color_strip.composite.opacity')}:</span>
|
||||
<span class="composite-opacity-val">${parseFloat(layer.opacity).toFixed(2)}</span>
|
||||
</label>
|
||||
<input type="range" class="composite-layer-opacity" data-idx="${i}"
|
||||
min="0" max="1" step="0.05" value="${layer.opacity}">
|
||||
<label class="settings-toggle composite-layer-toggle">
|
||||
<input type="checkbox" class="composite-layer-enabled" data-idx="${i}"${layer.enabled ? ' checked' : ''}>
|
||||
<span class="settings-toggle-slider"></span>
|
||||
</label>
|
||||
${canRemove
|
||||
? `<button type="button" class="btn btn-secondary composite-layer-remove-btn"
|
||||
onclick="compositeRemoveLayer(${i})">✕</button>`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Wire up live opacity display
|
||||
list.querySelectorAll('.composite-layer-opacity').forEach(el => {
|
||||
el.addEventListener('input', () => {
|
||||
const val = parseFloat(el.value);
|
||||
el.closest('.composite-layer-row').querySelector('.composite-opacity-val').textContent = val.toFixed(2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function compositeAddLayer() {
|
||||
_compositeLayersSyncFromDom();
|
||||
_compositeLayers.push({
|
||||
source_id: _compositeAvailableSources.length > 0 ? _compositeAvailableSources[0].id : '',
|
||||
blend_mode: 'normal',
|
||||
opacity: 1.0,
|
||||
enabled: true,
|
||||
});
|
||||
_compositeRenderList();
|
||||
}
|
||||
|
||||
export function compositeRemoveLayer(i) {
|
||||
_compositeLayersSyncFromDom();
|
||||
if (_compositeLayers.length <= 1) return;
|
||||
_compositeLayers.splice(i, 1);
|
||||
_compositeRenderList();
|
||||
}
|
||||
|
||||
function _compositeLayersSyncFromDom() {
|
||||
const list = document.getElementById('composite-layers-list');
|
||||
if (!list) return;
|
||||
const srcs = list.querySelectorAll('.composite-layer-source');
|
||||
const blends = list.querySelectorAll('.composite-layer-blend');
|
||||
const opacities = list.querySelectorAll('.composite-layer-opacity');
|
||||
const enableds = list.querySelectorAll('.composite-layer-enabled');
|
||||
if (srcs.length === _compositeLayers.length) {
|
||||
for (let i = 0; i < srcs.length; i++) {
|
||||
_compositeLayers[i].source_id = srcs[i].value;
|
||||
_compositeLayers[i].blend_mode = blends[i].value;
|
||||
_compositeLayers[i].opacity = parseFloat(opacities[i].value);
|
||||
_compositeLayers[i].enabled = enableds[i].checked;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _compositeGetLayers() {
|
||||
_compositeLayersSyncFromDom();
|
||||
return _compositeLayers.map(l => ({
|
||||
source_id: l.source_id,
|
||||
blend_mode: l.blend_mode,
|
||||
opacity: l.opacity,
|
||||
enabled: l.enabled,
|
||||
}));
|
||||
}
|
||||
|
||||
function _loadCompositeState(css) {
|
||||
const raw = css && css.layers;
|
||||
_compositeLayers = (raw && raw.length > 0)
|
||||
? raw.map(l => ({
|
||||
source_id: l.source_id || '',
|
||||
blend_mode: l.blend_mode || 'normal',
|
||||
opacity: l.opacity != null ? l.opacity : 1.0,
|
||||
enabled: l.enabled != null ? l.enabled : true,
|
||||
}))
|
||||
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true }];
|
||||
_compositeRenderList();
|
||||
}
|
||||
|
||||
/* ── Card ─────────────────────────────────────────────────────── */
|
||||
|
||||
export function createColorStripCard(source, pictureSourceMap) {
|
||||
@@ -267,6 +385,7 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
const isGradient = source.source_type === 'gradient';
|
||||
const isColorCycle = source.source_type === 'color_cycle';
|
||||
const isEffect = source.source_type === 'effect';
|
||||
const isComposite = source.source_type === 'composite';
|
||||
|
||||
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
|
||||
const animBadge = anim
|
||||
@@ -325,6 +444,13 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
<span class="stream-card-prop" title="${t('color_strip.effect.speed')}">⏩ ${(source.speed || 1.0).toFixed(1)}×</span>
|
||||
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
|
||||
`;
|
||||
} else if (isComposite) {
|
||||
const layerCount = (source.layers || []).length;
|
||||
const enabledCount = (source.layers || []).filter(l => l.enabled !== false).length;
|
||||
propsHtml = `
|
||||
<span class="stream-card-prop">🔗 ${enabledCount}/${layerCount} ${t('color_strip.composite.layers_count')}</span>
|
||||
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
|
||||
`;
|
||||
} else {
|
||||
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
|
||||
? pictureSourceMap[source.picture_source_id].name
|
||||
@@ -338,8 +464,8 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
`;
|
||||
}
|
||||
|
||||
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : '🎞️';
|
||||
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect)
|
||||
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : '🎞️';
|
||||
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite)
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
|
||||
: '';
|
||||
|
||||
@@ -369,6 +495,13 @@ export async function showCSSEditor(cssId = null) {
|
||||
const sourcesResp = await fetchWithAuth('/picture-sources');
|
||||
const sources = sourcesResp.ok ? ((await sourcesResp.json()).streams || []) : [];
|
||||
|
||||
// Fetch all color strip sources for composite layer dropdowns
|
||||
const cssListResp = await fetchWithAuth('/color-strip-sources');
|
||||
const allCssSources = cssListResp.ok ? ((await cssListResp.json()).sources || []) : [];
|
||||
_compositeAvailableSources = allCssSources.filter(s =>
|
||||
s.source_type !== 'composite' && (!cssId || s.id !== cssId)
|
||||
);
|
||||
|
||||
const sourceSelect = document.getElementById('css-editor-picture-source');
|
||||
sourceSelect.innerHTML = '';
|
||||
sources.forEach(s => {
|
||||
@@ -416,6 +549,12 @@ export async function showCSSEditor(cssId = null) {
|
||||
document.getElementById('css-editor-effect-scale').value = css.scale ?? 1.0;
|
||||
document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
|
||||
document.getElementById('css-editor-effect-mirror').checked = css.mirror || false;
|
||||
} else if (sourceType === 'composite') {
|
||||
// Exclude self from available sources when editing
|
||||
_compositeAvailableSources = allCssSources.filter(s =>
|
||||
s.source_type !== 'composite' && s.id !== css.id
|
||||
);
|
||||
_loadCompositeState(css);
|
||||
} else {
|
||||
sourceSelect.value = css.picture_source_id || '';
|
||||
|
||||
@@ -471,6 +610,7 @@ export async function showCSSEditor(cssId = null) {
|
||||
document.getElementById('css-editor-effect-scale').value = 1.0;
|
||||
document.getElementById('css-editor-effect-scale-val').textContent = '1.0';
|
||||
document.getElementById('css-editor-effect-mirror').checked = false;
|
||||
_loadCompositeState(null);
|
||||
document.getElementById('css-editor-title').textContent = t('color_strip.add');
|
||||
document.getElementById('css-editor-gradient-preset').value = '';
|
||||
gradientInit([
|
||||
@@ -558,6 +698,22 @@ export async function saveCSSEditor() {
|
||||
payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
|
||||
}
|
||||
if (!cssId) payload.source_type = 'effect';
|
||||
} else if (sourceType === 'composite') {
|
||||
const layers = _compositeGetLayers();
|
||||
if (layers.length < 1) {
|
||||
cssEditorModal.showError(t('color_strip.composite.error.min_layers'));
|
||||
return;
|
||||
}
|
||||
const hasEmpty = layers.some(l => !l.source_id);
|
||||
if (hasEmpty) {
|
||||
cssEditorModal.showError(t('color_strip.composite.error.no_source'));
|
||||
return;
|
||||
}
|
||||
payload = {
|
||||
name,
|
||||
layers,
|
||||
};
|
||||
if (!cssId) payload.source_type = 'composite';
|
||||
} else {
|
||||
payload = {
|
||||
name,
|
||||
|
||||
Reference in New Issue
Block a user