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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.",
|
||||
|
||||
@@ -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小时内的色温变化——从温暖的日出到冷白的日光,再到温暖的日落和昏暗的夜晚。",
|
||||
|
||||
@@ -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">✕</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">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user