diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index 7f73231..3c464b9 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -79,6 +79,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe description=source.description, frame_interpolation=getattr(source, "frame_interpolation", None), animation=getattr(source, "animation", None), + layers=getattr(source, "layers", None), overlay_active=overlay_active, created_at=source.created_at, updated_at=source.updated_at, @@ -128,6 +129,8 @@ async def create_color_strip_source( stops = [s.model_dump() for s in data.stops] if data.stops is not None else None + layers = [l.model_dump() for l in data.layers] if data.layers is not None else None + source = store.create_source( name=data.name, source_type=data.source_type, @@ -152,6 +155,7 @@ async def create_color_strip_source( intensity=data.intensity, scale=data.scale, mirror=data.mirror, + layers=layers, ) return _css_to_response(source) @@ -193,6 +197,8 @@ async def update_color_strip_source( stops = [s.model_dump() for s in data.stops] if data.stops is not None else None + layers = [l.model_dump() for l in data.layers] if data.layers is not None else None + source = store.update_source( source_id=source_id, name=data.name, @@ -217,6 +223,7 @@ async def update_color_strip_source( intensity=data.intensity, scale=data.scale, mirror=data.mirror, + layers=layers, ) # Hot-reload running stream (no restart needed for in-place param changes) @@ -249,6 +256,12 @@ async def delete_color_strip_source( detail="Color strip source is referenced by one or more LED targets. " "Delete or reassign the targets first.", ) + if store.is_referenced_by_composite(source_id): + raise HTTPException( + status_code=409, + detail="Color strip source is used as a layer in a composite source. " + "Remove it from the composite first.", + ) store.delete_source(source_id) except HTTPException: raise diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index 2e38673..5691e31 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -27,11 +27,20 @@ class ColorStop(BaseModel): ) +class CompositeLayer(BaseModel): + """A single layer in a composite color strip source.""" + + source_id: str = Field(description="ID of the layer's color strip source") + blend_mode: str = Field(default="normal", description="Blend mode: normal|add|multiply|screen") + opacity: float = Field(default=1.0, ge=0.0, le=1.0, description="Layer opacity 0.0-1.0") + enabled: bool = Field(default=True, description="Whether this layer is active") + + class ColorStripSourceCreate(BaseModel): """Request to create a color strip source.""" name: str = Field(description="Source name", min_length=1, max_length=100) - source_type: Literal["picture", "static", "gradient", "color_cycle", "effect"] = Field(default="picture", description="Source type") + source_type: Literal["picture", "static", "gradient", "color_cycle", "effect", "composite"] = Field(default="picture", description="Source type") # picture-type fields picture_source_id: str = Field(default="", description="Picture source ID (for picture type)") brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) @@ -54,6 +63,8 @@ class ColorStripSourceCreate(BaseModel): intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0) scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0) mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor)") + # composite-type fields + layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") # shared led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0) description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -87,6 +98,8 @@ class ColorStripSourceUpdate(BaseModel): intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0) scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0) mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") + # composite-type fields + layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") # shared led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0) description: Optional[str] = Field(None, description="Optional description", max_length=500) @@ -122,6 +135,8 @@ class ColorStripSourceResponse(BaseModel): intensity: Optional[float] = Field(None, description="Effect intensity") scale: Optional[float] = Field(None, description="Spatial scale") mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") + # composite-type fields + layers: Optional[List[dict]] = Field(None, description="Layers for composite type") # shared led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)") description: Optional[str] = Field(None, description="Description") diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index aa0230c..039511a 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -28,21 +28,36 @@ from wled_controller.utils.timer import high_resolution_timer logger = get_logger(__name__) -def _apply_saturation(colors: np.ndarray, saturation: float) -> np.ndarray: - """Adjust saturation via luminance mixing (Rec.601 weights). +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. """ - gray = ( - colors[:, 0].astype(np.int32) * 299 - + colors[:, 1].astype(np.int32) * 587 - + colors[:, 2].astype(np.int32) * 114 - ) // 1000 - gray = gray[:, np.newaxis] # (N, 1) for broadcast - result = gray + saturation * (colors.astype(np.int32) - gray) - return np.clip(result, 0, 255).astype(np.uint8) + 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: @@ -278,6 +293,45 @@ class PictureColorStripStream(ColorStripStream): """Background thread: poll source, process, cache colors.""" cached_frame = None + # Scratch buffer pool (pre-allocated, resized when LED count changes) + _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 + + def _blend_u16(a, b, alpha_b, out): + """Blend two uint8 arrays: out = ((256-alpha_b)*a + alpha_b*b) >> 8. + + Uses pre-allocated uint16 scratch buffers (_u16_a, _u16_b). + """ + np.copyto(_u16_a, a, casting='unsafe') + np.copyto(_u16_b, b, casting='unsafe') + _u16_a *= (256 - alpha_b) + _u16_b *= alpha_b + _u16_a += _u16_b + _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. + """ + 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 + with high_resolution_timer(): while self._running: loop_start = time.perf_counter() @@ -293,22 +347,14 @@ class PictureColorStripStream(ColorStripStream): 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) - alpha = int(t * 256) - led_colors = ( - (256 - alpha) * self._interp_from.astype(np.uint16) - + alpha * self._interp_to.astype(np.uint16) - ) >> 8 - led_colors = led_colors.astype(np.uint8) - if self._saturation != 1.0: - led_colors = _apply_saturation(led_colors, self._saturation) - if self._gamma != 1.0: - led_colors = self._gamma_lut[led_colors] - if self._brightness != 1.0: - led_colors = np.clip( - led_colors.astype(np.float32) * self._brightness, 0, 255 - ).astype(np.uint8) + 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 @@ -332,14 +378,32 @@ class PictureColorStripStream(ColorStripStream): led_colors = self._pixel_mapper.map_border_to_leds(border_pixels) t2 = time.perf_counter() - # Pad or truncate to match the declared led_count + # Ensure scratch pool is sized for this frame target_count = self._led_count - if target_count > 0 and len(led_colors) != target_count: - if len(led_colors) < target_count: - pad = np.zeros((target_count - len(led_colors), 3), dtype=np.uint8) - led_colors = np.concatenate([led_colors, pad]) + _n = target_count if target_count > 0 else len(led_colors) + if _n > 0 and _n != _pool_n: + _pool_n = _n + _frame_a = np.empty((_n, 3), dtype=np.uint8) + _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) + frame_buf = _frame_a if _use_a else _frame_b + _use_a = not _use_a + n_leds = len(led_colors) + if _pool_n > 0: + if n_leds < _pool_n: + frame_buf[:n_leds] = led_colors + frame_buf[n_leds:] = 0 + elif n_leds > _pool_n: + frame_buf[:] = led_colors[:_pool_n] else: - led_colors = led_colors[:target_count] + frame_buf[:] = led_colors + led_colors = frame_buf # Update interpolation buffers (raw colors, before corrections) if self._frame_interpolation: @@ -348,25 +412,22 @@ class PictureColorStripStream(ColorStripStream): self._interp_start = loop_start self._interp_duration = max(interval, 0.001) - # Temporal smoothing + # Temporal smoothing (pre-allocated uint16 scratch) smoothing = self._smoothing if ( self._previous_colors is not None and smoothing > 0 and len(self._previous_colors) == len(led_colors) + and _u16_a is not None ): - alpha = int(smoothing * 256) - led_colors = ( - (256 - alpha) * led_colors.astype(np.uint16) - + alpha * self._previous_colors.astype(np.uint16) - ) >> 8 - led_colors = led_colors.astype(np.uint8) + _blend_u16(led_colors, self._previous_colors, + int(smoothing * 256), led_colors) t3 = time.perf_counter() - # Saturation + # Saturation (pre-allocated int32 scratch) saturation = self._saturation if saturation != 1.0: - led_colors = _apply_saturation(led_colors, saturation) + _apply_saturation(led_colors, saturation, _i32, _i32_gray, led_colors) t4 = time.perf_counter() # Gamma (LUT lookup — O(1) per pixel) @@ -374,12 +435,16 @@ class PictureColorStripStream(ColorStripStream): led_colors = self._gamma_lut[led_colors] t5 = time.perf_counter() - # Brightness + # Brightness (integer math with pre-allocated int32 scratch) brightness = self._brightness if brightness != 1.0: - led_colors = np.clip( - led_colors.astype(np.float32) * brightness, 0, 255 - ).astype(np.uint8) + 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 @@ -913,6 +978,9 @@ class GradientColorStripStream(ColorStripStream): _pool_n = 0 _buf_a = _buf_b = _scratch_u16 = None _use_a = True + _wave_i = None # cached np.arange for wave animation + _wave_factors = None # float32 scratch for wave sin result + _wave_u16 = None # uint16 scratch for wave int factors with high_resolution_timer(): while self._running: @@ -940,6 +1008,9 @@ class GradientColorStripStream(ColorStripStream): _buf_a = np.empty((n, 3), dtype=np.uint8) _buf_b = np.empty((n, 3), dtype=np.uint8) _scratch_u16 = np.empty((n, 3), dtype=np.uint16) + _wave_i = np.arange(n, dtype=np.float32) + _wave_factors = np.empty(n, dtype=np.float32) + _wave_u16 = np.empty(n, dtype=np.uint16) buf = _buf_a if _use_a else _buf_b _use_a = not _use_a @@ -963,13 +1034,17 @@ class GradientColorStripStream(ColorStripStream): elif atype == "wave": if n > 1: - i_arr = np.arange(n, dtype=np.float32) - factor = 0.5 * (1 + np.sin( - 2 * math.pi * i_arr / n - 2 * math.pi * speed * t * 0.25 - )) - int_factors = np.clip(factor * 256, 0, 256).astype(np.uint16) + np.sin( + 2 * math.pi * _wave_i / n - 2 * math.pi * speed * t * 0.25, + out=_wave_factors, + ) + _wave_factors *= 0.5 + _wave_factors += 0.5 + np.multiply(_wave_factors, 256, out=_wave_factors) + np.clip(_wave_factors, 0, 256, out=_wave_factors) + np.copyto(_wave_u16, _wave_factors, casting='unsafe') np.copyto(_scratch_u16, base) - _scratch_u16 *= int_factors[:, None] + _scratch_u16 *= _wave_u16[:, None] _scratch_u16 >>= 8 np.copyto(buf, _scratch_u16, casting='unsafe') colors = buf diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index 963a0c5..bbfac11 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -100,12 +100,16 @@ class ColorStripStreamManager: # Non-sharable: always create a fresh per-consumer instance if not source.sharable: - stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) - if not stream_cls: - raise ValueError( - f"Unsupported color strip source type '{source.source_type}' for {css_id}" - ) - css_stream = stream_cls(source) + if source.source_type == "composite": + from wled_controller.core.processing.composite_stream import CompositeColorStripStream + css_stream = CompositeColorStripStream(source, self) + else: + stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type) + if not stream_cls: + raise ValueError( + f"Unsupported color strip source type '{source.source_type}' for {css_id}" + ) + css_stream = stream_cls(source) css_stream.start() key = f"{css_id}:{consumer_id}" if consumer_id else css_id self._streams[key] = _ColorStripEntry( diff --git a/server/src/wled_controller/core/processing/composite_stream.py b/server/src/wled_controller/core/processing/composite_stream.py new file mode 100644 index 0000000..95e8d42 --- /dev/null +++ b/server/src/wled_controller/core/processing/composite_stream.py @@ -0,0 +1,313 @@ +"""Composite color strip stream — blends multiple sub-streams as layers.""" + +import threading +import time +from typing import Dict, List, Optional + +import numpy as np + +from wled_controller.core.processing.color_strip_stream import ColorStripStream +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +# Blend-mode dispatch keys +_BLEND_NORMAL = "normal" +_BLEND_ADD = "add" +_BLEND_MULTIPLY = "multiply" +_BLEND_SCREEN = "screen" + + +class CompositeColorStripStream(ColorStripStream): + """Blends multiple ColorStripStreams as layers with blend modes and opacity. + + Each layer references an existing (non-composite) ColorStripSource. + Sub-streams are acquired from the ColorStripStreamManager so picture + sources share their existing capture pipeline. + + Processing runs in a background thread at 30 FPS, polling each + sub-stream's latest colors and blending bottom-to-top. + """ + + def __init__(self, source, css_manager): + self._source_id: str = source.id + self._layers: List[dict] = list(source.layers) + self._led_count: int = source.led_count + self._auto_size: bool = source.led_count == 0 + self._css_manager = css_manager + self._fps: int = 30 + + self._running = False + self._thread: Optional[threading.Thread] = None + self._latest_colors: Optional[np.ndarray] = None + self._colors_lock = threading.Lock() + + # layer_index -> (source_id, consumer_id, stream) + self._sub_streams: Dict[int, tuple] = {} + + # Pre-allocated scratch (rebuilt when LED count changes) + self._pool_n = 0 + self._result_a: Optional[np.ndarray] = None + self._result_b: Optional[np.ndarray] = None + self._use_a = True + self._u16_a: Optional[np.ndarray] = None + self._u16_b: Optional[np.ndarray] = None + self._resize_buf: Optional[np.ndarray] = None + + # ── ColorStripStream interface ────────────────────────────── + + @property + def target_fps(self) -> int: + return self._fps + + @property + def led_count(self) -> int: + return self._led_count + + @property + def is_animated(self) -> bool: + return True + + def start(self) -> None: + self._acquire_sub_streams() + self._running = True + self._thread = threading.Thread( + target=self._processing_loop, daemon=True, + name=f"CompositeCSS-{self._source_id[:12]}", + ) + self._thread.start() + logger.info( + f"CompositeColorStripStream started: {self._source_id} " + f"({len(self._sub_streams)} layers, {self._led_count} LEDs)" + ) + + def stop(self) -> None: + self._running = False + if self._thread is not None: + self._thread.join(timeout=5.0) + self._thread = None + self._release_sub_streams() + logger.info(f"CompositeColorStripStream stopped: {self._source_id}") + + def get_latest_colors(self) -> Optional[np.ndarray]: + with self._colors_lock: + return self._latest_colors + + def configure(self, device_led_count: int) -> None: + if self._auto_size and device_led_count > 0 and device_led_count != self._led_count: + self._led_count = device_led_count + # Re-configure sub-streams that support auto-sizing + for _idx, (src_id, consumer_id, stream) in self._sub_streams.items(): + if hasattr(stream, "configure"): + stream.configure(device_led_count) + logger.debug(f"CompositeColorStripStream auto-sized to {device_led_count} LEDs") + + def update_source(self, source) -> None: + """Hot-update: rebuild sub-streams if layer config changed.""" + new_layers = list(source.layers) + old_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled")) + for l in self._layers] + new_layer_ids = [(l.get("source_id"), l.get("blend_mode"), l.get("opacity"), l.get("enabled")) + for l in new_layers] + + self._layers = new_layers + + if source.led_count != 0: + self._led_count = source.led_count + self._auto_size = False + + # If layer composition changed, rebuild sub-streams + if old_layer_ids != new_layer_ids: + self._release_sub_streams() + self._acquire_sub_streams() + logger.info(f"CompositeColorStripStream rebuilt sub-streams: {self._source_id}") + + # ── Sub-stream lifecycle ──────────────────────────────────── + + def _acquire_sub_streams(self) -> None: + for i, layer in enumerate(self._layers): + if not layer.get("enabled", True): + continue + src_id = layer.get("source_id", "") + if not src_id: + continue + consumer_id = f"{self._source_id}__layer_{i}" + try: + stream = self._css_manager.acquire(src_id, consumer_id) + if hasattr(stream, "configure") and self._led_count > 0: + stream.configure(self._led_count) + self._sub_streams[i] = (src_id, consumer_id, stream) + except Exception as e: + logger.warning( + f"Composite layer {i} (source {src_id}) failed to acquire: {e}" + ) + + def _release_sub_streams(self) -> None: + for _idx, (src_id, consumer_id, _stream) in list(self._sub_streams.items()): + try: + self._css_manager.release(src_id, consumer_id) + except Exception as e: + logger.warning(f"Composite layer release error ({src_id}): {e}") + self._sub_streams.clear() + + # ── Scratch pool ──────────────────────────────────────────── + + def _ensure_pool(self, n: int) -> None: + if n == self._pool_n or n <= 0: + return + self._pool_n = n + self._result_a = np.empty((n, 3), dtype=np.uint8) + self._result_b = np.empty((n, 3), dtype=np.uint8) + self._u16_a = np.empty((n, 3), dtype=np.uint16) + self._u16_b = np.empty((n, 3), dtype=np.uint16) + self._resize_buf = np.empty((n, 3), dtype=np.uint8) + + # ── Resize helper ─────────────────────────────────────────── + + def _resize_to_target(self, colors: np.ndarray, target_n: int) -> np.ndarray: + """Resize (N, 3) uint8 array to (target_n, 3) via linear interpolation.""" + n_src = len(colors) + if n_src == target_n: + return colors + src_x = np.linspace(0, 1, n_src) + dst_x = np.linspace(0, 1, target_n) + buf = self._resize_buf + for ch in range(3): + np.copyto( + buf[:, ch], + np.interp(dst_x, src_x, colors[:, ch]), + casting="unsafe", + ) + return buf + + # ── Blend operations (integer math, pre-allocated) ────────── + + def _blend_normal(self, bottom: np.ndarray, top: np.ndarray, alpha: int, + out: np.ndarray) -> None: + """Normal blend: out = (bottom * (256-a) + top * a) >> 8""" + u16a, u16b = self._u16_a, self._u16_b + np.copyto(u16a, bottom, casting="unsafe") + np.copyto(u16b, top, casting="unsafe") + u16a *= (256 - alpha) + u16b *= alpha + u16a += u16b + u16a >>= 8 + np.copyto(out, u16a, casting="unsafe") + + def _blend_add(self, bottom: np.ndarray, top: np.ndarray, alpha: int, + out: np.ndarray) -> None: + """Additive blend: out = min(255, bottom + top * alpha >> 8)""" + u16a, u16b = self._u16_a, self._u16_b + np.copyto(u16a, bottom, casting="unsafe") + np.copyto(u16b, top, casting="unsafe") + u16b *= alpha + u16b >>= 8 + u16a += u16b + np.clip(u16a, 0, 255, out=u16a) + np.copyto(out, u16a, casting="unsafe") + + def _blend_multiply(self, bottom: np.ndarray, top: np.ndarray, alpha: int, + out: np.ndarray) -> None: + """Multiply blend: blended = bottom*top>>8, then lerp with alpha.""" + u16a, u16b = self._u16_a, self._u16_b + # blended = (bottom * top) >> 8 + np.copyto(u16a, bottom, casting="unsafe") + np.copyto(u16b, top, casting="unsafe") + u16a *= u16b + u16a >>= 8 + # lerp: result = (bottom * (256-a) + blended * a) >> 8 + np.copyto(u16b, bottom, casting="unsafe") + u16b *= (256 - alpha) + u16a *= alpha + u16a += u16b + u16a >>= 8 + np.copyto(out, u16a, casting="unsafe") + + def _blend_screen(self, bottom: np.ndarray, top: np.ndarray, alpha: int, + out: np.ndarray) -> None: + """Screen blend: blended = 255 - (255-bottom)*(255-top)>>8, then lerp.""" + u16a, u16b = self._u16_a, self._u16_b + # blended = 255 - ((255 - bottom) * (255 - top)) >> 8 + np.copyto(u16a, bottom, casting="unsafe") + np.copyto(u16b, top, casting="unsafe") + u16a[:] = 255 - u16a + u16b[:] = 255 - u16b + u16a *= u16b + u16a >>= 8 + u16a[:] = 255 - u16a + # lerp: result = (bottom * (256-a) + blended * a) >> 8 + np.copyto(u16b, bottom, casting="unsafe") + u16b *= (256 - alpha) + u16a *= alpha + u16a += u16b + u16a >>= 8 + np.copyto(out, u16a, casting="unsafe") + + _BLEND_DISPATCH = { + _BLEND_NORMAL: "_blend_normal", + _BLEND_ADD: "_blend_add", + _BLEND_MULTIPLY: "_blend_multiply", + _BLEND_SCREEN: "_blend_screen", + } + + # ── Processing loop ───────────────────────────────────────── + + def _processing_loop(self) -> None: + while self._running: + loop_start = time.perf_counter() + frame_time = 1.0 / self._fps + + try: + target_n = self._led_count + if target_n <= 0: + time.sleep(frame_time) + continue + + self._ensure_pool(target_n) + + result_buf = self._result_a if self._use_a else self._result_b + self._use_a = not self._use_a + has_result = False + + for i, layer in enumerate(self._layers): + if not layer.get("enabled", True): + continue + if i not in self._sub_streams: + continue + + _src_id, _consumer_id, stream = self._sub_streams[i] + colors = stream.get_latest_colors() + if colors is None: + continue + + # Resize to target LED count if needed + if len(colors) != target_n: + colors = self._resize_to_target(colors, target_n) + + opacity = layer.get("opacity", 1.0) + blend_mode = layer.get("blend_mode", _BLEND_NORMAL) + alpha = int(opacity * 256) + alpha = max(0, min(256, alpha)) + + if not has_result: + # First layer: copy directly (or blend with black if opacity < 1) + if alpha >= 256 and blend_mode == _BLEND_NORMAL: + result_buf[:] = colors + else: + result_buf[:] = 0 + blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal")) + blend_fn(result_buf, colors, alpha, result_buf) + has_result = True + else: + blend_fn = getattr(self, self._BLEND_DISPATCH.get(blend_mode, "_blend_normal")) + blend_fn(result_buf, colors, alpha, result_buf) + + if has_result: + with self._colors_lock: + self._latest_colors = result_buf + + except Exception as e: + logger.error(f"CompositeColorStripStream processing error: {e}", exc_info=True) + + elapsed = time.perf_counter() - loop_start + time.sleep(max(frame_time - elapsed, 0.001)) diff --git a/server/src/wled_controller/core/processing/effect_stream.py b/server/src/wled_controller/core/processing/effect_stream.py index a2dda8d..d4e4316 100644 --- a/server/src/wled_controller/core/processing/effect_stream.py +++ b/server/src/wled_controller/core/processing/effect_stream.py @@ -74,34 +74,85 @@ def _build_palette_lut(name: str) -> np.ndarray: # ── 1-D value noise (no external deps) ────────────────────────────────── class _ValueNoise1D: - """Simple 1-D value noise with smoothstep interpolation and fractal octaves.""" + """Simple 1-D value noise with smoothstep interpolation and fractal octaves. + + Scratch buffers are lazily allocated and reused across calls to avoid + per-frame numpy allocations in hot loops. + """ def __init__(self, seed: int = 42): rng = np.random.RandomState(seed) self._table = rng.random(512).astype(np.float32) + self._scratch_n = 0 + + def _ensure_scratch(self, n: int) -> None: + """(Re)allocate scratch buffers when array size changes.""" + if n == self._scratch_n: + return + self._scratch_n = n + self._xi = np.empty(n, dtype=np.int64) + self._frac = np.empty(n, dtype=np.float32) + self._t = np.empty(n, dtype=np.float32) + self._a = np.empty(n, dtype=np.float32) + self._b = np.empty(n, dtype=np.float32) + self._oct_x = np.empty(n, dtype=np.float32) + self._fbm_result = np.empty(n, dtype=np.float32) def noise(self, x: np.ndarray) -> np.ndarray: - """Single-octave smooth noise for an array of float positions.""" + """Single-octave smooth noise for an array of float positions. + + Returns an internal buffer (_b) — caller must copy if the result + is needed beyond the next noise() or fbm() call. + """ + n = len(x) + self._ensure_scratch(n) size = len(self._table) - xi = np.floor(x).astype(np.int64) - frac = (x - xi).astype(np.float32) - t = frac * frac * (3.0 - 2.0 * frac) # smoothstep - a = self._table[xi % size] - b = self._table[(xi + 1) % size] - return a + t * (b - a) + # xi = floor(x) + np.floor(x, out=self._frac) + np.copyto(self._xi, self._frac, casting='unsafe') + # frac = x - xi + np.subtract(x, self._frac, out=self._frac) + # t = frac * frac * (3 - 2 * frac) (smoothstep) + np.multiply(self._frac, self._frac, out=self._t) + np.multiply(self._frac, -2.0, out=self._a) + self._a += 3.0 + self._t *= self._a + # Table lookups (fancy indexing is unavoidable but copies into pre-allocated) + np.remainder(self._xi, size, out=self._xi) + self._a[:] = self._table[self._xi] + self._xi += 1 + np.remainder(self._xi, size, out=self._xi) + self._b[:] = self._table[self._xi] + # result = a + t * (b - a) + self._b -= self._a + self._b *= self._t + self._b += self._a + return self._b def fbm(self, x: np.ndarray, octaves: int = 3) -> np.ndarray: - """Fractal Brownian Motion — layered noise at decreasing amplitude.""" - result = np.zeros_like(x, dtype=np.float32) + """Fractal Brownian Motion — layered noise at decreasing amplitude. + + Returns an internal buffer (_fbm_result) — caller must copy if the + result is needed beyond the next fbm() call. + """ + n = len(x) + self._ensure_scratch(n) + self._fbm_result[:] = 0 amp = 1.0 freq = 1.0 total_amp = 0.0 for _ in range(octaves): - result += amp * self.noise(x * freq) + np.multiply(x, freq, out=self._oct_x) + self.noise(self._oct_x) + # noise() result is in self._b; copy to _a for accumulation + self._a[:] = self._b + self._a *= amp + self._fbm_result += self._a total_amp += amp amp *= 0.5 freq *= 2.0 - return result / total_amp + self._fbm_result /= total_amp + return self._fbm_result # ── Effect stream ──────────────────────────────────────────────────────── @@ -135,6 +186,17 @@ class EffectColorStripStream(ColorStripStream): # Fire state — allocated lazily in render loop self._heat: Optional[np.ndarray] = None self._heat_n = 0 + # Scratch arrays (allocated in _animate_loop when LED count is known) + self._s_f32_a: Optional[np.ndarray] = None + self._s_f32_b: Optional[np.ndarray] = None + self._s_f32_c: Optional[np.ndarray] = None + self._s_i32: Optional[np.ndarray] = None + self._s_f32_rgb: Optional[np.ndarray] = None + self._s_arange: Optional[np.ndarray] = None + self._s_layer1: Optional[np.ndarray] = None + self._s_layer2: Optional[np.ndarray] = None + self._plasma_key = (0, 0.0) + self._plasma_x: Optional[np.ndarray] = None self._update_from_source(source) def _update_from_source(self, source) -> None: @@ -232,6 +294,16 @@ class EffectColorStripStream(ColorStripStream): _pool_n = n _buf_a = np.empty((n, 3), dtype=np.uint8) _buf_b = np.empty((n, 3), dtype=np.uint8) + # Scratch arrays for render methods + self._s_f32_a = np.empty(n, dtype=np.float32) + self._s_f32_b = np.empty(n, dtype=np.float32) + self._s_f32_c = np.empty(n, dtype=np.float32) + self._s_i32 = np.empty(n, dtype=np.int32) + self._s_f32_rgb = np.empty((n, 3), dtype=np.float32) + self._s_arange = np.arange(n, dtype=np.float32) + self._s_layer1 = np.empty(n, dtype=np.float32) + self._s_layer2 = np.empty(n, dtype=np.float32) + self._plasma_key = (0, 0.0) buf = _buf_a if _use_a else _buf_b _use_a = not _use_a @@ -271,8 +343,7 @@ class EffectColorStripStream(ColorStripStream): # Diffuse heat upward (index 0 = bottom, index n-1 = top) if n >= 3: - # Average of neighbors, shifted upward - new_heat = np.empty_like(heat) + new_heat = self._s_f32_a new_heat[0] = (heat[0] + heat[1]) * 0.5 new_heat[1:-1] = (heat[:-2] + heat[1:-1] + heat[2:]) / 3.0 new_heat[-1] = heat[-1] * 0.5 @@ -285,9 +356,11 @@ class EffectColorStripStream(ColorStripStream): if np.random.random() < spark_prob: heat[i] = min(1.0, heat[i] + 0.4 + 0.6 * np.random.random()) - # Map heat to palette - indices = np.clip((heat * 255).astype(np.int32), 0, 255) - buf[:] = lut[indices] + # Map heat to palette (pre-allocated scratch) + np.multiply(heat, 255, out=self._s_f32_a) + np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a) + np.copyto(self._s_i32, self._s_f32_a, casting='unsafe') + buf[:] = lut[self._s_i32] # ── Meteor ─────────────────────────────────────────────────────── @@ -313,22 +386,32 @@ class EffectColorStripStream(ColorStripStream): decay = 0.05 + 0.25 * (1.0 - min(1.0, intensity)) # 0.05 (long) to 0.30 (short) # Compute brightness for each LED based on distance behind the meteor - indices = np.arange(n, dtype=np.float32) + indices = self._s_arange + dist = self._s_f32_a if mirror: - dist = np.abs(indices - pos) + np.subtract(indices, pos, out=dist) + np.abs(dist, out=dist) else: - # Signed distance in the direction of travel (behind = positive) - dist = (pos - indices) % n + np.subtract(pos, indices, out=dist) + dist %= n - brightness = np.exp(-dist * decay) + np.multiply(dist, -decay, out=self._s_f32_b) + np.exp(self._s_f32_b, out=self._s_f32_b) + brightness = self._s_f32_b - # Apply color + # Apply color using pre-allocated scratch r, g, b = color - buf[:, 0] = np.clip(brightness * r, 0, 255).astype(np.uint8) - buf[:, 1] = np.clip(brightness * g, 0, 255).astype(np.uint8) - buf[:, 2] = np.clip(brightness * b, 0, 255).astype(np.uint8) + np.multiply(brightness, r, out=self._s_f32_c) + np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c) + np.copyto(buf[:, 0], self._s_f32_c, casting='unsafe') + np.multiply(brightness, g, out=self._s_f32_c) + np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c) + np.copyto(buf[:, 1], self._s_f32_c, casting='unsafe') + np.multiply(brightness, b, out=self._s_f32_c) + 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 (within ±1 LED of position) + # 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( @@ -352,8 +435,14 @@ class EffectColorStripStream(ColorStripStream): scale = self._scale lut = self._palette_lut + # Cache x array (only changes when n or scale change) + key = (n, scale) + if key != self._plasma_key: + self._plasma_key = key + self._plasma_x = np.linspace(0, scale * math.pi * 2, n, dtype=np.float64) + phase = t * speed * 0.5 - x = np.linspace(0, scale * math.pi * 2, n, dtype=np.float64) + x = self._plasma_x v = ( np.sin(x + phase) @@ -373,10 +462,15 @@ class EffectColorStripStream(ColorStripStream): scale = self._scale lut = self._palette_lut - positions = np.arange(n, dtype=np.float32) * scale * 0.1 + t * speed * 0.5 - values = self._noise.fbm(positions, octaves=3) - indices = np.clip((values * 255).astype(np.int32), 0, 255) - buf[:] = lut[indices] + # Positions from cached arange (avoids per-frame np.arange) + np.multiply(self._s_arange, scale * 0.1, out=self._s_f32_a) + self._s_f32_a += t * speed * 0.5 + values = self._noise.fbm(self._s_f32_a, octaves=3) + # Map to palette indices using pre-allocated scratch + np.multiply(values, 255, out=self._s_f32_b) + np.clip(self._s_f32_b, 0, 255, out=self._s_f32_b) + np.copyto(self._s_i32, self._s_f32_b, casting='unsafe') + buf[:] = lut[self._s_i32] # ── Aurora ─────────────────────────────────────────────────────── @@ -387,22 +481,39 @@ class EffectColorStripStream(ColorStripStream): intensity = self._intensity lut = self._palette_lut - positions = np.arange(n, dtype=np.float32) * scale * 0.08 + # Positions from cached arange + np.multiply(self._s_arange, scale * 0.08, out=self._s_f32_a) - # Three noise layers at different speeds and offsets - layer1 = self._noise.fbm(positions + t * speed * 0.2, octaves=3) - layer2 = self._noise.fbm(positions * 1.5 + t * speed * 0.35 + 100.0, octaves=3) - layer3 = self._noise.fbm(positions * 0.7 + t * speed * 0.15 + 200.0, octaves=2) + # Three noise layers — copy results to dedicated buffers since fbm + # may return an internal reference that gets overwritten on the next call + np.add(self._s_f32_a, t * speed * 0.2, out=self._s_f32_b) + self._s_layer1[:] = self._noise.fbm(self._s_f32_b, octaves=3) - # Combine layers: layer1 drives hue, layer2 modulates brightness, - # layer3 adds slow undulation - hue = (layer1 + layer3 * 0.5) * 0.67 # 0–1 range for palette lookup - hue = np.clip(hue, 0.0, 1.0) + np.multiply(self._s_f32_a, 1.5, out=self._s_f32_b) + self._s_f32_b += t * speed * 0.35 + 100.0 + self._s_layer2[:] = self._noise.fbm(self._s_f32_b, octaves=3) - brightness = 0.3 + 0.7 * layer2 * intensity - brightness = np.clip(brightness, 0.0, 1.0) + np.multiply(self._s_f32_a, 0.7, out=self._s_f32_b) + self._s_f32_b += t * speed * 0.15 + 200.0 + layer3 = self._noise.fbm(self._s_f32_b, octaves=2) - indices = np.clip((hue * 255).astype(np.int32), 0, 255) - colors = lut[indices].astype(np.float32) - colors *= brightness[:, np.newaxis] - buf[:] = np.clip(colors, 0, 255).astype(np.uint8) + # Combine layers: hue from layer1 + layer3, brightness from layer2 + hue = self._s_f32_a # reuse (positions no longer needed) + np.multiply(layer3, 0.5, out=hue) + hue += self._s_layer1 + hue *= 0.67 + np.clip(hue, 0.0, 1.0, out=hue) + + bright = self._s_f32_b + np.multiply(self._s_layer2, 0.7 * intensity, out=bright) + bright += 0.3 + np.clip(bright, 0.0, 1.0, out=bright) + + # Map to palette using pre-allocated scratch + np.multiply(hue, 255, out=hue) + np.copyto(self._s_i32, hue, casting='unsafe') + np.clip(self._s_i32, 0, 255, out=self._s_i32) + self._s_f32_rgb[:] = lut[self._s_i32] + self._s_f32_rgb *= bright[:, np.newaxis] + np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb) + np.copyto(buf, self._s_f32_rgb, casting='unsafe') diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index b89b732..905fa6a 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -122,14 +122,8 @@ class WledTargetProcessor(TargetProcessor): self._color_strip_stream = stream self._resolved_display_index = stream.display_index - # For auto-sized static/gradient/color_cycle/effect streams (led_count == 0), size to device LED count - from wled_controller.core.processing.color_strip_stream import ( - ColorCycleColorStripStream, - GradientColorStripStream, - StaticColorStripStream, - ) - from wled_controller.core.processing.effect_stream import EffectColorStripStream - if isinstance(stream, (StaticColorStripStream, GradientColorStripStream, ColorCycleColorStripStream, EffectColorStripStream)) and device_info.led_count > 0: + # For auto-sized non-picture streams (led_count == 0), size to device LED count + if hasattr(stream, "configure") and device_info.led_count > 0: effective_leds = device_info.led_count - self._led_skip_start - self._led_skip_end stream.configure(max(1, effective_leds)) @@ -415,19 +409,20 @@ class WledTargetProcessor(TargetProcessor): ]) return result - def _apply_led_skip(self, colors: np.ndarray) -> np.ndarray: - """Pad color array with black at start/end for skipped LEDs.""" - s, e = self._led_skip_start, self._led_skip_end - if s <= 0 and e <= 0: + @staticmethod + def _apply_led_skip(colors: np.ndarray, buf: Optional[np.ndarray], skip_start: int) -> np.ndarray: + """Copy effective colors into pre-allocated buffer with black padding. + + Args: + colors: Effective LED colors (skip-excluded) + buf: Pre-allocated (device_led_count, 3) buffer with black edges, + or None when no skip is configured. + skip_start: Number of black LEDs at the start (write offset) + """ + if buf is None: return colors - channels = colors.shape[1] if colors.ndim == 2 else 3 - parts = [] - if s > 0: - parts.append(np.zeros((s, channels), dtype=np.uint8)) - parts.append(colors) - if e > 0: - parts.append(np.zeros((e, channels), dtype=np.uint8)) - return np.vstack(parts) + buf[skip_start:skip_start + len(colors)] = colors + return buf async def _processing_loop(self) -> None: """Main processing loop — poll ColorStripStream → apply brightness → send.""" @@ -440,7 +435,58 @@ class WledTargetProcessor(TargetProcessor): last_send_time = 0.0 prev_frame_time_stamp = time.perf_counter() loop = asyncio.get_running_loop() - effective_leds = max(1, (device_info.led_count if device_info else 0) - self._led_skip_start - self._led_skip_end) + _init_device_info = self._ctx.get_device_info(self._device_id) + _total_leds = _init_device_info.led_count if _init_device_info else 0 + effective_leds = max(1, _total_leds - self._led_skip_start - self._led_skip_end) + + # Pre-allocate skip buffer (reused every frame — edges stay black) + if (self._led_skip_start > 0 or self._led_skip_end > 0) and _total_leds > 0: + _skip_buf: Optional[np.ndarray] = np.zeros((_total_leds, 3), dtype=np.uint8) + else: + _skip_buf = None + + # Pre-allocate resampling cache (linspace + result reused while sizes unchanged) + _fit_key = (0, 0) + _fit_src_x = _fit_dst_x = _fit_result = None + + def _cached_fit(colors_in): + """Resample colors to effective_leds using cached linspace arrays.""" + nonlocal _fit_key, _fit_src_x, _fit_dst_x, _fit_result + n_src = len(colors_in) + if n_src == effective_leds or effective_leds <= 0: + return colors_in + if (n_src, effective_leds) != _fit_key: + _fit_key = (n_src, effective_leds) + _fit_src_x = np.linspace(0, 1, n_src) + _fit_dst_x = np.linspace(0, 1, effective_leds) + _fit_result = np.empty((effective_leds, 3), dtype=np.uint8) + for _ch in range(3): + np.copyto(_fit_result[:, _ch], + np.interp(_fit_dst_x, _fit_src_x, colors_in[:, _ch]), + casting='unsafe') + return _fit_result + + # Pre-allocate brightness scratch (uint16 intermediate + uint8 output) + _bright_u16: Optional[np.ndarray] = None + _bright_out: Optional[np.ndarray] = None + _bright_n = 0 + + def _cached_brightness(colors_in, dev_info): + """Apply software brightness using pre-allocated uint16 scratch.""" + nonlocal _bright_n, _bright_u16, _bright_out + if not dev_info or dev_info.software_brightness >= 255: + return colors_in + _dn = len(colors_in) + if _dn != _bright_n: + _bright_n = _dn + _bright_u16 = np.empty((_dn, 3), dtype=np.uint16) + _bright_out = np.empty((_dn, 3), dtype=np.uint8) + np.copyto(_bright_u16, colors_in, casting='unsafe') + _bright_u16 *= dev_info.software_brightness + _bright_u16 >>= 8 + np.copyto(_bright_out, _bright_u16, casting='unsafe') + return _bright_out + # Short re-poll interval when the animation thread hasn't produced a new # frame yet. The animation thread and this loop both target the same FPS # but are unsynchronised; without a short re-poll the loop can miss a @@ -502,9 +548,9 @@ class WledTargetProcessor(TargetProcessor): break kc = prev_colors if device_info and device_info.led_count > 0: - kc = self._fit_to_device(kc, effective_leds) - kc = self._apply_led_skip(kc) - send_colors = self._apply_brightness(kc, device_info) + kc = _cached_fit(kc) + kc = self._apply_led_skip(kc, _skip_buf, self._led_skip_start) + send_colors = _cached_brightness(kc, device_info) if self._led_client.supports_fast_send: self._led_client.send_pixels_fast(send_colors) else: @@ -525,11 +571,11 @@ class WledTargetProcessor(TargetProcessor): # Fit to effective LED count (excluding skipped) then pad with blacks if device_info and device_info.led_count > 0: - colors = self._fit_to_device(colors, effective_leds) - colors = self._apply_led_skip(colors) + colors = _cached_fit(colors) + colors = self._apply_led_skip(colors, _skip_buf, self._led_skip_start) # Apply device software brightness - send_colors = self._apply_brightness(colors, device_info) + send_colors = _cached_brightness(colors, device_info) # Send to LED device if not self._is_running or self._led_client is None: diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index a316d22..8a96ead 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -678,3 +678,61 @@ min-width: unset; line-height: 1; } + +/* ── Composite layer editor ────────────────────────────────────── */ + +#composite-layers-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 8px; +} + +.composite-layer-item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 8px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--card-bg); +} + +.composite-layer-row { + display: flex; + align-items: center; + gap: 6px; +} + +.composite-layer-source { + flex: 1; + min-width: 0; +} + +.composite-layer-blend { + width: 100px; + flex-shrink: 0; +} + +.composite-layer-opacity-label { + font-size: 0.8rem; + white-space: nowrap; + flex-shrink: 0; +} + +.composite-layer-opacity { + flex: 1; + min-width: 60px; +} + +.composite-layer-toggle { + flex-shrink: 0; +} + +.composite-layer-remove-btn { + font-size: 0.75rem; + padding: 0; + width: 26px; + height: 26px; + flex: 0 0 26px; +} diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index ef1d30c..8e4a2ca 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -91,6 +91,7 @@ import { showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip, onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview, colorCycleAddColor, colorCycleRemoveColor, + compositeAddLayer, compositeRemoveLayer, applyGradientPreset, } from './features/color-strips.js'; @@ -281,6 +282,8 @@ Object.assign(window, { updateEffectPreview, colorCycleAddColor, colorCycleRemoveColor, + compositeAddLayer, + compositeRemoveLayer, applyGradientPreset, // calibration diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 9eae8ea..273d867 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -38,6 +38,7 @@ class CSSEditorModal extends Modal { effect_intensity: document.getElementById('css-editor-effect-intensity').value, effect_scale: document.getElementById('css-editor-effect-scale').value, effect_mirror: document.getElementById('css-editor-effect-mirror').checked, + composite_layers: JSON.stringify(_compositeLayers), }; } } @@ -53,6 +54,7 @@ export function onCSSTypeChange() { document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none'; document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none'; document.getElementById('css-editor-effect-section').style.display = type === 'effect' ? '' : 'none'; + document.getElementById('css-editor-composite-section').style.display = type === 'composite' ? '' : 'none'; if (type === 'effect') onEffectTypeChange(); @@ -85,7 +87,12 @@ export function onCSSTypeChange() { } _syncAnimationSpeedState(); - if (type === 'gradient') { + // LED count — not needed for composite (uses device count) + document.getElementById('css-editor-led-count-group').style.display = type === 'composite' ? 'none' : ''; + + if (type === 'composite') { + _compositeRenderList(); + } else if (type === 'gradient') { requestAnimationFrame(() => gradientRenderAll()); } } @@ -260,6 +267,117 @@ function hexToRgbArray(hex) { return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255]; } +/* ── Composite layer helpers ──────────────────────────────────── */ + +let _compositeLayers = []; +let _compositeAvailableSources = []; // non-composite sources for layer dropdowns + +function _compositeRenderList() { + const list = document.getElementById('composite-layers-list'); + if (!list) return; + list.innerHTML = _compositeLayers.map((layer, i) => { + const srcOptions = _compositeAvailableSources.map(s => + `` + ).join(''); + const canRemove = _compositeLayers.length > 1; + return ` +