diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index dcfb3f6..e6695d5 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -71,6 +71,7 @@ def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSche smoothing=settings.smoothing, pattern_template_id=settings.pattern_template_id, brightness=settings.brightness, + brightness_value_source_id=settings.brightness_value_source_id, ) @@ -82,6 +83,7 @@ def _kc_schema_to_settings(schema: KeyColorsSettingsSchema) -> KeyColorsSettings smoothing=schema.smoothing, pattern_template_id=schema.pattern_template_id, brightness=schema.brightness, + brightness_value_source_id=schema.brightness_value_source_id, ) @@ -254,6 +256,7 @@ async def update_target( smoothing=incoming.get("smoothing", ex.smoothing), pattern_template_id=incoming.get("pattern_template_id", ex.pattern_template_id), brightness=incoming.get("brightness", ex.brightness), + brightness_value_source_id=incoming.get("brightness_value_source_id", ex.brightness_value_source_id), ) kc_settings = _kc_schema_to_settings(merged) else: @@ -273,6 +276,13 @@ async def update_target( description=data.description, ) + # Detect KC brightness VS change (inside key_colors_settings) + kc_brightness_vs_changed = False + if data.key_colors_settings is not None: + kc_incoming = data.key_colors_settings.model_dump(exclude_unset=True) + if "brightness_value_source_id" in kc_incoming: + kc_brightness_vs_changed = True + # Sync processor manager try: target.sync_with_manager( @@ -283,7 +293,7 @@ async def update_target( data.key_colors_settings is not None), css_changed=data.color_strip_source_id is not None, device_changed=data.device_id is not None, - brightness_vs_changed=data.brightness_value_source_id is not None, + brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed), ) except ValueError: pass diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index 09dab7c..4b39c33 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -26,6 +26,7 @@ class KeyColorsSettingsSchema(BaseModel): smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0) pattern_template_id: str = Field(default="", description="Pattern template ID for rectangle layout") brightness: float = Field(default=1.0, description="Output brightness (0.0-1.0)", ge=0.0, le=1.0) + brightness_value_source_id: str = Field(default="", description="Brightness value source ID") class ExtractedColorResponse(BaseModel): diff --git a/server/src/wled_controller/core/processing/kc_target_processor.py b/server/src/wled_controller/core/processing/kc_target_processor.py index 6cbf830..c4ff683 100644 --- a/server/src/wled_controller/core/processing/kc_target_processor.py +++ b/server/src/wled_controller/core/processing/kc_target_processor.py @@ -96,9 +96,11 @@ class KCTargetProcessor(TargetProcessor): ): super().__init__(target_id, ctx, picture_source_id) self._settings = settings + self._brightness_vs_id = settings.brightness_value_source_id if settings else "" # Runtime state self._live_stream: Optional[LiveStream] = None + self._value_stream = None # active brightness value stream self._previous_colors: Optional[Dict[str, Tuple[int, int, int]]] = None self._latest_colors: Optional[Dict[str, Tuple[int, int, int]]] = None self._ws_clients: List = [] @@ -156,6 +158,16 @@ class KCTargetProcessor(TargetProcessor): logger.error(f"Failed to initialize live stream for KC target {self._target_id}: {e}") raise RuntimeError(f"Failed to initialize live stream: {e}") + # Acquire value stream for brightness modulation (if configured) + if self._brightness_vs_id and self._ctx.value_stream_manager: + try: + self._value_stream = self._ctx.value_stream_manager.acquire( + self._brightness_vs_id, self._target_id + ) + except Exception as e: + logger.warning(f"Failed to acquire value stream {self._brightness_vs_id}: {e}") + self._value_stream = None + # Reset metrics self._metrics = ProcessingMetrics(start_time=datetime.utcnow()) self._previous_colors = None @@ -192,6 +204,14 @@ class KCTargetProcessor(TargetProcessor): logger.warning(f"Error releasing live stream for KC target: {e}") self._live_stream = None + # Release value stream + if self._value_stream is not None and self._ctx.value_stream_manager: + try: + self._ctx.value_stream_manager.release(self._brightness_vs_id, self._target_id) + except Exception as e: + logger.warning(f"Error releasing value stream: {e}") + self._value_stream = None + logger.info(f"Stopped KC processing for target {self._target_id}") self._ctx.fire_event({"type": "state_change", "target_id": self._target_id, "processing": False}) @@ -199,8 +219,37 @@ class KCTargetProcessor(TargetProcessor): def update_settings(self, settings) -> None: self._settings = settings + # Keep _brightness_vs_id in sync (hot-swap handled separately) + self._brightness_vs_id = settings.brightness_value_source_id if settings else "" logger.info(f"Updated KC target settings: {self._target_id}") + def update_brightness_value_source(self, vs_id: str) -> None: + """Hot-swap the brightness value source for a running KC target.""" + old_vs_id = self._brightness_vs_id + self._brightness_vs_id = vs_id + vs_mgr = self._ctx.value_stream_manager + + if not self._is_running or vs_mgr is None: + return + + # Release old stream + if self._value_stream is not None and old_vs_id: + try: + vs_mgr.release(old_vs_id, self._target_id) + except Exception as e: + logger.warning(f"Error releasing old value stream {old_vs_id}: {e}") + self._value_stream = None + + # Acquire new stream + if vs_id: + try: + self._value_stream = vs_mgr.acquire(vs_id, self._target_id) + except Exception as e: + logger.warning(f"Failed to acquire value stream {vs_id}: {e}") + self._value_stream = None + + logger.info(f"Hot-swapped brightness VS for KC target {self._target_id}: {old_vs_id} -> {vs_id}") + # ----- State / Metrics ----- def get_state(self) -> dict: @@ -220,6 +269,7 @@ class KCTargetProcessor(TargetProcessor): "timing_total_ms": round(metrics.timing_total_ms, 1) if self._is_running else None, "last_update": metrics.last_update, "errors": [metrics.last_error] if metrics.last_error else [], + "brightness_value_source_id": self._brightness_vs_id, } def get_metrics(self) -> dict: @@ -333,11 +383,17 @@ class KCTargetProcessor(TargetProcessor): s = self._settings calc_fn = calc_fns.get(s.interpolation_mode, calculate_average_color) + # Effective brightness: static setting * value stream + eff_brightness = s.brightness + vs = self._value_stream + if vs is not None: + eff_brightness *= vs.get_value() + # CPU-bound work in thread pool colors, colors_arr, frame_timing = await asyncio.to_thread( _process_kc_frame, capture, rect_names, rect_bounds, calc_fn, - prev_colors_arr, s.smoothing, s.brightness, + prev_colors_arr, s.smoothing, eff_brightness, ) prev_colors_arr = colors_arr diff --git a/server/src/wled_controller/static/js/features/kc-targets.js b/server/src/wled_controller/static/js/features/kc-targets.js index 004d289..1fd9b8d 100644 --- a/server/src/wled_controller/static/js/features/kc-targets.js +++ b/server/src/wled_controller/static/js/features/kc-targets.js @@ -8,6 +8,7 @@ import { _kcNameManuallyEdited, set_kcNameManuallyEdited, kcWebSockets, PATTERN_RECT_BORDERS, + _cachedValueSources, set_cachedValueSources, } from '../core/state.js'; import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; @@ -27,6 +28,7 @@ class KCEditorModal extends Modal { interpolation: document.getElementById('kc-editor-interpolation').value, smoothing: document.getElementById('kc-editor-smoothing').value, patternTemplateId: document.getElementById('kc-editor-pattern-template').value, + brightness_vs: document.getElementById('kc-editor-brightness-vs').value, }; } } @@ -348,15 +350,33 @@ function _autoGenerateKCName() { document.getElementById('kc-editor-name').value = `${sourceName} \u00b7 ${patName} (${modeName})`; } +function _populateKCBrightnessVsDropdown(selectedId = '') { + const sel = document.getElementById('kc-editor-brightness-vs'); + // Keep the first "None" option, remove the rest + while (sel.options.length > 1) sel.remove(1); + _cachedValueSources.forEach(vs => { + const typeIcons = { static: '📊', animated: '🔄', audio: '🎵' }; + const icon = typeIcons[vs.source_type] || '🔢'; + const opt = document.createElement('option'); + opt.value = vs.id; + opt.textContent = `${icon} ${vs.name}`; + sel.appendChild(opt); + }); + sel.value = selectedId || ''; +} + export async function showKCEditor(targetId = null, cloneData = null) { try { - // Load sources and pattern templates in parallel - const [sourcesResp, patResp] = await Promise.all([ + // Load sources, pattern templates, and value sources in parallel + const [sourcesResp, patResp, vsResp] = await Promise.all([ fetchWithAuth('/picture-sources').catch(() => null), fetchWithAuth('/pattern-templates').catch(() => null), + fetchWithAuth('/value-sources').catch(() => null), ]); const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : []; const patTemplates = (patResp && patResp.ok) ? (await patResp.json()).templates || [] : []; + const valueSources = (vsResp && vsResp.ok) ? (await vsResp.json()).sources || [] : []; + set_cachedValueSources(valueSources); // Populate source select const sourceSelect = document.getElementById('kc-editor-source'); @@ -397,6 +417,7 @@ export async function showKCEditor(targetId = null, cloneData = null) { document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3; document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3; patSelect.value = kcSettings.pattern_template_id || ''; + _populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || ''); document.getElementById('kc-editor-title').textContent = t('kc.edit'); } else if (cloneData) { const kcSettings = cloneData.key_colors_settings || {}; @@ -409,6 +430,7 @@ export async function showKCEditor(targetId = null, cloneData = null) { document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3; document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3; patSelect.value = kcSettings.pattern_template_id || ''; + _populateKCBrightnessVsDropdown(kcSettings.brightness_value_source_id || ''); document.getElementById('kc-editor-title').textContent = t('kc.add'); } else { document.getElementById('kc-editor-id').value = ''; @@ -420,6 +442,7 @@ export async function showKCEditor(targetId = null, cloneData = null) { document.getElementById('kc-editor-smoothing').value = 0.3; document.getElementById('kc-editor-smoothing-value').textContent = '0.3'; if (patTemplates.length > 0) patSelect.value = patTemplates[0].id; + _populateKCBrightnessVsDropdown(''); document.getElementById('kc-editor-title').textContent = t('kc.add'); } @@ -464,6 +487,7 @@ export async function saveKCEditor() { const interpolation = document.getElementById('kc-editor-interpolation').value; const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value); const patternTemplateId = document.getElementById('kc-editor-pattern-template').value; + const brightnessVsId = document.getElementById('kc-editor-brightness-vs').value; if (!name) { kcEditorModal.showError(t('kc.error.required')); @@ -483,6 +507,7 @@ export async function saveKCEditor() { interpolation_mode: interpolation, smoothing, pattern_template_id: patternTemplateId, + brightness_value_source_id: brightnessVsId, }, }; diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index 3a0db87..44ab5ec 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -492,10 +492,10 @@ export async function loadPictureSources() { } export function switchStreamTab(tabKey) { - document.querySelectorAll('.stream-tab-btn').forEach(btn => + document.querySelectorAll('.stream-tab-btn[data-stream-tab]').forEach(btn => btn.classList.toggle('active', btn.dataset.streamTab === tabKey) ); - document.querySelectorAll('.stream-tab-panel').forEach(panel => + document.querySelectorAll('.stream-tab-panel[id^="stream-tab-"]').forEach(panel => panel.classList.toggle('active', panel.id === `stream-tab-${tabKey}`) ); localStorage.setItem('activeStreamTab', tabKey); @@ -629,7 +629,7 @@ function renderPictureSourcesList(streams) { { key: 'static_image', icon: '🖼️', titleKey: 'streams.group.static_image', count: staticImageStreams.length }, { key: 'processed', icon: '🎨', titleKey: 'streams.group.processed', count: processedStreams.length }, { key: 'audio', icon: '🔊', titleKey: 'streams.group.audio', count: _cachedAudioSources.length }, - { key: 'value', icon: '🎚️', titleKey: 'streams.group.value', count: _cachedValueSources.length }, + { key: 'value', icon: '🔢', titleKey: 'streams.group.value', count: _cachedValueSources.length }, ]; const tabBar = `
+