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:
@@ -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.2–2.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
|
||||
|
||||
Reference in New Issue
Block a user