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 a5ff3bd..6c2aace 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -34,7 +34,7 @@ from wled_controller.core.capture.calibration import ( ) from wled_controller.core.capture.screen_capture import get_available_displays from wled_controller.core.processing.processor_manager import ProcessorManager -from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, NotificationColorStripSource, PictureColorStripSource +from wled_controller.storage.color_strip_source import AdvancedPictureColorStripSource, ApiInputColorStripSource, CompositeColorStripSource, NotificationColorStripSource, PictureColorStripSource from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource from wled_controller.storage.picture_source_store import PictureSourceStore @@ -705,10 +705,11 @@ async def test_color_strip_ws( await websocket.close(code=4004, reason=str(e)) return - # Acquire stream + # Acquire stream – unique consumer ID per WS to avoid release races + import uuid as _uuid manager: ProcessorManager = get_processor_manager() csm = manager.color_strip_stream_manager - consumer_id = "__test__" + consumer_id = f"__test_{_uuid.uuid4().hex[:8]}__" try: stream = csm.acquire(source_id, consumer_id) except Exception as e: @@ -724,8 +725,11 @@ async def test_color_strip_ws( logger.info(f"CSS test WebSocket connected for {source_id}") try: + from wled_controller.core.processing.composite_stream import CompositeColorStripStream + # Send metadata as first message is_picture = isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)) + is_composite = isinstance(source, CompositeColorStripSource) meta: dict = { "type": "meta", "source_type": source.source_type, @@ -745,13 +749,48 @@ async def test_color_strip_ws( indices = [(idx + offset) % total for idx in indices] edges.append({"edge": seg.edge, "indices": indices}) meta["edges"] = edges + if is_composite and hasattr(source, "layers"): + # Send layer info for composite preview + enabled_layers = [l for l in source.layers if l.get("enabled", True)] + layer_infos = [] # [{name, id, is_notification}, ...] + for layer in enabled_layers: + info = {"id": layer["source_id"], "name": layer.get("source_id", "?"), "is_notification": False} + try: + layer_src = store.get_source(layer["source_id"]) + info["name"] = layer_src.name + info["is_notification"] = isinstance(layer_src, NotificationColorStripSource) + except (ValueError, KeyError): + pass + layer_infos.append(info) + meta["layers"] = [li["name"] for li in layer_infos] + meta["layer_infos"] = layer_infos await websocket.send_text(_json.dumps(meta)) # Stream binary RGB frames at ~20 Hz while True: - colors = stream.get_latest_colors() - if colors is not None: - await websocket.send_bytes(colors.tobytes()) + # For composite sources, send per-layer data like target preview does + if is_composite and isinstance(stream, CompositeColorStripStream): + layer_colors = stream.get_layer_colors() + composite_colors = stream.get_latest_colors() + if composite_colors is not None and layer_colors and len(layer_colors) > 1: + led_count = composite_colors.shape[0] + rgb_size = led_count * 3 + # Wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layer0_rgb...] ... [composite_rgb] + header = bytes([0xFE, len(layer_colors), (led_count >> 8) & 0xFF, led_count & 0xFF]) + parts = [header] + for lc in layer_colors: + if lc is not None and lc.shape[0] == led_count: + parts.append(lc.tobytes()) + else: + parts.append(b'\x00' * rgb_size) + parts.append(composite_colors.tobytes()) + await websocket.send_bytes(b''.join(parts)) + elif composite_colors is not None: + await websocket.send_bytes(composite_colors.tobytes()) + else: + colors = stream.get_latest_colors() + if colors is not None: + await websocket.send_bytes(colors.tobytes()) await asyncio.sleep(0.05) except WebSocketDisconnect: pass diff --git a/server/src/wled_controller/static/css/cards.css b/server/src/wled_controller/static/css/cards.css index 913b43e..5fb763c 100644 --- a/server/src/wled_controller/static/css/cards.css +++ b/server/src/wled_controller/static/css/cards.css @@ -1052,15 +1052,10 @@ ul.section-tip li { .led-preview-layer-canvas { display: block; width: 100%; - height: 14px; - border-radius: 2px; - image-rendering: pixelated; - background: #111; -} - -.led-preview-layer-composite .led-preview-layer-canvas { height: 24px; border-radius: 3px; + image-rendering: pixelated; + background: #111; } .led-preview-layer-label { diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 6b082c2..1b22e3b 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -119,6 +119,10 @@ } /* Color strip test preview */ +.css-test-strip-wrap { + position: relative; +} + .css-test-strip-canvas { display: block; width: 100%; @@ -128,6 +132,30 @@ image-rendering: pixelated; } +.css-test-fire-btn { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + width: 32px; + height: 32px; + border: none; + border-radius: 6px; + background: rgba(255, 255, 255, 0.1); + color: #fff; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; + line-height: 1; +} + +.css-test-fire-btn:hover { + background: rgba(255, 255, 255, 0.25); +} + .css-test-rect { display: grid; grid-template-columns: 14px 1fr 14px; @@ -178,7 +206,71 @@ font-size: 0.9em; } -#test-css-source-modal.css-test-wide .modal-content { +/* Composite layers preview */ +.css-test-layers { + display: flex; + flex-direction: column; + gap: 4px; +} + +.css-test-layer { + position: relative; +} + +.css-test-layer-canvas { + display: block; + width: 100%; + height: 48px; + border-radius: 4px; + image-rendering: pixelated; + background: #111; +} + +.css-test-layer-label { + position: absolute; + left: 4px; + top: 50%; + transform: translateY(-50%); + font-size: 0.6rem; + font-family: var(--font-mono, monospace); + color: #fff; + text-shadow: 0 0 3px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.6); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s; + white-space: nowrap; +} + +.css-test-layers:hover .css-test-layer-label { + opacity: 1; +} + +/* LED count control */ +.css-test-led-control { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0 0; + font-size: 0.85em; +} + +.css-test-led-input { + width: 70px; + padding: 3px 6px; + border: 1px solid var(--border-color, #333); + border-radius: 4px; + background: var(--input-bg, #1a1a2e); + color: var(--text-color, #e0e0e0); + font-size: 0.85em; + text-align: center; +} + +.css-test-led-apply { + padding: 3px 10px; + font-size: 0.8em; +} + +#test-css-source-modal .modal-content { max-width: 700px; } diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index baf55d8..cd3bbac 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -125,7 +125,7 @@ import { onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, testNotification, - testColorStrip, closeTestCssSourceModal, + testColorStrip, closeTestCssSourceModal, applyCssTestLedCount, fireCssTestNotification, fireCssTestNotificationLayer, } from './features/color-strips.js'; // Layer 5: audio sources @@ -405,7 +405,7 @@ Object.assign(window, { onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, testNotification, - testColorStrip, closeTestCssSourceModal, + testColorStrip, closeTestCssSourceModal, applyCssTestLedCount, fireCssTestNotification, fireCssTestNotificationLayer, // audio sources showAudioSourceModal, 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 a640194..0a51a85 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -1259,7 +1259,7 @@ export function createColorStripCard(source, pictureSourceMap, audioSourceMap) { const testNotifyBtn = isNotification ? `` : ''; - const testPreviewBtn = !isNotification && !isApiInput + const testPreviewBtn = !isApiInput ? `` : ''; @@ -1884,69 +1884,195 @@ export async function stopCSSOverlay(cssId) { /* ── Test / Preview ───────────────────────────────────────────── */ +const _CSS_TEST_LED_KEY = 'css_test_led_count'; let _cssTestWs = null; let _cssTestRaf = null; let _cssTestLatestRgb = null; let _cssTestMeta = null; +let _cssTestSourceId = null; +let _cssTestIsComposite = false; +let _cssTestLayerData = null; // { layerCount, ledCount, layers: [Uint8Array], composite: Uint8Array } +let _cssTestGeneration = 0; // bumped on each connect to ignore stale WS messages +let _cssTestNotificationIds = []; // notification source IDs to fire (self or composite layers) + +function _getCssTestLedCount() { + const stored = parseInt(localStorage.getItem(_CSS_TEST_LED_KEY), 10); + return (stored > 0 && stored <= 2000) ? stored : 100; +} export function testColorStrip(sourceId) { + // Clean up any previous session fully + if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; } + if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; } + _cssTestLatestRgb = null; + _cssTestMeta = null; + _cssTestIsComposite = false; + _cssTestLayerData = null; + const modal = document.getElementById('test-css-source-modal'); if (!modal) return; modal.style.display = 'flex'; modal.onclick = (e) => { if (e.target === modal) closeTestCssSourceModal(); }; + _cssTestSourceId = sourceId; // Reset views document.getElementById('css-test-strip-view').style.display = 'none'; document.getElementById('css-test-rect-view').style.display = 'none'; + document.getElementById('css-test-layers-view').style.display = 'none'; + document.getElementById('css-test-led-control').style.display = ''; + const layersContainer = document.getElementById('css-test-layers'); + if (layersContainer) layersContainer.innerHTML = ''; document.getElementById('css-test-status').style.display = ''; document.getElementById('css-test-status').textContent = t('color_strip.test.connecting'); document.getElementById('css-test-led-count').textContent = ''; - _cssTestLatestRgb = null; - _cssTestMeta = null; + + // Restore LED count + Enter key handler + const ledCount = _getCssTestLedCount(); + const ledInput = document.getElementById('css-test-led-input'); + ledInput.value = ledCount; + ledInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestLedCount(); } }; + + _cssTestConnect(sourceId, ledCount); +} + +function _cssTestConnect(sourceId, ledCount) { + // Close existing connection if any + if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; } + + // Bump generation so any late messages from old WS are ignored + const gen = ++_cssTestGeneration; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const apiKey = localStorage.getItem('wled_api_key') || ''; - const wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=100`; + const wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}`; _cssTestWs = new WebSocket(wsUrl); _cssTestWs.binaryType = 'arraybuffer'; _cssTestWs.onmessage = (event) => { + // Ignore messages from a stale connection + if (gen !== _cssTestGeneration) return; + if (typeof event.data === 'string') { // JSON metadata _cssTestMeta = JSON.parse(event.data); const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0; - document.getElementById('css-test-strip-view').style.display = isPicture ? 'none' : ''; + _cssTestIsComposite = _cssTestMeta.layers && _cssTestMeta.layers.length > 0; + + // Show correct view + document.getElementById('css-test-strip-view').style.display = (isPicture || _cssTestIsComposite) ? 'none' : ''; document.getElementById('css-test-rect-view').style.display = isPicture ? '' : 'none'; + document.getElementById('css-test-layers-view').style.display = _cssTestIsComposite ? '' : 'none'; document.getElementById('css-test-status').style.display = 'none'; - document.getElementById('test-css-source-modal').classList.toggle('css-test-wide', isPicture); + + // Hide LED count control for picture sources (LED count is fixed by calibration) + document.getElementById('css-test-led-control').style.display = isPicture ? 'none' : ''; + + // Show fire button for notification sources (direct only; composite has per-layer buttons) + const isNotify = _cssTestMeta.source_type === 'notification'; + const layerInfos = _cssTestMeta.layer_infos || []; + _cssTestNotificationIds = isNotify + ? [_cssTestSourceId] + : layerInfos.filter(li => li.is_notification).map(li => li.id); + const fireBtn = document.getElementById('css-test-fire-btn'); + if (fireBtn) fireBtn.style.display = (isNotify && !_cssTestIsComposite) ? '' : 'none'; + document.getElementById('css-test-led-count').textContent = `${_cssTestMeta.led_count} LEDs`; + + // Build composite layer canvases + if (_cssTestIsComposite) { + _cssTestBuildLayers(_cssTestMeta.layers, _cssTestMeta.source_type, _cssTestMeta.layer_infos); + } } else { - // Binary RGB frame - _cssTestLatestRgb = new Uint8Array(event.data); + const raw = new Uint8Array(event.data); + // Check composite wire format: [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...] + if (raw.length > 3 && raw[0] === 0xFE && _cssTestIsComposite) { + const layerCount = raw[1]; + const ledCount = (raw[2] << 8) | raw[3]; + const rgbSize = ledCount * 3; + let offset = 4; + const layers = []; + for (let i = 0; i < layerCount; i++) { + layers.push(raw.subarray(offset, offset + rgbSize)); + offset += rgbSize; + } + const composite = raw.subarray(offset, offset + rgbSize); + _cssTestLayerData = { layerCount, ledCount, layers, composite }; + _cssTestLatestRgb = composite; + } else { + // Standard format: raw RGB + _cssTestLatestRgb = raw; + } } }; _cssTestWs.onerror = () => { + if (gen !== _cssTestGeneration) return; document.getElementById('css-test-status').textContent = t('color_strip.test.error'); }; _cssTestWs.onclose = () => { - _cssTestWs = null; + if (gen === _cssTestGeneration) _cssTestWs = null; }; - // Start render loop - _cssTestRenderLoop(); + // Start render loop (only once) + if (!_cssTestRaf) _cssTestRenderLoop(); +} + +const _BELL_SVG = ''; + +function _cssTestBuildLayers(layerNames, sourceType, layerInfos) { + const container = document.getElementById('css-test-layers'); + if (!container) return; + // Composite result first, then individual layers + let html = `
` + + `` + + `${sourceType === 'composite' ? t('color_strip.test.composite') : ''}` + + `
`; + for (let i = 0; i < layerNames.length; i++) { + const info = layerInfos && layerInfos[i]; + const isNotify = info && info.is_notification; + const fireBtn = isNotify + ? `` + : ''; + html += `
` + + `` + + `${escapeHtml(layerNames[i])}` + + fireBtn + + `
`; + } + container.innerHTML = html; +} + +export function applyCssTestLedCount() { + const input = document.getElementById('css-test-led-input'); + if (!input || !_cssTestSourceId) return; + let val = parseInt(input.value, 10); + if (isNaN(val) || val < 1) val = 1; + if (val > 2000) val = 2000; + input.value = val; + localStorage.setItem(_CSS_TEST_LED_KEY, String(val)); + + // Clear frame data but keep views/layout intact to avoid size jump + _cssTestLatestRgb = null; + _cssTestMeta = null; + _cssTestLayerData = null; + + // Reconnect (generation counter ignores stale frames from old WS) + _cssTestConnect(_cssTestSourceId, val); } function _cssTestRenderLoop() { _cssTestRaf = requestAnimationFrame(_cssTestRenderLoop); - if (!_cssTestLatestRgb || !_cssTestMeta) return; + if (!_cssTestMeta) return; const isPicture = _cssTestMeta.edges && _cssTestMeta.edges.length > 0; - if (isPicture) { + + if (_cssTestIsComposite && _cssTestLayerData) { + _cssTestRenderLayers(_cssTestLayerData); + } else if (isPicture && _cssTestLatestRgb) { _cssTestRenderRect(_cssTestLatestRgb, _cssTestMeta.edges); - } else { + } else if (_cssTestLatestRgb) { _cssTestRenderStrip(_cssTestLatestRgb); } } @@ -1971,6 +2097,41 @@ function _cssTestRenderStrip(rgbBytes) { ctx.putImageData(imageData, 0, 0); } +function _cssTestRenderLayers(data) { + const container = document.getElementById('css-test-layers'); + if (!container) return; + const canvases = container.querySelectorAll('.css-test-layer-canvas'); + + // Composite canvas is first + const compositeCanvas = container.querySelector('[data-layer-idx="composite"]'); + if (compositeCanvas) _cssTestRenderStripCanvas(compositeCanvas, data.composite); + + // Individual layer canvases + for (let i = 0; i < data.layers.length; i++) { + const canvas = container.querySelector(`[data-layer-idx="${i}"]`); + if (canvas) _cssTestRenderStripCanvas(canvas, data.layers[i]); + } +} + +function _cssTestRenderStripCanvas(canvas, rgbBytes) { + const ledCount = rgbBytes.length / 3; + if (ledCount <= 0) return; + canvas.width = ledCount; + canvas.height = 1; + const ctx = canvas.getContext('2d'); + const imageData = ctx.createImageData(ledCount, 1); + const data = imageData.data; + for (let i = 0; i < ledCount; i++) { + const si = i * 3; + const di = i * 4; + data[di] = rgbBytes[si]; + data[di + 1] = rgbBytes[si + 1]; + data[di + 2] = rgbBytes[si + 2]; + data[di + 3] = 255; + } + ctx.putImageData(imageData, 0, 0); +} + function _cssTestRenderRect(rgbBytes, edges) { // edges: [{ edge: "top"|..., indices: [outputIdx, ...] }, ...] // indices are pre-computed on server: reverse + offset already applied @@ -2003,13 +2164,27 @@ function _cssTestRenderRect(rgbBytes, edges) { } } +export function fireCssTestNotification() { + for (const id of _cssTestNotificationIds) { + testNotification(id); + } +} + +export function fireCssTestNotificationLayer(sourceId) { + testNotification(sourceId); +} + export function closeTestCssSourceModal() { if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; } if (_cssTestRaf) { cancelAnimationFrame(_cssTestRaf); _cssTestRaf = null; } _cssTestLatestRgb = null; _cssTestMeta = null; + _cssTestSourceId = null; + _cssTestIsComposite = false; + _cssTestLayerData = null; + _cssTestNotificationIds = []; const modal = document.getElementById('test-css-source-modal'); - if (modal) { modal.style.display = 'none'; modal.classList.remove('css-test-wide'); } + if (modal) { modal.style.display = 'none'; } } /* Gradient editor moved to css-gradient-editor.js */ diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 804a492..7ef6e89 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -931,6 +931,9 @@ "color_strip.test.title": "Test Preview", "color_strip.test.connecting": "Connecting...", "color_strip.test.error": "Failed to connect to preview stream", + "color_strip.test.led_count": "LEDs:", + "color_strip.test.apply": "Apply", + "color_strip.test.composite": "Composite", "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 16372df..17d23d1 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -931,6 +931,9 @@ "color_strip.test.title": "Предпросмотр", "color_strip.test.connecting": "Подключение...", "color_strip.test.error": "Не удалось подключиться к потоку предпросмотра", + "color_strip.test.led_count": "Кол-во LED:", + "color_strip.test.apply": "Применить", + "color_strip.test.composite": "Композит", "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 d0f1d3e..c72fd93 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -931,6 +931,9 @@ "color_strip.test.title": "预览测试", "color_strip.test.connecting": "连接中...", "color_strip.test.error": "无法连接到预览流", + "color_strip.test.led_count": "LED数量:", + "color_strip.test.apply": "应用", + "color_strip.test.composite": "合成", "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/test-css-source.html b/server/src/wled_controller/templates/modals/test-css-source.html index ac641df..d9d3857 100644 --- a/server/src/wled_controller/templates/modals/test-css-source.html +++ b/server/src/wled_controller/templates/modals/test-css-source.html @@ -2,13 +2,16 @@