Add composite color strip source type with layer blending

Composite sources stack multiple existing color strip sources as layers
with configurable blend modes (Normal, Add, Multiply, Screen) and per-layer
opacity. Includes full CRUD, hot-reload, delete protection for referenced
layers, and pre-allocated integer blend math at 30 FPS.

Also eliminates per-frame numpy allocations in color_strip_stream,
effect_stream, and wled_target_processor (buffer pre-allocation).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 11:01:44 +03:00
parent e5a6eafd09
commit 2657f46e5d
15 changed files with 1042 additions and 144 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -100,6 +100,10 @@ class ColorStripStreamManager:
# Non-sharable: always create a fresh per-consumer instance
if not source.sharable:
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(

View File

@@ -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))

View File

@@ -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 # 01 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')

View File

@@ -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:

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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 =>
`<option value="${s.id}"${layer.source_id === s.id ? ' selected' : ''}>${escapeHtml(s.name)}</option>`
).join('');
const canRemove = _compositeLayers.length > 1;
return `
<div class="composite-layer-item">
<div class="composite-layer-row">
<select class="composite-layer-source" data-idx="${i}">${srcOptions}</select>
<select class="composite-layer-blend" data-idx="${i}">
<option value="normal"${layer.blend_mode === 'normal' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.normal')}</option>
<option value="add"${layer.blend_mode === 'add' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.add')}</option>
<option value="multiply"${layer.blend_mode === 'multiply' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.multiply')}</option>
<option value="screen"${layer.blend_mode === 'screen' ? ' selected' : ''}>${t('color_strip.composite.blend_mode.screen')}</option>
</select>
</div>
<div class="composite-layer-row">
<label class="composite-layer-opacity-label">
<span>${t('color_strip.composite.opacity')}:</span>
<span class="composite-opacity-val">${parseFloat(layer.opacity).toFixed(2)}</span>
</label>
<input type="range" class="composite-layer-opacity" data-idx="${i}"
min="0" max="1" step="0.05" value="${layer.opacity}">
<label class="settings-toggle composite-layer-toggle">
<input type="checkbox" class="composite-layer-enabled" data-idx="${i}"${layer.enabled ? ' checked' : ''}>
<span class="settings-toggle-slider"></span>
</label>
${canRemove
? `<button type="button" class="btn btn-secondary composite-layer-remove-btn"
onclick="compositeRemoveLayer(${i})">&#x2715;</button>`
: ''}
</div>
</div>
`;
}).join('');
// Wire up live opacity display
list.querySelectorAll('.composite-layer-opacity').forEach(el => {
el.addEventListener('input', () => {
const val = parseFloat(el.value);
el.closest('.composite-layer-row').querySelector('.composite-opacity-val').textContent = val.toFixed(2);
});
});
}
export function compositeAddLayer() {
_compositeLayersSyncFromDom();
_compositeLayers.push({
source_id: _compositeAvailableSources.length > 0 ? _compositeAvailableSources[0].id : '',
blend_mode: 'normal',
opacity: 1.0,
enabled: true,
});
_compositeRenderList();
}
export function compositeRemoveLayer(i) {
_compositeLayersSyncFromDom();
if (_compositeLayers.length <= 1) return;
_compositeLayers.splice(i, 1);
_compositeRenderList();
}
function _compositeLayersSyncFromDom() {
const list = document.getElementById('composite-layers-list');
if (!list) return;
const srcs = list.querySelectorAll('.composite-layer-source');
const blends = list.querySelectorAll('.composite-layer-blend');
const opacities = list.querySelectorAll('.composite-layer-opacity');
const enableds = list.querySelectorAll('.composite-layer-enabled');
if (srcs.length === _compositeLayers.length) {
for (let i = 0; i < srcs.length; i++) {
_compositeLayers[i].source_id = srcs[i].value;
_compositeLayers[i].blend_mode = blends[i].value;
_compositeLayers[i].opacity = parseFloat(opacities[i].value);
_compositeLayers[i].enabled = enableds[i].checked;
}
}
}
function _compositeGetLayers() {
_compositeLayersSyncFromDom();
return _compositeLayers.map(l => ({
source_id: l.source_id,
blend_mode: l.blend_mode,
opacity: l.opacity,
enabled: l.enabled,
}));
}
function _loadCompositeState(css) {
const raw = css && css.layers;
_compositeLayers = (raw && raw.length > 0)
? raw.map(l => ({
source_id: l.source_id || '',
blend_mode: l.blend_mode || 'normal',
opacity: l.opacity != null ? l.opacity : 1.0,
enabled: l.enabled != null ? l.enabled : true,
}))
: [{ source_id: '', blend_mode: 'normal', opacity: 1.0, enabled: true }];
_compositeRenderList();
}
/* ── Card ─────────────────────────────────────────────────────── */
export function createColorStripCard(source, pictureSourceMap) {
@@ -267,6 +385,7 @@ export function createColorStripCard(source, pictureSourceMap) {
const isGradient = source.source_type === 'gradient';
const isColorCycle = source.source_type === 'color_cycle';
const isEffect = source.source_type === 'effect';
const isComposite = source.source_type === 'composite';
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
const animBadge = anim
@@ -325,6 +444,13 @@ export function createColorStripCard(source, pictureSourceMap) {
<span class="stream-card-prop" title="${t('color_strip.effect.speed')}">⏩ ${(source.speed || 1.0).toFixed(1)}×</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`;
} else if (isComposite) {
const layerCount = (source.layers || []).length;
const enabledCount = (source.layers || []).filter(l => l.enabled !== false).length;
propsHtml = `
<span class="stream-card-prop">🔗 ${enabledCount}/${layerCount} ${t('color_strip.composite.layers_count')}</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`;
} else {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
? pictureSourceMap[source.picture_source_id].name
@@ -338,8 +464,8 @@ export function createColorStripCard(source, pictureSourceMap) {
`;
}
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect)
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite)
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
: '';
@@ -369,6 +495,13 @@ export async function showCSSEditor(cssId = null) {
const sourcesResp = await fetchWithAuth('/picture-sources');
const sources = sourcesResp.ok ? ((await sourcesResp.json()).streams || []) : [];
// Fetch all color strip sources for composite layer dropdowns
const cssListResp = await fetchWithAuth('/color-strip-sources');
const allCssSources = cssListResp.ok ? ((await cssListResp.json()).sources || []) : [];
_compositeAvailableSources = allCssSources.filter(s =>
s.source_type !== 'composite' && (!cssId || s.id !== cssId)
);
const sourceSelect = document.getElementById('css-editor-picture-source');
sourceSelect.innerHTML = '';
sources.forEach(s => {
@@ -416,6 +549,12 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-effect-scale').value = css.scale ?? 1.0;
document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-mirror').checked = css.mirror || false;
} else if (sourceType === 'composite') {
// Exclude self from available sources when editing
_compositeAvailableSources = allCssSources.filter(s =>
s.source_type !== 'composite' && s.id !== css.id
);
_loadCompositeState(css);
} else {
sourceSelect.value = css.picture_source_id || '';
@@ -471,6 +610,7 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-effect-scale').value = 1.0;
document.getElementById('css-editor-effect-scale-val').textContent = '1.0';
document.getElementById('css-editor-effect-mirror').checked = false;
_loadCompositeState(null);
document.getElementById('css-editor-title').textContent = t('color_strip.add');
document.getElementById('css-editor-gradient-preset').value = '';
gradientInit([
@@ -558,6 +698,22 @@ export async function saveCSSEditor() {
payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
}
if (!cssId) payload.source_type = 'effect';
} else if (sourceType === 'composite') {
const layers = _compositeGetLayers();
if (layers.length < 1) {
cssEditorModal.showError(t('color_strip.composite.error.min_layers'));
return;
}
const hasEmpty = layers.some(l => !l.source_id);
if (hasEmpty) {
cssEditorModal.showError(t('color_strip.composite.error.no_source'));
return;
}
payload = {
name,
layers,
};
if (!cssId) payload.source_type = 'composite';
} else {
payload = {
name,

View File

@@ -578,7 +578,7 @@
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target",
"color_strip.error.name_required": "Please enter a name",
"color_strip.type": "Type:",
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors.",
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs. Color Cycle smoothly cycles through a user-defined list of colors. Composite stacks multiple sources as blended layers.",
"color_strip.type.picture": "Picture Source",
"color_strip.type.static": "Static Color",
"color_strip.type.gradient": "Gradient",
@@ -640,6 +640,22 @@
"color_strip.color_cycle.min_colors": "Color cycle must have at least 2 colors",
"color_strip.type.effect": "Effect",
"color_strip.type.effect.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.",
"color_strip.type.composite": "Composite",
"color_strip.type.composite.hint": "Stack multiple color strip sources as layers with blend modes and opacity.",
"color_strip.composite.layers": "Layers:",
"color_strip.composite.layers.hint": "Stack multiple color strip sources. First layer is the bottom, last is the top. Each layer can have its own blend mode and opacity.",
"color_strip.composite.add_layer": "+ Add Layer",
"color_strip.composite.source": "Source",
"color_strip.composite.blend_mode": "Blend",
"color_strip.composite.blend_mode.normal": "Normal",
"color_strip.composite.blend_mode.add": "Add",
"color_strip.composite.blend_mode.multiply": "Multiply",
"color_strip.composite.blend_mode.screen": "Screen",
"color_strip.composite.opacity": "Opacity",
"color_strip.composite.enabled": "Enabled",
"color_strip.composite.error.min_layers": "At least 1 layer is required",
"color_strip.composite.error.no_source": "Each layer must have a source selected",
"color_strip.composite.layers_count": "layers",
"color_strip.effect.type": "Effect Type:",
"color_strip.effect.type.hint": "Choose the procedural algorithm.",
"color_strip.effect.fire": "Fire",

View File

@@ -578,7 +578,7 @@
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
"color_strip.error.name_required": "Введите название",
"color_strip.type": "Тип:",
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами.",
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои.",
"color_strip.type.picture": "Источник изображения",
"color_strip.type.static": "Статический цвет",
"color_strip.type.gradient": "Градиент",
@@ -640,6 +640,22 @@
"color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов",
"color_strip.type.effect": "Эффект",
"color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.",
"color_strip.type.composite": "Композит",
"color_strip.type.composite.hint": "Наложение нескольких источников цветовой ленты как слоёв с режимами смешивания и прозрачностью.",
"color_strip.composite.layers": "Слои:",
"color_strip.composite.layers.hint": "Наложение нескольких источников. Первый слой — нижний, последний — верхний. Каждый слой может иметь свой режим смешивания и прозрачность.",
"color_strip.composite.add_layer": "+ Добавить слой",
"color_strip.composite.source": "Источник",
"color_strip.composite.blend_mode": "Смешивание",
"color_strip.composite.blend_mode.normal": "Обычное",
"color_strip.composite.blend_mode.add": "Сложение",
"color_strip.composite.blend_mode.multiply": "Умножение",
"color_strip.composite.blend_mode.screen": "Экран",
"color_strip.composite.opacity": "Непрозрачность",
"color_strip.composite.enabled": "Включён",
"color_strip.composite.error.min_layers": "Необходим хотя бы 1 слой",
"color_strip.composite.error.no_source": "Для каждого слоя должен быть выбран источник",
"color_strip.composite.layers_count": "слоёв",
"color_strip.effect.type": "Тип эффекта:",
"color_strip.effect.type.hint": "Выберите процедурный алгоритм.",
"color_strip.effect.fire": "Огонь",

View File

@@ -71,6 +71,7 @@ class ColorStripSource:
"intensity": None,
"scale": None,
"mirror": None,
"layers": None,
}
@staticmethod
@@ -139,6 +140,14 @@ class ColorStripSource:
led_count=data.get("led_count") or 0,
)
if source_type == "composite":
return CompositeColorStripSource(
id=sid, name=name, source_type="composite",
created_at=created_at, updated_at=updated_at, description=description,
layers=data.get("layers") or [],
led_count=data.get("led_count") or 0,
)
if source_type == "effect":
raw_color = data.get("color")
color = (
@@ -317,3 +326,23 @@ class EffectColorStripSource(ColorStripSource):
d["scale"] = self.scale
d["mirror"] = self.mirror
return d
@dataclass
class CompositeColorStripSource(ColorStripSource):
"""Color strip source that composites (stacks) multiple other sources as layers.
Each layer references a non-composite ColorStripSource with blend mode and opacity.
Layers are blended bottom-to-top. LED count auto-sizes from the connected device
when led_count == 0.
"""
# Each layer: {"source_id": str, "blend_mode": str, "opacity": float, "enabled": bool}
layers: list = field(default_factory=list)
led_count: int = 0 # 0 = use device LED count
def to_dict(self) -> dict:
d = super().to_dict()
d["layers"] = [dict(layer) for layer in self.layers]
d["led_count"] = self.led_count
return d

View File

@@ -10,6 +10,7 @@ from wled_controller.core.capture.calibration import CalibrationConfig, calibrat
from wled_controller.storage.color_strip_source import (
ColorCycleColorStripSource,
ColorStripSource,
CompositeColorStripSource,
EffectColorStripSource,
GradientColorStripSource,
PictureColorStripSource,
@@ -116,6 +117,7 @@ class ColorStripStore:
intensity: float = 1.0,
scale: float = 1.0,
mirror: bool = False,
layers: Optional[list] = None,
) -> ColorStripSource:
"""Create a new color strip source.
@@ -194,6 +196,17 @@ class ColorStripStore:
scale=float(scale) if scale else 1.0,
mirror=bool(mirror),
)
elif source_type == "composite":
source = CompositeColorStripSource(
id=source_id,
name=name,
source_type="composite",
created_at=now,
updated_at=now,
description=description,
layers=layers if isinstance(layers, list) else [],
led_count=led_count,
)
else:
if calibration is None:
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
@@ -248,6 +261,7 @@ class ColorStripStore:
intensity: Optional[float] = None,
scale: Optional[float] = None,
mirror: Optional[bool] = None,
layers: Optional[list] = None,
) -> ColorStripSource:
"""Update an existing color strip source.
@@ -328,6 +342,11 @@ class ColorStripStore:
source.scale = float(scale)
if mirror is not None:
source.mirror = bool(mirror)
elif isinstance(source, CompositeColorStripSource):
if layers is not None and isinstance(layers, list):
source.layers = layers
if led_count is not None:
source.led_count = led_count
source.updated_at = datetime.utcnow()
self._save()
@@ -349,6 +368,15 @@ class ColorStripStore:
logger.info(f"Deleted color strip source: {source_id}")
def is_referenced_by_composite(self, source_id: str) -> bool:
"""Check if this source is referenced as a layer in any composite source."""
for source in self._sources.values():
if isinstance(source, CompositeColorStripSource):
for layer in source.layers:
if layer.get("source_id") == source_id:
return True
return False
def is_referenced_by_target(self, source_id: str, target_store) -> bool:
"""Check if this source is referenced by any picture target."""
from wled_controller.storage.wled_picture_target import WledPictureTarget

View File

@@ -26,6 +26,7 @@
<option value="gradient" data-i18n="color_strip.type.gradient">Gradient</option>
<option value="color_cycle" data-i18n="color_strip.type.color_cycle">Color Cycle</option>
<option value="effect" data-i18n="color_strip.type.effect">Procedural Effect</option>
<option value="composite" data-i18n="color_strip.type.composite">Composite</option>
</select>
</div>
@@ -40,15 +41,6 @@
<select id="css-editor-picture-source"></select>
</div>
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row">
<label for="css-editor-led-count" data-i18n="color_strip.led_count">LED Count:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.led_count.hint">Total number of LEDs on the strip. Set to 0 to use the sum from calibration. If your strip has LEDs behind the TV that are not mapped to screen edges, set the exact count here and they will be filled with black.</small>
<input type="number" id="css-editor-led-count" min="0" max="1500" step="1" value="0">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-interpolation" data-i18n="color_strip.interpolation">Color Mode:</label>
@@ -310,6 +302,29 @@
</div>
</div>
<!-- Composite-specific fields -->
<div id="css-editor-composite-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.composite.layers">Layers:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.composite.layers.hint">Stack multiple color strip sources. First layer is the bottom, last is the top.</small>
<div id="composite-layers-list"></div>
<button type="button" class="btn btn-secondary" onclick="compositeAddLayer()" data-i18n="color_strip.composite.add_layer">+ Add Layer</button>
</div>
</div>
<!-- Shared LED count field -->
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row">
<label for="css-editor-led-count" data-i18n="color_strip.led_count">LED Count:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.led_count.hint">Total number of LEDs on the strip. Set to 0 to auto-detect from calibration or device.</small>
<input type="number" id="css-editor-led-count" min="0" max="1500" step="1" value="0">
</div>
<!-- Animation — shown for static/gradient, hidden for picture -->
<div id="css-editor-animation-section" style="display:none">
<details class="form-collapse">