diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index ba1e311..512f9c1 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -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): diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index 8d6635e..486ffff 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -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) diff --git a/server/src/wled_controller/core/processing/composite_stream.py b/server/src/wled_controller/core/processing/composite_stream.py index 17acbab..cd85fe4 100644 --- a/server/src/wled_controller/core/processing/composite_stream.py +++ b/server/src/wled_controller/core/processing/composite_stream.py @@ -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) diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index c57b5e6..56ee49c 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -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) diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 749abb1..1efd3bc 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -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) => ``; @@ -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 => `` ).join(''); + const vsOptions = `` + + vsList.map(v => + `` + ).join(''); const canRemove = _compositeLayers.length > 1; return `