Add configurable FPS to test preview and fix composite stream release race
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -682,6 +682,7 @@ async def test_color_strip_ws(
|
|||||||
source_id: str,
|
source_id: str,
|
||||||
token: str = Query(""),
|
token: str = Query(""),
|
||||||
led_count: int = Query(100),
|
led_count: int = Query(100),
|
||||||
|
fps: int = Query(20),
|
||||||
):
|
):
|
||||||
"""WebSocket for real-time CSS source preview. Auth via ``?token=<api_key>``.
|
"""WebSocket for real-time CSS source preview. Auth via ``?token=<api_key>``.
|
||||||
|
|
||||||
@@ -723,8 +724,12 @@ async def test_color_strip_ws(
|
|||||||
if hasattr(stream, "configure"):
|
if hasattr(stream, "configure"):
|
||||||
stream.configure(max(1, led_count))
|
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()
|
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:
|
try:
|
||||||
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
|
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
|
||||||
@@ -846,7 +851,7 @@ async def test_color_strip_ws(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"JPEG frame preview error: {e}")
|
logger.warning(f"JPEG frame preview error: {e}")
|
||||||
|
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(_frame_interval)
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, source, css_manager, value_stream_manager=None):
|
def __init__(self, source, css_manager, value_stream_manager=None):
|
||||||
|
import uuid as _uuid
|
||||||
self._source_id: str = source.id
|
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._layers: List[dict] = list(source.layers)
|
||||||
self._led_count: int = source.led_count
|
self._led_count: int = source.led_count
|
||||||
self._auto_size: bool = source.led_count == 0
|
self._auto_size: bool = source.led_count == 0
|
||||||
@@ -167,7 +169,7 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
src_id = layer.get("source_id", "")
|
src_id = layer.get("source_id", "")
|
||||||
if not src_id:
|
if not src_id:
|
||||||
continue
|
continue
|
||||||
consumer_id = f"{self._source_id}__layer_{i}"
|
consumer_id = f"{self._source_id}__{self._instance_id}__layer_{i}"
|
||||||
try:
|
try:
|
||||||
stream = self._css_manager.acquire(src_id, consumer_id)
|
stream = self._css_manager.acquire(src_id, consumer_id)
|
||||||
if hasattr(stream, "configure") and self._led_count > 0:
|
if hasattr(stream, "configure") and self._led_count > 0:
|
||||||
|
|||||||
@@ -317,6 +317,7 @@
|
|||||||
|
|
||||||
.css-test-led-input {
|
.css-test-led-input {
|
||||||
width: 70px;
|
width: 70px;
|
||||||
|
flex: 0 0 70px;
|
||||||
padding: 3px 6px;
|
padding: 3px 6px;
|
||||||
border: 1px solid var(--border-color, #333);
|
border: 1px solid var(--border-color, #333);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -327,8 +328,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.css-test-led-apply {
|
.css-test-led-apply {
|
||||||
padding: 3px 10px;
|
padding: 2px 6px;
|
||||||
font-size: 0.8em;
|
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 {
|
#test-css-source-modal .modal-content {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ import {
|
|||||||
onNotificationFilterModeChange,
|
onNotificationFilterModeChange,
|
||||||
notificationAddAppColor, notificationRemoveAppColor,
|
notificationAddAppColor, notificationRemoveAppColor,
|
||||||
testNotification,
|
testNotification,
|
||||||
testColorStrip, closeTestCssSourceModal, applyCssTestLedCount, fireCssTestNotification, fireCssTestNotificationLayer,
|
testColorStrip, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||||
} from './features/color-strips.js';
|
} from './features/color-strips.js';
|
||||||
|
|
||||||
// Layer 5: audio sources
|
// Layer 5: audio sources
|
||||||
@@ -405,7 +405,7 @@ Object.assign(window, {
|
|||||||
onNotificationFilterModeChange,
|
onNotificationFilterModeChange,
|
||||||
notificationAddAppColor, notificationRemoveAppColor,
|
notificationAddAppColor, notificationRemoveAppColor,
|
||||||
testNotification,
|
testNotification,
|
||||||
testColorStrip, closeTestCssSourceModal, applyCssTestLedCount, fireCssTestNotification, fireCssTestNotificationLayer,
|
testColorStrip, closeTestCssSourceModal, applyCssTestSettings, fireCssTestNotification, fireCssTestNotificationLayer,
|
||||||
|
|
||||||
// audio sources
|
// audio sources
|
||||||
showAudioSourceModal,
|
showAudioSourceModal,
|
||||||
|
|||||||
@@ -1886,6 +1886,7 @@ export async function stopCSSOverlay(cssId) {
|
|||||||
/* ── Test / Preview ───────────────────────────────────────────── */
|
/* ── Test / Preview ───────────────────────────────────────────── */
|
||||||
|
|
||||||
const _CSS_TEST_LED_KEY = 'css_test_led_count';
|
const _CSS_TEST_LED_KEY = 'css_test_led_count';
|
||||||
|
const _CSS_TEST_FPS_KEY = 'css_test_fps';
|
||||||
let _cssTestWs = null;
|
let _cssTestWs = null;
|
||||||
let _cssTestRaf = null;
|
let _cssTestRaf = null;
|
||||||
let _cssTestLatestRgb = null;
|
let _cssTestLatestRgb = null;
|
||||||
@@ -1901,6 +1902,11 @@ function _getCssTestLedCount() {
|
|||||||
return (stored > 0 && stored <= 2000) ? stored : 100;
|
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) {
|
export function testColorStrip(sourceId) {
|
||||||
// Clean up any previous session fully
|
// Clean up any previous session fully
|
||||||
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
|
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-strip-view').style.display = 'none';
|
||||||
document.getElementById('css-test-rect-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-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');
|
const layersContainer = document.getElementById('css-test-layers');
|
||||||
if (layersContainer) layersContainer.innerHTML = '';
|
if (layersContainer) layersContainer.innerHTML = '';
|
||||||
document.getElementById('css-test-status').style.display = '';
|
document.getElementById('css-test-status').style.display = '';
|
||||||
document.getElementById('css-test-status').textContent = t('color_strip.test.connecting');
|
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 ledCount = _getCssTestLedCount();
|
||||||
const ledInput = document.getElementById('css-test-led-input');
|
const ledInput = document.getElementById('css-test-led-input');
|
||||||
ledInput.value = ledCount;
|
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
|
// Close existing connection if any
|
||||||
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
|
if (_cssTestWs) { _cssTestWs.close(); _cssTestWs = null; }
|
||||||
|
|
||||||
// Bump generation so any late messages from old WS are ignored
|
// Bump generation so any late messages from old WS are ignored
|
||||||
const gen = ++_cssTestGeneration;
|
const gen = ++_cssTestGeneration;
|
||||||
|
|
||||||
|
if (!fps) fps = _getCssTestFps();
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const apiKey = localStorage.getItem('wled_api_key') || '';
|
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 = new WebSocket(wsUrl);
|
||||||
_cssTestWs.binaryType = 'arraybuffer';
|
_cssTestWs.binaryType = 'arraybuffer';
|
||||||
@@ -1978,7 +1990,7 @@ function _cssTestConnect(sourceId, ledCount) {
|
|||||||
if (modalContent) modalContent.style.maxWidth = isPicture ? '900px' : '';
|
if (modalContent) modalContent.style.maxWidth = isPicture ? '900px' : '';
|
||||||
|
|
||||||
// Hide LED count control for picture sources (LED count is fixed by calibration)
|
// 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)
|
// Show fire button for notification sources (direct only; composite has per-layer buttons)
|
||||||
const isNotify = _cssTestMeta.source_type === 'notification';
|
const isNotify = _cssTestMeta.source_type === 'notification';
|
||||||
@@ -2115,14 +2127,22 @@ function _cssTestUpdateBrightness(values) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyCssTestLedCount() {
|
export function applyCssTestSettings() {
|
||||||
const input = document.getElementById('css-test-led-input');
|
if (!_cssTestSourceId) return;
|
||||||
if (!input || !_cssTestSourceId) return;
|
|
||||||
let val = parseInt(input.value, 10);
|
const ledInput = document.getElementById('css-test-led-input');
|
||||||
if (isNaN(val) || val < 1) val = 1;
|
let leds = parseInt(ledInput?.value, 10);
|
||||||
if (val > 2000) val = 2000;
|
if (isNaN(leds) || leds < 1) leds = 1;
|
||||||
input.value = val;
|
if (leds > 2000) leds = 2000;
|
||||||
localStorage.setItem(_CSS_TEST_LED_KEY, String(val));
|
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
|
// Clear frame data but keep views/layout intact to avoid size jump
|
||||||
_cssTestLatestRgb = null;
|
_cssTestLatestRgb = null;
|
||||||
@@ -2130,7 +2150,7 @@ export function applyCssTestLedCount() {
|
|||||||
_cssTestLayerData = null;
|
_cssTestLayerData = null;
|
||||||
|
|
||||||
// Reconnect (generation counter ignores stale frames from old WS)
|
// Reconnect (generation counter ignores stale frames from old WS)
|
||||||
_cssTestConnect(_cssTestSourceId, val);
|
_cssTestConnect(_cssTestSourceId, leds, fps);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _cssTestRenderLoop() {
|
function _cssTestRenderLoop() {
|
||||||
|
|||||||
@@ -932,6 +932,7 @@
|
|||||||
"color_strip.test.connecting": "Connecting...",
|
"color_strip.test.connecting": "Connecting...",
|
||||||
"color_strip.test.error": "Failed to connect to preview stream",
|
"color_strip.test.error": "Failed to connect to preview stream",
|
||||||
"color_strip.test.led_count": "LEDs:",
|
"color_strip.test.led_count": "LEDs:",
|
||||||
|
"color_strip.test.fps": "FPS:",
|
||||||
"color_strip.test.apply": "Apply",
|
"color_strip.test.apply": "Apply",
|
||||||
"color_strip.test.composite": "Composite",
|
"color_strip.test.composite": "Composite",
|
||||||
"color_strip.type.daylight": "Daylight Cycle",
|
"color_strip.type.daylight": "Daylight Cycle",
|
||||||
|
|||||||
@@ -932,6 +932,7 @@
|
|||||||
"color_strip.test.connecting": "Подключение...",
|
"color_strip.test.connecting": "Подключение...",
|
||||||
"color_strip.test.error": "Не удалось подключиться к потоку предпросмотра",
|
"color_strip.test.error": "Не удалось подключиться к потоку предпросмотра",
|
||||||
"color_strip.test.led_count": "Кол-во LED:",
|
"color_strip.test.led_count": "Кол-во LED:",
|
||||||
|
"color_strip.test.fps": "FPS:",
|
||||||
"color_strip.test.apply": "Применить",
|
"color_strip.test.apply": "Применить",
|
||||||
"color_strip.test.composite": "Композит",
|
"color_strip.test.composite": "Композит",
|
||||||
"color_strip.type.daylight": "Дневной цикл",
|
"color_strip.type.daylight": "Дневной цикл",
|
||||||
|
|||||||
@@ -932,6 +932,7 @@
|
|||||||
"color_strip.test.connecting": "连接中...",
|
"color_strip.test.connecting": "连接中...",
|
||||||
"color_strip.test.error": "无法连接到预览流",
|
"color_strip.test.error": "无法连接到预览流",
|
||||||
"color_strip.test.led_count": "LED数量:",
|
"color_strip.test.led_count": "LED数量:",
|
||||||
|
"color_strip.test.fps": "FPS:",
|
||||||
"color_strip.test.apply": "应用",
|
"color_strip.test.apply": "应用",
|
||||||
"color_strip.test.composite": "合成",
|
"color_strip.test.composite": "合成",
|
||||||
"color_strip.type.daylight": "日光循环",
|
"color_strip.type.daylight": "日光循环",
|
||||||
|
|||||||
@@ -42,11 +42,16 @@
|
|||||||
<div id="css-test-layers" class="css-test-layers"></div>
|
<div id="css-test-layers" class="css-test-layers"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LED count control -->
|
<!-- LED count & FPS controls -->
|
||||||
<div class="css-test-led-control" id="css-test-led-control">
|
<div class="css-test-led-control">
|
||||||
<label for="css-test-led-input" data-i18n="color_strip.test.led_count">LEDs:</label>
|
<span id="css-test-led-group">
|
||||||
<input type="number" id="css-test-led-input" min="1" max="2000" step="1" value="100" class="css-test-led-input">
|
<label for="css-test-led-input" data-i18n="color_strip.test.led_count">LEDs:</label>
|
||||||
<button class="btn btn-sm btn-secondary css-test-led-apply" onclick="applyCssTestLedCount()" data-i18n="color_strip.test.apply">Apply</button>
|
<input type="number" id="css-test-led-input" min="1" max="2000" step="1" value="100" class="css-test-led-input">
|
||||||
|
<span class="css-test-separator"></span>
|
||||||
|
</span>
|
||||||
|
<label for="css-test-fps-input" data-i18n="color_strip.test.fps">FPS:</label>
|
||||||
|
<input type="number" id="css-test-fps-input" min="1" max="60" step="1" value="20" class="css-test-led-input">
|
||||||
|
<button class="btn btn-icon btn-sm btn-secondary css-test-led-apply" onclick="applyCssTestSettings()" title="Apply" data-i18n-title="color_strip.test.apply">✓</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="css-test-status" class="css-test-status" data-i18n="color_strip.test.connecting">Connecting...</div>
|
<div id="css-test-status" class="css-test-status" data-i18n="color_strip.test.connecting">Connecting...</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user