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