Add per-layer brightness source to composite CSS and enhance selectors

- Add optional brightness_source_id per composite layer using ValueStreamManager
- Use EntitySelect for composite layer source and brightness dropdowns
- Use IconSelect for composite blend mode and notification filter mode
- Add i18n keys for blend mode and filter mode descriptions (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 21:03:58 +03:00
parent c78797ba09
commit d498bb72a9
9 changed files with 181 additions and 17 deletions

View File

@@ -34,6 +34,7 @@ class CompositeLayer(BaseModel):
blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen")
opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0")
enabled: bool = Field(default=True, description="Whether this layer is active")
brightness_source_id: Optional[str] = Field(None, description="Optional value source ID for dynamic brightness")
class MappedZone(BaseModel):

View File

@@ -68,7 +68,7 @@ class ColorStripStreamManager:
keyed by ``{css_id}:{consumer_id}``.
"""
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None):
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None):
"""
Args:
color_strip_store: ColorStripStore for resolving source configs
@@ -76,6 +76,7 @@ class ColorStripStreamManager:
audio_capture_manager: AudioCaptureManager for audio-reactive sources
audio_source_store: AudioSourceStore for resolving audio source chains
sync_clock_manager: SyncClockManager for acquiring clock runtimes
value_stream_manager: ValueStreamManager for per-layer brightness sources
"""
self._color_strip_store = color_strip_store
self._live_stream_manager = live_stream_manager
@@ -83,6 +84,7 @@ class ColorStripStreamManager:
self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_store
self._sync_clock_manager = sync_clock_manager
self._value_stream_manager = value_stream_manager
self._streams: Dict[str, _ColorStripEntry] = {}
def _inject_clock(self, css_stream, source) -> Optional[str]:
@@ -159,7 +161,7 @@ class ColorStripStreamManager:
css_stream = AudioColorStripStream(source, self._audio_capture_manager, self._audio_source_store, self._audio_template_store)
elif source.source_type == "composite":
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
css_stream = CompositeColorStripStream(source, self)
css_stream = CompositeColorStripStream(source, self, self._value_stream_manager)
elif source.source_type == "mapped":
from wled_controller.core.processing.mapped_stream import MappedColorStripStream
css_stream = MappedColorStripStream(source, self)

View File

@@ -29,12 +29,13 @@ class CompositeColorStripStream(ColorStripStream):
sub-stream's latest colors and blending bottom-to-top.
"""
def __init__(self, source, css_manager):
def __init__(self, source, css_manager, value_stream_manager=None):
self._source_id: str = source.id
self._layers: List[dict] = list(source.layers)
self._led_count: int = source.led_count
self._auto_size: bool = source.led_count == 0
self._css_manager = css_manager
self._value_stream_manager = value_stream_manager
self._fps: int = 30
self._frame_time: float = 1.0 / 30
@@ -45,7 +46,9 @@ class CompositeColorStripStream(ColorStripStream):
# layer_index -> (source_id, consumer_id, stream)
self._sub_streams: Dict[int, tuple] = {}
self._sub_lock = threading.Lock() # guards _sub_streams access across threads
# layer_index -> (vs_id, value_stream)
self._brightness_streams: Dict[int, tuple] = {}
self._sub_lock = threading.Lock() # guards _sub_streams and _brightness_streams
# Pre-allocated scratch (rebuilt when LED count changes)
self._pool_n = 0
@@ -115,9 +118,9 @@ class CompositeColorStripStream(ColorStripStream):
def update_source(self, source) -> None:
"""Hot-update: rebuild sub-streams if layer config changed."""
new_layers = list(source.layers)
old_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"))
old_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"), l.get("brightness_source_id"))
for l in self._layers]
new_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"))
new_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled"), l.get("brightness_source_id"))
for l in new_layers]
self._layers = new_layers
@@ -152,6 +155,16 @@ class CompositeColorStripStream(ColorStripStream):
logger.warning(
f"Composite layer {i} (source {src_id}) failed to acquire: {e}"
)
# Acquire brightness value stream if configured
vs_id = layer.get("brightness_source_id")
if vs_id and self._value_stream_manager:
try:
vs = self._value_stream_manager.acquire(vs_id)
self._brightness_streams[i] = (vs_id, vs)
except Exception as e:
logger.warning(
f"Composite layer {i} brightness source {vs_id} failed: {e}"
)
def _release_sub_streams(self) -> None:
for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()):
@@ -160,6 +173,14 @@ class CompositeColorStripStream(ColorStripStream):
except Exception as e:
logger.warning(f"Composite layer release error ({src_id}): {e}")
self._sub_streams.clear()
# Release brightness value streams
if self._value_stream_manager:
for _idx, (vs_id, _vs) in list(self._brightness_streams.items()):
try:
self._value_stream_manager.release(vs_id)
except Exception as e:
logger.warning(f"Composite brightness release error ({vs_id}): {e}")
self._brightness_streams.clear()
# ── Scratch pool ────────────────────────────────────────────
@@ -299,6 +320,13 @@ class CompositeColorStripStream(ColorStripStream):
if len(colors) != target_n:
colors = self._resize_to_target(colors, target_n)
# Apply per-layer brightness from value source
if i in self._brightness_streams:
_vs_id, vs = self._brightness_streams[i]
bri = vs.get_value()
if bri < 1.0:
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8)
opacity = layer.get("opacity", 1.0)
blend_mode = layer.get("blend_mode", _BLEND_NORMAL)
alpha = int(opacity * 256)

View File

@@ -122,6 +122,8 @@ class ProcessorManager:
live_stream_manager=self._live_stream_manager,
audio_template_store=audio_template_store,
) if value_source_store else None
# Wire value stream manager into CSS stream manager for composite layer brightness
self._color_strip_stream_manager._value_stream_manager = self._value_stream_manager
self._overlay_manager = OverlayManager()
self._event_queues: List[asyncio.Queue] = []
self._metrics_history = MetricsHistory(self)

View File

@@ -3,12 +3,12 @@
*/
import { fetchWithAuth, escapeHtml } from '../core/api.js';
import { _cachedSyncClocks, audioSourcesCache, streamsCache, colorStripSourcesCache } from '../core/state.js';
import { _cachedSyncClocks, _cachedValueSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache } from '../core/state.js';
import { t } from '../core/i18n.js';
import { showToast, showConfirm } from '../core/ui.js';
import { Modal } from '../core/modal.js';
import {
getColorStripIcon, getPictureSourceIcon, getAudioSourceIcon,
getColorStripIcon, getPictureSourceIcon, getAudioSourceIcon, getValueSourceIcon,
ICON_CLONE, ICON_EDIT, ICON_CALIBRATION,
ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC,
ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM,
@@ -36,6 +36,7 @@ class CSSEditorModal extends Modal {
onForceClose() {
if (_cssTagsInput) { _cssTagsInput.destroy(); _cssTagsInput = null; }
_compositeDestroyEntitySelects();
}
snapshotValues() {
@@ -170,7 +171,10 @@ export function onCSSTypeChange() {
onAudioVizChange();
}
if (type === 'gradient') _ensureGradientPresetIconSelect();
if (type === 'notification') _ensureNotificationEffectIconSelect();
if (type === 'notification') {
_ensureNotificationEffectIconSelect();
_ensureNotificationFilterModeIconSelect();
}
// Animation section — shown for static/gradient only
const animSection = document.getElementById('css-editor-animation-section');
@@ -319,6 +323,7 @@ let _audioPaletteIconSelect = null;
let _audioVizIconSelect = null;
let _gradientPresetIconSelect = null;
let _notificationEffectIconSelect = null;
let _notificationFilterModeIconSelect = null;
const _icon = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
@@ -405,6 +410,18 @@ function _ensureNotificationEffectIconSelect() {
_notificationEffectIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
function _ensureNotificationFilterModeIconSelect() {
const sel = document.getElementById('css-editor-notification-filter-mode');
if (!sel) return;
const items = [
{ value: 'off', icon: _icon(P.globe), label: t('color_strip.notification.filter_mode.off'), desc: t('color_strip.notification.filter_mode.off.desc') },
{ value: 'whitelist', icon: _icon(P.circleCheck), label: t('color_strip.notification.filter_mode.whitelist'), desc: t('color_strip.notification.filter_mode.whitelist.desc') },
{ value: 'blacklist', icon: _icon(P.eyeOff), label: t('color_strip.notification.filter_mode.blacklist'), desc: t('color_strip.notification.filter_mode.blacklist.desc') },
];
if (_notificationFilterModeIconSelect) { _notificationFilterModeIconSelect.updateItems(items); return; }
_notificationFilterModeIconSelect = new IconSelect({ target: sel, items, columns: 3 });
}
/* ── Effect type helpers ──────────────────────────────────────── */
// Palette color control points — mirrors _PALETTE_DEFS in effect_stream.py
@@ -504,14 +521,57 @@ function _loadColorCycleState(css) {
let _compositeLayers = [];
let _compositeAvailableSources = []; // non-composite sources for layer dropdowns
let _compositeSourceEntitySelects = [];
let _compositeBrightnessEntitySelects = [];
let _compositeBlendIconSelects = [];
function _compositeDestroyEntitySelects() {
_compositeSourceEntitySelects.forEach(es => es.destroy());
_compositeSourceEntitySelects = [];
_compositeBrightnessEntitySelects.forEach(es => es.destroy());
_compositeBrightnessEntitySelects = [];
_compositeBlendIconSelects.forEach(is => is.destroy());
_compositeBlendIconSelects = [];
}
function _getCompositeBlendItems() {
return [
{ value: 'normal', icon: _icon(P.square), label: t('color_strip.composite.blend_mode.normal'), desc: t('color_strip.composite.blend_mode.normal.desc') },
{ value: 'add', icon: _icon(P.sun), label: t('color_strip.composite.blend_mode.add'), desc: t('color_strip.composite.blend_mode.add.desc') },
{ value: 'multiply', icon: _icon(P.eye), label: t('color_strip.composite.blend_mode.multiply'), desc: t('color_strip.composite.blend_mode.multiply.desc') },
{ value: 'screen', icon: _icon(P.monitor), label: t('color_strip.composite.blend_mode.screen'), desc: t('color_strip.composite.blend_mode.screen.desc') },
];
}
function _getCompositeSourceItems() {
return _compositeAvailableSources.map(s => ({
value: s.id,
label: s.name,
icon: getColorStripIcon(s.source_type),
}));
}
function _getCompositeBrightnessItems() {
return (_cachedValueSources || []).map(v => ({
value: v.id,
label: v.name,
icon: getValueSourceIcon(v.source_type),
}));
}
function _compositeRenderList() {
const list = document.getElementById('composite-layers-list');
if (!list) return;
_compositeDestroyEntitySelects();
const vsList = _cachedValueSources || [];
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 vsOptions = `<option value="">${t('color_strip.composite.brightness.none')}</option>` +
vsList.map(v =>
`<option value="${v.id}"${layer.brightness_source_id === v.id ? ' selected' : ''}>${escapeHtml(v.name)}</option>`
).join('');
const canRemove = _compositeLayers.length > 1;
return `
<div class="composite-layer-item">
@@ -540,6 +600,12 @@ function _compositeRenderList() {
onclick="compositeRemoveLayer(${i})">&#x2715;</button>`
: ''}
</div>
<div class="composite-layer-row">
<label class="composite-layer-brightness-label">
<span>${t('color_strip.composite.brightness')}:</span>
</label>
<select class="composite-layer-brightness" data-idx="${i}">${vsOptions}</select>
</div>
</div>
`;
}).join('');
@@ -551,6 +617,33 @@ function _compositeRenderList() {
el.closest('.composite-layer-row').querySelector('.composite-opacity-val').textContent = val.toFixed(2);
});
});
// Attach IconSelect to each layer's blend mode dropdown
const blendItems = _getCompositeBlendItems();
list.querySelectorAll('.composite-layer-blend').forEach(sel => {
const is = new IconSelect({ target: sel, items: blendItems, columns: 2 });
_compositeBlendIconSelects.push(is);
});
// Attach EntitySelect to each layer's source dropdown
list.querySelectorAll('.composite-layer-source').forEach(sel => {
_compositeSourceEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getCompositeSourceItems,
placeholder: t('palette.search'),
}));
});
// Attach EntitySelect to each layer's brightness dropdown
list.querySelectorAll('.composite-layer-brightness').forEach(sel => {
_compositeBrightnessEntitySelects.push(new EntitySelect({
target: sel,
getItems: _getCompositeBrightnessItems,
placeholder: t('palette.search'),
allowNone: true,
noneLabel: t('color_strip.composite.brightness.none'),
}));
});
}
export function compositeAddLayer() {
@@ -560,6 +653,7 @@ export function compositeAddLayer() {
blend_mode: 'normal',
opacity: 1.0,
enabled: true,
brightness_source_id: null,
});
_compositeRenderList();
}
@@ -578,24 +672,30 @@ function _compositeLayersSyncFromDom() {
const blends = list.querySelectorAll('.composite-layer-blend');
const opacities = list.querySelectorAll('.composite-layer-opacity');
const enableds = list.querySelectorAll('.composite-layer-enabled');
const briSrcs = list.querySelectorAll('.composite-layer-brightness');
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;
_compositeLayers[i].brightness_source_id = briSrcs[i] ? (briSrcs[i].value || null) : null;
}
}
}
function _compositeGetLayers() {
_compositeLayersSyncFromDom();
return _compositeLayers.map(l => ({
source_id: l.source_id,
blend_mode: l.blend_mode,
opacity: l.opacity,
enabled: l.enabled,
}));
return _compositeLayers.map(l => {
const layer = {
source_id: l.source_id,
blend_mode: l.blend_mode,
opacity: l.opacity,
enabled: l.enabled,
};
if (l.brightness_source_id) layer.brightness_source_id = l.brightness_source_id;
return layer;
});
}
function _loadCompositeState(css) {
@@ -606,8 +706,9 @@ function _loadCompositeState(css) {
blend_mode: l.blend_mode || 'normal',
opacity: l.opacity != null ? l.opacity : 1.0,
enabled: l.enabled != null ? l.enabled : true,
brightness_source_id: l.brightness_source_id || null,
}))
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true }];
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true, brightness_source_id: null }];
_compositeRenderList();
}
@@ -905,6 +1006,7 @@ function _loadNotificationState(css) {
document.getElementById('css-editor-notification-duration-val').textContent = dur;
document.getElementById('css-editor-notification-default-color').value = css.default_color || '#ffffff';
document.getElementById('css-editor-notification-filter-mode').value = css.app_filter_mode || 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue(css.app_filter_mode || 'off');
document.getElementById('css-editor-notification-filter-list').value = (css.app_filter_list || []).join('\n');
onNotificationFilterModeChange();
_attachNotificationProcessPicker();
@@ -924,6 +1026,7 @@ function _resetNotificationState() {
document.getElementById('css-editor-notification-duration-val').textContent = '1500';
document.getElementById('css-editor-notification-default-color').value = '#ffffff';
document.getElementById('css-editor-notification-filter-mode').value = 'off';
if (_notificationFilterModeIconSelect) _notificationFilterModeIconSelect.setValue('off');
document.getElementById('css-editor-notification-filter-list').value = '';
onNotificationFilterModeChange();
_attachNotificationProcessPicker();
@@ -1195,6 +1298,7 @@ export async function showCSSEditor(cssId = null, cloneData = null) {
const sources = await streamsCache.fetch();
// Fetch all color strip sources for composite layer dropdowns
await valueSourcesCache.fetch().catch(() => []);
const allCssSources = await colorStripSourcesCache.fetch().catch(() => []);
_compositeAvailableSources = allCssSources.filter(s =>
s.source_type !== 'composite' && (!cssId || s.id !== cssId)

View File

@@ -902,6 +902,9 @@
"color_strip.notification.filter_mode.off": "Off",
"color_strip.notification.filter_mode.whitelist": "Whitelist",
"color_strip.notification.filter_mode.blacklist": "Blacklist",
"color_strip.notification.filter_mode.off.desc": "Accept all notifications",
"color_strip.notification.filter_mode.whitelist.desc": "Only listed apps",
"color_strip.notification.filter_mode.blacklist.desc": "All except listed apps",
"color_strip.notification.filter_list": "App List:",
"color_strip.notification.filter_list.hint": "One app name per line. Use Browse to pick from running processes.",
"color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram",
@@ -945,10 +948,16 @@
"color_strip.composite.source": "Source",
"color_strip.composite.blend_mode": "Blend",
"color_strip.composite.blend_mode.normal": "Normal",
"color_strip.composite.blend_mode.normal.desc": "Standard alpha blending",
"color_strip.composite.blend_mode.add": "Add",
"color_strip.composite.blend_mode.add.desc": "Brightens by adding colors",
"color_strip.composite.blend_mode.multiply": "Multiply",
"color_strip.composite.blend_mode.multiply.desc": "Darkens by multiplying colors",
"color_strip.composite.blend_mode.screen": "Screen",
"color_strip.composite.blend_mode.screen.desc": "Brightens, inverse of multiply",
"color_strip.composite.opacity": "Opacity",
"color_strip.composite.brightness": "Brightness",
"color_strip.composite.brightness.none": "— None —",
"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",

View File

@@ -902,6 +902,9 @@
"color_strip.notification.filter_mode.off": "Выкл",
"color_strip.notification.filter_mode.whitelist": "Белый список",
"color_strip.notification.filter_mode.blacklist": "Чёрный список",
"color_strip.notification.filter_mode.off.desc": "Принимать все уведомления",
"color_strip.notification.filter_mode.whitelist.desc": "Только указанные приложения",
"color_strip.notification.filter_mode.blacklist.desc": "Все кроме указанных приложений",
"color_strip.notification.filter_list": "Список приложений:",
"color_strip.notification.filter_list.hint": "Одно имя приложения на строку. Используйте «Обзор» для выбора из запущенных процессов.",
"color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram",
@@ -945,10 +948,16 @@
"color_strip.composite.source": "Источник",
"color_strip.composite.blend_mode": "Смешивание",
"color_strip.composite.blend_mode.normal": "Обычное",
"color_strip.composite.blend_mode.normal.desc": "Стандартное альфа-смешивание",
"color_strip.composite.blend_mode.add": "Сложение",
"color_strip.composite.blend_mode.add.desc": "Осветляет, складывая цвета",
"color_strip.composite.blend_mode.multiply": "Умножение",
"color_strip.composite.blend_mode.multiply.desc": "Затемняет, умножая цвета",
"color_strip.composite.blend_mode.screen": "Экран",
"color_strip.composite.blend_mode.screen.desc": "Осветляет, обратное умножение",
"color_strip.composite.opacity": "Непрозрачность",
"color_strip.composite.brightness": "Яркость",
"color_strip.composite.brightness.none": "— Нет —",
"color_strip.composite.enabled": "Включён",
"color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой",
"color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник",

View File

@@ -902,6 +902,9 @@
"color_strip.notification.filter_mode.off": "关闭",
"color_strip.notification.filter_mode.whitelist": "白名单",
"color_strip.notification.filter_mode.blacklist": "黑名单",
"color_strip.notification.filter_mode.off.desc": "接受所有通知",
"color_strip.notification.filter_mode.whitelist.desc": "仅列出的应用",
"color_strip.notification.filter_mode.blacklist.desc": "排除列出的应用",
"color_strip.notification.filter_list": "应用列表:",
"color_strip.notification.filter_list.hint": "每行一个应用名称。使用「浏览」从运行中的进程中选择。",
"color_strip.notification.filter_list.placeholder": "Discord\nSlack\nTelegram",
@@ -945,10 +948,16 @@
"color_strip.composite.source": "源",
"color_strip.composite.blend_mode": "混合",
"color_strip.composite.blend_mode.normal": "正常",
"color_strip.composite.blend_mode.normal.desc": "标准 Alpha 混合",
"color_strip.composite.blend_mode.add": "叠加",
"color_strip.composite.blend_mode.add.desc": "通过叠加颜色提亮",
"color_strip.composite.blend_mode.multiply": "正片叠底",
"color_strip.composite.blend_mode.multiply.desc": "通过相乘颜色变暗",
"color_strip.composite.blend_mode.screen": "滤色",
"color_strip.composite.blend_mode.screen.desc": "提亮,正片叠底的反转",
"color_strip.composite.opacity": "不透明度",
"color_strip.composite.brightness": "亮度",
"color_strip.composite.brightness.none": "— 无 —",
"color_strip.composite.enabled": "启用",
"color_strip.composite.error.min_layers": "至少需要 1 个图层",
"color_strip.composite.error.no_source": "每个图层必须选择一个源",

View File

@@ -518,7 +518,7 @@ class CompositeColorStripSource(ColorStripSource):
when led_count == 0.
"""
# Each layer: {"source_id": str, "blend_mode": str, "opacity": float, "enabled": bool}
# Each layer: {"source_id": str, "blend_mode": str, "opacity": float, "enabled": bool, "brightness_source_id": str|None}
layers: list = field(default_factory=list)
led_count: int = 0 # 0 = use device LED count