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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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}"
|
||||
|
||||
408
server/src/wled_controller/core/processing/effect_stream.py
Normal file
408
server/src/wled_controller/core/processing/effect_stream.py
Normal 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 # 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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Лёд"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user