diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index fedc0f9..7f73231 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -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") diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index 42f7b08..2e38673 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -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.1–10.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.1–10.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") diff --git a/server/src/wled_controller/core/filters/auto_crop.py b/server/src/wled_controller/core/filters/auto_crop.py index c1a652d..1d0bf2e 100644 --- a/server/src/wled_controller/core/filters/auto_crop.py +++ b/server/src/wled_controller/core/filters/auto_crop.py @@ -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 diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index a6c1b06..aa0230c 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -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)) diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index ec6c629..03aaf49 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -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}" diff --git a/server/src/wled_controller/core/processing/effect_stream.py b/server/src/wled_controller/core/processing/effect_stream.py new file mode 100644 index 0000000..a2dda8d --- /dev/null +++ b/server/src/wled_controller/core/processing/effect_stream.py @@ -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 # 0–1 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) diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index e72e8e6..42e77bd 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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 diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 0e6a282..1dbee51 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -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, diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 5bd2b69..5b2d772 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -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 = ``; @@ -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 ? `💡 ${source.led_count}` : ''} ${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 = ` + ⚡ ${escapeHtml(effectLabel)} + ${paletteLabel ? `🎨 ${escapeHtml(paletteLabel)}` : ''} + ⏩ ${(source.speed || 1.0).toFixed(1)}× + ${source.led_count ? `💡 ${source.led_count}` : ''} + `; } 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) ? `` : ''; @@ -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() { diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 3edd9f5..9ef3e8d 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -589,6 +589,21 @@ "color_strip.gradient.position": "Position (0.0–1.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" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 33622c8..9904683 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -589,6 +589,21 @@ "color_strip.gradient.position": "Позиция (0.0–1.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": "Лёд" } diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index bd0b242..b1ec372 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -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.1–10.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.1–2.0) + scale: float = 1.0 # spatial scale / zoom (0.5–5.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 diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index 196892c..97f512f 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -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() diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 70b72e9..597e5c4 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -25,6 +25,7 @@ + @@ -166,6 +167,29 @@