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 = `
${tabs.map(tab => diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index f48cbfb..b1ff583 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -424,6 +424,9 @@ "kc.pattern_template": "Pattern Template:", "kc.pattern_template.hint": "Select the rectangle pattern to use for color extraction", "kc.pattern_template.none": "-- Select a pattern template --", + "kc.brightness_vs": "🔢 Brightness Source:", + "kc.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (multiplied with the manual brightness slider)", + "kc.brightness_vs.none": "None (manual brightness only)", "kc.created": "Key colors target created successfully", "kc.updated": "Key colors target updated successfully", "kc.deleted": "Key colors target deleted successfully", @@ -763,7 +766,7 @@ "audio_source.error.name_required": "Please enter a name", "streams.group.value": "Value Sources", - "value_source.group.title": "🎚️ Value Sources", + "value_source.group.title": "🔢 Value Sources", "value_source.add": "Add Value Source", "value_source.edit": "Edit Value Source", "value_source.name": "Name:", @@ -807,7 +810,7 @@ "value_source.deleted": "Value source deleted", "value_source.delete.confirm": "Are you sure you want to delete this value source?", "value_source.error.name_required": "Please enter a name", - "targets.brightness_vs": "Brightness Source:", + "targets.brightness_vs": "🔢 Brightness Source:", "targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)", "targets.brightness_vs.none": "None (device brightness)" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 7eeb398..1e452f3 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -424,6 +424,9 @@ "kc.pattern_template": "Шаблон Паттерна:", "kc.pattern_template.hint": "Выберите шаблон прямоугольников для извлечения цветов", "kc.pattern_template.none": "-- Выберите шаблон паттерна --", + "kc.brightness_vs": "🔢 Источник Яркости:", + "kc.brightness_vs.hint": "Опциональный источник значений, динамически управляющий яркостью каждый кадр (умножается на ручной слайдер яркости)", + "kc.brightness_vs.none": "Нет (только ручная яркость)", "kc.created": "Цель ключевых цветов успешно создана", "kc.updated": "Цель ключевых цветов успешно обновлена", "kc.deleted": "Цель ключевых цветов успешно удалена", @@ -763,7 +766,7 @@ "audio_source.error.name_required": "Введите название", "streams.group.value": "Источники значений", - "value_source.group.title": "🎚️ Источники значений", + "value_source.group.title": "🔢 Источники значений", "value_source.add": "Добавить источник значений", "value_source.edit": "Редактировать источник значений", "value_source.name": "Название:", @@ -807,7 +810,7 @@ "value_source.deleted": "Источник значений удалён", "value_source.delete.confirm": "Удалить этот источник значений?", "value_source.error.name_required": "Введите название", - "targets.brightness_vs": "Источник яркости:", + "targets.brightness_vs": "🔢 Источник яркости:", "targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)", "targets.brightness_vs.none": "Нет (яркость устройства)" } diff --git a/server/src/wled_controller/storage/key_colors_picture_target.py b/server/src/wled_controller/storage/key_colors_picture_target.py index dc42b34..d7dd72b 100644 --- a/server/src/wled_controller/storage/key_colors_picture_target.py +++ b/server/src/wled_controller/storage/key_colors_picture_target.py @@ -46,6 +46,7 @@ class KeyColorsSettings: smoothing: float = 0.3 pattern_template_id: str = "" brightness: float = 1.0 + brightness_value_source_id: str = "" def to_dict(self) -> dict: return { @@ -54,6 +55,7 @@ class KeyColorsSettings: "smoothing": self.smoothing, "pattern_template_id": self.pattern_template_id, "brightness": self.brightness, + "brightness_value_source_id": self.brightness_value_source_id, } @classmethod @@ -64,6 +66,7 @@ class KeyColorsSettings: smoothing=data.get("smoothing", 0.3), pattern_template_id=data.get("pattern_template_id", ""), brightness=data.get("brightness", 1.0), + brightness_value_source_id=data.get("brightness_value_source_id", ""), ) @@ -82,12 +85,18 @@ class KeyColorsPictureTarget(PictureTarget): settings=self.settings, ) - def sync_with_manager(self, manager, *, settings_changed: bool, source_changed: bool, device_changed: bool) -> None: + def sync_with_manager(self, manager, *, settings_changed: bool, + source_changed: bool = False, + css_changed: bool = False, + device_changed: bool = False, + brightness_vs_changed: bool = False) -> None: """Push changed fields to the processor manager.""" if settings_changed: manager.update_target_settings(self.id, self.settings) if source_changed: manager.update_target_source(self.id, self.picture_source_id) + if brightness_vs_changed: + manager.update_target_brightness_vs(self.id, self.settings.brightness_value_source_id) def update_fields(self, *, name=None, device_id=None, picture_source_id=None, settings=None, key_colors_settings=None, description=None, diff --git a/server/src/wled_controller/templates/modals/kc-editor.html b/server/src/wled_controller/templates/modals/kc-editor.html index 51545f5..ebeb147 100644 --- a/server/src/wled_controller/templates/modals/kc-editor.html +++ b/server/src/wled_controller/templates/modals/kc-editor.html @@ -32,6 +32,17 @@
+
+
+ + +
+ + +
+
diff --git a/server/src/wled_controller/templates/modals/target-editor.html b/server/src/wled_controller/templates/modals/target-editor.html index 7262115..984caee 100644 --- a/server/src/wled_controller/templates/modals/target-editor.html +++ b/server/src/wled_controller/templates/modals/target-editor.html @@ -35,7 +35,7 @@
- +