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) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 14:49:22 +03:00
parent 6c7b7ea7d7
commit 014b4175b9
9 changed files with 281 additions and 3 deletions

View File

@@ -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=<api_key>&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,

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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) {
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testNotification('${source.id}')" title="${t('color_strip.notification.test')}">${ICON_BELL}</button>`
: '';
const testPreviewBtn = !isApiInput
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_EYE}</button>`
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); testColorStrip('${source.id}')" title="${t('color_strip.test.title')}">${ICON_TEST}</button>`
: '';
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;

View File

@@ -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() {

View File

@@ -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.",

View File

@@ -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": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.",

View File

@@ -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小时内的色温变化——从温暖的日出到冷白的日光再到温暖的日落和昏暗的夜晚。",

View File

@@ -621,6 +621,7 @@
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeCSSEditorModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-secondary" onclick="previewCSSFromEditor()" data-i18n-title="color_strip.test.title" title="Test Preview"><svg class="icon" viewBox="0 0 24 24"><path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0 0 1 18 22H6a2 2 0 0 1-1.755-2.96l5.51-10.08A2 2 0 0 0 10 8V2"/><path d="M6.453 15h11.094"/><path d="M8.5 2h7"/></svg></button>
<button class="btn btn-icon btn-primary" onclick="saveCSSEditor()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>