feat(color-strips): in-editor live preview for all viable source types

Extend the editor's Preview button to render unsaved form values for every
CSS source type that can be previewed without external calibration. New
types now supported transiently: audio, math_wave, weather, game_event,
api_input, mapped, composite, processed.

Backend (preview WebSocket):
- Dispatch in _create_stream by source_type, injecting the dependencies each
  stream needs (audio managers, weather manager, value stream manager, CSPT
  store via public get_cspt_store, color strip stream manager).
- Roll back clock + stream resources if start() fails so failed previews
  don't leak refs.
- On source_type change mid-preview, drop the rebuilt-stream reference if
  rebuild fails and close the WS rather than poll a stopped stream.

Stream lifecycle fixes flushed out by the new preview paths:
- MappedColorStripStream and ProcessedColorStripStream now stamp a per-
  instance UUID into the sub-stream consumer_id so concurrent consumers
  (multiple preview WS connections) don't collide in the CSM registry.
- ProcessedColorStripStream.update_source now re-acquires the input stream
  when input_source_id changes (previously silently kept the old input).

Frontend:
- Expand _PREVIEW_TYPES; route non-quirky types through a new exported
  getCSSEditorPreviewPayload helper that reuses the existing per-type
  handler registry.
- For picture / picture_advanced / key_colors (which depend on calibration
  or rectangles edited elsewhere), show a clearer "save the source first"
  message instead of the generic "unsupported" toast.
This commit is contained in:
2026-05-16 00:40:26 +03:00
parent 530316c2c3
commit 337984c618
8 changed files with 219 additions and 21 deletions
@@ -35,6 +35,14 @@ _PREVIEW_ALLOWED_TYPES = {
"daylight", "daylight",
"candlelight", "candlelight",
"notification", "notification",
"audio",
"math_wave",
"weather",
"game_event",
"api_input",
"mapped",
"composite",
"processed",
} }
@@ -92,6 +100,58 @@ async def preview_color_strip_ws(
"""Instantiate and start the appropriate stream class for *source*.""" """Instantiate and start the appropriate stream class for *source*."""
from ledgrab.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP from ledgrab.core.processing.color_strip_stream_manager import _SIMPLE_STREAM_MAP
mgr = get_processor_manager()
csm = mgr.color_strip_stream_manager
if source.source_type == "audio":
from ledgrab.api.dependencies import (
get_audio_processing_template_store,
get_audio_source_store,
get_audio_template_store,
)
from ledgrab.core.processing.audio_stream import AudioColorStripStream
s = AudioColorStripStream(
source,
mgr.audio_capture_manager,
get_audio_source_store(),
get_audio_template_store(),
get_audio_processing_template_store(),
)
elif source.source_type == "weather":
from ledgrab.core.processing.weather_stream import WeatherColorStripStream
s = WeatherColorStripStream(source, mgr.weather_manager)
elif source.source_type == "game_event":
from ledgrab.core.processing.game_event_stream import GameEventColorStripStream
s = GameEventColorStripStream(source)
try:
from ledgrab.api.dependencies import get_game_event_bus
bus = get_game_event_bus()
except RuntimeError as e:
logger.debug("Preview: no game event bus available: %s", e)
else:
if bus is not None:
s.set_event_bus(bus)
elif source.source_type == "mapped":
from ledgrab.core.processing.mapped_stream import MappedColorStripStream
s = MappedColorStripStream(source, csm)
elif source.source_type == "composite":
from ledgrab.api.dependencies import get_cspt_store
from ledgrab.core.processing.composite_stream import CompositeColorStripStream
s = CompositeColorStripStream(
source, csm, mgr.value_stream_manager, get_cspt_store(), depth=0
)
elif source.source_type == "processed":
from ledgrab.api.dependencies import get_cspt_store
from ledgrab.core.processing.processed_stream import ProcessedColorStripStream
s = ProcessedColorStripStream(source, csm, get_cspt_store())
else:
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls: if not stream_cls:
raise ValueError(f"Unsupported preview source_type: {source.source_type}") raise ValueError(f"Unsupported preview source_type: {source.source_type}")
@@ -121,7 +181,24 @@ async def preview_color_strip_ws(
cid = None cid = None
else: else:
cid = None cid = None
# Start the stream; if start() raises, release any resources we
# already acquired (clock + anything the stream itself grabbed in
# its __init__) so we don't leak refs across failed previews.
try:
s.start() s.start()
except Exception:
try:
s.stop()
except Exception as e_stop:
logger.exception("unexpected in start-failure rollback s.stop: %s", e_stop)
if cid:
scm = _get_sync_clock_manager()
if scm:
try:
scm.release(cid)
except Exception as e_rel:
logger.exception("unexpected in start-failure clock release: %s", e_rel)
raise
return s, cid return s, cid
def _stop_stream(s, cid): def _stop_stream(s, cid):
@@ -222,10 +299,24 @@ async def preview_color_strip_ws(
continue continue
new_source = _build_source(new_config) new_source = _build_source(new_config)
if new_type != current_source_type: if new_type != current_source_type:
# Source type changed — recreate stream # Source type changed — stop the old stream first, then
# build the new one. If the rebuild fails, drop the
# reference so the frame loop doesn't keep polling a
# stopped stream and the finally-block doesn't double-stop.
_stop_stream(stream, clock_id) _stop_stream(stream, clock_id)
stream, clock_id = None, None
try:
stream, clock_id = _create_stream(new_source) stream, clock_id = _create_stream(new_source)
current_source_type = new_type current_source_type = new_type
except Exception as rebuild_err:
logger.error(
f"Preview WS: failed to rebuild stream for new type {new_type}: {rebuild_err}"
)
await websocket.send_text(
_json.dumps({"type": "error", "detail": str(rebuild_err)})
)
await websocket.close(code=4003, reason=str(rebuild_err))
return
else: else:
stream.update_source(new_source) stream.update_source(new_source)
if hasattr(stream, "configure"): if hasattr(stream, "configure"):
@@ -236,6 +327,9 @@ async def preview_color_strip_ws(
await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)})) await websocket.send_text(_json.dumps({"type": "error", "detail": str(e)}))
# Send frame # Send frame
if stream is None:
await websocket.send_bytes(b"\x00" * led_count * 3)
else:
colors = stream.get_latest_colors() colors = stream.get_latest_colors()
if colors is not None: if colors is not None:
await websocket.send_bytes(colors.tobytes()) await websocket.send_bytes(colors.tobytes())
@@ -25,7 +25,13 @@ class MappedColorStripStream(ColorStripStream):
""" """
def __init__(self, source, css_manager): def __init__(self, source, css_manager):
import uuid as _uuid
self._source_id: str = source.id self._source_id: str = source.id
# Unique per instance so concurrent consumers don't collide on
# sub-stream consumer IDs (e.g. two preview WS connections against
# the same mapped source, or future fan-out to multiple targets).
self._instance_id: str = _uuid.uuid4().hex[:8]
self._zones: List[dict] = list(source.zones) self._zones: List[dict] = list(source.zones)
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
@@ -127,7 +133,7 @@ class MappedColorStripStream(ColorStripStream):
src_id = zone.get("source_id", "") src_id = zone.get("source_id", "")
if not src_id: if not src_id:
continue continue
consumer_id = f"{self._source_id}__zone_{i}" consumer_id = f"{self._source_id}__{self._instance_id}__zone_{i}"
try: try:
stream = self._css_manager.acquire(src_id, consumer_id) stream = self._css_manager.acquire(src_id, consumer_id)
zone_len = self._zone_length(zone) zone_len = self._zone_length(zone)
@@ -22,11 +22,17 @@ class ProcessedColorStripStream(ColorStripStream):
""" """
def __init__(self, source, css_manager, cspt_store=None): def __init__(self, source, css_manager, cspt_store=None):
import uuid as _uuid
self._source = source self._source = source
self._css_manager = css_manager self._css_manager = css_manager
self._cspt_store = cspt_store self._cspt_store = cspt_store
self._input_stream: Optional[ColorStripStream] = None self._input_stream: Optional[ColorStripStream] = None
self._consumer_id = f"__processed_{source.id}__" # Unique per instance so concurrent consumers don't collide on
# sub-stream consumer IDs (e.g. two preview WS connections against
# the same processed source).
self._instance_id = _uuid.uuid4().hex[:8]
self._consumer_id = f"__processed_{source.id}__{self._instance_id}__"
self._filters = [] self._filters = []
self._cached_template_id = None self._cached_template_id = None
self._running = False self._running = False
@@ -97,9 +103,28 @@ class ProcessedColorStripStream(ColorStripStream):
return self._colors return self._colors
def update_source(self, source) -> None: def update_source(self, source) -> None:
old_input = self._source.input_source_id
new_input = source.input_source_id
self._source = source self._source = source
# Force re-resolve filters on next iteration # Force re-resolve filters on next iteration
self._cached_template_id = None self._cached_template_id = None
# If the input source changed while running, swap the acquired stream.
if self._running and new_input != old_input:
if self._input_stream and old_input:
try:
self._css_manager.release(old_input, self._consumer_id)
except Exception as e:
logger.warning(
f"Processed update: release of old input {old_input} failed: {e}"
)
self._input_stream = None
if new_input:
try:
self._input_stream = self._css_manager.acquire(new_input, self._consumer_id)
except Exception as e:
logger.warning(
f"Processed update: acquire of new input {new_input} failed: {e}"
)
def set_clock(self, clock_runtime) -> None: def set_clock(self, clock_runtime) -> None:
if self._input_stream and hasattr(self._input_stream, "set_clock"): if self._input_stream and hasattr(self._input_stream, "set_clock"):
@@ -1405,6 +1405,26 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
}, },
}; };
// ══════════════════════════════════════════════════════════════════
// Preview payload — collects the current editor form into a config dict
// suitable for the transient preview WebSocket. Returns null if the type
// has no handler or if validation fails. Used by test.ts.
// ══════════════════════════════════════════════════════════════════
export function getCSSEditorPreviewPayload(sourceType: string): any {
const handler = _typeHandlers[sourceType];
if (!handler) return null;
const payload = handler.getPayload('__preview__');
if (!payload) return null;
payload.source_type = sourceType;
const clockTypes = ['static', 'gradient', 'effect', 'daylight', 'candlelight', 'weather', 'math_wave'];
if (clockTypes.includes(sourceType)) {
const clockEl = document.getElementById('css-editor-clock') as HTMLInputElement | null;
if (clockEl && clockEl.value) payload.clock_id = clockEl.value;
}
return payload;
}
// ══════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════
// Editor open / close / save // Editor open / close / save
// ══════════════════════════════════════════════════════════════════ // ══════════════════════════════════════════════════════════════════
@@ -15,14 +15,23 @@ import {
} from '../../core/icons.ts'; } from '../../core/icons.ts';
import { EntitySelect } from '../../core/entity-palette.ts'; import { EntitySelect } from '../../core/entity-palette.ts';
import { hexToRgbArray, getGradientStops } from '../css-gradient-editor.ts'; import { hexToRgbArray, getGradientStops } from '../css-gradient-editor.ts';
import { testNotification, _getAnimationPayload } from './index.ts'; import { testNotification, _getAnimationPayload, getCSSEditorPreviewPayload } from './index.ts';
import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './notification.ts'; import { notificationGetAppColorsDict, notificationGetAppSoundsDict, getNotificationDurationValue, getNotificationVolumeValue } from './notification.ts';
import {
ensureAudioSensitivityWidget,
ensureAudioSmoothingWidget,
ensureAudioBeatDecayWidget,
ensureAudioColorWidget,
ensureAudioColorPeakWidget,
} from './audio.ts';
import { ensureMathWaveSpeedWidget, mathWaveGetLayers } from './math-wave.ts';
import { openAuthedWs } from '../../core/ws-auth.ts'; import { openAuthedWs } from '../../core/ws-auth.ts';
/* ── Preview config builder ───────────────────────────────────── */ /* ── Preview config builder ───────────────────────────────────── */
const _PREVIEW_TYPES = new Set([ const _PREVIEW_TYPES = new Set([
'static', 'gradient', 'effect', 'daylight', 'candlelight', 'notification', 'static', 'gradient', 'effect', 'daylight', 'candlelight', 'notification', 'audio', 'math_wave',
'weather', 'game_event', 'api_input', 'mapped', 'composite', 'processed',
]); ]);
function _collectPreviewConfig() { function _collectPreviewConfig() {
@@ -42,6 +51,28 @@ function _collectPreviewConfig() {
config = { source_type: 'daylight', speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value), use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value), longitude: parseFloat((document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value) }; config = { source_type: 'daylight', speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value), use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value), longitude: parseFloat((document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value) };
} else if (sourceType === 'candlelight') { } else if (sourceType === 'candlelight') {
config = { source_type: 'candlelight', color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value), intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value), num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3, speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value), wind_strength: parseFloat((document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value), candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value }; config = { source_type: 'candlelight', color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value), intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value), num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3, speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value), wind_strength: parseFloat((document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value), candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value };
} else if (sourceType === 'math_wave') {
const waves = mathWaveGetLayers();
if (waves.length === 0) return null;
config = {
source_type: 'math_wave',
gradient_id: (document.getElementById('css-editor-math-wave-gradient') as HTMLInputElement).value || null,
speed: ensureMathWaveSpeedWidget().getValue(),
waves,
};
} else if (sourceType === 'audio') {
config = {
source_type: 'audio',
visualization_mode: (document.getElementById('css-editor-audio-viz') as HTMLInputElement).value,
audio_source_id: (document.getElementById('css-editor-audio-source') as HTMLInputElement).value || '',
sensitivity: ensureAudioSensitivityWidget().getValue(),
smoothing: ensureAudioSmoothingWidget().getValue(),
beat_decay: ensureAudioBeatDecayWidget().getValue(),
gradient_id: (document.getElementById('css-editor-audio-palette') as HTMLInputElement).value,
color: ensureAudioColorWidget().getValue(),
color_peak: ensureAudioColorPeakWidget().getValue(),
mirror: (document.getElementById('css-editor-audio-mirror') as HTMLInputElement).checked,
};
} else if (sourceType === 'notification') { } else if (sourceType === 'notification') {
const filterList = (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value.split('\n').map(s => s.trim()).filter(Boolean); const filterList = (document.getElementById('css-editor-notification-filter-list') as HTMLInputElement).value.split('\n').map(s => s.trim()).filter(Boolean);
config = { config = {
@@ -56,9 +87,13 @@ function _collectPreviewConfig() {
sound_volume: getNotificationVolumeValue(), sound_volume: getNotificationVolumeValue(),
app_sounds: notificationGetAppSoundsDict(), app_sounds: notificationGetAppSoundsDict(),
}; };
} else {
// Types without quirks: build config from the editor handler payload
config = getCSSEditorPreviewPayload(sourceType);
if (!config) return null;
} }
const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null; const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;
if (clockEl && clockEl.value) config.clock_id = clockEl.value; if (clockEl && clockEl.value && !config.clock_id) config.clock_id = clockEl.value;
return config; return config;
} }
@@ -67,14 +102,29 @@ function _collectPreviewConfig() {
* For saved sources, uses the normal test endpoint. * For saved sources, uses the normal test endpoint.
* For unsaved/self-contained types, uses the transient preview endpoint. * For unsaved/self-contained types, uses the transient preview endpoint.
*/ */
// Types whose preview needs data edited outside the source-editor form
// (calibration on a separate Calibration screen, or rectangles in the
// key-colors region picker). The Preview button falls back to the saved
// source for these; if the source isn't saved yet, prompt the user to save
// first instead of showing the generic "unsupported" message.
const _NEEDS_SAVE_FIRST = new Set(['picture', 'picture_advanced', 'key_colors']);
export function previewCSSFromEditor() { export function previewCSSFromEditor() {
// Always use transient preview with current form values // Always use transient preview with current form values
const sourceType = (document.getElementById('css-editor-type') as HTMLInputElement).value;
const config = _collectPreviewConfig(); const config = _collectPreviewConfig();
if (!config) { if (!config) {
// Non-previewable type (picture, composite, etc.) — fall back to saved source test // Either the type can't be previewed transiently
// (picture, picture_advanced, key_colors) or handler validation
// failed (already showed error in modal).
const cssId = (document.getElementById('css-editor-id') as HTMLInputElement).value; const cssId = (document.getElementById('css-editor-id') as HTMLInputElement).value;
if (cssId) { testColorStrip(cssId); return; } if (!_PREVIEW_TYPES.has(sourceType) && cssId) { testColorStrip(cssId); return; }
showToast(t('color_strip.preview.unsupported'), 'info'); if (!_PREVIEW_TYPES.has(sourceType)) {
const key = _NEEDS_SAVE_FIRST.has(sourceType)
? 'color_strip.preview.save_first'
: 'color_strip.preview.unsupported';
showToast(t(key), 'info');
}
return; return;
} }
@@ -1489,6 +1489,7 @@
"color_strip.preview.connecting": "Connecting...", "color_strip.preview.connecting": "Connecting...",
"color_strip.preview.connected": "Connected", "color_strip.preview.connected": "Connected",
"color_strip.preview.unsupported": "Preview not available for this source type", "color_strip.preview.unsupported": "Preview not available for this source type",
"color_strip.preview.save_first": "Save the source first — calibration is needed for preview",
"color_strip.type.daylight": "Daylight Cycle", "color_strip.type.daylight": "Daylight Cycle",
"color_strip.type.daylight.desc": "Simulates natural daylight over 24 hours", "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.", "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.",
@@ -1501,6 +1501,7 @@
"color_strip.preview.connecting": "Подключение...", "color_strip.preview.connecting": "Подключение...",
"color_strip.preview.connected": "Подключено", "color_strip.preview.connected": "Подключено",
"color_strip.preview.unsupported": "Предпросмотр недоступен для этого типа источника", "color_strip.preview.unsupported": "Предпросмотр недоступен для этого типа источника",
"color_strip.preview.save_first": "Сначала сохраните источник — для предпросмотра нужна калибровка",
"color_strip.type.daylight": "Дневной цикл", "color_strip.type.daylight": "Дневной цикл",
"color_strip.type.daylight.desc": "Имитация естественного дневного света за 24 часа", "color_strip.type.daylight.desc": "Имитация естественного дневного света за 24 часа",
"color_strip.type.daylight.hint": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.", "color_strip.type.daylight.hint": "Имитирует цветовую температуру солнца в течение суток — от тёплого рассвета до прохладного дневного света, заката и ночи.",
@@ -1498,6 +1498,7 @@
"color_strip.preview.connecting": "连接中...", "color_strip.preview.connecting": "连接中...",
"color_strip.preview.connected": "已连接", "color_strip.preview.connected": "已连接",
"color_strip.preview.unsupported": "此源类型不支持预览", "color_strip.preview.unsupported": "此源类型不支持预览",
"color_strip.preview.save_first": "请先保存源——预览需要校准数据",
"color_strip.type.daylight": "日光循环", "color_strip.type.daylight": "日光循环",
"color_strip.type.daylight.desc": "模拟24小时自然日光变化", "color_strip.type.daylight.desc": "模拟24小时自然日光变化",
"color_strip.type.daylight.hint": "模拟太阳在24小时内的色温变化——从温暖的日出到冷白的日光,再到温暖的日落和昏暗的夜晚。", "color_strip.type.daylight.hint": "模拟太阳在24小时内的色温变化——从温暖的日出到冷白的日光,再到温暖的日落和昏暗的夜晚。",