From 561229a7fe1a8e860d9a792bc6a9ba7d2d8e8d59 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 13 Mar 2026 02:04:09 +0300 Subject: [PATCH] Add configurable FPS to test preview and fix composite stream release race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FPS control (1-60, default 20) to test preview modal next to LED count - Server accepts fps query param, controls frame send interval - Single Apply icon button (✓) applies both LED count and FPS - FPS control stays visible for picture sources (LED count hidden) - Fix composite sub-stream consumer ID collision: use unique instance ID to prevent old WebSocket release from killing new connection's streams Co-Authored-By: Claude Opus 4.6 --- .../api/routes/color_strip_sources.py | 9 +++- .../core/processing/composite_stream.py | 4 +- .../src/wled_controller/static/css/modal.css | 19 ++++++- server/src/wled_controller/static/js/app.js | 4 +- .../static/js/features/color-strips.js | 52 +++++++++++++------ .../wled_controller/static/locales/en.json | 1 + .../wled_controller/static/locales/ru.json | 1 + .../wled_controller/static/locales/zh.json | 1 + .../templates/modals/test-css-source.html | 15 ++++-- 9 files changed, 78 insertions(+), 28 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 9bc36bc..622d316 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -682,6 +682,7 @@ async def test_color_strip_ws( source_id: str, token: str = Query(""), led_count: int = Query(100), + fps: int = Query(20), ): """WebSocket for real-time CSS source preview. Auth via ``?token=``. @@ -723,8 +724,12 @@ async def test_color_strip_ws( if hasattr(stream, "configure"): stream.configure(max(1, led_count)) + # Clamp FPS to sane range + fps = max(1, min(60, fps)) + _frame_interval = 1.0 / fps + await websocket.accept() - logger.info(f"CSS test WebSocket connected for {source_id}") + logger.info(f"CSS test WebSocket connected for {source_id} (fps={fps})") try: from wled_controller.core.processing.composite_stream import CompositeColorStripStream @@ -846,7 +851,7 @@ async def test_color_strip_ws( except Exception as e: logger.warning(f"JPEG frame preview error: {e}") - await asyncio.sleep(0.05) + await asyncio.sleep(_frame_interval) except WebSocketDisconnect: pass except Exception as e: diff --git a/server/src/wled_controller/core/processing/composite_stream.py b/server/src/wled_controller/core/processing/composite_stream.py index 6106d8f..03dbd1c 100644 --- a/server/src/wled_controller/core/processing/composite_stream.py +++ b/server/src/wled_controller/core/processing/composite_stream.py @@ -30,7 +30,9 @@ class CompositeColorStripStream(ColorStripStream): """ def __init__(self, source, css_manager, value_stream_manager=None): + import uuid as _uuid self._source_id: str = source.id + self._instance_id: str = _uuid.uuid4().hex[:8] # unique per instance to avoid release races self._layers: List[dict] = list(source.layers) self._led_count: int = source.led_count self._auto_size: bool = source.led_count == 0 @@ -167,7 +169,7 @@ class CompositeColorStripStream(ColorStripStream): src_id = layer.get("source_id", "") if not src_id: continue - consumer_id = f"{self._source_id}__layer_{i}" + consumer_id = f"{self._source_id}__{self._instance_id}__layer_{i}" try: stream = self._css_manager.acquire(src_id, consumer_id) if hasattr(stream, "configure") and self._led_count > 0: diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index 9cc82ac..cbdfbe3 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -317,6 +317,7 @@ .css-test-led-input { width: 70px; + flex: 0 0 70px; padding: 3px 6px; border: 1px solid var(--border-color, #333); border-radius: 4px; @@ -327,8 +328,22 @@ } .css-test-led-apply { - padding: 3px 10px; - font-size: 0.8em; + padding: 2px 6px; + font-size: 0.85em; + line-height: 1; +} + +#css-test-led-group { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.css-test-separator { + width: 1px; + height: 18px; + background: var(--border-color, #333); + margin: 0 4px; } #test-css-source-modal .modal-content { diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index cd3bbac..ce8f9e4 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, applyCssTestLedCount, fireCssTestNotification, fireCssTestNotificationLayer, + testColorStrip, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer, } from './features/color-strips.js'; // Layer 5: audio sources @@ -405,7 +405,7 @@ Object.assign(window, { onNotificationFilterModeChange, notificationAddAppColor, notificationRemoveAppColor, testNotification, - testColorStrip, closeTestCssSourceModal, applyCssTestLedCount, fireCssTestNotification, fireCssTestNotificationLayer, + testColorStrip, closeTestCssSourceModal, applyCssTestSettings, 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 56e7bc5..aa3d00b 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -1886,6 +1886,7 @@ export async function stopCSSOverlay(cssId) { /* ── Test / Preview ───────────────────────────────────────────── */ const _CSS_TEST_LED_KEY = 'css_test_led_count'; +const _CSS_TEST_FPS_KEY = 'css_test_fps'; let _cssTestWs = null; let _cssTestRaf = null; let _cssTestLatestRgb = null; @@ -1901,6 +1902,11 @@ function _getCssTestLedCount() { return (stored > 0 && stored <= 2000) ? stored : 100; } +function _getCssTestFps() { + const stored = parseInt(localStorage.getItem(_CSS_TEST_FPS_KEY), 10); + return (stored >= 1 && stored <= 60) ? stored : 20; +} + export function testColorStrip(sourceId) { // Clean up any previous session fully if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; } @@ -1920,31 +1926,37 @@ export function testColorStrip(sourceId) { 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 = ''; + document.getElementById('css-test-led-group').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'); - // Restore LED count + Enter key handler + // Restore LED count + FPS + Enter key handlers const ledCount = _getCssTestLedCount(); const ledInput = document.getElementById('css-test-led-input'); ledInput.value = ledCount; - ledInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestLedCount(); } }; + ledInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } }; - _cssTestConnect(sourceId, ledCount); + const fpsVal = _getCssTestFps(); + const fpsInput = document.getElementById('css-test-fps-input'); + fpsInput.value = fpsVal; + fpsInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); applyCssTestSettings(); } }; + + _cssTestConnect(sourceId, ledCount, fpsVal); } -function _cssTestConnect(sourceId, ledCount) { +function _cssTestConnect(sourceId, ledCount, fps) { // 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; + if (!fps) fps = _getCssTestFps(); 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=${ledCount}`; + const wsUrl = `${protocol}//${window.location.host}/api/v1/color-strip-sources/${sourceId}/test/ws?token=${encodeURIComponent(apiKey)}&led_count=${ledCount}&fps=${fps}`; _cssTestWs = new WebSocket(wsUrl); _cssTestWs.binaryType = 'arraybuffer'; @@ -1978,7 +1990,7 @@ function _cssTestConnect(sourceId, ledCount) { if (modalContent) modalContent.style.maxWidth = isPicture ? '900px' : ''; // Hide LED count control for picture sources (LED count is fixed by calibration) - document.getElementById('css-test-led-control').style.display = isPicture ? 'none' : ''; + document.getElementById('css-test-led-group').style.display = isPicture ? 'none' : ''; // Show fire button for notification sources (direct only; composite has per-layer buttons) const isNotify = _cssTestMeta.source_type === 'notification'; @@ -2115,14 +2127,22 @@ function _cssTestUpdateBrightness(values) { } } -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)); +export function applyCssTestSettings() { + if (!_cssTestSourceId) return; + + const ledInput = document.getElementById('css-test-led-input'); + let leds = parseInt(ledInput?.value, 10); + if (isNaN(leds) || leds < 1) leds = 1; + if (leds > 2000) leds = 2000; + if (ledInput) ledInput.value = leds; + localStorage.setItem(_CSS_TEST_LED_KEY, String(leds)); + + const fpsInput = document.getElementById('css-test-fps-input'); + let fps = parseInt(fpsInput?.value, 10); + if (isNaN(fps) || fps < 1) fps = 1; + if (fps > 60) fps = 60; + if (fpsInput) fpsInput.value = fps; + localStorage.setItem(_CSS_TEST_FPS_KEY, String(fps)); // Clear frame data but keep views/layout intact to avoid size jump _cssTestLatestRgb = null; @@ -2130,7 +2150,7 @@ export function applyCssTestLedCount() { _cssTestLayerData = null; // Reconnect (generation counter ignores stale frames from old WS) - _cssTestConnect(_cssTestSourceId, val); + _cssTestConnect(_cssTestSourceId, leds, fps); } function _cssTestRenderLoop() { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 7ef6e89..708a3ff 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -932,6 +932,7 @@ "color_strip.test.connecting": "Connecting...", "color_strip.test.error": "Failed to connect to preview stream", "color_strip.test.led_count": "LEDs:", + "color_strip.test.fps": "FPS:", "color_strip.test.apply": "Apply", "color_strip.test.composite": "Composite", "color_strip.type.daylight": "Daylight Cycle", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 17d23d1..904ef9c 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -932,6 +932,7 @@ "color_strip.test.connecting": "Подключение...", "color_strip.test.error": "Не удалось подключиться к потоку предпросмотра", "color_strip.test.led_count": "Кол-во LED:", + "color_strip.test.fps": "FPS:", "color_strip.test.apply": "Применить", "color_strip.test.composite": "Композит", "color_strip.type.daylight": "Дневной цикл", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index c72fd93..5eb4808 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -932,6 +932,7 @@ "color_strip.test.connecting": "连接中...", "color_strip.test.error": "无法连接到预览流", "color_strip.test.led_count": "LED数量:", + "color_strip.test.fps": "FPS:", "color_strip.test.apply": "应用", "color_strip.test.composite": "合成", "color_strip.type.daylight": "日光循环", 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 675f93d..290005c 100644 --- a/server/src/wled_controller/templates/modals/test-css-source.html +++ b/server/src/wled_controller/templates/modals/test-css-source.html @@ -42,11 +42,16 @@
- -
- - - + +
+ + + + + + + +
Connecting...