From 014b4175b905e939eaf03d00f78fe6498c266128 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sun, 15 Mar 2026 14:49:22 +0300 Subject: [PATCH] Add transient preview WS endpoint and test button in CSS editor modal - Add /color-strip-sources/preview/ws endpoint for ad-hoc source preview without saving (accepts full config JSON, streams RGB frames) - Add test preview button (flask icon) to CSS editor modal footer - For self-contained types (static, gradient, color_cycle, effect, daylight, candlelight), always previews current form values via transient WS - For non-previewable types, falls back to saved source test endpoint - Fix route ordering: preview/ws registered before {source_id}/ws - Fix css-test-led-control label alignment (display: inline globally) - Add gradient onChange callback for future live-update support - Add i18n keys for preview (en/ru/zh) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../api/routes/color_strip_sources.py | 180 ++++++++++++++++++ .../src/wled_controller/static/css/modal.css | 6 + server/src/wled_controller/static/js/app.js | 2 + .../static/js/features/color-strips.js | 75 +++++++- .../static/js/features/css-gradient-editor.js | 5 + .../wled_controller/static/locales/en.json | 5 + .../wled_controller/static/locales/ru.json | 5 + .../wled_controller/static/locales/zh.json | 5 + .../templates/modals/css-editor.html | 1 + 9 files changed, 281 insertions(+), 3 deletions(-) diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index 4ca9ee6..a617fcf 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -488,6 +488,186 @@ async def os_notification_history(_auth: AuthRequired): } +# ── Transient Preview WebSocket ──────────────────────────────────────── + +_PREVIEW_ALLOWED_TYPES = {"static", "gradient", "color_cycle", "effect", "daylight", "candlelight"} + + +@router.websocket("/api/v1/color-strip-sources/preview/ws") +async def preview_color_strip_ws( + websocket: WebSocket, + token: str = Query(""), + led_count: int = Query(100), + fps: int = Query(20), +): + """Transient preview WebSocket — stream frames for an ad-hoc source config. + + Auth via ``?token=&led_count=100&fps=20``. + + After accepting, waits for a text message containing the full source config + JSON (must include ``source_type``). Responds with a JSON metadata message, + then streams binary RGB frames at the requested FPS. + + Subsequent text messages are treated as config updates: if the source_type + changed the old stream is replaced; otherwise ``update_source()`` is used. + """ + from wled_controller.api.auth import verify_ws_token + if not verify_ws_token(token): + await websocket.close(code=4001, reason="Unauthorized") + return + + await websocket.accept() + + led_count = max(1, min(1000, led_count)) + fps = max(1, min(60, fps)) + frame_interval = 1.0 / fps + + stream = None + clock_id = None + current_source_type = None + + # Helpers ──────────────────────────────────────────────────────────── + + def _get_sync_clock_manager(): + """Return the SyncClockManager if available.""" + try: + mgr = get_processor_manager() + return getattr(mgr, "_sync_clock_manager", None) + except Exception: + return None + + def _build_source(config: dict): + """Build a ColorStripSource from a raw config dict, injecting synthetic id/name.""" + from wled_controller.storage.color_strip_source import ColorStripSource + config.setdefault("id", "__preview__") + config.setdefault("name", "__preview__") + return ColorStripSource.from_dict(config) + + def _create_stream(source): + """Instantiate and start the appropriate stream class for *source*.""" + from wled_controller.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) + if hasattr(s, "configure"): + s.configure(led_count) + # Inject sync clock if requested + cid = getattr(source, "clock_id", None) + if cid and hasattr(s, "set_clock"): + scm = _get_sync_clock_manager() + if scm: + try: + clock_rt = scm.acquire(cid) + s.set_clock(clock_rt) + except Exception as e: + logger.warning(f"Preview: could not acquire clock {cid}: {e}") + cid = None + else: + cid = None + else: + cid = None + s.start() + return s, cid + + def _stop_stream(s, cid): + """Stop a stream and release its clock.""" + try: + s.stop() + except Exception: + pass + if cid: + scm = _get_sync_clock_manager() + if scm: + try: + scm.release(cid) + except Exception: + pass + + async def _send_meta(source_type: str): + meta = {"type": "meta", "led_count": led_count, "source_type": source_type} + await websocket.send_text(_json.dumps(meta)) + + # Wait for initial config ──────────────────────────────────────────── + + try: + initial_text = await websocket.receive_text() + except WebSocketDisconnect: + return + except Exception: + return + + try: + config = _json.loads(initial_text) + source_type = config.get("source_type") + if source_type not in _PREVIEW_ALLOWED_TYPES: + await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"})) + await websocket.close(code=4003, reason="Invalid source_type") + return + source = _build_source(config) + stream, clock_id = _create_stream(source) + current_source_type = source_type + except Exception as e: + logger.error(f"Preview WS: bad initial config: {e}") + await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)})) + await websocket.close(code=4003, reason=str(e)) + return + + await _send_meta(current_source_type) + logger.info(f"Preview WS connected: source_type={current_source_type}, led_count={led_count}, fps={fps}") + + # Frame loop ───────────────────────────────────────────────────────── + + try: + while True: + # Non-blocking check for incoming config updates + try: + msg = await asyncio.wait_for(websocket.receive_text(), timeout=frame_interval) + except asyncio.TimeoutError: + msg = None + except WebSocketDisconnect: + break + + if msg is not None: + try: + new_config = _json.loads(msg) + new_type = new_config.get("source_type") + if new_type not in _PREVIEW_ALLOWED_TYPES: + await websocket.send_text(_json.dumps({"type": "error", "detail": f"source_type must be one of {sorted(_PREVIEW_ALLOWED_TYPES)}"})) + continue + new_source = _build_source(new_config) + if new_type != current_source_type: + # Source type changed — recreate stream + _stop_stream(stream, clock_id) + stream, clock_id = _create_stream(new_source) + current_source_type = new_type + else: + stream.update_source(new_source) + if hasattr(stream, "configure"): + stream.configure(led_count) + await _send_meta(current_source_type) + except Exception as e: + logger.warning(f"Preview WS: bad config update: {e}") + 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 + await websocket.send_bytes(b'\x00' * led_count * 3) + + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"Preview WS error: {e}") + finally: + if stream is not None: + _stop_stream(stream, clock_id) + logger.info("Preview WS disconnected") + + @router.websocket("/api/v1/color-strip-sources/{source_id}/ws") async def css_api_input_ws( websocket: WebSocket, diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 8feff33..25659ce 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -342,6 +342,11 @@ font-size: 0.85em; } +.css-test-led-control label { + display: inline; + margin-bottom: 0; +} + .css-test-led-input { width: 70px; flex: 0 0 70px; @@ -1263,3 +1268,4 @@ height: 26px; flex: 0 0 26px; } + diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 2a92844..80a9b28 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -125,6 +125,7 @@ import { applyGradientPreset, cloneColorStrip, toggleCSSOverlay, + previewCSSFromEditor, copyEndpointUrl, onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, @@ -425,6 +426,7 @@ Object.assign(window, { applyGradientPreset, cloneColorStrip, toggleCSSOverlay, + previewCSSFromEditor, copyEndpointUrl, onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, 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 de71062..782a1df 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -12,7 +12,7 @@ import { ICON_CLONE, ICON_EDIT, ICON_CALIBRATION, ICON_OVERLAY, ICON_LED, ICON_PALETTE, ICON_FPS, ICON_MAP_PIN, ICON_MUSIC, ICON_AUDIO_LOOPBACK, ICON_TIMER, ICON_LINK_SOURCE, ICON_FILM, - ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_EYE, + ICON_LINK, ICON_SPARKLES, ICON_ACTIVITY, ICON_CLOCK, ICON_BELL, ICON_TEST, ICON_SUN_DIM, ICON_WARNING, } from '../core/icons.js'; import * as P from '../core/icon-paths.js'; @@ -1354,7 +1354,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { ? `` : ''; const testPreviewBtn = !isApiInput - ? `` + ? `` : ''; return wrapCard({ @@ -2006,6 +2006,65 @@ export async function stopCSSOverlay(cssId) { } } +/* ── Editor Preview (opens existing test modal with transient WS) ──── */ + +const _PREVIEW_TYPES = new Set(['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight']); + +/** Collect current editor form state into a source config for transient preview. */ +function _collectPreviewConfig() { + const sourceType = document.getElementById('css-editor-type').value; + if (!_PREVIEW_TYPES.has(sourceType)) return null; + let config; + if (sourceType === 'static') { + config = { source_type: 'static', color: hexToRgbArray(document.getElementById('css-editor-color').value), animation: _getAnimationPayload() }; + } else if (sourceType === 'gradient') { + const stops = getGradientStops(); + if (stops.length < 2) return null; + config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload() }; + } else if (sourceType === 'color_cycle') { + const colors = _colorCycleGetColors(); + if (colors.length < 2) return null; + config = { source_type: 'color_cycle', colors }; + } else if (sourceType === 'effect') { + config = { source_type: 'effect', effect_type: document.getElementById('css-editor-effect-type').value, palette: document.getElementById('css-editor-effect-palette').value, intensity: parseFloat(document.getElementById('css-editor-effect-intensity').value), scale: parseFloat(document.getElementById('css-editor-effect-scale').value), mirror: document.getElementById('css-editor-effect-mirror').checked }; + if (config.effect_type === 'meteor') { const hex = document.getElementById('css-editor-effect-color').value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; } + } else if (sourceType === 'daylight') { + config = { source_type: 'daylight', speed: parseFloat(document.getElementById('css-editor-daylight-speed').value), use_real_time: document.getElementById('css-editor-daylight-real-time').checked, latitude: parseFloat(document.getElementById('css-editor-daylight-latitude').value) }; + } else if (sourceType === 'candlelight') { + config = { source_type: 'candlelight', color: hexToRgbArray(document.getElementById('css-editor-candlelight-color').value), intensity: parseFloat(document.getElementById('css-editor-candlelight-intensity').value), num_candles: parseInt(document.getElementById('css-editor-candlelight-num-candles').value) || 3, speed: parseFloat(document.getElementById('css-editor-candlelight-speed').value) }; + } + const clockEl = document.getElementById('css-editor-clock'); + if (clockEl && clockEl.value) config.clock_id = clockEl.value; + return config; +} + +/** + * Open the existing Test Preview modal from the CSS editor. + * For saved sources, uses the normal test endpoint. + * For unsaved/self-contained types, uses the transient preview endpoint. + */ +export function previewCSSFromEditor() { + // Always use transient preview with current form values + const config = _collectPreviewConfig(); + if (!config) { + // Non-previewable type (picture, composite, etc.) — fall back to saved source test + const cssId = document.getElementById('css-editor-id').value; + if (cssId) { testColorStrip(cssId); return; } + showToast(t('color_strip.preview.unsupported'), 'info'); + return; + } + + _cssTestCSPTMode = false; + _cssTestCSPTId = null; + _cssTestTransientConfig = config; + const csptGroup = document.getElementById('css-test-cspt-input-group'); + if (csptGroup) csptGroup.style.display = 'none'; + _openTestModal('__preview__'); +} + +/** Store transient config so _cssTestConnect and applyCssTestSettings can use it. */ +let _cssTestTransientConfig = null; + /* ── Test / Preview ───────────────────────────────────────────── */ const _CSS_TEST_LED_KEY = 'css_test_led_count'; @@ -2124,8 +2183,11 @@ function _cssTestConnect(sourceId, ledCount, fps) { if (!fps) fps = _getCssTestFps(); const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const apiKey = localStorage.getItem('wled_api_key') || ''; + const isTransient = sourceId === '__preview__' && _cssTestTransientConfig; let wsUrl; - if (_cssTestCSPTMode && _cssTestCSPTId) { + if (isTransient) { + wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/preview/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`; + } else if (_cssTestCSPTMode && _cssTestCSPTId) { wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-processing-templates/${_cssTestCSPTId}/test/ws?token=${encodeURIComponent(apiKey)}&input_source_id=${encodeURIComponent(sourceId)}&led_count=${ledCount}&fps=${fps}`; } else { wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`; @@ -2134,6 +2196,13 @@ function _cssTestConnect(sourceId, ledCount, fps) { _cssTestWs = new WebSocket(wsUrl); _cssTestWs.binaryType = 'arraybuffer'; + if (isTransient) { + _cssTestWs.onopen = () => { + if (gen !== _cssTestGeneration) return; + _cssTestWs.send(JSON.stringify(_cssTestTransientConfig)); + }; + } + _cssTestWs.onmessage = (event) => { // Ignore messages from a stale connection if (gen !== _cssTestGeneration) return; diff --git a/server/src/wled_controller/static/js/features/css-gradient-editor.js b/server/src/wled_controller/static/js/features/css-gradient-editor.js index bdeee18..8b4cb92 100644 --- a/server/src/wled_controller/static/js/features/css-gradient-editor.js +++ b/server/src/wled_controller/static/js/features/css-gradient-editor.js @@ -29,6 +29,10 @@ export function hexToRgbArray(hex) { let _gradientStops = []; let _gradientSelectedIdx = -1; let _gradientDragging = null; // { idx, trackRect } while dragging +let _gradientOnChange = null; + +/** Set a callback that fires whenever stops change. */ +export function gradientSetOnChange(fn) { _gradientOnChange = fn; } /** Read-only accessor for save/dirty-check from the parent module. */ export function getGradientStops() { @@ -183,6 +187,7 @@ export function gradientRenderAll() { _gradientRenderCanvas(); _gradientRenderMarkers(); _gradientRenderStopList(); + if (_gradientOnChange) _gradientOnChange(); } function _gradientRenderCanvas() { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 52cd5a3..ae05831 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1030,6 +1030,11 @@ "color_strip.test.fps": "FPS:", "color_strip.test.apply": "Apply", "color_strip.test.composite": "Composite", + "color_strip.preview.title": "Live Preview", + "color_strip.preview.not_connected": "Not connected", + "color_strip.preview.connecting": "Connecting...", + "color_strip.preview.connected": "Connected", + "color_strip.preview.unsupported": "Preview not available for this source type", "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/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 5880ed7..131e0bf 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -979,6 +979,11 @@ "color_strip.test.fps": "FPS:", "color_strip.test.apply": "Применить", "color_strip.test.composite": "Композит", + "color_strip.preview.title": "Предпросмотр", + "color_strip.preview.not_connected": "Не подключено", + "color_strip.preview.connecting": "Подключение...", + "color_strip.preview.connected": "Подключено", + "color_strip.preview.unsupported": "Предпросмотр недоступен для этого типа источника", "color_strip.type.daylight": "Дневной цикл", "color_strip.type.daylight.desc": "Имитация естественного дневного света за 24 часа", "color_strip.type.daylight.hint": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 7922d91..9cbb99e 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -979,6 +979,11 @@ "color_strip.test.fps": "FPS:", "color_strip.test.apply": "应用", "color_strip.test.composite": "合成", + "color_strip.preview.title": "实时预览", + "color_strip.preview.not_connected": "未连接", + "color_strip.preview.connecting": "连接中...", + "color_strip.preview.connected": "已连接", + "color_strip.preview.unsupported": "此源类型不支持预览", "color_strip.type.daylight": "日光循环", "color_strip.type.daylight.desc": "模拟24小时自然日光变化", "color_strip.type.daylight.hint": "模拟太阳在24小时内的色温变化——从温暖的日出到冷白的日光,再到温暖的日落和昏暗的夜晚。", diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index a4a0a57..515926e 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -621,6 +621,7 @@