Add CSPT entity, processed CSS source type, reverse filter, and UI improvements

- Add Color Strip Processing Template (CSPT) entity: reusable filter chains
  for 1D LED strip postprocessing (backend, storage, API, frontend CRUD)
- Add "processed" color strip source type that wraps another CSS source and
  applies a CSPT filter chain (dataclass, stream, schema, modal, cards)
- Add Reverse filter for strip LED order reversal
- Add CSPT and processed CSS nodes/edges to visual graph editor
- Add CSPT test preview WS endpoint with input source selection
- Add device settings CSPT template selector (add + edit modals with hints)
- Use icon grids for palette quantization preset selector in filter lists
- Use EntitySelect for template references and test modal source selectors
- Fix filters.css_filter_template.desc missing localization
- Fix icon grid cell height inequality (grid-auto-rows: 1fr)
- Rename "Processed" subtab to "Processing Templates"
- Localize all new strings (en/ru/zh)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 02:16:59 +03:00
parent 7e78323c9c
commit 294d704eb0
72 changed files with 2992 additions and 1416 deletions
@@ -20,8 +20,10 @@ import wled_controller.core.filters.flip # noqa: F401
import wled_controller.core.filters.color_correction # noqa: F401
import wled_controller.core.filters.frame_interpolation # noqa: F401
import wled_controller.core.filters.filter_template # noqa: F401
import wled_controller.core.filters.css_filter_template # noqa: F401
import wled_controller.core.filters.noise_gate # noqa: F401
import wled_controller.core.filters.palette_quantization # noqa: F401
import wled_controller.core.filters.reverse # noqa: F401
__all__ = [
"FilterOptionDef",
@@ -18,6 +18,7 @@ class AutoCropFilter(PostprocessingFilter):
filter_id = "auto_crop"
filter_name = "Auto Crop"
supports_strip = False
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
@@ -44,11 +44,21 @@ class PostprocessingFilter(ABC):
Each filter operates on a full image (np.ndarray H×W×3 uint8).
Filters that preserve dimensions modify in-place and return None.
Filters that change dimensions return a new array from the image pool.
Filters that also support 1D LED strip arrays (N×3 uint8) should
leave ``supports_strip = True`` (the default). The base class
provides a generic ``process_strip`` that reshapes (N,3) → (1,N,3),
delegates to ``process_image``, and reshapes back. Subclasses may
override for a more efficient implementation.
Filters that are purely spatial (auto-crop, downscaler, flip) should
set ``supports_strip = False``.
"""
filter_id: str = ""
filter_name: str = ""
supports_idle_frames: bool = False
supports_strip: bool = True
def __init__(self, options: Dict[str, Any]):
"""Initialize filter with validated options."""
@@ -74,6 +84,31 @@ class PostprocessingFilter(ABC):
"""
...
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
"""Process a 1D LED strip array (N, 3) uint8.
Default implementation reshapes to (1, N, 3), calls process_image
with a no-op pool, and reshapes back. Override for filters that
need strip-specific behaviour or use ImagePool.
Returns:
None if modified in-place.
New np.ndarray if a new array was created.
"""
from wled_controller.core.filters.image_pool import ImagePool
img = strip[np.newaxis, :, :] # (1, N, 3)
pool = ImagePool(max_size=2)
result = self.process_image(img, pool)
if result is not None:
out = result[0] # (N, 3)
pool.release_all()
return out
# Modified in-place — extract back
np.copyto(strip, img[0])
pool.release_all()
return None
@classmethod
def validate_options(cls, options: dict) -> dict:
"""Validate and clamp options against the schema. Returns cleaned dict."""
@@ -0,0 +1,46 @@
"""CSS Filter Template meta-filter — references a color strip processing template.
This filter exists in the registry for UI discovery only. It is never
instantiated at runtime: the store expands it into the referenced
template's filters when building the processing pipeline.
"""
from typing import List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class CSSFilterTemplateFilter(PostprocessingFilter):
"""Include another color strip processing template's chain at this position."""
filter_id = "css_filter_template"
filter_name = "Strip Filter Template"
supports_strip = True
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="template_id",
label="Template",
option_type="select",
default="",
min_value=None,
max_value=None,
step=None,
choices=[], # populated dynamically by GET /api/v1/strip-filters
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
# Never called — expanded at pipeline build time.
return None
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
# Never called — expanded at pipeline build time.
return None
@@ -16,6 +16,7 @@ class DownscalerFilter(PostprocessingFilter):
filter_id = "downscaler"
filter_name = "Downscaler"
supports_strip = False
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
@@ -20,6 +20,7 @@ class FilterTemplateFilter(PostprocessingFilter):
filter_id = "filter_template"
filter_name = "Filter Template"
supports_strip = False
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
@@ -15,6 +15,7 @@ class FlipFilter(PostprocessingFilter):
filter_id = "flip"
filter_name = "Flip"
supports_strip = False
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
@@ -59,15 +59,23 @@ class FrameInterpolationFilter(PostprocessingFilter):
None — image passes through unchanged (no blend needed).
ndarray — blended output acquired from image_pool.
"""
return self._blend(image, lambda shape: image_pool.acquire(*shape))
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
"""Frame interpolation for 1D LED strips — allocates directly."""
return self._blend(strip, lambda shape: np.empty(shape, dtype=np.uint8))
def _blend(self, data: np.ndarray, alloc_fn) -> Optional[np.ndarray]:
"""Shared blend logic for both images and strips."""
now = time.perf_counter()
# Detect new vs idle frame via cheap 64-byte signature
sig = bytes(image.ravel()[:64])
sig = bytes(data.ravel()[:64])
if sig != self._sig_b:
# New source frame — shift A ← B, B ← current
self._frame_a = self._frame_b
self._time_a = self._time_b
self._frame_b = image.copy()
self._frame_b = data.copy()
self._time_b = now
self._sig_b = sig
@@ -83,8 +91,7 @@ class FrameInterpolationFilter(PostprocessingFilter):
# Blend: output = (1 - alpha)*A + alpha*B (integer fast path)
alpha_i = int(alpha * 256)
h, w, c = image.shape
shape = (h, w, c)
shape = data.shape
# Resize scratch buffers on shape change
if self._blend_shape != shape:
@@ -92,9 +99,9 @@ class FrameInterpolationFilter(PostprocessingFilter):
self._u16_b = np.empty(shape, dtype=np.uint16)
self._blend_shape = shape
out = image_pool.acquire(h, w, c)
out = alloc_fn(shape)
np.copyto(self._u16_a, self._frame_a, casting='unsafe')
np.copyto(self._u16_b, image, casting='unsafe')
np.copyto(self._u16_b, data, casting='unsafe')
self._u16_a *= (256 - alpha_i)
self._u16_b *= alpha_i
self._u16_a += self._u16_b
@@ -83,7 +83,7 @@ class PaletteQuantizationFilter(PostprocessingFilter):
min_value=None,
max_value=None,
step=None,
choices=[{"value": k, "label": k.capitalize()} for k in _PRESETS],
choices=[{"value": k, "label": k.capitalize(), "colors": v} for k, v in _PRESETS.items()],
),
FilterOptionDef(
key="colors",
@@ -0,0 +1,30 @@
"""Reverse filter — reverses the LED order in a 1D strip."""
from typing import List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class ReverseFilter(PostprocessingFilter):
"""Reverses the order of LEDs in a color strip."""
filter_id = "reverse"
filter_name = "Reverse"
supports_strip = True
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return []
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
"""Reverse image horizontally (for 2D fallback)."""
return image[:, ::-1].copy()
def process_strip(self, strip: np.ndarray) -> Optional[np.ndarray]:
"""Reverse the LED array order."""
return strip[::-1].copy()
@@ -251,6 +251,7 @@ class AudioColorStripStream(ColorStripStream):
_full_amp = np.empty(n, dtype=np.float32)
_vu_gradient = np.linspace(0, 1, n, dtype=np.float32)
_indices_buf = np.empty(n, dtype=np.int32)
_f32_rgb = np.empty((n, 3), dtype=np.float32)
self._prev_spectrum = None # reset smoothing on resize
# Make pre-computed arrays available to render methods
@@ -260,6 +261,7 @@ class AudioColorStripStream(ColorStripStream):
self._full_amp = _full_amp
self._vu_gradient = _vu_gradient
self._indices_buf = _indices_buf
self._f32_rgb = _f32_rgb
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
@@ -352,8 +354,11 @@ class AudioColorStripStream(ColorStripStream):
# Scale brightness by amplitude — restore full_amp to [0, 1]
full_amp *= (1.0 / 255.0)
for ch in range(3):
buf[:, ch] = (colors[:, ch].astype(np.float32) * full_amp).astype(np.uint8)
f32_rgb = self._f32_rgb
np.copyto(f32_rgb, colors, casting='unsafe')
f32_rgb *= full_amp[:, np.newaxis]
np.clip(f32_rgb, 0, 255, out=f32_rgb)
np.copyto(buf, f32_rgb, casting='unsafe')
# ── VU Meter ───────────────────────────────────────────────────
@@ -6,7 +6,7 @@ by processing frames from a LiveStream.
Multiple WledTargetProcessors may read from the same ColorStripStream instance
(shared via ColorStripStreamManager reference counting), meaning the CPU-bound
processing — border extraction, pixel mapping, color correction — runs only once
processing — border extraction, pixel mapping, smoothing — runs only once
even when multiple devices share the same source configuration.
"""
@@ -28,54 +28,6 @@ from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
def _apply_saturation(colors: np.ndarray, saturation: float,
_i32: np.ndarray = None, _i32_gray: np.ndarray = None,
_out: np.ndarray = None) -> np.ndarray:
"""Adjust saturation via luminance mixing (Rec.601 weights, integer math).
saturation=1.0: no change
saturation=0.0: grayscale
saturation=2.0: double saturation (clipped to 0-255)
Optional pre-allocated scratch buffers (_i32, _i32_gray, _out) avoid
per-frame allocations when called from a hot loop.
"""
n = len(colors)
if _i32 is None:
_i32 = np.empty((n, 3), dtype=np.int32)
if _i32_gray is None:
_i32_gray = np.empty((n, 1), dtype=np.int32)
if _out is None:
_out = np.empty((n, 3), dtype=np.uint8)
sat_int = int(saturation * 256)
np.copyto(_i32, colors, casting='unsafe')
_i32_gray[:, 0] = (_i32[:, 0] * 299 + _i32[:, 1] * 587 + _i32[:, 2] * 114) // 1000
_i32 *= sat_int
_i32_gray *= (256 - sat_int)
_i32 += _i32_gray
_i32 >>= 8
np.clip(_i32, 0, 255, out=_i32)
np.copyto(_out, _i32, casting='unsafe')
return _out
def _build_gamma_lut(gamma: float) -> np.ndarray:
"""Build a 256-entry uint8 LUT for gamma correction.
gamma=1.0: identity (no correction)
gamma<1.0: brighter midtones (gamma < 1 lifts shadows)
gamma>1.0: darker midtones (standard LED gamma, e.g. 2.22.8)
"""
if gamma == 1.0:
return np.arange(256, dtype=np.uint8)
lut = np.array(
[min(255, int(((i / 255.0) ** gamma) * 255 + 0.5)) for i in range(256)],
dtype=np.uint8,
)
return lut
class ColorStripStream(ABC):
"""Abstract base: a runtime source of LED color arrays.
@@ -142,10 +94,7 @@ class PictureColorStripStream(ColorStripStream):
2. Extracts border pixels using the calibration's border_width
3. Maps border pixels to LED colors via PixelMapper
4. Applies temporal smoothing
5. Applies saturation correction
6. Applies gamma correction (LUT-based, O(1) per pixel)
7. Applies brightness scaling
8. Caches the result for lock-free consumer reads
5. Caches the result for lock-free consumer reads
Processing parameters can be hot-updated via update_source() without
restarting the thread (except when the underlying LiveStream changes).
@@ -167,11 +116,10 @@ class PictureColorStripStream(ColorStripStream):
else:
self._live_streams = {}
self._live_stream = live_stream
self._fps: int = 30 # internal capture rate (send FPS is on the target)
self._frame_time: float = 1.0 / 30
self._smoothing: float = source.smoothing
self._brightness: float = source.brightness
self._saturation: float = source.saturation
self._gamma: float = source.gamma
self._interpolation_mode: str = source.interpolation_mode
self._calibration: CalibrationConfig = source.calibration
self._pixel_mapper = create_pixel_mapper(
@@ -179,25 +127,21 @@ class PictureColorStripStream(ColorStripStream):
)
cal_leds = self._calibration.get_total_leds()
self._led_count: int = source.led_count if source.led_count > 0 else cal_leds
self._gamma_lut: np.ndarray = _build_gamma_lut(self._gamma)
# Thread-safe color cache
self._latest_colors: Optional[np.ndarray] = None
self._colors_lock = threading.Lock()
self._previous_colors: Optional[np.ndarray] = None
# Frame interpolation state
self._frame_interpolation: bool = source.frame_interpolation
self._interp_from: Optional[np.ndarray] = None
self._interp_to: Optional[np.ndarray] = None
self._interp_start: float = 0.0
self._interp_duration: float = 1.0 / self._fps if self._fps > 0 else 1.0
self._last_capture_time: float = 0.0
self._running = False
self._thread: Optional[threading.Thread] = None
self._last_timing: dict = {}
@property
def live_stream(self):
"""Public accessor for the underlying LiveStream (used by preview WebSocket)."""
return self._live_stream
@property
def target_fps(self) -> int:
return self._fps
@@ -239,9 +183,6 @@ class PictureColorStripStream(ColorStripStream):
self._thread = None
self._latest_colors = None
self._previous_colors = None
self._interp_from = None
self._interp_to = None
self._last_capture_time = 0.0
logger.info("PictureColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
@@ -256,7 +197,7 @@ class PictureColorStripStream(ColorStripStream):
fps = max(1, min(90, fps))
if fps != self._fps:
self._fps = fps
self._interp_duration = 1.0 / fps
self._frame_time = 1.0 / fps
logger.info(f"PictureColorStripStream capture FPS set to {fps}")
def update_source(self, source) -> None:
@@ -270,12 +211,6 @@ class PictureColorStripStream(ColorStripStream):
return
self._smoothing = source.smoothing
self._brightness = source.brightness
self._saturation = source.saturation
if source.gamma != self._gamma:
self._gamma = source.gamma
self._gamma_lut = _build_gamma_lut(source.gamma)
if (
source.interpolation_mode != self._interpolation_mode
@@ -291,11 +226,6 @@ class PictureColorStripStream(ColorStripStream):
)
self._previous_colors = None # Reset smoothing history on calibration change
if source.frame_interpolation != self._frame_interpolation:
self._frame_interpolation = source.frame_interpolation
self._interp_from = None
self._interp_to = None
logger.info("PictureColorStripStream params updated in-place")
def _processing_loop(self) -> None:
@@ -306,8 +236,7 @@ class PictureColorStripStream(ColorStripStream):
_pool_n = 0
_frame_a = _frame_b = None # double-buffered uint8 output
_use_a = True
_u16_a = _u16_b = None # uint16 scratch for smoothing / interp blending
_i32 = _i32_gray = None # int32 scratch for saturation + brightness
_u16_a = _u16_b = None # uint16 scratch for smoothing blending
def _blend_u16(a, b, alpha_b, out):
"""Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8.
@@ -323,62 +252,20 @@ class PictureColorStripStream(ColorStripStream):
_u16_a >>= 8
np.copyto(out, _u16_a, casting='unsafe')
def _apply_corrections(led_colors, frame_buf):
"""Apply saturation, gamma, brightness using pre-allocated scratch.
Returns the (possibly reassigned) led_colors array.
"""
nonlocal _i32
if self._saturation != 1.0:
_apply_saturation(led_colors, self._saturation, _i32, _i32_gray, led_colors)
if self._gamma != 1.0:
led_colors = self._gamma_lut[led_colors]
if self._brightness != 1.0:
bright_int = int(self._brightness * 256)
np.copyto(_i32, led_colors, casting='unsafe')
_i32 *= bright_int
_i32 >>= 8
np.clip(_i32, 0, 255, out=_i32)
np.copyto(frame_buf, _i32, casting='unsafe')
led_colors = frame_buf
return led_colors
try:
with high_resolution_timer():
while self._running:
loop_start = time.perf_counter()
fps = self._fps
frame_time = 1.0 / fps if fps > 0 else 1.0
frame_time = self._frame_time
try:
frame = self._live_stream.get_latest_frame()
if frame is None or frame is cached_frame:
if (
frame is not None
and self._frame_interpolation
and self._interp_from is not None
and self._interp_to is not None
and _u16_a is not None
):
# Interpolate between previous and current capture
t = min(1.0, (loop_start - self._interp_start) / self._interp_duration)
frame_buf = _frame_a if _use_a else _frame_b
_use_a = not _use_a
_blend_u16(self._interp_from, self._interp_to, int(t * 256), frame_buf)
led_colors = _apply_corrections(frame_buf, frame_buf)
with self._colors_lock:
self._latest_colors = led_colors
elapsed = time.perf_counter() - loop_start
time.sleep(max(frame_time - elapsed, 0.001))
continue
interval = (
loop_start - self._last_capture_time
if self._last_capture_time > 0
else frame_time
)
self._last_capture_time = loop_start
cached_frame = frame
t0 = time.perf_counter()
@@ -410,8 +297,6 @@ class PictureColorStripStream(ColorStripStream):
_frame_b = np.empty((_n, 3), dtype=np.uint8)
_u16_a = np.empty((_n, 3), dtype=np.uint16)
_u16_b = np.empty((_n, 3), dtype=np.uint16)
_i32 = np.empty((_n, 3), dtype=np.int32)
_i32_gray = np.empty((_n, 1), dtype=np.int32)
self._previous_colors = None
# Copy/pad into double-buffered frame (avoids per-frame allocations)
@@ -440,38 +325,6 @@ class PictureColorStripStream(ColorStripStream):
int(smoothing * 256), led_colors)
t3 = time.perf_counter()
# Update interpolation buffers (smoothed colors, before corrections)
# Must be AFTER smoothing so idle-tick interpolation produces
# output consistent with new-frame ticks (both smoothed).
if self._frame_interpolation:
self._interp_from = self._interp_to
self._interp_to = led_colors.copy()
self._interp_start = loop_start
self._interp_duration = max(interval, 0.001)
# Saturation (pre-allocated int32 scratch)
saturation = self._saturation
if saturation != 1.0:
_apply_saturation(led_colors, saturation, _i32, _i32_gray, led_colors)
t4 = time.perf_counter()
# Gamma (LUT lookup — O(1) per pixel)
if self._gamma != 1.0:
led_colors = self._gamma_lut[led_colors]
t5 = time.perf_counter()
# Brightness (integer math with pre-allocated int32 scratch)
brightness = self._brightness
if brightness != 1.0:
bright_int = int(brightness * 256)
np.copyto(_i32, led_colors, casting='unsafe')
_i32 *= bright_int
_i32 >>= 8
np.clip(_i32, 0, 255, out=_i32)
np.copyto(frame_buf, _i32, casting='unsafe')
led_colors = frame_buf
t6 = time.perf_counter()
self._previous_colors = led_colors
with self._colors_lock:
@@ -481,10 +334,7 @@ class PictureColorStripStream(ColorStripStream):
"extract_ms": (t1 - t0) * 1000,
"map_leds_ms": (t2 - t1) * 1000,
"smooth_ms": (t3 - t2) * 1000,
"saturation_ms": (t4 - t3) * 1000,
"gamma_ms": (t5 - t4) * 1000,
"brightness_ms": (t6 - t5) * 1000,
"total_ms": (t6 - t0) * 1000,
"total_ms": (t3 - t0) * 1000,
}
except Exception as e:
@@ -1201,12 +1051,52 @@ class GradientColorStripStream(ColorStripStream):
elif atype == "rainbow_fade":
h_shift = (speed * t * 0.1) % 1.0
for i in range(n):
r, g, b = base[i]
h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0)
new_h = (h + h_shift) % 1.0
nr, ng, nb = colorsys.hsv_to_rgb(new_h, max(s, 0.5), max(v, 0.3))
buf[i] = (int(nr * 255), int(ng * 255), int(nb * 255))
# Vectorized RGB->HSV shift->RGB (no per-LED colorsys)
rgb_f = base.astype(np.float32) * (1.0 / 255.0)
r_f = rgb_f[:, 0]
g_f = rgb_f[:, 1]
b_f = rgb_f[:, 2]
cmax = np.maximum(np.maximum(r_f, g_f), b_f)
cmin = np.minimum(np.minimum(r_f, g_f), b_f)
delta = cmax - cmin
# Hue
h_arr = np.zeros(n, dtype=np.float32)
mask_r = (delta > 0) & (cmax == r_f)
mask_g = (delta > 0) & (cmax == g_f) & ~mask_r
mask_b = (delta > 0) & ~mask_r & ~mask_g
h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
h_arr *= (1.0 / 6.0)
h_arr %= 1.0
# Saturation & Value with clamping
s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
np.maximum(s_arr, 0.5, out=s_arr)
v_arr = cmax.copy()
np.maximum(v_arr, 0.3, out=v_arr)
# Shift hue
h_arr += h_shift
h_arr %= 1.0
# Vectorized HSV->RGB
h6 = h_arr * 6.0
hi = h6.astype(np.int32) % 6
f_arr = h6 - np.floor(h6)
p = v_arr * (1.0 - s_arr)
q = v_arr * (1.0 - s_arr * f_arr)
tt = v_arr * (1.0 - s_arr * (1.0 - f_arr))
ro = np.empty(n, dtype=np.float32)
go = np.empty(n, dtype=np.float32)
bo = np.empty(n, dtype=np.float32)
for sxt, rv, gv, bv in (
(0, v_arr, tt, p), (1, q, v_arr, p),
(2, p, v_arr, tt), (3, p, q, v_arr),
(4, tt, p, v_arr), (5, v_arr, p, q),
):
m = hi == sxt
ro[m] = rv[m]; go[m] = gv[m]; bo[m] = bv[m]
buf[:, 0] = np.clip(ro * 255.0, 0, 255).astype(np.uint8)
buf[:, 1] = np.clip(go * 255.0, 0, 255).astype(np.uint8)
buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8)
colors = buf
if colors is not None:
@@ -18,6 +18,7 @@ from wled_controller.core.processing.color_strip_stream import (
PictureColorStripStream,
StaticColorStripStream,
)
from wled_controller.core.processing.processed_stream import ProcessedColorStripStream
from wled_controller.core.processing.effect_stream import EffectColorStripStream
from wled_controller.core.processing.api_input_stream import ApiInputColorStripStream
from wled_controller.core.processing.notification_stream import NotificationColorStripStream
@@ -68,7 +69,7 @@ class ColorStripStreamManager:
keyed by ``{css_id}:{consumer_id}``.
"""
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None):
def __init__(self, color_strip_store, live_stream_manager, audio_capture_manager=None, audio_source_store=None, audio_template_store=None, sync_clock_manager=None, value_stream_manager=None, cspt_store=None):
"""
Args:
color_strip_store: ColorStripStore for resolving source configs
@@ -77,6 +78,7 @@ class ColorStripStreamManager:
audio_source_store: AudioSourceStore for resolving audio source chains
sync_clock_manager: SyncClockManager for acquiring clock runtimes
value_stream_manager: ValueStreamManager for per-layer brightness sources
cspt_store: ColorStripProcessingTemplateStore for per-layer filter chains
"""
self._color_strip_store = color_strip_store
self._live_stream_manager = live_stream_manager
@@ -85,6 +87,7 @@ class ColorStripStreamManager:
self._audio_template_store = audio_template_store
self._sync_clock_manager = sync_clock_manager
self._value_stream_manager = value_stream_manager
self._cspt_store = cspt_store
self._streams: Dict[str, _ColorStripEntry] = {}
def _inject_clock(self, css_stream, source) -> Optional[str]:
@@ -161,10 +164,12 @@ class ColorStripStreamManager:
css_stream = AudioColorStripStream(source, self._audio_capture_manager, self._audio_source_store, self._audio_template_store)
elif source.source_type == "composite":
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
css_stream = CompositeColorStripStream(source, self, self._value_stream_manager)
css_stream = CompositeColorStripStream(source, self, self._value_stream_manager, self._cspt_store)
elif source.source_type == "mapped":
from wled_controller.core.processing.mapped_stream import MappedColorStripStream
css_stream = MappedColorStripStream(source, self)
elif source.source_type == "processed":
css_stream = ProcessedColorStripStream(source, self, self._cspt_store)
else:
stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls:
@@ -29,7 +29,7 @@ class CompositeColorStripStream(ColorStripStream):
sub-stream's latest colors and blending bottom-to-top.
"""
def __init__(self, source, css_manager, value_stream_manager=None):
def __init__(self, source, css_manager, value_stream_manager=None, cspt_store=None):
import uuid as _uuid
self._source_id: str = source.id
self._instance_id: str = _uuid.uuid4().hex[:8] # unique per instance to avoid release races
@@ -38,6 +38,7 @@ class CompositeColorStripStream(ColorStripStream):
self._auto_size: bool = source.led_count == 0
self._css_manager = css_manager
self._value_stream_manager = value_stream_manager
self._cspt_store = cspt_store
self._fps: int = 30
self._frame_time: float = 1.0 / 30
@@ -309,6 +310,9 @@ class CompositeColorStripStream(ColorStripStream):
# ── Processing loop ─────────────────────────────────────────
def _processing_loop(self) -> None:
# Per-layer CSPT filter cache: layer_index -> (template_id, [PostprocessingFilter, ...])
_layer_cspt_cache: Dict[int, tuple] = {}
try:
while self._running:
loop_start = time.perf_counter()
@@ -341,6 +345,37 @@ class CompositeColorStripStream(ColorStripStream):
if colors is None:
continue
# Apply per-layer CSPT filters
_layer_tmpl_id = layer.get("processing_template_id") or ""
if _layer_tmpl_id and self._cspt_store:
cached = _layer_cspt_cache.get(i)
if cached is None or cached[0] != _layer_tmpl_id:
# Resolve and cache filters for this layer
try:
from wled_controller.core.filters.registry import FilterRegistry
_resolved = self._cspt_store.resolve_filter_instances(
self._cspt_store.get_template(_layer_tmpl_id).filters
)
_filters = [
FilterRegistry.create_instance(fi.filter_id, fi.options)
for fi in _resolved
if getattr(FilterRegistry.get(fi.filter_id), "supports_strip", True)
]
_layer_cspt_cache[i] = (_layer_tmpl_id, _filters)
logger.info(
f"Composite layer {i} CSPT resolved {len(_filters)} filters "
f"from template {_layer_tmpl_id}"
)
except Exception as e:
logger.warning(f"Failed to resolve layer {i} CSPT {_layer_tmpl_id}: {e}")
_layer_cspt_cache[i] = (_layer_tmpl_id, [])
_layer_filters = _layer_cspt_cache[i][1]
if _layer_filters:
for _flt in _layer_filters:
_result = _flt.process_strip(colors)
if _result is not None:
colors = _result
# Resize to target LED count if needed
if len(colors) != target_n:
colors = self._resize_to_target(colors, target_n)
@@ -444,21 +444,26 @@ class EffectColorStripStream(ColorStripStream):
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
np.copyto(buf[:, 2], self._s_f32_c, casting='unsafe')
# Bright white-ish head (2-3 LEDs — small, leave allocating)
head_mask = np.abs(indices - pos) < 1.5
head_brightness = np.clip(1.0 - np.abs(indices - pos), 0, 1)
buf[head_mask, 0] = np.clip(
buf[head_mask, 0].astype(np.int16) + (head_brightness[head_mask] * (255 - r)).astype(np.int16),
0, 255,
).astype(np.uint8)
buf[head_mask, 1] = np.clip(
buf[head_mask, 1].astype(np.int16) + (head_brightness[head_mask] * (255 - g)).astype(np.int16),
0, 255,
).astype(np.uint8)
buf[head_mask, 2] = np.clip(
buf[head_mask, 2].astype(np.int16) + (head_brightness[head_mask] * (255 - b)).astype(np.int16),
0, 255,
).astype(np.uint8)
# Bright white-ish head (2-3 LEDs)direct index range to avoid
# boolean mask allocations and fancy indexing temporaries.
head_lo = max(0, int(pos - 1.5) + 1)
head_hi = min(n, int(pos + 1.5) + 1)
if head_hi > head_lo:
head_sl = slice(head_lo, head_hi)
head_dist = self._s_f32_a[head_sl]
np.subtract(indices[head_sl], pos, out=head_dist)
np.abs(head_dist, out=head_dist)
# head_brightness = clip(1 - abs_dist, 0, 1)
head_br = self._s_f32_b[head_sl]
np.subtract(1.0, head_dist, out=head_br)
np.clip(head_br, 0, 1, out=head_br)
# Additive blend towards white using scratch _s_f32_c slice
tmp = self._s_f32_c[head_sl]
for ch_idx, ch_base in enumerate((r, g, b)):
np.multiply(head_br, 255 - ch_base, out=tmp)
tmp += buf[head_sl, ch_idx]
np.clip(tmp, 0, 255, out=tmp)
np.copyto(buf[head_sl, ch_idx], tmp, casting='unsafe')
# ── Plasma ───────────────────────────────────────────────────────
@@ -266,7 +266,12 @@ class LiveStreamManager:
@staticmethod
def _load_static_image(image_source: str) -> np.ndarray:
"""Load a static image from URL or file path, return as RGB numpy array."""
"""Load a static image from URL or file path, return as RGB numpy array.
Note: Uses synchronous httpx.get() for URLs, which blocks up to 15s.
This is acceptable because acquire() (the only caller chain) is always
invoked from background worker threads, never from the async event loop.
"""
from io import BytesIO
from pathlib import Path
@@ -163,6 +163,9 @@ class MappedColorStripStream(ColorStripStream):
def _processing_loop(self) -> None:
frame_time = self._frame_time
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
try:
while self._running:
loop_start = time.perf_counter()
@@ -173,7 +176,14 @@ class MappedColorStripStream(ColorStripStream):
time.sleep(frame_time)
continue
result = np.zeros((target_n, 3), dtype=np.uint8)
if target_n != _pool_n:
_pool_n = target_n
_buf_a = np.zeros((target_n, 3), dtype=np.uint8)
_buf_b = np.zeros((target_n, 3), dtype=np.uint8)
result = _buf_a if _use_a else _buf_b
_use_a = not _use_a
result[:] = 0
with self._sub_lock:
sub_snapshot = dict(self._sub_streams)
@@ -5,7 +5,10 @@ from collections import deque
from datetime import datetime, timezone
from typing import Dict, Optional
import psutil
from wled_controller.utils import get_logger
from wled_controller.utils.gpu import nvml_available as _nvml_available, nvml as _nvml, nvml_handle as _nvml_handle
logger = get_logger(__name__)
@@ -18,8 +21,6 @@ def _collect_system_snapshot() -> dict:
Returns a dict suitable for direct JSON serialization.
"""
import psutil
mem = psutil.virtual_memory()
snapshot = {
"t": datetime.now(timezone.utc).isoformat(),
@@ -32,8 +33,6 @@ def _collect_system_snapshot() -> dict:
}
try:
from wled_controller.api.routes.system import _nvml_available, _nvml, _nvml_handle
if _nvml_available:
util = _nvml.nvmlDeviceGetUtilizationRates(_nvml_handle)
temp = _nvml.nvmlDeviceGetTemperature(_nvml_handle, _nvml.NVML_TEMPERATURE_GPU)
@@ -258,6 +258,11 @@ class OsNotificationListener:
# Recent notification history (thread-safe deque, newest first)
self._history: collections.deque = collections.deque(maxlen=50)
@property
def available(self) -> bool:
"""Whether a platform backend is active and listening."""
return self._available
def start(self) -> None:
global _instance
_instance = self
@@ -0,0 +1,159 @@
"""Processed color strip stream — wraps another CSS and applies a CSPT filter chain."""
import threading
import time
from typing import Optional
import numpy as np
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.core.filters import FilterRegistry
from wled_controller.utils import get_logger
logger = get_logger(__name__)
class ProcessedColorStripStream(ColorStripStream):
"""Color strip stream that wraps an input CSS and applies CSPT filters.
Acquires the input stream from the manager, reads its colors, applies
the filter chain from the referenced processing template, and caches
the result.
"""
def __init__(self, source, css_manager, cspt_store=None):
self._source = source
self._css_manager = css_manager
self._cspt_store = cspt_store
self._input_stream: Optional[ColorStripStream] = None
self._consumer_id = f"__processed_{source.id}__"
self._filters = []
self._cached_template_id = None
self._running = False
self._thread: Optional[threading.Thread] = None
self._colors: Optional[np.ndarray] = None
self._colors_lock = threading.Lock()
self._led_count = 0
self._auto_size = True
self._fps = 30
self._frame_time = 1.0 / 30
self._resolve_count = 0
@property
def target_fps(self) -> int:
return self._fps
@property
def led_count(self) -> int:
if self._input_stream:
return self._input_stream.led_count
return self._led_count or 1
@property
def is_animated(self) -> bool:
if self._input_stream:
return self._input_stream.is_animated
return True
def configure(self, device_led_count: int) -> None:
self._led_count = device_led_count
self._auto_size = True
if self._input_stream and hasattr(self._input_stream, 'configure'):
self._input_stream.configure(device_led_count)
def start(self) -> None:
if self._running:
return
# Acquire input stream
input_id = self._source.input_source_id
if not input_id:
raise ValueError(f"Processed source {self._source.id} has no input_source_id")
self._input_stream = self._css_manager.acquire(input_id, self._consumer_id)
# Resolve initial filter chain
self._resolve_filters()
self._running = True
self._thread = threading.Thread(
target=self._processing_loop,
name=f"css-processed-{self._source.id[:8]}",
daemon=True,
)
self._thread.start()
logger.info(f"ProcessedColorStripStream started for {self._source.id}")
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
self._thread = None
# Release input stream
if self._input_stream:
input_id = self._source.input_source_id
self._css_manager.release(input_id, self._consumer_id)
self._input_stream = None
logger.info(f"ProcessedColorStripStream stopped for {self._source.id}")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
self._source = source
# Force re-resolve filters on next iteration
self._cached_template_id = None
def set_clock(self, clock_runtime) -> None:
if self._input_stream and hasattr(self._input_stream, 'set_clock'):
self._input_stream.set_clock(clock_runtime)
def _resolve_filters(self) -> None:
"""Resolve the CSPT filter chain from the template."""
template_id = self._source.processing_template_id
if template_id == self._cached_template_id:
return
self._cached_template_id = template_id
self._filters = []
if not template_id or not self._cspt_store:
return
try:
template = self._cspt_store.get_template(template_id)
resolved = self._cspt_store.resolve_filter_instances(template.filters)
self._filters = [
FilterRegistry.create_instance(fi.filter_id, fi.options)
for fi in resolved
]
except Exception as e:
logger.warning(f"Failed to resolve CSPT {template_id}: {e}")
self._filters = []
def _processing_loop(self) -> None:
"""Main loop: read input, apply filters, cache result."""
while self._running:
t0 = time.monotonic()
# Periodically re-resolve filters (every 30 iterations)
self._resolve_count += 1
if self._resolve_count >= 30:
self._resolve_count = 0
self._resolve_filters()
colors = None
if self._input_stream:
colors = self._input_stream.get_latest_colors()
if colors is not None and self._filters:
for flt in self._filters:
try:
result = flt.process_strip(colors)
if result is not None:
colors = result
except Exception as e:
logger.warning(f"Filter error in processed stream: {e}")
if colors is not None:
with self._colors_lock:
self._colors = colors
elapsed = time.monotonic() - t0
sleep_time = self._frame_time - elapsed
if sleep_time > 0:
time.sleep(sleep_time)
@@ -86,7 +86,7 @@ class ProcessorManager:
Targets are registered for processing via polymorphic TargetProcessor subclasses.
"""
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None, audio_template_store=None, sync_clock_manager=None):
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None, device_store=None, color_strip_store=None, audio_source_store=None, value_source_store=None, audio_template_store=None, sync_clock_manager=None, cspt_store=None):
"""Initialize processor manager."""
self._devices: Dict[str, DeviceState] = {}
self._processors: Dict[str, TargetProcessor] = {}
@@ -102,6 +102,7 @@ class ProcessorManager:
self._audio_source_store = audio_source_store
self._audio_template_store = audio_template_store
self._value_source_store = value_source_store
self._cspt_store = cspt_store
self._live_stream_manager = LiveStreamManager(
picture_source_store, capture_template_store, pp_template_store
)
@@ -114,6 +115,7 @@ class ProcessorManager:
audio_source_store=audio_source_store,
audio_template_store=audio_template_store,
sync_clock_manager=sync_clock_manager,
cspt_store=cspt_store,
)
self._value_stream_manager = ValueStreamManager(
value_source_store=value_source_store,
@@ -160,6 +162,7 @@ class ProcessorManager:
device_store=self._device_store,
color_strip_stream_manager=self._color_strip_stream_manager,
value_stream_manager=self._value_stream_manager,
cspt_store=self._cspt_store,
fire_event=self.fire_event,
get_device_info=self._get_device_info,
)
@@ -185,8 +188,8 @@ class ProcessorManager:
chroma_device_type = "chromalink"
gamesense_device_type = "keyboard"
if self._device_store:
dev = self._device_store.get_device(ds.device_id)
if dev:
try:
dev = self._device_store.get_device(ds.device_id)
send_latency_ms = getattr(dev, "send_latency_ms", 0)
rgbw = getattr(dev, "rgbw", False)
dmx_protocol = getattr(dev, "dmx_protocol", "artnet")
@@ -201,6 +204,8 @@ class ProcessorManager:
spi_led_type = getattr(dev, "spi_led_type", "WS2812B")
chroma_device_type = getattr(dev, "chroma_device_type", "chromalink")
gamesense_device_type = getattr(dev, "gamesense_device_type", "keyboard")
except ValueError:
pass
return DeviceInfo(
device_id=ds.device_id,
@@ -356,9 +361,11 @@ class ProcessorManager:
# (e.g. mock devices have no real hardware to query)
rgbw = h.device_rgbw
if rgbw is None and self._device_store:
dev = self._device_store.get_device(device_id)
if dev:
try:
dev = self._device_store.get_device(device_id)
rgbw = getattr(dev, "rgbw", False)
except ValueError:
pass
return {
"device_id": device_id,
"device_online": h.online,
@@ -529,9 +536,11 @@ class ProcessorManager:
dev_name = proc.device_id
tgt_name = other_id
if self._device_store:
dev = self._device_store.get_device(proc.device_id)
if dev:
try:
dev = self._device_store.get_device(proc.device_id)
dev_name = dev.name
except ValueError:
pass
raise RuntimeError(
f"Device '{dev_name}' is already being processed by target {tgt_name}"
)
@@ -26,6 +26,7 @@ if TYPE_CHECKING:
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.pattern_template_store import PatternTemplateStore
from wled_controller.storage.color_strip_processing_template_store import ColorStripProcessingTemplateStore
# ---------------------------------------------------------------------------
@@ -112,6 +113,7 @@ class TargetContext:
device_store: Optional["DeviceStore"] = None
color_strip_stream_manager: Optional["ColorStripStreamManager"] = None
value_stream_manager: Optional["ValueStreamManager"] = None
cspt_store: Optional["ColorStripProcessingTemplateStore"] = None
fire_event: Callable[[dict], None] = lambda e: None
get_device_info: Callable[[str], Optional[DeviceInfo]] = lambda _: None
@@ -644,6 +644,12 @@ class WledTargetProcessor(TargetProcessor):
self._effective_fps = self._target_fps
self._device_reachable = None
# --- CSPT (Color Strip Processing Template) filter cache ---
_cspt_cached_template_id: Optional[str] = None # last resolved template ID
_cspt_filters: list = [] # list of PostprocessingFilter instances
_cspt_check_interval = 30 # re-check device template ID every N iterations
_cspt_check_counter = 0
logger.info(
f"Processing loop started for target {self._target_id} "
f"(css={self._css_id}, {_total_leds} LEDs, fps={self._target_fps}"
@@ -746,6 +752,45 @@ class WledTargetProcessor(TargetProcessor):
await asyncio.sleep(frame_time)
continue
# --- Apply device default CSPT filters ---
_cspt_check_counter += 1
if _cspt_check_counter >= _cspt_check_interval:
_cspt_check_counter = 0
# Re-read the device's default template ID
_cur_cspt_id = ""
if self._ctx.device_store:
try:
_dev = self._ctx.device_store.get(self._device_id)
_cur_cspt_id = getattr(_dev, "default_css_processing_template_id", "") or ""
except Exception:
_cur_cspt_id = ""
if _cur_cspt_id != _cspt_cached_template_id:
_cspt_cached_template_id = _cur_cspt_id
_cspt_filters = []
if _cur_cspt_id and self._ctx.cspt_store:
try:
from wled_controller.core.filters.registry import FilterRegistry
_resolved = self._ctx.cspt_store.resolve_filter_instances(
self._ctx.cspt_store.get_template(_cur_cspt_id).filters
)
_cspt_filters = [
FilterRegistry.create_instance(fi.filter_id, fi.options)
for fi in _resolved
if getattr(FilterRegistry.get(fi.filter_id), "supports_strip", True)
]
logger.info(
f"CSPT resolved {len(_cspt_filters)} filters for "
f"device {self._device_id} template {_cur_cspt_id}"
)
except Exception as e:
logger.warning(f"Failed to resolve CSPT {_cur_cspt_id}: {e}")
_cspt_filters = []
if _cspt_filters:
for _flt in _cspt_filters:
_result = _flt.process_strip(frame)
if _result is not None:
frame = _result
cur_brightness = _effective_brightness(device_info)
# Min brightness threshold: combine brightness source