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, description=source.description,
frame_interpolation=getattr(source, "frame_interpolation", None), frame_interpolation=getattr(source, "frame_interpolation", None),
animation=getattr(source, "animation", None), animation=getattr(source, "animation", None),
layers=getattr(source, "layers", None),
overlay_active=overlay_active, overlay_active=overlay_active,
created_at=source.created_at, created_at=source.created_at,
updated_at=source.updated_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 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( source = store.create_source(
name=data.name, name=data.name,
source_type=data.source_type, source_type=data.source_type,
@@ -152,6 +155,7 @@ async def create_color_strip_source(
intensity=data.intensity, intensity=data.intensity,
scale=data.scale, scale=data.scale,
mirror=data.mirror, mirror=data.mirror,
layers=layers,
) )
return _css_to_response(source) 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 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 = store.update_source(
source_id=source_id, source_id=source_id,
name=data.name, name=data.name,
@@ -217,6 +223,7 @@ async def update_color_strip_source(
intensity=data.intensity, intensity=data.intensity,
scale=data.scale, scale=data.scale,
mirror=data.mirror, mirror=data.mirror,
layers=layers,
) )
# Hot-reload running stream (no restart needed for in-place param changes) # 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. " detail="Color strip source is referenced by one or more LED targets. "
"Delete or reassign the targets first.", "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) store.delete_source(source_id)
except HTTPException: except HTTPException:
raise 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): class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source.""" """Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100) 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-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)") 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) 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) 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) 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)") 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 # shared
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0) 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) 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) 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) 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") mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
# composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
# shared # shared
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0) 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) 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") intensity: Optional[float] = Field(None, description="Effect intensity")
scale: Optional[float] = Field(None, description="Spatial scale") scale: Optional[float] = Field(None, description="Spatial scale")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
# composite-type fields
layers: Optional[List[dict]] = Field(None, description="Layers for composite type")
# shared # shared
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)") led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
description: Optional[str] = Field(None, description="Description") 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__) logger = get_logger(__name__)
def _apply_saturation(colors: np.ndarray, saturation: float) -> np.ndarray: def _apply_saturation(colors: np.ndarray, saturation: float,
"""Adjust saturation via luminance mixing (Rec.601 weights). _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=1.0: no change
saturation=0.0: grayscale saturation=0.0: grayscale
saturation=2.0: double saturation (clipped to 0-255) 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 = ( n = len(colors)
colors[:, 0].astype(np.int32) * 299 if _i32 is None:
+ colors[:, 1].astype(np.int32) * 587 _i32 = np.empty((n, 3), dtype=np.int32)
+ colors[:, 2].astype(np.int32) * 114 if _i32_gray is None:
) // 1000 _i32_gray = np.empty((n, 1), dtype=np.int32)
gray = gray[:, np.newaxis] # (N, 1) for broadcast if _out is None:
result = gray + saturation * (colors.astype(np.int32) - gray) _out = np.empty((n, 3), dtype=np.uint8)
return np.clip(result, 0, 255).astype(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: def _build_gamma_lut(gamma: float) -> np.ndarray:
@@ -278,6 +293,45 @@ class PictureColorStripStream(ColorStripStream):
"""Background thread: poll source, process, cache colors.""" """Background thread: poll source, process, cache colors."""
cached_frame = None 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(): with high_resolution_timer():
while self._running: while self._running:
loop_start = time.perf_counter() loop_start = time.perf_counter()
@@ -293,22 +347,14 @@ class PictureColorStripStream(ColorStripStream):
and self._frame_interpolation and self._frame_interpolation
and self._interp_from is not None and self._interp_from is not None
and self._interp_to 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) t = min(1.0, (loop_start - self._interp_start) / self._interp_duration)
alpha = int(t * 256) frame_buf = _frame_a if _use_a else _frame_b
led_colors = ( _use_a = not _use_a
(256 - alpha) * self._interp_from.astype(np.uint16) _blend_u16(self._interp_from, self._interp_to, int(t * 256), frame_buf)
+ alpha * self._interp_to.astype(np.uint16) led_colors = _apply_corrections(frame_buf, frame_buf)
) >> 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)
with self._colors_lock: with self._colors_lock:
self._latest_colors = led_colors self._latest_colors = led_colors
elapsed = time.perf_counter() - loop_start elapsed = time.perf_counter() - loop_start
@@ -332,14 +378,32 @@ class PictureColorStripStream(ColorStripStream):
led_colors = self._pixel_mapper.map_border_to_leds(border_pixels) led_colors = self._pixel_mapper.map_border_to_leds(border_pixels)
t2 = time.perf_counter() 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 target_count = self._led_count
if target_count > 0 and len(led_colors) != target_count: _n = target_count if target_count > 0 else len(led_colors)
if len(led_colors) < target_count: if _n > 0 and _n != _pool_n:
pad = np.zeros((target_count - len(led_colors), 3), dtype=np.uint8) _pool_n = _n
led_colors = np.concatenate([led_colors, pad]) _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: else:
led_colors = led_colors[:target_count] frame_buf[:] = led_colors
led_colors = frame_buf
# Update interpolation buffers (raw colors, before corrections) # Update interpolation buffers (raw colors, before corrections)
if self._frame_interpolation: if self._frame_interpolation:
@@ -348,25 +412,22 @@ class PictureColorStripStream(ColorStripStream):
self._interp_start = loop_start self._interp_start = loop_start
self._interp_duration = max(interval, 0.001) self._interp_duration = max(interval, 0.001)
# Temporal smoothing # Temporal smoothing (pre-allocated uint16 scratch)
smoothing = self._smoothing smoothing = self._smoothing
if ( if (
self._previous_colors is not None self._previous_colors is not None
and smoothing > 0 and smoothing > 0
and len(self._previous_colors) == len(led_colors) and len(self._previous_colors) == len(led_colors)
and _u16_a is not None
): ):
alpha = int(smoothing * 256) _blend_u16(led_colors, self._previous_colors,
led_colors = ( 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)
t3 = time.perf_counter() t3 = time.perf_counter()
# Saturation # Saturation (pre-allocated int32 scratch)
saturation = self._saturation saturation = self._saturation
if saturation != 1.0: 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() t4 = time.perf_counter()
# Gamma (LUT lookup — O(1) per pixel) # Gamma (LUT lookup — O(1) per pixel)
@@ -374,12 +435,16 @@ class PictureColorStripStream(ColorStripStream):
led_colors = self._gamma_lut[led_colors] led_colors = self._gamma_lut[led_colors]
t5 = time.perf_counter() t5 = time.perf_counter()
# Brightness # Brightness (integer math with pre-allocated int32 scratch)
brightness = self._brightness brightness = self._brightness
if brightness != 1.0: if brightness != 1.0:
led_colors = np.clip( bright_int = int(brightness * 256)
led_colors.astype(np.float32) * brightness, 0, 255 np.copyto(_i32, led_colors, casting='unsafe')
).astype(np.uint8) _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() t6 = time.perf_counter()
self._previous_colors = led_colors self._previous_colors = led_colors
@@ -913,6 +978,9 @@ class GradientColorStripStream(ColorStripStream):
_pool_n = 0 _pool_n = 0
_buf_a = _buf_b = _scratch_u16 = None _buf_a = _buf_b = _scratch_u16 = None
_use_a = True _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(): with high_resolution_timer():
while self._running: while self._running:
@@ -940,6 +1008,9 @@ class GradientColorStripStream(ColorStripStream):
_buf_a = np.empty((n, 3), dtype=np.uint8) _buf_a = np.empty((n, 3), dtype=np.uint8)
_buf_b = 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) _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 buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a _use_a = not _use_a
@@ -963,13 +1034,17 @@ class GradientColorStripStream(ColorStripStream):
elif atype == "wave": elif atype == "wave":
if n > 1: if n > 1:
i_arr = np.arange(n, dtype=np.float32) np.sin(
factor = 0.5 * (1 + np.sin( 2 * math.pi * _wave_i / n - 2 * math.pi * speed * t * 0.25,
2 * math.pi * i_arr / n - 2 * math.pi * speed * t * 0.25 out=_wave_factors,
)) )
int_factors = np.clip(factor * 256, 0, 256).astype(np.uint16) _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) np.copyto(_scratch_u16, base)
_scratch_u16 *= int_factors[:, None] _scratch_u16 *= _wave_u16[:, None]
_scratch_u16 >>= 8 _scratch_u16 >>= 8
np.copyto(buf, _scratch_u16, casting='unsafe') np.copyto(buf, _scratch_u16, casting='unsafe')
colors = buf colors = buf

View File

@@ -100,6 +100,10 @@ class ColorStripStreamManager:
# Non-sharable: always create a fresh per-consumer instance # Non-sharable: always create a fresh per-consumer instance
if not source.sharable: 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) stream_cls = _SIMPLE_STREAM_MAP.get(source.source_type)
if not stream_cls: if not stream_cls:
raise ValueError( 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) ────────────────────────────────── # ── 1-D value noise (no external deps) ──────────────────────────────────
class _ValueNoise1D: 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): def __init__(self, seed: int = 42):
rng = np.random.RandomState(seed) rng = np.random.RandomState(seed)
self._table = rng.random(512).astype(np.float32) 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: 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) size = len(self._table)
xi = np.floor(x).astype(np.int64) # xi = floor(x)
frac = (x - xi).astype(np.float32) np.floor(x, out=self._frac)
t = frac * frac * (3.0 - 2.0 * frac) # smoothstep np.copyto(self._xi, self._frac, casting='unsafe')
a = self._table[xi % size] # frac = x - xi
b = self._table[(xi + 1) % size] np.subtract(x, self._frac, out=self._frac)
return a + t * (b - a) # 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: def fbm(self, x: np.ndarray, octaves: int = 3) -> np.ndarray:
"""Fractal Brownian Motion — layered noise at decreasing amplitude.""" """Fractal Brownian Motion — layered noise at decreasing amplitude.
result = np.zeros_like(x, dtype=np.float32)
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 amp = 1.0
freq = 1.0 freq = 1.0
total_amp = 0.0 total_amp = 0.0
for _ in range(octaves): 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 total_amp += amp
amp *= 0.5 amp *= 0.5
freq *= 2.0 freq *= 2.0
return result / total_amp self._fbm_result /= total_amp
return self._fbm_result
# ── Effect stream ──────────────────────────────────────────────────────── # ── Effect stream ────────────────────────────────────────────────────────
@@ -135,6 +186,17 @@ class EffectColorStripStream(ColorStripStream):
# Fire state — allocated lazily in render loop # Fire state — allocated lazily in render loop
self._heat: Optional[np.ndarray] = None self._heat: Optional[np.ndarray] = None
self._heat_n = 0 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) self._update_from_source(source)
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
@@ -232,6 +294,16 @@ class EffectColorStripStream(ColorStripStream):
_pool_n = n _pool_n = n
_buf_a = np.empty((n, 3), dtype=np.uint8) _buf_a = np.empty((n, 3), dtype=np.uint8)
_buf_b = 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 buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a _use_a = not _use_a
@@ -271,8 +343,7 @@ class EffectColorStripStream(ColorStripStream):
# Diffuse heat upward (index 0 = bottom, index n-1 = top) # Diffuse heat upward (index 0 = bottom, index n-1 = top)
if n >= 3: if n >= 3:
# Average of neighbors, shifted upward new_heat = self._s_f32_a
new_heat = np.empty_like(heat)
new_heat[0] = (heat[0] + heat[1]) * 0.5 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:-1] = (heat[:-2] + heat[1:-1] + heat[2:]) / 3.0
new_heat[-1] = heat[-1] * 0.5 new_heat[-1] = heat[-1] * 0.5
@@ -285,9 +356,11 @@ class EffectColorStripStream(ColorStripStream):
if np.random.random() < spark_prob: if np.random.random() < spark_prob:
heat[i] = min(1.0, heat[i] + 0.4 + 0.6 * np.random.random()) heat[i] = min(1.0, heat[i] + 0.4 + 0.6 * np.random.random())
# Map heat to palette # Map heat to palette (pre-allocated scratch)
indices = np.clip((heat * 255).astype(np.int32), 0, 255) np.multiply(heat, 255, out=self._s_f32_a)
buf[:] = lut[indices] 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 ─────────────────────────────────────────────────────── # ── 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) 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 # 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: if mirror:
dist = np.abs(indices - pos) np.subtract(indices, pos, out=dist)
np.abs(dist, out=dist)
else: else:
# Signed distance in the direction of travel (behind = positive) np.subtract(pos, indices, out=dist)
dist = (pos - indices) % n 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 r, g, b = color
buf[:, 0] = np.clip(brightness * r, 0, 255).astype(np.uint8) np.multiply(brightness, r, out=self._s_f32_c)
buf[:, 1] = np.clip(brightness * g, 0, 255).astype(np.uint8) np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
buf[:, 2] = np.clip(brightness * b, 0, 255).astype(np.uint8) 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_mask = np.abs(indices - pos) < 1.5
head_brightness = np.clip(1.0 - np.abs(indices - pos), 0, 1) head_brightness = np.clip(1.0 - np.abs(indices - pos), 0, 1)
buf[head_mask, 0] = np.clip( buf[head_mask, 0] = np.clip(
@@ -352,8 +435,14 @@ class EffectColorStripStream(ColorStripStream):
scale = self._scale scale = self._scale
lut = self._palette_lut 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 phase = t * speed * 0.5
x = np.linspace(0, scale * math.pi * 2, n, dtype=np.float64) x = self._plasma_x
v = ( v = (
np.sin(x + phase) np.sin(x + phase)
@@ -373,10 +462,15 @@ class EffectColorStripStream(ColorStripStream):
scale = self._scale scale = self._scale
lut = self._palette_lut lut = self._palette_lut
positions = np.arange(n, dtype=np.float32) * scale * 0.1 + t * speed * 0.5 # Positions from cached arange (avoids per-frame np.arange)
values = self._noise.fbm(positions, octaves=3) np.multiply(self._s_arange, scale * 0.1, out=self._s_f32_a)
indices = np.clip((values * 255).astype(np.int32), 0, 255) self._s_f32_a += t * speed * 0.5
buf[:] = lut[indices] 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 ─────────────────────────────────────────────────────── # ── Aurora ───────────────────────────────────────────────────────
@@ -387,22 +481,39 @@ class EffectColorStripStream(ColorStripStream):
intensity = self._intensity intensity = self._intensity
lut = self._palette_lut 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 # Three noise layers — copy results to dedicated buffers since fbm
layer1 = self._noise.fbm(positions + t * speed * 0.2, octaves=3) # may return an internal reference that gets overwritten on the next call
layer2 = self._noise.fbm(positions * 1.5 + t * speed * 0.35 + 100.0, octaves=3) np.add(self._s_f32_a, t * speed * 0.2, out=self._s_f32_b)
layer3 = self._noise.fbm(positions * 0.7 + t * speed * 0.15 + 200.0, octaves=2) self._s_layer1[:] = self._noise.fbm(self._s_f32_b, octaves=3)
# Combine layers: layer1 drives hue, layer2 modulates brightness, np.multiply(self._s_f32_a, 1.5, out=self._s_f32_b)
# layer3 adds slow undulation self._s_f32_b += t * speed * 0.35 + 100.0
hue = (layer1 + layer3 * 0.5) * 0.67 # 01 range for palette lookup self._s_layer2[:] = self._noise.fbm(self._s_f32_b, octaves=3)
hue = np.clip(hue, 0.0, 1.0)
brightness = 0.3 + 0.7 * layer2 * intensity np.multiply(self._s_f32_a, 0.7, out=self._s_f32_b)
brightness = np.clip(brightness, 0.0, 1.0) 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) # Combine layers: hue from layer1 + layer3, brightness from layer2
colors = lut[indices].astype(np.float32) hue = self._s_f32_a # reuse (positions no longer needed)
colors *= brightness[:, np.newaxis] np.multiply(layer3, 0.5, out=hue)
buf[:] = np.clip(colors, 0, 255).astype(np.uint8) 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._color_strip_stream = stream
self._resolved_display_index = stream.display_index self._resolved_display_index = stream.display_index
# For auto-sized static/gradient/color_cycle/effect streams (led_count == 0), size to device LED count # For auto-sized non-picture streams (led_count == 0), size to device LED count
from wled_controller.core.processing.color_strip_stream import ( if hasattr(stream, "configure") and device_info.led_count > 0:
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:
effective_leds = device_info.led_count - self._led_skip_start - self._led_skip_end effective_leds = device_info.led_count - self._led_skip_start - self._led_skip_end
stream.configure(max(1, effective_leds)) stream.configure(max(1, effective_leds))
@@ -415,19 +409,20 @@ class WledTargetProcessor(TargetProcessor):
]) ])
return result return result
def _apply_led_skip(self, colors: np.ndarray) -> np.ndarray: @staticmethod
"""Pad color array with black at start/end for skipped LEDs.""" def _apply_led_skip(colors: np.ndarray, buf: Optional[np.ndarray], skip_start: int) -> np.ndarray:
s, e = self._led_skip_start, self._led_skip_end """Copy effective colors into pre-allocated buffer with black padding.
if s <= 0 and e <= 0:
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 return colors
channels = colors.shape[1] if colors.ndim == 2 else 3 buf[skip_start:skip_start + len(colors)] = colors
parts = [] return buf
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)
async def _processing_loop(self) -> None: async def _processing_loop(self) -> None:
"""Main processing loop — poll ColorStripStream → apply brightness → send.""" """Main processing loop — poll ColorStripStream → apply brightness → send."""
@@ -440,7 +435,58 @@ class WledTargetProcessor(TargetProcessor):
last_send_time = 0.0 last_send_time = 0.0
prev_frame_time_stamp = time.perf_counter() prev_frame_time_stamp = time.perf_counter()
loop = asyncio.get_running_loop() 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 # 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 # 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 # but are unsynchronised; without a short re-poll the loop can miss a
@@ -502,9 +548,9 @@ class WledTargetProcessor(TargetProcessor):
break break
kc = prev_colors kc = prev_colors
if device_info and device_info.led_count > 0: if device_info and device_info.led_count > 0:
kc = self._fit_to_device(kc, effective_leds) kc = _cached_fit(kc)
kc = self._apply_led_skip(kc) kc = self._apply_led_skip(kc, _skip_buf, self._led_skip_start)
send_colors = self._apply_brightness(kc, device_info) send_colors = _cached_brightness(kc, device_info)
if self._led_client.supports_fast_send: if self._led_client.supports_fast_send:
self._led_client.send_pixels_fast(send_colors) self._led_client.send_pixels_fast(send_colors)
else: else:
@@ -525,11 +571,11 @@ class WledTargetProcessor(TargetProcessor):
# Fit to effective LED count (excluding skipped) then pad with blacks # Fit to effective LED count (excluding skipped) then pad with blacks
if device_info and device_info.led_count > 0: if device_info and device_info.led_count > 0:
colors = self._fit_to_device(colors, effective_leds) colors = _cached_fit(colors)
colors = self._apply_led_skip(colors) colors = self._apply_led_skip(colors, _skip_buf, self._led_skip_start)
# Apply device software brightness # Apply device software brightness
send_colors = self._apply_brightness(colors, device_info) send_colors = _cached_brightness(colors, device_info)
# Send to LED device # Send to LED device
if not self._is_running or self._led_client is None: if not self._is_running or self._led_client is None:

View File

@@ -678,3 +678,61 @@
min-width: unset; min-width: unset;
line-height: 1; 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, showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview, onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, updateEffectPreview,
colorCycleAddColor, colorCycleRemoveColor, colorCycleAddColor, colorCycleRemoveColor,
compositeAddLayer, compositeRemoveLayer,
applyGradientPreset, applyGradientPreset,
} from './features/color-strips.js'; } from './features/color-strips.js';
@@ -281,6 +282,8 @@ Object.assign(window, {
updateEffectPreview, updateEffectPreview,
colorCycleAddColor, colorCycleAddColor,
colorCycleRemoveColor, colorCycleRemoveColor,
compositeAddLayer,
compositeRemoveLayer,
applyGradientPreset, applyGradientPreset,
// calibration // calibration

View File

@@ -38,6 +38,7 @@ class CSSEditorModal extends Modal {
effect_intensity: document.getElementById('css-editor-effect-intensity').value, effect_intensity: document.getElementById('css-editor-effect-intensity').value,
effect_scale: document.getElementById('css-editor-effect-scale').value, effect_scale: document.getElementById('css-editor-effect-scale').value,
effect_mirror: document.getElementById('css-editor-effect-mirror').checked, 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-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none';
document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : '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-effect-section').style.display = type === 'effect' ? '' : 'none';
document.getElementById('css-editor-composite-section').style.display = type === 'composite' ? '' : 'none';
if (type === 'effect') onEffectTypeChange(); if (type === 'effect') onEffectTypeChange();
@@ -85,7 +87,12 @@ export function onCSSTypeChange() {
} }
_syncAnimationSpeedState(); _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()); 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]; 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 ─────────────────────────────────────────────────────── */ /* ── Card ─────────────────────────────────────────────────────── */
export function createColorStripCard(source, pictureSourceMap) { export function createColorStripCard(source, pictureSourceMap) {
@@ -267,6 +385,7 @@ export function createColorStripCard(source, pictureSourceMap) {
const isGradient = source.source_type === 'gradient'; const isGradient = source.source_type === 'gradient';
const isColorCycle = source.source_type === 'color_cycle'; const isColorCycle = source.source_type === 'color_cycle';
const isEffect = source.source_type === 'effect'; 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 anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
const animBadge = anim 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> <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>` : ''} ${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 { } else {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id]) const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
? pictureSourceMap[source.picture_source_id].name ? pictureSourceMap[source.picture_source_id].name
@@ -338,8 +464,8 @@ export function createColorStripCard(source, pictureSourceMap) {
`; `;
} }
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : '🎞️'; const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : isComposite ? '🔗' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect) const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite)
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>` ? `<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 sourcesResp = await fetchWithAuth('/picture-sources');
const sources = sourcesResp.ok ? ((await sourcesResp.json()).streams || []) : []; 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'); const sourceSelect = document.getElementById('css-editor-picture-source');
sourceSelect.innerHTML = ''; sourceSelect.innerHTML = '';
sources.forEach(s => { 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').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-scale-val').textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-mirror').checked = css.mirror || false; 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 { } else {
sourceSelect.value = css.picture_source_id || ''; 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').value = 1.0;
document.getElementById('css-editor-effect-scale-val').textContent = '1.0'; document.getElementById('css-editor-effect-scale-val').textContent = '1.0';
document.getElementById('css-editor-effect-mirror').checked = false; document.getElementById('css-editor-effect-mirror').checked = false;
_loadCompositeState(null);
document.getElementById('css-editor-title').textContent = t('color_strip.add'); document.getElementById('css-editor-title').textContent = t('color_strip.add');
document.getElementById('css-editor-gradient-preset').value = ''; document.getElementById('css-editor-gradient-preset').value = '';
gradientInit([ 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)]; 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'; 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 { } else {
payload = { payload = {
name, name,

View File

@@ -578,7 +578,7 @@
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target", "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.error.name_required": "Please enter a name",
"color_strip.type": "Type:", "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.picture": "Picture Source",
"color_strip.type.static": "Static Color", "color_strip.type.static": "Static Color",
"color_strip.type.gradient": "Gradient", "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.color_cycle.min_colors": "Color cycle must have at least 2 colors",
"color_strip.type.effect": "Effect", "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.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": "Effect Type:",
"color_strip.effect.type.hint": "Choose the procedural algorithm.", "color_strip.effect.type.hint": "Choose the procedural algorithm.",
"color_strip.effect.fire": "Fire", "color_strip.effect.fire": "Fire",

View File

@@ -578,7 +578,7 @@
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели", "color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
"color_strip.error.name_required": "Введите название", "color_strip.error.name_required": "Введите название",
"color_strip.type": "Тип:", "color_strip.type": "Тип:",
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами.", "color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами. Композит накладывает несколько источников как смешанные слои.",
"color_strip.type.picture": "Источник изображения", "color_strip.type.picture": "Источник изображения",
"color_strip.type.static": "Статический цвет", "color_strip.type.static": "Статический цвет",
"color_strip.type.gradient": "Градиент", "color_strip.type.gradient": "Градиент",
@@ -640,6 +640,22 @@
"color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов", "color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов",
"color_strip.type.effect": "Эффект", "color_strip.type.effect": "Эффект",
"color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.", "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": "Тип эффекта:",
"color_strip.effect.type.hint": "Выберите процедурный алгоритм.", "color_strip.effect.type.hint": "Выберите процедурный алгоритм.",
"color_strip.effect.fire": "Огонь", "color_strip.effect.fire": "Огонь",

View File

@@ -71,6 +71,7 @@ class ColorStripSource:
"intensity": None, "intensity": None,
"scale": None, "scale": None,
"mirror": None, "mirror": None,
"layers": None,
} }
@staticmethod @staticmethod
@@ -139,6 +140,14 @@ class ColorStripSource:
led_count=data.get("led_count") or 0, 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": if source_type == "effect":
raw_color = data.get("color") raw_color = data.get("color")
color = ( color = (
@@ -317,3 +326,23 @@ class EffectColorStripSource(ColorStripSource):
d["scale"] = self.scale d["scale"] = self.scale
d["mirror"] = self.mirror d["mirror"] = self.mirror
return d 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 ( from wled_controller.storage.color_strip_source import (
ColorCycleColorStripSource, ColorCycleColorStripSource,
ColorStripSource, ColorStripSource,
CompositeColorStripSource,
EffectColorStripSource, EffectColorStripSource,
GradientColorStripSource, GradientColorStripSource,
PictureColorStripSource, PictureColorStripSource,
@@ -116,6 +117,7 @@ class ColorStripStore:
intensity: float = 1.0, intensity: float = 1.0,
scale: float = 1.0, scale: float = 1.0,
mirror: bool = False, mirror: bool = False,
layers: Optional[list] = None,
) -> ColorStripSource: ) -> ColorStripSource:
"""Create a new color strip source. """Create a new color strip source.
@@ -194,6 +196,17 @@ class ColorStripStore:
scale=float(scale) if scale else 1.0, scale=float(scale) if scale else 1.0,
mirror=bool(mirror), 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: else:
if calibration is None: if calibration is None:
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left") calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
@@ -248,6 +261,7 @@ class ColorStripStore:
intensity: Optional[float] = None, intensity: Optional[float] = None,
scale: Optional[float] = None, scale: Optional[float] = None,
mirror: Optional[bool] = None, mirror: Optional[bool] = None,
layers: Optional[list] = None,
) -> ColorStripSource: ) -> ColorStripSource:
"""Update an existing color strip source. """Update an existing color strip source.
@@ -328,6 +342,11 @@ class ColorStripStore:
source.scale = float(scale) source.scale = float(scale)
if mirror is not None: if mirror is not None:
source.mirror = bool(mirror) 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() source.updated_at = datetime.utcnow()
self._save() self._save()
@@ -349,6 +368,15 @@ class ColorStripStore:
logger.info(f"Deleted color strip source: {source_id}") 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: def is_referenced_by_target(self, source_id: str, target_store) -> bool:
"""Check if this source is referenced by any picture target.""" """Check if this source is referenced by any picture target."""
from wled_controller.storage.wled_picture_target import WledPictureTarget 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="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="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="effect" data-i18n="color_strip.type.effect">Procedural Effect</option>
<option value="composite" data-i18n="color_strip.type.composite">Composite</option>
</select> </select>
</div> </div>
@@ -40,15 +41,6 @@
<select id="css-editor-picture-source"></select> <select id="css-editor-picture-source"></select>
</div> </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="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-interpolation" data-i18n="color_strip.interpolation">Color Mode:</label> <label for="css-editor-interpolation" data-i18n="color_strip.interpolation">Color Mode:</label>
@@ -310,6 +302,29 @@
</div> </div>
</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 --> <!-- Animation — shown for static/gradient, hidden for picture -->
<div id="css-editor-animation-section" style="display:none"> <div id="css-editor-animation-section" style="display:none">
<details class="form-collapse"> <details class="form-collapse">