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

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