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:
2026-02-23 11:01:44 +03:00
parent e5a6eafd09
commit 2657f46e5d
15 changed files with 1042 additions and 144 deletions

View File

@@ -91,6 +91,7 @@ import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview,
colorCycleAddColor, colorCycleRemoveColor,
compositeAddLayer, compositeRemoveLayer,
applyGradientPreset,
} from './features/color-strips.js';
@@ -281,6 +282,8 @@ Object.assign(window, {
updateEffectPreview,
colorCycleAddColor,
colorCycleRemoveColor,
compositeAddLayer,
compositeRemoveLayer,
applyGradientPreset,
// calibration

View File

@@ -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})">&#x2715;</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,