Add 5 procedural LED effects, gradient presets, auto-crop min aspect ratio, static source polling optimization

New features:
- Procedural effect source type with fire, meteor, plasma, noise, and aurora algorithms
  using palette LUT system and 1D value noise generator
- 12 predefined gradient presets (rainbow, sunset, ocean, forest, fire, lava, aurora,
  ice, warm, cool, neon, pastel) selectable from a dropdown in the gradient editor
- Auto-crop filter: min aspect ratio parameter to prevent false-positive cropping
  in dark scenes on ultrawide displays

Optimization:
- Static/gradient sources without animation: stream thread sleeps 0.25s instead of
  frame_time; processor repolls at frame_time instead of 5ms (~40x fewer iterations)
- Inverted isinstance checks in routes to test for PictureColorStripSource only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 01:03:16 +03:00
parent 9392741f08
commit a4083764fb
14 changed files with 1033 additions and 19 deletions

View File

@@ -26,7 +26,7 @@ from wled_controller.core.capture.calibration import (
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import ColorCycleColorStripSource, GradientColorStripSource, PictureColorStripSource, StaticColorStripSource
from wled_controller.storage.color_strip_source import PictureColorStripSource
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
@@ -70,6 +70,12 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
stops=stops,
colors=getattr(source, "colors", None),
cycle_speed=getattr(source, "cycle_speed", None),
effect_type=getattr(source, "effect_type", None),
speed=getattr(source, "speed", None),
palette=getattr(source, "palette", None),
intensity=getattr(source, "intensity", None),
scale=getattr(source, "scale", None),
mirror=getattr(source, "mirror", None),
description=source.description,
frame_interpolation=getattr(source, "frame_interpolation", None),
animation=getattr(source, "animation", None),
@@ -140,6 +146,12 @@ async def create_color_strip_source(
animation=data.animation.model_dump() if data.animation else None,
colors=data.colors,
cycle_speed=data.cycle_speed,
effect_type=data.effect_type,
speed=data.speed,
palette=data.palette,
intensity=data.intensity,
scale=data.scale,
mirror=data.mirror,
)
return _css_to_response(source)
@@ -199,6 +211,12 @@ async def update_color_strip_source(
animation=data.animation.model_dump() if data.animation else None,
colors=data.colors,
cycle_speed=data.cycle_speed,
effect_type=data.effect_type,
speed=data.speed,
palette=data.palette,
intensity=data.intensity,
scale=data.scale,
mirror=data.mirror,
)
# Hot-reload running stream (no restart needed for in-place param changes)
@@ -284,12 +302,12 @@ async def test_css_calibration(
if body.edges:
try:
source = store.get_source(source_id)
if isinstance(source, (StaticColorStripSource, GradientColorStripSource, ColorCycleColorStripSource)):
if not isinstance(source, PictureColorStripSource):
raise HTTPException(
status_code=400,
detail="Calibration test is not applicable for this color strip source type",
detail="Calibration test is only available for picture color strip sources",
)
if isinstance(source, PictureColorStripSource) and source.calibration:
if source.calibration:
calibration = source.calibration
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -330,10 +348,8 @@ async def start_css_overlay(
"""Start screen overlay visualization for a color strip source."""
try:
source = store.get_source(source_id)
if isinstance(source, (StaticColorStripSource, GradientColorStripSource, ColorCycleColorStripSource)):
raise HTTPException(status_code=400, detail="Overlay is not supported for this color strip source type")
if not isinstance(source, PictureColorStripSource):
raise HTTPException(status_code=400, detail="Overlay only supported for picture color strip sources")
raise HTTPException(status_code=400, detail="Overlay is only supported for picture color strip sources")
if not source.calibration:
raise HTTPException(status_code=400, detail="Color strip source has no calibration configured")

View File

@@ -31,7 +31,7 @@ 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"] = Field(default="picture", description="Source type")
source_type: Literal["picture", "static", "gradient", "color_cycle", "effect"] = 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)
@@ -47,6 +47,13 @@ class ColorStripSourceCreate(BaseModel):
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
cycle_speed: Optional[float] = Field(None, description="Cycle speed multiplier 0.110.0 (color_cycle type)", ge=0.1, le=10.0)
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
speed: Optional[float] = Field(None, description="Effect speed multiplier 0.1-10.0", ge=0.1, le=10.0)
palette: Optional[str] = Field(None, description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice)")
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)")
# 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)
@@ -73,6 +80,13 @@ class ColorStripSourceUpdate(BaseModel):
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
cycle_speed: Optional[float] = Field(None, description="Cycle speed multiplier 0.110.0 (color_cycle type)", ge=0.1, le=10.0)
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
speed: Optional[float] = Field(None, description="Effect speed multiplier 0.1-10.0", ge=0.1, le=10.0)
palette: Optional[str] = Field(None, description="Named palette")
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")
# 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)
@@ -101,6 +115,13 @@ class ColorStripSourceResponse(BaseModel):
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
cycle_speed: Optional[float] = Field(None, description="Cycle speed multiplier (color_cycle type)")
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm")
speed: Optional[float] = Field(None, description="Effect speed multiplier")
palette: Optional[str] = Field(None, description="Named palette")
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")
# shared
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
description: Optional[str] = Field(None, description="Description")

View File

@@ -37,11 +37,21 @@ class AutoCropFilter(PostprocessingFilter):
max_value=200,
step=5,
),
FilterOptionDef(
key="min_aspect_ratio",
label="Min Aspect Ratio",
option_type="float",
default=0.0,
min_value=0.0,
max_value=3.0,
step=0.01,
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
threshold = self.options.get("threshold", 15)
min_bar_size = self.options.get("min_bar_size", 20)
min_aspect_ratio = float(self.options.get("min_aspect_ratio", 0.0))
h, w = image.shape[:2]
min_h = max(1, h // 10)
@@ -81,6 +91,22 @@ class AutoCropFilter(PostprocessingFilter):
if (w - right) < min_bar_size:
right = w
# Enforce minimum aspect ratio: if the cropped region is too narrow,
# symmetrically reduce the crop on the tighter axis.
if min_aspect_ratio > 0:
cropped_w_cur = right - left
cropped_h_cur = bottom - top
if cropped_h_cur > 0:
ratio = cropped_w_cur / cropped_h_cur
if ratio < min_aspect_ratio:
# Too narrow — widen by reducing left/right crop
needed_w = int(cropped_h_cur * min_aspect_ratio)
deficit = needed_w - cropped_w_cur
expand_left = deficit // 2
expand_right = deficit - expand_left
left = max(0, left - expand_left)
right = min(w, right + expand_right)
# Safety: don't crop if remaining content is too small
if (bottom - top) < min_h:
top, bottom = 0, h

View File

@@ -79,6 +79,16 @@ class ColorStripStream(ABC):
def led_count(self) -> int:
"""Number of LEDs this stream produces colors for."""
@property
def is_animated(self) -> bool:
"""Whether this stream is actively producing new frames.
Used by the processor to adjust polling rate: animated streams are
polled at 5 ms (SKIP_REPOLL) to stay in sync with the animation
thread, while static streams are polled at frame_time rate.
"""
return True
@property
def display_index(self) -> Optional[int]:
"""Display index of the underlying capture, or None."""
@@ -505,6 +515,11 @@ class StaticColorStripStream(ColorStripStream):
def target_fps(self) -> int:
return self._fps
@property
def is_animated(self) -> bool:
anim = self._animation
return bool(anim and anim.get("enabled"))
@property
def led_count(self) -> int:
return self._led_count
@@ -644,7 +659,8 @@ class StaticColorStripStream(ColorStripStream):
self._colors = colors
elapsed = time.perf_counter() - loop_start
time.sleep(max(frame_time - elapsed, 0.001))
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
time.sleep(max(sleep_target - elapsed, 0.001))
class ColorCycleColorStripStream(ColorStripStream):
@@ -834,6 +850,11 @@ class GradientColorStripStream(ColorStripStream):
def target_fps(self) -> int:
return self._fps
@property
def is_animated(self) -> bool:
anim = self._animation
return bool(anim and anim.get("enabled"))
@property
def led_count(self) -> int:
return self._led_count
@@ -1011,4 +1032,5 @@ class GradientColorStripStream(ColorStripStream):
self._colors = colors
elapsed = time.perf_counter() - loop_start
time.sleep(max(frame_time - elapsed, 0.001))
sleep_target = frame_time if anim and anim.get("enabled") else 0.25
time.sleep(max(sleep_target - elapsed, 0.001))

View File

@@ -19,6 +19,7 @@ from wled_controller.core.processing.color_strip_stream import (
PictureColorStripStream,
StaticColorStripStream,
)
from wled_controller.core.processing.effect_stream import EffectColorStripStream
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -89,6 +90,7 @@ class ColorStripStreamManager:
from wled_controller.storage.color_strip_source import (
ColorCycleColorStripSource,
EffectColorStripSource,
GradientColorStripSource,
PictureColorStripSource,
StaticColorStripSource,
@@ -129,6 +131,17 @@ class ColorStripStreamManager:
logger.info(f"Created gradient color strip stream for source {css_id}")
return css_stream
if isinstance(source, EffectColorStripSource):
css_stream = EffectColorStripStream(source)
css_stream.start()
self._streams[css_id] = _ColorStripEntry(
stream=css_stream,
ref_count=1,
picture_source_id="",
)
logger.info(f"Created effect stream for source {css_id} (effect={source.effect_type})")
return css_stream
if not isinstance(source, PictureColorStripSource):
raise ValueError(
f"Unsupported color strip source type '{source.source_type}' for {css_id}"

View File

@@ -0,0 +1,408 @@
"""Procedural LED effect stream — fire, meteor, plasma, noise, aurora.
Implements EffectColorStripStream which produces animated LED color arrays
using purely algorithmic rendering (no capture source). Each effect is a
render method dispatched by effect_type.
Palette LUTs and a simple 1-D value-noise generator are included so that
no external dependencies are required.
"""
import math
import threading
import time
from typing import Dict, Optional
import numpy as np
from wled_controller.core.processing.color_strip_stream import ColorStripStream
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer
logger = get_logger(__name__)
# ── Palette LUT system ──────────────────────────────────────────────────
# Each palette is a list of (position, R, G, B) control points.
# Positions must be monotonically increasing from 0.0 to 1.0.
_PALETTE_DEFS: Dict[str, list] = {
"fire": [(0, 0, 0, 0), (0.33, 200, 24, 0), (0.66, 255, 160, 0), (1.0, 255, 255, 200)],
"ocean": [(0, 0, 0, 32), (0.33, 0, 16, 128), (0.66, 0, 128, 255), (1.0, 128, 224, 255)],
"lava": [(0, 0, 0, 0), (0.25, 128, 0, 0), (0.5, 255, 32, 0), (0.75, 255, 160, 0), (1.0, 255, 255, 128)],
"forest": [(0, 0, 16, 0), (0.33, 0, 80, 0), (0.66, 32, 160, 0), (1.0, 128, 255, 64)],
"rainbow": [(0, 255, 0, 0), (0.17, 255, 255, 0), (0.33, 0, 255, 0),
(0.5, 0, 255, 255), (0.67, 0, 0, 255), (0.83, 255, 0, 255), (1.0, 255, 0, 0)],
"aurora": [(0, 0, 16, 32), (0.2, 0, 80, 64), (0.4, 0, 200, 100),
(0.6, 64, 128, 255), (0.8, 128, 0, 200), (1.0, 0, 16, 32)],
"sunset": [(0, 32, 0, 64), (0.25, 128, 0, 128), (0.5, 255, 64, 0),
(0.75, 255, 192, 64), (1.0, 255, 255, 192)],
"ice": [(0, 0, 0, 64), (0.33, 0, 64, 192), (0.66, 128, 192, 255), (1.0, 240, 248, 255)],
}
_palette_cache: Dict[str, np.ndarray] = {}
def _build_palette_lut(name: str) -> np.ndarray:
"""Build a (256, 3) uint8 lookup table for the named palette."""
if name in _palette_cache:
return _palette_cache[name]
points = _PALETTE_DEFS.get(name, _PALETTE_DEFS["fire"])
lut = np.zeros((256, 3), dtype=np.uint8)
for i in range(256):
t = i / 255.0
# Find surrounding control points
a_idx = 0
for j in range(len(points) - 1):
if points[j + 1][0] >= t:
a_idx = j
break
else:
a_idx = len(points) - 2
a_pos, ar, ag, ab = points[a_idx]
b_pos, br, bg, bb = points[a_idx + 1]
span = b_pos - a_pos
frac = (t - a_pos) / span if span > 0 else 0.0
lut[i] = (
int(ar + (br - ar) * frac),
int(ag + (bg - ag) * frac),
int(ab + (bb - ab) * frac),
)
_palette_cache[name] = lut
return lut
# ── 1-D value noise (no external deps) ──────────────────────────────────
class _ValueNoise1D:
"""Simple 1-D value noise with smoothstep interpolation and fractal octaves."""
def __init__(self, seed: int = 42):
rng = np.random.RandomState(seed)
self._table = rng.random(512).astype(np.float32)
def noise(self, x: np.ndarray) -> np.ndarray:
"""Single-octave smooth noise for an array of float positions."""
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)
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)
amp = 1.0
freq = 1.0
total_amp = 0.0
for _ in range(octaves):
result += amp * self.noise(x * freq)
total_amp += amp
amp *= 0.5
freq *= 2.0
return result / total_amp
# ── Effect stream ────────────────────────────────────────────────────────
# Default palette per effect type
_EFFECT_DEFAULT_PALETTE = {
"fire": "fire",
"meteor": "fire",
"plasma": "rainbow",
"noise": "rainbow",
"aurora": "aurora",
}
class EffectColorStripStream(ColorStripStream):
"""Color strip stream that runs a procedural LED effect.
Dispatches to one of five render methods based on effect_type:
fire, meteor, plasma, noise, aurora.
Uses the same lifecycle pattern as StaticColorStripStream:
background thread, double-buffered output, configure() for auto-sizing.
"""
def __init__(self, source):
self._colors_lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 30
self._noise = _ValueNoise1D(seed=42)
# Fire state — allocated lazily in render loop
self._heat: Optional[np.ndarray] = None
self._heat_n = 0
self._update_from_source(source)
def _update_from_source(self, source) -> None:
self._effect_type = getattr(source, "effect_type", "fire")
self._speed = float(getattr(source, "speed", 1.0))
self._auto_size = not source.led_count
self._led_count = source.led_count if source.led_count and source.led_count > 0 else 1
self._palette_name = getattr(source, "palette", None) or _EFFECT_DEFAULT_PALETTE.get(self._effect_type, "fire")
self._palette_lut = _build_palette_lut(self._palette_name)
color = getattr(source, "color", None)
self._color = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0]
self._intensity = float(getattr(source, "intensity", 1.0))
self._scale = float(getattr(source, "scale", 1.0))
self._mirror = bool(getattr(source, "mirror", False))
with self._colors_lock:
self._colors: Optional[np.ndarray] = None
def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0:
new_count = max(self._led_count, device_led_count)
if new_count != self._led_count:
self._led_count = new_count
logger.debug(f"EffectColorStripStream auto-sized to {new_count} LEDs")
@property
def target_fps(self) -> int:
return self._fps
@property
def led_count(self) -> int:
return self._led_count
def set_capture_fps(self, fps: int) -> None:
self._fps = max(1, min(90, fps))
def start(self) -> None:
if self._running:
return
self._running = True
self._thread = threading.Thread(
target=self._animate_loop,
name=f"css-effect-{self._effect_type}",
daemon=True,
)
self._thread.start()
logger.info(f"EffectColorStripStream started (effect={self._effect_type}, leds={self._led_count})")
def stop(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=5.0)
if self._thread.is_alive():
logger.warning("EffectColorStripStream animate thread did not terminate within 5s")
self._thread = None
self._heat = None
self._heat_n = 0
logger.info("EffectColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
with self._colors_lock:
return self._colors
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import EffectColorStripSource
if isinstance(source, EffectColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
logger.info("EffectColorStripStream params updated in-place")
# ── Main animation loop ──────────────────────────────────────────
def _animate_loop(self) -> None:
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
# Dispatch table
renderers = {
"fire": self._render_fire,
"meteor": self._render_meteor,
"plasma": self._render_plasma,
"noise": self._render_noise,
"aurora": self._render_aurora,
}
with high_resolution_timer():
while self._running:
loop_start = time.perf_counter()
frame_time = 1.0 / self._fps
n = self._led_count
if n != _pool_n:
_pool_n = n
_buf_a = np.empty((n, 3), dtype=np.uint8)
_buf_b = np.empty((n, 3), dtype=np.uint8)
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
render_fn = renderers.get(self._effect_type, self._render_fire)
render_fn(buf, n, loop_start)
with self._colors_lock:
self._colors = buf
elapsed = time.perf_counter() - loop_start
time.sleep(max(frame_time - elapsed, 0.001))
# ── Fire ─────────────────────────────────────────────────────────
def _render_fire(self, buf: np.ndarray, n: int, t: float) -> None:
"""Heat-propagation fire simulation.
A 1-D heat array cools, diffuses upward, and receives random sparks
at the bottom. Heat values are mapped to the palette LUT.
"""
speed = self._speed
intensity = self._intensity
lut = self._palette_lut
# (Re)allocate heat array when LED count changes
if self._heat is None or self._heat_n != n:
self._heat = np.zeros(n, dtype=np.float32)
self._heat_n = n
heat = self._heat
# Cooling: proportional to strip length so larger strips don't go dark
cooling = 0.02 * speed * (60.0 / max(n, 1))
heat -= cooling
np.clip(heat, 0.0, 1.0, out=heat)
# 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[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
heat[:] = new_heat
# Sparks at the bottom
spark_zone = max(1, n // 8)
spark_prob = 0.3 * intensity
for i in range(spark_zone):
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]
# ── Meteor ───────────────────────────────────────────────────────
def _render_meteor(self, buf: np.ndarray, n: int, t: float) -> None:
"""Bright meteor head with exponential-decay trail."""
speed = self._speed
intensity = self._intensity
color = self._color
mirror = self._mirror
# Compute position along the strip
travel_speed = speed * 8.0 # LEDs per second
if mirror:
# Bounce: position ping-pongs 0→n-1→0
cycle = 2 * (n - 1) if n > 1 else 1
raw_pos = (t * travel_speed) % cycle
pos = raw_pos if raw_pos < n else cycle - raw_pos
else:
pos = (t * travel_speed) % n
# Tail decay factor: intensity controls how long the tail is
# Higher intensity → slower decay → longer tail
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)
if mirror:
dist = np.abs(indices - pos)
else:
# Signed distance in the direction of travel (behind = positive)
dist = (pos - indices) % n
brightness = np.exp(-dist * decay)
# Apply color
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)
# Bright white-ish head (within ±1 LED of position)
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(
buf[head_mask, 0].astype(np.int16) + (head_brightness[head_mask] * (255 - r)).astype(np.int16),
0, 255,
).astype(np.uint8)
buf[head_mask, 1] = np.clip(
buf[head_mask, 1].astype(np.int16) + (head_brightness[head_mask] * (255 - g)).astype(np.int16),
0, 255,
).astype(np.uint8)
buf[head_mask, 2] = np.clip(
buf[head_mask, 2].astype(np.int16) + (head_brightness[head_mask] * (255 - b)).astype(np.int16),
0, 255,
).astype(np.uint8)
# ── Plasma ───────────────────────────────────────────────────────
def _render_plasma(self, buf: np.ndarray, n: int, t: float) -> None:
"""Overlapping sine waves creating colorful plasma patterns."""
speed = self._speed
scale = self._scale
lut = self._palette_lut
phase = t * speed * 0.5
x = np.linspace(0, scale * math.pi * 2, n, dtype=np.float64)
v = (
np.sin(x + phase)
+ np.sin(x * 0.5 + phase * 1.3)
+ np.sin(x * 0.3 + phase * 0.7)
+ np.sin(x * 0.7 + phase * 1.1)
)
# Normalize from [-4, 4] to [0, 255]
indices = np.clip(((v + 4.0) * (255.0 / 8.0)).astype(np.int32), 0, 255)
buf[:] = lut[indices]
# ── Perlin Noise ─────────────────────────────────────────────────
def _render_noise(self, buf: np.ndarray, n: int, t: float) -> None:
"""Smooth scrolling fractal noise mapped to a color palette."""
speed = self._speed
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]
# ── Aurora ───────────────────────────────────────────────────────
def _render_aurora(self, buf: np.ndarray, n: int, t: float) -> None:
"""Layered noise bands simulating aurora borealis."""
speed = self._speed
scale = self._scale
intensity = self._intensity
lut = self._palette_lut
positions = np.arange(n, dtype=np.float32) * scale * 0.08
# 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)
# 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)
brightness = 0.3 + 0.7 * layer2 * intensity
brightness = np.clip(brightness, 0.0, 1.0)
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)

View File

@@ -116,13 +116,14 @@ class WledTargetProcessor(TargetProcessor):
self._color_strip_stream = stream
self._resolved_display_index = stream.display_index
# For auto-sized static/gradient/color_cycle streams (led_count == 0), size to device LED count
# 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,
)
if isinstance(stream, (StaticColorStripStream, GradientColorStripStream, ColorCycleColorStripStream)) and device_info.led_count > 0:
from wled_controller.core.processing.effect_stream import EffectColorStripStream
if isinstance(stream, (StaticColorStripStream, GradientColorStripStream, ColorCycleColorStripStream, EffectColorStripStream)) and device_info.led_count > 0:
stream.configure(device_info.led_count)
# Notify stream manager of our target FPS so it can adjust capture rate
@@ -486,7 +487,8 @@ class WledTargetProcessor(TargetProcessor):
while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps)
await asyncio.sleep(SKIP_REPOLL)
repoll = SKIP_REPOLL if stream.is_animated else frame_time
await asyncio.sleep(repoll)
continue
prev_colors = colors

View File

@@ -89,7 +89,8 @@ import {
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onAnimationTypeChange, colorCycleAddColor, colorCycleRemoveColor,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, colorCycleAddColor, colorCycleRemoveColor,
applyGradientPreset,
} from './features/color-strips.js';
// Layer 5: calibration
@@ -274,9 +275,11 @@ Object.assign(window, {
saveCSSEditor,
deleteColorStrip,
onCSSTypeChange,
onEffectTypeChange,
onAnimationTypeChange,
colorCycleAddColor,
colorCycleRemoveColor,
applyGradientPreset,
// calibration
showCalibration,

View File

@@ -31,6 +31,13 @@ class CSSEditorModal extends Modal {
animation_speed: document.getElementById('css-editor-animation-speed').value,
cycle_speed: document.getElementById('css-editor-cycle-speed').value,
cycle_colors: JSON.stringify(_colorCycleColors),
effect_type: document.getElementById('css-editor-effect-type').value,
effect_speed: document.getElementById('css-editor-effect-speed').value,
effect_palette: document.getElementById('css-editor-effect-palette').value,
effect_color: document.getElementById('css-editor-effect-color').value,
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,
};
}
}
@@ -45,8 +52,11 @@ export function onCSSTypeChange() {
document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : '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-effect-section').style.display = type === 'effect' ? '' : 'none';
// Animation section — shown for static/gradient only (color_cycle is always animating)
if (type === 'effect') onEffectTypeChange();
// Animation section — shown for static/gradient only
const animSection = document.getElementById('css-editor-animation-section');
const animTypeSelect = document.getElementById('css-editor-animation-type');
const noneOpt = `<option value="none">${t('color_strip.animation.type.none')}</option>`;
@@ -119,6 +129,31 @@ function _syncAnimationSpeedState() {
}
}
/* ── Effect type helpers ──────────────────────────────────────── */
export function onEffectTypeChange() {
const et = document.getElementById('css-editor-effect-type').value;
// palette: all except meteor
document.getElementById('css-editor-effect-palette-group').style.display = et !== 'meteor' ? '' : 'none';
// color picker: meteor only
document.getElementById('css-editor-effect-color-group').style.display = et === 'meteor' ? '' : 'none';
// intensity: fire, meteor, aurora
document.getElementById('css-editor-effect-intensity-group').style.display =
['fire', 'meteor', 'aurora'].includes(et) ? '' : 'none';
// scale: plasma, noise, aurora
document.getElementById('css-editor-effect-scale-group').style.display =
['plasma', 'noise', 'aurora'].includes(et) ? '' : 'none';
// mirror: meteor only
document.getElementById('css-editor-effect-mirror-group').style.display = et === 'meteor' ? '' : 'none';
// description
const descEl = document.getElementById('css-editor-effect-type-desc');
if (descEl) {
const desc = t('color_strip.effect.' + et + '.desc') || '';
descEl.textContent = desc;
descEl.style.display = desc ? '' : 'none';
}
}
/* ── Color Cycle helpers ──────────────────────────────────────── */
const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff'];
@@ -197,6 +232,7 @@ export function createColorStripCard(source, pictureSourceMap) {
const isStatic = source.source_type === 'static';
const isGradient = source.source_type === 'gradient';
const isColorCycle = source.source_type === 'color_cycle';
const isEffect = source.source_type === 'effect';
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
const animBadge = anim
@@ -246,6 +282,15 @@ export function createColorStripCard(source, pictureSourceMap) {
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
${animBadge}
`;
} else if (isEffect) {
const effectLabel = t('color_strip.effect.' + (source.effect_type || 'fire')) || source.effect_type || 'fire';
const paletteLabel = source.palette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
propsHtml = `
<span class="stream-card-prop">⚡ ${escapeHtml(effectLabel)}</span>
${paletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.effect.palette')}">🎨 ${escapeHtml(paletteLabel)}</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>` : ''}
`;
} else {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
? pictureSourceMap[source.picture_source_id].name
@@ -259,8 +304,8 @@ export function createColorStripCard(source, pictureSourceMap) {
`;
}
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle)
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : isEffect ? '⚡' : '🎞️';
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle && !isEffect)
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
: '';
@@ -319,11 +364,24 @@ export async function showCSSEditor(cssId = null) {
} else if (sourceType === 'color_cycle') {
_loadColorCycleState(css);
} else if (sourceType === 'gradient') {
document.getElementById('css-editor-gradient-preset').value = '';
gradientInit(css.stops || [
{ position: 0.0, color: [255, 0, 0] },
{ position: 1.0, color: [0, 0, 255] },
]);
_loadAnimationState(css.animation);
} else if (sourceType === 'effect') {
document.getElementById('css-editor-effect-type').value = css.effect_type || 'fire';
onEffectTypeChange();
document.getElementById('css-editor-effect-speed').value = css.speed ?? 1.0;
document.getElementById('css-editor-effect-speed-val').textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
document.getElementById('css-editor-effect-palette').value = css.palette || 'fire';
document.getElementById('css-editor-effect-color').value = rgbArrayToHex(css.color || [255, 80, 0]);
document.getElementById('css-editor-effect-intensity').value = css.intensity ?? 1.0;
document.getElementById('css-editor-effect-intensity-val').textContent = parseFloat(css.intensity ?? 1.0).toFixed(1);
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 {
sourceSelect.value = css.picture_source_id || '';
@@ -369,7 +427,18 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-led-count').value = 0;
_loadAnimationState(null);
_loadColorCycleState(null);
document.getElementById('css-editor-effect-type').value = 'fire';
document.getElementById('css-editor-effect-speed').value = 1.0;
document.getElementById('css-editor-effect-speed-val').textContent = '1.0';
document.getElementById('css-editor-effect-palette').value = 'fire';
document.getElementById('css-editor-effect-color').value = '#ff5000';
document.getElementById('css-editor-effect-intensity').value = 1.0;
document.getElementById('css-editor-effect-intensity-val').textContent = '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-mirror').checked = false;
document.getElementById('css-editor-title').textContent = t('color_strip.add');
document.getElementById('css-editor-gradient-preset').value = '';
gradientInit([
{ position: 0.0, color: [255, 0, 0] },
{ position: 1.0, color: [0, 0, 255] },
@@ -438,6 +507,23 @@ export async function saveCSSEditor() {
animation: _getAnimationPayload(),
};
if (!cssId) payload.source_type = 'gradient';
} else if (sourceType === 'effect') {
payload = {
name,
effect_type: document.getElementById('css-editor-effect-type').value,
speed: parseFloat(document.getElementById('css-editor-effect-speed').value),
palette: document.getElementById('css-editor-effect-palette').value,
intensity: parseFloat(document.getElementById('css-editor-effect-intensity').value),
scale: parseFloat(document.getElementById('css-editor-effect-scale').value),
mirror: document.getElementById('css-editor-effect-mirror').checked,
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
// Meteor uses a color picker
if (payload.effect_type === 'meteor') {
const hex = document.getElementById('css-editor-effect-color').value;
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 {
payload = {
name,
@@ -596,6 +682,101 @@ export function gradientInit(stops) {
gradientRenderAll();
}
/* ── Presets ──────────────────────────────────────────────────── */
const _GRADIENT_PRESETS = {
rainbow: [
{ position: 0.0, color: [255, 0, 0] },
{ position: 0.17, color: [255, 165, 0] },
{ position: 0.33, color: [255, 255, 0] },
{ position: 0.5, color: [0, 255, 0] },
{ position: 0.67, color: [0, 100, 255] },
{ position: 0.83, color: [75, 0, 130] },
{ position: 1.0, color: [148, 0, 211] },
],
sunset: [
{ position: 0.0, color: [255, 60, 0] },
{ position: 0.3, color: [255, 120, 20] },
{ position: 0.6, color: [200, 40, 80] },
{ position: 0.8, color: [120, 20, 120] },
{ position: 1.0, color: [40, 10, 60] },
],
ocean: [
{ position: 0.0, color: [0, 10, 40] },
{ position: 0.3, color: [0, 60, 120] },
{ position: 0.6, color: [0, 140, 180] },
{ position: 0.8, color: [100, 220, 240] },
{ position: 1.0, color: [200, 240, 255] },
],
forest: [
{ position: 0.0, color: [0, 40, 0] },
{ position: 0.3, color: [0, 100, 20] },
{ position: 0.6, color: [60, 180, 30] },
{ position: 0.8, color: [140, 220, 50] },
{ position: 1.0, color: [220, 255, 80] },
],
fire: [
{ position: 0.0, color: [0, 0, 0] },
{ position: 0.25, color: [80, 0, 0] },
{ position: 0.5, color: [255, 40, 0] },
{ position: 0.75, color: [255, 160, 0] },
{ position: 1.0, color: [255, 255, 60] },
],
lava: [
{ position: 0.0, color: [0, 0, 0] },
{ position: 0.3, color: [120, 0, 0] },
{ position: 0.6, color: [255, 60, 0] },
{ position: 0.8, color: [255, 160, 40] },
{ position: 1.0, color: [255, 255, 120] },
],
aurora: [
{ position: 0.0, color: [0, 20, 40] },
{ position: 0.25, color: [0, 200, 100] },
{ position: 0.5, color: [0, 100, 200] },
{ position: 0.75, color: [120, 0, 200] },
{ position: 1.0, color: [0, 200, 140] },
],
ice: [
{ position: 0.0, color: [255, 255, 255] },
{ position: 0.3, color: [180, 220, 255] },
{ position: 0.6, color: [80, 160, 255] },
{ position: 0.85, color: [20, 60, 180] },
{ position: 1.0, color: [10, 20, 80] },
],
warm: [
{ position: 0.0, color: [255, 255, 80] },
{ position: 0.33, color: [255, 160, 0] },
{ position: 0.67, color: [255, 60, 0] },
{ position: 1.0, color: [160, 0, 0] },
],
cool: [
{ position: 0.0, color: [0, 255, 200] },
{ position: 0.33, color: [0, 120, 255] },
{ position: 0.67, color: [60, 0, 255] },
{ position: 1.0, color: [120, 0, 180] },
],
neon: [
{ position: 0.0, color: [255, 0, 200] },
{ position: 0.25, color: [0, 255, 255] },
{ position: 0.5, color: [0, 255, 50] },
{ position: 0.75, color: [255, 255, 0] },
{ position: 1.0, color: [255, 0, 100] },
],
pastel: [
{ position: 0.0, color: [255, 180, 180] },
{ position: 0.2, color: [255, 220, 160] },
{ position: 0.4, color: [255, 255, 180] },
{ position: 0.6, color: [180, 255, 200] },
{ position: 0.8, color: [180, 200, 255] },
{ position: 1.0, color: [220, 180, 255] },
],
};
export function applyGradientPreset(key) {
if (!key || !_GRADIENT_PRESETS[key]) return;
gradientInit(_GRADIENT_PRESETS[key]);
}
/* ── Render ───────────────────────────────────────────────────── */
export function gradientRenderAll() {

View File

@@ -589,6 +589,21 @@
"color_strip.gradient.position": "Position (0.01.0)",
"color_strip.gradient.bidir.hint": "Add a second color on the right side of this stop to create a hard edge in the gradient.",
"color_strip.gradient.min_stops": "Gradient must have at least 2 stops",
"color_strip.gradient.preset": "Preset:",
"color_strip.gradient.preset.hint": "Load a predefined gradient palette. Selecting a preset replaces the current stops.",
"color_strip.gradient.preset.custom": "— Custom —",
"color_strip.gradient.preset.rainbow": "Rainbow",
"color_strip.gradient.preset.sunset": "Sunset",
"color_strip.gradient.preset.ocean": "Ocean",
"color_strip.gradient.preset.forest": "Forest",
"color_strip.gradient.preset.fire": "Fire",
"color_strip.gradient.preset.lava": "Lava",
"color_strip.gradient.preset.aurora": "Aurora",
"color_strip.gradient.preset.ice": "Ice",
"color_strip.gradient.preset.warm": "Warm",
"color_strip.gradient.preset.cool": "Cool",
"color_strip.gradient.preset.neon": "Neon",
"color_strip.gradient.preset.pastel": "Pastel",
"color_strip.animation": "Animation",
"color_strip.animation.type": "Effect:",
"color_strip.animation.type.hint": "Animation effect to apply.",
@@ -617,5 +632,39 @@
"color_strip.color_cycle.add_color": "+ Add Color",
"color_strip.color_cycle.speed": "Speed:",
"color_strip.color_cycle.speed.hint": "Cycle speed multiplier. 1.0 ≈ one full cycle every 20 seconds; higher values cycle faster.",
"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.hint": "Procedural LED effects (fire, meteor, plasma, noise, aurora) generated in real time.",
"color_strip.effect.type": "Effect Type:",
"color_strip.effect.type.hint": "Choose the procedural algorithm.",
"color_strip.effect.fire": "Fire",
"color_strip.effect.fire.desc": "Cellular automaton simulating rising flames with heat diffusion",
"color_strip.effect.meteor": "Meteor",
"color_strip.effect.meteor.desc": "Bright head travels along the strip with an exponential-decay tail",
"color_strip.effect.plasma": "Plasma",
"color_strip.effect.plasma.desc": "Overlapping sine waves mapped to a palette — classic demo-scene effect",
"color_strip.effect.noise": "Noise",
"color_strip.effect.noise.desc": "Scrolling fractal value noise mapped to a palette",
"color_strip.effect.aurora": "Aurora",
"color_strip.effect.aurora.desc": "Layered noise bands that drift and blend — northern lights style",
"color_strip.effect.speed": "Speed:",
"color_strip.effect.speed.hint": "Speed multiplier for the effect animation (0.1 = very slow, 10.0 = very fast).",
"color_strip.effect.palette": "Palette:",
"color_strip.effect.palette.hint": "Color palette used to map effect values to RGB colors.",
"color_strip.effect.color": "Meteor Color:",
"color_strip.effect.color.hint": "Head color for the meteor effect.",
"color_strip.effect.intensity": "Intensity:",
"color_strip.effect.intensity.hint": "Effect intensity — controls spark rate (fire), tail decay (meteor), or brightness range (aurora).",
"color_strip.effect.scale": "Scale:",
"color_strip.effect.scale.hint": "Spatial scale — wave frequency (plasma), zoom level (noise), or band width (aurora).",
"color_strip.effect.mirror": "Mirror:",
"color_strip.effect.mirror.hint": "Bounce mode — the meteor reverses direction at strip ends instead of wrapping.",
"color_strip.palette.fire": "Fire",
"color_strip.palette.ocean": "Ocean",
"color_strip.palette.lava": "Lava",
"color_strip.palette.forest": "Forest",
"color_strip.palette.rainbow": "Rainbow",
"color_strip.palette.aurora": "Aurora",
"color_strip.palette.sunset": "Sunset",
"color_strip.palette.ice": "Ice"
}

View File

@@ -589,6 +589,21 @@
"color_strip.gradient.position": "Позиция (0.01.0)",
"color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.",
"color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок",
"color_strip.gradient.preset": "Пресет:",
"color_strip.gradient.preset.hint": "Загрузить готовую палитру градиента. Выбор пресета заменяет текущие остановки.",
"color_strip.gradient.preset.custom": "— Свой —",
"color_strip.gradient.preset.rainbow": "Радуга",
"color_strip.gradient.preset.sunset": "Закат",
"color_strip.gradient.preset.ocean": "Океан",
"color_strip.gradient.preset.forest": "Лес",
"color_strip.gradient.preset.fire": "Огонь",
"color_strip.gradient.preset.lava": "Лава",
"color_strip.gradient.preset.aurora": "Аврора",
"color_strip.gradient.preset.ice": "Лёд",
"color_strip.gradient.preset.warm": "Тёплый",
"color_strip.gradient.preset.cool": "Холодный",
"color_strip.gradient.preset.neon": "Неон",
"color_strip.gradient.preset.pastel": "Пастельный",
"color_strip.animation": "Анимация",
"color_strip.animation.type": "Эффект:",
"color_strip.animation.type.hint": "Эффект анимации.",
@@ -617,5 +632,39 @@
"color_strip.color_cycle.add_color": "+ Добавить цвет",
"color_strip.color_cycle.speed": "Скорость:",
"color_strip.color_cycle.speed.hint": "Множитель скорости смены. 1.0 ≈ один полный цикл за 20 секунд; большие значения ускоряют смену.",
"color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов"
"color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов",
"color_strip.type.effect": "Эффект",
"color_strip.type.effect.hint": "Процедурные LED-эффекты (огонь, метеор, плазма, шум, аврора), генерируемые в реальном времени.",
"color_strip.effect.type": "Тип эффекта:",
"color_strip.effect.type.hint": "Выберите процедурный алгоритм.",
"color_strip.effect.fire": "Огонь",
"color_strip.effect.fire.desc": "Клеточный автомат, имитирующий поднимающееся пламя с диффузией тепла",
"color_strip.effect.meteor": "Метеор",
"color_strip.effect.meteor.desc": "Яркая точка движется по ленте с экспоненциально затухающим хвостом",
"color_strip.effect.plasma": "Плазма",
"color_strip.effect.plasma.desc": "Наложение синусоидальных волн с палитрой — классический демо-эффект",
"color_strip.effect.noise": "Шум",
"color_strip.effect.noise.desc": "Прокручиваемый фрактальный шум, отображённый на палитру",
"color_strip.effect.aurora": "Аврора",
"color_strip.effect.aurora.desc": "Наложенные шумовые полосы, дрейфующие и смешивающиеся — в стиле северного сияния",
"color_strip.effect.speed": "Скорость:",
"color_strip.effect.speed.hint": "Множитель скорости анимации эффекта (0.1 = очень медленно, 10.0 = очень быстро).",
"color_strip.effect.palette": "Палитра:",
"color_strip.effect.palette.hint": "Цветовая палитра для отображения значений эффекта в RGB-цвета.",
"color_strip.effect.color": "Цвет метеора:",
"color_strip.effect.color.hint": "Цвет головной точки метеора.",
"color_strip.effect.intensity": "Интенсивность:",
"color_strip.effect.intensity.hint": "Интенсивность эффекта — частота искр (огонь), затухание хвоста (метеор) или диапазон яркости (аврора).",
"color_strip.effect.scale": "Масштаб:",
"color_strip.effect.scale.hint": "Пространственный масштаб — частота волн (плазма), уровень масштабирования (шум) или ширина полос (аврора).",
"color_strip.effect.mirror": "Отражение:",
"color_strip.effect.mirror.hint": "Режим отскока — метеор меняет направление у краёв ленты вместо переноса.",
"color_strip.palette.fire": "Огонь",
"color_strip.palette.ocean": "Океан",
"color_strip.palette.lava": "Лава",
"color_strip.palette.forest": "Лес",
"color_strip.palette.rainbow": "Радуга",
"color_strip.palette.aurora": "Аврора",
"color_strip.palette.sunset": "Закат",
"color_strip.palette.ice": "Лёд"
}

View File

@@ -57,6 +57,11 @@ class ColorStripSource:
"animation": None,
"colors": None,
"cycle_speed": None,
"effect_type": None,
"palette": None,
"intensity": None,
"scale": None,
"mirror": None,
}
@staticmethod
@@ -125,6 +130,25 @@ class ColorStripSource:
led_count=data.get("led_count") or 0,
)
if source_type == "effect":
raw_color = data.get("color")
color = (
raw_color if isinstance(raw_color, list) and len(raw_color) == 3
else [255, 80, 0]
)
return EffectColorStripSource(
id=sid, name=name, source_type="effect",
created_at=created_at, updated_at=updated_at, description=description,
effect_type=data.get("effect_type") or "fire",
speed=float(data.get("speed") or 1.0),
led_count=data.get("led_count") or 0,
palette=data.get("palette") or "fire",
color=color,
intensity=float(data.get("intensity") or 1.0),
scale=float(data.get("scale") or 1.0),
mirror=bool(data.get("mirror", False)),
)
# Default: "picture" type
return PictureColorStripSource(
id=sid, name=name, source_type=source_type,
@@ -248,3 +272,34 @@ class ColorCycleColorStripSource(ColorStripSource):
d["cycle_speed"] = self.cycle_speed
d["led_count"] = self.led_count
return d
@dataclass
class EffectColorStripSource(ColorStripSource):
"""Color strip source that runs a procedural LED effect.
The effect_type field selects which algorithm to use:
fire, meteor, plasma, noise, aurora.
LED count auto-sizes from the connected device when led_count == 0.
"""
effect_type: str = "fire" # fire | meteor | plasma | noise | aurora
speed: float = 1.0 # animation speed multiplier (0.110.0)
led_count: int = 0 # 0 = use device LED count
palette: str = "fire" # named color palette
color: list = field(default_factory=lambda: [255, 80, 0]) # [R,G,B] for meteor head
intensity: float = 1.0 # effect-specific intensity (0.12.0)
scale: float = 1.0 # spatial scale / zoom (0.55.0)
mirror: bool = False # bounce mode (meteor)
def to_dict(self) -> dict:
d = super().to_dict()
d["effect_type"] = self.effect_type
d["speed"] = self.speed
d["led_count"] = self.led_count
d["palette"] = self.palette
d["color"] = list(self.color)
d["intensity"] = self.intensity
d["scale"] = self.scale
d["mirror"] = self.mirror
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,
EffectColorStripSource,
GradientColorStripSource,
PictureColorStripSource,
StaticColorStripSource,
@@ -109,6 +110,12 @@ class ColorStripStore:
animation: Optional[dict] = None,
colors: Optional[list] = None,
cycle_speed: float = 1.0,
effect_type: str = "fire",
speed: float = 1.0,
palette: str = "fire",
intensity: float = 1.0,
scale: float = 1.0,
mirror: bool = False,
) -> ColorStripSource:
"""Create a new color strip source.
@@ -169,6 +176,24 @@ class ColorStripStore:
cycle_speed=float(cycle_speed) if cycle_speed else 1.0,
led_count=led_count,
)
elif source_type == "effect":
rgb = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0]
source = EffectColorStripSource(
id=source_id,
name=name,
source_type="effect",
created_at=now,
updated_at=now,
description=description,
effect_type=effect_type or "fire",
speed=float(speed) if speed else 1.0,
led_count=led_count,
palette=palette or "fire",
color=rgb,
intensity=float(intensity) if intensity else 1.0,
scale=float(scale) if scale else 1.0,
mirror=bool(mirror),
)
else:
if calibration is None:
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
@@ -217,6 +242,12 @@ class ColorStripStore:
animation: Optional[dict] = None,
colors: Optional[list] = None,
cycle_speed: Optional[float] = None,
effect_type: Optional[str] = None,
speed: Optional[float] = None,
palette: Optional[str] = None,
intensity: Optional[float] = None,
scale: Optional[float] = None,
mirror: Optional[bool] = None,
) -> ColorStripSource:
"""Update an existing color strip source.
@@ -280,6 +311,23 @@ class ColorStripStore:
source.cycle_speed = float(cycle_speed)
if led_count is not None:
source.led_count = led_count
elif isinstance(source, EffectColorStripSource):
if effect_type is not None:
source.effect_type = effect_type
if speed is not None:
source.speed = float(speed)
if led_count is not None:
source.led_count = led_count
if palette is not None:
source.palette = palette
if color is not None and isinstance(color, list) and len(color) == 3:
source.color = color
if intensity is not None:
source.intensity = float(intensity)
if scale is not None:
source.scale = float(scale)
if mirror is not None:
source.mirror = bool(mirror)
source.updated_at = datetime.utcnow()
self._save()

View File

@@ -25,6 +25,7 @@
<option value="static" data-i18n="color_strip.type.static">Static Color</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="effect" data-i18n="color_strip.type.effect">Procedural Effect</option>
</select>
</div>
@@ -166,6 +167,29 @@
<!-- Gradient-specific fields -->
<div id="css-editor-gradient-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-gradient-preset" data-i18n="color_strip.gradient.preset">Preset:</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.gradient.preset.hint">Load a predefined gradient palette. Selecting a preset replaces the current stops.</small>
<select id="css-editor-gradient-preset" onchange="applyGradientPreset(this.value)">
<option value="" data-i18n="color_strip.gradient.preset.custom">— Custom —</option>
<option value="rainbow" data-i18n="color_strip.gradient.preset.rainbow">Rainbow</option>
<option value="sunset" data-i18n="color_strip.gradient.preset.sunset">Sunset</option>
<option value="ocean" data-i18n="color_strip.gradient.preset.ocean">Ocean</option>
<option value="forest" data-i18n="color_strip.gradient.preset.forest">Forest</option>
<option value="fire" data-i18n="color_strip.gradient.preset.fire">Fire</option>
<option value="lava" data-i18n="color_strip.gradient.preset.lava">Lava</option>
<option value="aurora" data-i18n="color_strip.gradient.preset.aurora">Aurora</option>
<option value="ice" data-i18n="color_strip.gradient.preset.ice">Ice</option>
<option value="warm" data-i18n="color_strip.gradient.preset.warm">Warm</option>
<option value="cool" data-i18n="color_strip.gradient.preset.cool">Cool</option>
<option value="neon" data-i18n="color_strip.gradient.preset.neon">Neon</option>
<option value="pastel" data-i18n="color_strip.gradient.preset.pastel">Pastel</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.gradient.preview">Gradient:</label>
@@ -188,6 +212,103 @@
</div>
</div>
<!-- Procedural effect fields -->
<div id="css-editor-effect-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label for="css-editor-effect-type" data-i18n="color_strip.effect.type">Effect:</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.effect.type.hint">The procedural effect algorithm to use.</small>
<select id="css-editor-effect-type" onchange="onEffectTypeChange()">
<option value="fire" data-i18n="color_strip.effect.fire">Fire</option>
<option value="meteor" data-i18n="color_strip.effect.meteor">Meteor</option>
<option value="plasma" data-i18n="color_strip.effect.plasma">Plasma</option>
<option value="noise" data-i18n="color_strip.effect.noise">Perlin Noise</option>
<option value="aurora" data-i18n="color_strip.effect.aurora">Aurora</option>
</select>
<small id="css-editor-effect-type-desc" class="field-desc"></small>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-effect-speed">
<span data-i18n="color_strip.effect.speed">Speed:</span>
<span id="css-editor-effect-speed-val">1.0</span>x
</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.effect.speed.hint">How fast the effect animates. 1.0 = default speed.</small>
<input type="range" id="css-editor-effect-speed" min="0.1" max="10.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-effect-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div id="css-editor-effect-palette-group" class="form-group">
<div class="label-row">
<label for="css-editor-effect-palette" data-i18n="color_strip.effect.palette">Palette:</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.effect.palette.hint">Color palette used by the effect.</small>
<select id="css-editor-effect-palette">
<option value="fire" data-i18n="color_strip.palette.fire">Fire</option>
<option value="ocean" data-i18n="color_strip.palette.ocean">Ocean</option>
<option value="lava" data-i18n="color_strip.palette.lava">Lava</option>
<option value="forest" data-i18n="color_strip.palette.forest">Forest</option>
<option value="rainbow" data-i18n="color_strip.palette.rainbow">Rainbow</option>
<option value="aurora" data-i18n="color_strip.palette.aurora">Aurora</option>
<option value="sunset" data-i18n="color_strip.palette.sunset">Sunset</option>
<option value="ice" data-i18n="color_strip.palette.ice">Ice</option>
</select>
</div>
<div id="css-editor-effect-color-group" class="form-group" style="display:none">
<div class="label-row">
<label for="css-editor-effect-color" data-i18n="color_strip.effect.color">Color:</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.effect.color.hint">Head color for the meteor effect.</small>
<input type="color" id="css-editor-effect-color" value="#ff5000">
</div>
<div id="css-editor-effect-intensity-group" class="form-group">
<div class="label-row">
<label for="css-editor-effect-intensity">
<span data-i18n="color_strip.effect.intensity">Intensity:</span>
<span id="css-editor-effect-intensity-val">1.0</span>
</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.effect.intensity.hint">Effect-specific intensity.</small>
<input type="range" id="css-editor-effect-intensity" min="0.1" max="2.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-effect-intensity-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div id="css-editor-effect-scale-group" class="form-group" style="display:none">
<div class="label-row">
<label for="css-editor-effect-scale">
<span data-i18n="color_strip.effect.scale">Scale:</span>
<span id="css-editor-effect-scale-val">1.0</span>
</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.effect.scale.hint">Spatial zoom level.</small>
<input type="range" id="css-editor-effect-scale" min="0.5" max="5.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-effect-scale-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div id="css-editor-effect-mirror-group" class="form-group" style="display:none">
<div class="label-row">
<label for="css-editor-effect-mirror" data-i18n="color_strip.effect.mirror">Mirror:</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.effect.mirror.hint">Bounce back and forth instead of wrapping around.</small>
<label class="settings-toggle">
<input type="checkbox" id="css-editor-effect-mirror">
<span class="settings-toggle-slider"></span>
</label>
</div>
</div>
<!-- Animation — shown for static/gradient, hidden for picture -->
<div id="css-editor-animation-section" style="display:none">
<details class="form-collapse">