diff --git a/server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py b/server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py index f17ead9..c00cee4 100644 --- a/server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py +++ b/server/src/ledgrab/api/routes/color_strip_sources/ws_stream.py @@ -35,6 +35,14 @@ _PREVIEW_ALLOWED_TYPES = { "daylight", "candlelight", "notification", + "audio", + "math_wave", + "weather", + "game_event", + "api_input", + "mapped", + "composite", + "processed", } @@ -92,10 +100,62 @@ async def preview_color_strip_ws( """Instantiate and start the appropriate stream class for *source*.""" from ledgrab.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP - stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) - if not stream_cls: - raise ValueError(f"Unsupported preview source_type: {source.source_type}") - s = stream_cls(source) + mgr = get_processor_manager() + csm = mgr.color_strip_stream_manager + + if source.source_type == "audio": + from ledgrab.api.dependencies import ( + get_audio_processing_template_store, + get_audio_source_store, + get_audio_template_store, + ) + from ledgrab.core.processing.audio_stream import AudioColorStripStream + + s = AudioColorStripStream( + source, + mgr.audio_capture_manager, + get_audio_source_store(), + get_audio_template_store(), + get_audio_processing_template_store(), + ) + elif source.source_type == "weather": + from ledgrab.core.processing.weather_stream import WeatherColorStripStream + + s = WeatherColorStripStream(source, mgr.weather_manager) + elif source.source_type == "game_event": + from ledgrab.core.processing.game_event_stream import GameEventColorStripStream + + s = GameEventColorStripStream(source) + try: + from ledgrab.api.dependencies import get_game_event_bus + + bus = get_game_event_bus() + except RuntimeError as e: + logger.debug("Preview: no game event bus available: %s", e) + else: + if bus is not None: + s.set_event_bus(bus) + elif source.source_type == "mapped": + from ledgrab.core.processing.mapped_stream import MappedColorStripStream + + s = MappedColorStripStream(source, csm) + elif source.source_type == "composite": + from ledgrab.api.dependencies import get_cspt_store + from ledgrab.core.processing.composite_stream import CompositeColorStripStream + + s = CompositeColorStripStream( + source, csm, mgr.value_stream_manager, get_cspt_store(), depth=0 + ) + elif source.source_type == "processed": + from ledgrab.api.dependencies import get_cspt_store + from ledgrab.core.processing.processed_stream import ProcessedColorStripStream + + s = ProcessedColorStripStream(source, csm, get_cspt_store()) + else: + stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) + if not stream_cls: + raise ValueError(f"Unsupported preview source_type: {source.source_type}") + s = stream_cls(source) # Inject gradient store for palette resolution if hasattr(s, "set_gradient_store"): try: @@ -121,7 +181,24 @@ async def preview_color_strip_ws( cid = None else: cid = None - s.start() + # Start the stream; if start() raises, release any resources we + # already acquired (clock + anything the stream itself grabbed in + # its __init__) so we don't leak refs across failed previews. + try: + s.start() + except Exception: + try: + s.stop() + except Exception as e_stop: + logger.exception("unexpected in start-failure rollback s.stop: %s", e_stop) + if cid: + scm = _get_sync_clock_manager() + if scm: + try: + scm.release(cid) + except Exception as e_rel: + logger.exception("unexpected in start-failure clock release: %s", e_rel) + raise return s, cid def _stop_stream(s, cid): @@ -222,10 +299,24 @@ async def preview_color_strip_ws( continue new_source = _build_source(new_config) if new_type != current_source_type: - # Source type changed — recreate stream + # Source type changed — stop the old stream first, then + # build the new one. If the rebuild fails, drop the + # reference so the frame loop doesn't keep polling a + # stopped stream and the finally-block doesn't double-stop. _stop_stream(stream, clock_id) - stream, clock_id = _create_stream(new_source) - current_source_type = new_type + stream, clock_id = None, None + try: + stream, clock_id = _create_stream(new_source) + current_source_type = new_type + except Exception as rebuild_err: + logger.error( + f"Preview WS: failed to rebuild stream for new type {new_type}: {rebuild_err}" + ) + await websocket.send_text( + _json.dumps({"type": "error", "detail": str(rebuild_err)}) + ) + await websocket.close(code=4003, reason=str(rebuild_err)) + return else: stream.update_source(new_source) if hasattr(stream, "configure"): @@ -236,12 +327,15 @@ async def preview_color_strip_ws( await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)})) # Send frame - colors = stream.get_latest_colors() - if colors is not None: - await websocket.send_bytes(colors.tobytes()) - else: - # Stream hasn't produced a frame yet — send black + if stream is None: await websocket.send_bytes(b"\x00" * led_count * 3) + else: + colors = stream.get_latest_colors() + if colors is not None: + await websocket.send_bytes(colors.tobytes()) + else: + # Stream hasn't produced a frame yet — send black + await websocket.send_bytes(b"\x00" * led_count * 3) except WebSocketDisconnect: pass diff --git a/server/src/ledgrab/core/processing/mapped_stream.py b/server/src/ledgrab/core/processing/mapped_stream.py index f4f1382..e219e61 100644 --- a/server/src/ledgrab/core/processing/mapped_stream.py +++ b/server/src/ledgrab/core/processing/mapped_stream.py @@ -25,7 +25,13 @@ class MappedColorStripStream(ColorStripStream): """ def __init__(self, source, css_manager): + import uuid as _uuid + self._source_id: str = source.id + # Unique per instance so concurrent consumers don't collide on + # sub-stream consumer IDs (e.g. two preview WS connections against + # the same mapped source, or future fan-out to multiple targets). + self._instance_id: str = _uuid.uuid4().hex[:8] self._zones: List[dict] = list(source.zones) self._led_count: int = source.led_count self._auto_size: bool = source.led_count == 0 @@ -127,7 +133,7 @@ class MappedColorStripStream(ColorStripStream): src_id = zone.get("source_id", "") if not src_id: continue - consumer_id = f"{self._source_id}__zone_{i}" + consumer_id = f"{self._source_id}__{self._instance_id}__zone_{i}" try: stream = self._css_manager.acquire(src_id, consumer_id) zone_len = self._zone_length(zone) diff --git a/server/src/ledgrab/core/processing/processed_stream.py b/server/src/ledgrab/core/processing/processed_stream.py index e345198..6689e95 100644 --- a/server/src/ledgrab/core/processing/processed_stream.py +++ b/server/src/ledgrab/core/processing/processed_stream.py @@ -22,11 +22,17 @@ class ProcessedColorStripStream(ColorStripStream): """ def __init__(self, source, css_manager, cspt_store=None): + import uuid as _uuid + self._source = source self._css_manager = css_manager self._cspt_store = cspt_store self._input_stream: Optional[ColorStripStream] = None - self._consumer_id = f"__processed_{source.id}__" + # Unique per instance so concurrent consumers don't collide on + # sub-stream consumer IDs (e.g. two preview WS connections against + # the same processed source). + self._instance_id = _uuid.uuid4().hex[:8] + self._consumer_id = f"__processed_{source.id}__{self._instance_id}__" self._filters = [] self._cached_template_id = None self._running = False @@ -97,9 +103,28 @@ class ProcessedColorStripStream(ColorStripStream): return self._colors def update_source(self, source) -> None: + old_input = self._source.input_source_id + new_input = source.input_source_id self._source = source # Force re-resolve filters on next iteration self._cached_template_id = None + # If the input source changed while running, swap the acquired stream. + if self._running and new_input != old_input: + if self._input_stream and old_input: + try: + self._css_manager.release(old_input, self._consumer_id) + except Exception as e: + logger.warning( + f"Processed update: release of old input {old_input} failed: {e}" + ) + self._input_stream = None + if new_input: + try: + self._input_stream = self._css_manager.acquire(new_input, self._consumer_id) + except Exception as e: + logger.warning( + f"Processed update: acquire of new input {new_input} failed: {e}" + ) def set_clock(self, clock_runtime) -> None: if self._input_stream and hasattr(self._input_stream, "set_clock"): diff --git a/server/src/ledgrab/static/js/features/color-strips/index.ts b/server/src/ledgrab/static/js/features/color-strips/index.ts index 9f323e4..9298465 100644 --- a/server/src/ledgrab/static/js/features/color-strips/index.ts +++ b/server/src/ledgrab/static/js/features/color-strips/index.ts @@ -1405,6 +1405,26 @@ const _typeHandlers: Record any; reset: (... }, }; +// ══════════════════════════════════════════════════════════════════ +// Preview payload — collects the current editor form into a config dict +// suitable for the transient preview WebSocket. Returns null if the type +// has no handler or if validation fails. Used by test.ts. +// ══════════════════════════════════════════════════════════════════ + +export function getCSSEditorPreviewPayload(sourceType: string): any { + const handler = _typeHandlers[sourceType]; + if (!handler) return null; + const payload = handler.getPayload('__preview__'); + if (!payload) return null; + payload.source_type = sourceType; + const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave']; + if (clockTypes.includes(sourceType)) { + const clockEl = document.getElementById('css-editor-clock') as HTMLInputElement | null; + if (clockEl && clockEl.value) payload.clock_id = clockEl.value; + } + return payload; +} + // ══════════════════════════════════════════════════════════════════ // Editor open / close / save // ══════════════════════════════════════════════════════════════════ diff --git a/server/src/ledgrab/static/js/features/color-strips/test.ts b/server/src/ledgrab/static/js/features/color-strips/test.ts index 80a1336..6e55651 100644 --- a/server/src/ledgrab/static/js/features/color-strips/test.ts +++ b/server/src/ledgrab/static/js/features/color-strips/test.ts @@ -15,14 +15,23 @@ import { } from '../../core/icons.ts'; import { EntitySelect } from '../../core/entity-palette.ts'; import { hexToRgbArray, getGradientStops } from '../css-gradient-editor.ts'; -import { testNotification, _getAnimationPayload } from './index.ts'; +import { testNotification, _getAnimationPayload, getCSSEditorPreviewPayload } from './index.ts'; import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './notification.ts'; +import { + ensureAudioSensitivityWidget, + ensureAudioSmoothingWidget, + ensureAudioBeatDecayWidget, + ensureAudioColorWidget, + ensureAudioColorPeakWidget, +} from './audio.ts'; +import { ensureMathWaveSpeedWidget, mathWaveGetLayers } from './math-wave.ts'; import { openAuthedWs } from '../../core/ws-auth.ts'; /* ── Preview config builder ───────────────────────────────────── */ const _PREVIEW_TYPES = new Set([ - 'static', 'gradient', 'effect', 'daylight', 'candlelight', 'notification', + 'static', 'gradient', 'effect', 'daylight', 'candlelight', 'notification', 'audio', 'math_wave', + 'weather', 'game_event', 'api_input', 'mapped', 'composite', 'processed', ]); function _collectPreviewConfig() { @@ -42,6 +51,28 @@ function _collectPreviewConfig() { config = { source_type: 'daylight', speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value), use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value), longitude: parseFloat((document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value) }; } else if (sourceType === 'candlelight') { config = { source_type: 'candlelight', color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value), intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value), num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3, speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value), wind_strength: parseFloat((document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value), candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value }; + } else if (sourceType === 'math_wave') { + const waves = mathWaveGetLayers(); + if (waves.length === 0) return null; + config = { + source_type: 'math_wave', + gradient_id: (document.getElementById('css-editor-math-wave-gradient') as HTMLInputElement).value || null, + speed: ensureMathWaveSpeedWidget().getValue(), + waves, + }; + } else if (sourceType === 'audio') { + config = { + source_type: 'audio', + visualization_mode: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value, + audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || '', + sensitivity: ensureAudioSensitivityWidget().getValue(), + smoothing: ensureAudioSmoothingWidget().getValue(), + beat_decay: ensureAudioBeatDecayWidget().getValue(), + gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value, + color: ensureAudioColorWidget().getValue(), + color_peak: ensureAudioColorPeakWidget().getValue(), + mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked, + }; } else if (sourceType === 'notification') { const filterList = (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value.split('\n').map(s => s.trim()).filter(Boolean); config = { @@ -56,9 +87,13 @@ function _collectPreviewConfig() { sound_volume: getNotificationVolumeValue(), app_sounds: notificationGetAppSoundsDict(), }; + } else { + // Types without quirks: build config from the editor handler payload + config = getCSSEditorPreviewPayload(sourceType); + if (!config) return null; } const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null; - if (clockEl && clockEl.value) config.clock_id = clockEl.value; + if (clockEl && clockEl.value && !config.clock_id) config.clock_id = clockEl.value; return config; } @@ -67,14 +102,29 @@ function _collectPreviewConfig() { * For saved sources, uses the normal test endpoint. * For unsaved/self-contained types, uses the transient preview endpoint. */ +// Types whose preview needs data edited outside the source-editor form +// (calibration on a separate Calibration screen, or rectangles in the +// key-colors region picker). The Preview button falls back to the saved +// source for these; if the source isn't saved yet, prompt the user to save +// first instead of showing the generic "unsupported" message. +const _NEEDS_SAVE_FIRST = new Set(['picture', 'picture_advanced', 'key_colors']); + export function previewCSSFromEditor() { // Always use transient preview with current form values + const sourceType = (document.getElementById('css-editor-type') as HTMLInputElement).value; const config = _collectPreviewConfig(); if (!config) { - // Non-previewable type (picture, composite, etc.) — fall back to saved source test + // Either the type can't be previewed transiently + // (picture, picture_advanced, key_colors) or handler validation + // failed (already showed error in modal). const cssId = (document.getElementById('css-editor-id') as HTMLInputElement).value; - if (cssId) { testColorStrip(cssId); return; } - showToast(t('color_strip.preview.unsupported'), 'info'); + if (!_PREVIEW_TYPES.has(sourceType) && cssId) { testColorStrip(cssId); return; } + if (!_PREVIEW_TYPES.has(sourceType)) { + const key = _NEEDS_SAVE_FIRST.has(sourceType) + ? 'color_strip.preview.save_first' + : 'color_strip.preview.unsupported'; + showToast(t(key), 'info'); + } return; } diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index 1c39bb5..be66a56 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -1489,6 +1489,7 @@ "color_strip.preview.connecting": "Connecting...", "color_strip.preview.connected": "Connected", "color_strip.preview.unsupported": "Preview not available for this source type", + "color_strip.preview.save_first": "Save the source first — calibration is needed for preview", "color_strip.type.daylight": "Daylight Cycle", "color_strip.type.daylight.desc": "Simulates natural daylight over 24 hours", "color_strip.type.daylight.hint": "Simulates the sun's color temperature throughout a 24-hour day/night cycle — from warm sunrise to cool daylight to warm sunset and dim night.", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 13b00ab..7feb4c3 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -1501,6 +1501,7 @@ "color_strip.preview.connecting": "Подключение...", "color_strip.preview.connected": "Подключено", "color_strip.preview.unsupported": "Предпросмотр недоступен для этого типа источника", + "color_strip.preview.save_first": "Сначала сохраните источник — для предпросмотра нужна калибровка", "color_strip.type.daylight": "Дневной цикл", "color_strip.type.daylight.desc": "Имитация естественного дневного света за 24 часа", "color_strip.type.daylight.hint": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 691f65a..5f9e0f3 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -1498,6 +1498,7 @@ "color_strip.preview.connecting": "连接中...", "color_strip.preview.connected": "已连接", "color_strip.preview.unsupported": "此源类型不支持预览", + "color_strip.preview.save_first": "请先保存源——预览需要校准数据", "color_strip.type.daylight": "日光循环", "color_strip.type.daylight.desc": "模拟24小时自然日光变化", "color_strip.type.daylight.hint": "模拟太阳在24小时内的色温变化——从温暖的日出到冷白的日光,再到温暖的日落和昏暗的夜晚。",