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

@@ -678,3 +678,61 @@
min-width: unset;
line-height: 1;
}
/* ── Composite layer editor ────────────────────────────────────── */
#composite-layers-list {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 8px;
}
.composite-layer-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--card-bg);
}
.composite-layer-row {
display: flex;
align-items: center;
gap: 6px;
}
.composite-layer-source {
flex: 1;
min-width: 0;
}
.composite-layer-blend {
width: 100px;
flex-shrink: 0;
}
.composite-layer-opacity-label {
font-size: 0.8rem;
white-space: nowrap;
flex-shrink: 0;
}
.composite-layer-opacity {
flex: 1;
min-width: 60px;
}
.composite-layer-toggle {
flex-shrink: 0;
}
.composite-layer-remove-btn {
font-size: 0.75rem;
padding: 0;
width: 26px;
height: 26px;
flex: 0 0 26px;
}

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,

View File

@@ -578,7 +578,7 @@
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target",
"color_strip.error.name_required": "Please enter a name",
"color_strip.type": "Type:",
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors.",
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers.",
"color_strip.type.picture": "Picture Source",
"color_strip.type.static": "Static Color",
"color_strip.type.gradient": "Gradient",
@@ -640,6 +640,22 @@
"color_strip.color_cycle.min_colors": "Color cycle must have at least 2 colors",
"color_strip.type.effect": "Effect",
"color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.",
"color_strip.type.composite": "Composite",
"color_strip.type.composite.hint": "Stack multiple color strip sources as layers with blend modes and opacity.",
"color_strip.composite.layers": "Layers:",
"color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.",
"color_strip.composite.add_layer": "+ Add Layer",
"color_strip.composite.source": "Source",
"color_strip.composite.blend_mode": "Blend",
"color_strip.composite.blend_mode.normal": "Normal",
"color_strip.composite.blend_mode.add": "Add",
"color_strip.composite.blend_mode.multiply": "Multiply",
"color_strip.composite.blend_mode.screen": "Screen",
"color_strip.composite.opacity": "Opacity",
"color_strip.composite.enabled": "Enabled",
"color_strip.composite.error.min_layers": "At least 1 layer is required",
"color_strip.composite.error.no_source": "Each layer must have a source selected",
"color_strip.composite.layers_count": "layers",
"color_strip.effect.type": "Effect Type:",
"color_strip.effect.type.hint": "Choose the procedural algorithm.",
"color_strip.effect.fire": "Fire",

View File

@@ -578,7 +578,7 @@
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
"color_strip.error.name_required": "Введите название",
"color_strip.type": "Тип:",
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами.",
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои.",
"color_strip.type.picture": "Источник изображения",
"color_strip.type.static": "Статический цвет",
"color_strip.type.gradient": "Градиент",
@@ -640,6 +640,22 @@
"color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов",
"color_strip.type.effect": "Эффект",
"color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.",
"color_strip.type.composite": "Композит",
"color_strip.type.composite.hint": "Наложение нескольких источников цветовой ленты как слоёв с режимами смешивания и прозрачностью.",
"color_strip.composite.layers": "Слои:",
"color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.",
"color_strip.composite.add_layer": "+ Добавить слой",
"color_strip.composite.source": "Источник",
"color_strip.composite.blend_mode": "Смешивание",
"color_strip.composite.blend_mode.normal": "Обычное",
"color_strip.composite.blend_mode.add": "Сложение",
"color_strip.composite.blend_mode.multiply": "Умножение",
"color_strip.composite.blend_mode.screen": "Экран",
"color_strip.composite.opacity": "Непрозрачность",
"color_strip.composite.enabled": "Включён",
"color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой",
"color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник",
"color_strip.composite.layers_count": "слоёв",
"color_strip.effect.type": "Тип эффекта:",
"color_strip.effect.type.hint": "Выберите процедурный алгоритм.",
"color_strip.effect.fire": "Огонь",