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

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