feat: expand color strip sources with new effects, gradient improvements, and daylight/candlelight enhancements
Some checks failed
Lint & Test / test (push) Failing after 29s

Effects: add 7 new procedural effects (rain, comet, bouncing ball, fireworks,
sparkle rain, lava lamp, wave interference) and custom palette support via
user-defined [[pos,R,G,B],...] stops.

Gradient: add easing functions (linear, ease_in_out, step, cubic) for stop
interpolation, plus noise_perturb and hue_rotate animation types.

Daylight: add longitude field and NOAA solar equations for accurate
sunrise/sunset based on latitude, longitude, and day of year.

Candlelight: add wind simulation (correlated gusts), candle type presets
(taper/votive/bonfire), and wax drip effect with localized brightness dips.

Also fixes editor preview to include all new fields for inline LED test.
This commit is contained in:
2026-03-23 22:40:55 +03:00
parent c4dce19b2e
commit 9b80076b5b
12 changed files with 1015 additions and 120 deletions

View File

@@ -66,6 +66,14 @@ This applies to: file paths in `StorageConfig`, JSON root keys (e.g. `picture_ta
**Incident context:** A past rename of `picture_targets.json``output_targets.json` was done without migration. The app created a new empty `output_targets.json` while the user's 7 targets sat unread in the old file. Data was silently lost.
## UI Component Rules (CRITICAL)
**NEVER use plain HTML `<select>` elements.** The project uses custom selector components:
- **IconSelect** (icon grid) — for predefined items (effect types, palettes, easing modes, animation types)
- **EntitySelect** (entity picker) — for entity references (sources, templates, devices)
Plain HTML selects break the visual consistency of the UI.
## Pre-Commit Checks (MANDATORY)
Before every commit, run the relevant linters and fix any issues:

View File

@@ -64,11 +64,14 @@ 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)")
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
palette: Optional[str] = Field(None, description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice)")
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora|rain|comet|bouncing_ball|fireworks|sparkle_rain|lava_lamp|wave_interference")
palette: Optional[str] = Field(None, description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice) or 'custom'")
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)")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor/comet)")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops [[pos,R,G,B],...]")
# gradient-type easing
easing: Optional[str] = Field(None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic")
# composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
# mapped-type fields
@@ -97,8 +100,11 @@ class ColorStripSourceCreate(BaseModel):
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0)
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
wind_strength: Optional[float] = Field(None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0)
candle_type: Optional[str] = Field(None, description="Candle type preset: default|taper|votive|bonfire")
# processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
@@ -123,11 +129,14 @@ 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)")
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
effect_type: Optional[str] = Field(None, description="Effect algorithm")
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")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops [[pos,R,G,B],...]")
# gradient-type easing
easing: Optional[str] = Field(None, description="Gradient interpolation easing: linear|ease_in_out|step|cubic")
# composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
# mapped-type fields
@@ -156,8 +165,11 @@ class ColorStripSourceUpdate(BaseModel):
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier", ge=0.1, le=10.0)
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing (-90 to 90)", ge=-90.0, le=90.0)
longitude: Optional[float] = Field(None, description="Longitude for daylight timing (-180 to 180)", ge=-180.0, le=180.0)
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20)
wind_strength: Optional[float] = Field(None, description="Wind simulation strength (0.0-2.0)", ge=0.0, le=2.0)
candle_type: Optional[str] = Field(None, description="Candle type preset: default|taper|votive|bonfire")
# processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)")
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID (for processed type)")
@@ -189,6 +201,9 @@ class ColorStripSourceResponse(BaseModel):
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")
custom_palette: Optional[List[List[float]]] = Field(None, description="Custom palette stops")
# gradient-type easing
easing: Optional[str] = Field(None, description="Gradient interpolation easing")
# composite-type fields
layers: Optional[List[dict]] = Field(None, description="Layers for composite type")
# mapped-type fields
@@ -217,8 +232,11 @@ class ColorStripSourceResponse(BaseModel):
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier")
use_real_time: Optional[bool] = Field(None, description="Use wall-clock time for daylight cycle")
latitude: Optional[float] = Field(None, description="Latitude for daylight timing")
longitude: Optional[float] = Field(None, description="Longitude for daylight timing")
# candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources")
wind_strength: Optional[float] = Field(None, description="Wind simulation strength")
candle_type: Optional[str] = Field(None, description="Candle type preset")
# processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID")
processing_template_id: Optional[str] = Field(None, description="Color strip processing template ID")

View File

@@ -4,12 +4,17 @@ Implements CandlelightColorStripStream which produces warm, organic
flickering across all LEDs using layered sine waves and value noise.
Each "candle" is an independent flicker source that illuminates
nearby LEDs with smooth falloff.
Features:
- Wind simulation: correlated brightness drops across all candles
- Candle type presets: taper / votive / bonfire
- Wax drip effect: localized brightness dips that recover over ~0.5 s
"""
import math
import threading
import time
from typing import Optional
from typing import List, Optional
import numpy as np
@@ -38,6 +43,18 @@ def _noise1d(x: np.ndarray) -> np.ndarray:
return a + u * (b - a)
# ── Candle type preset multipliers ──────────────────────────────────
# (flicker_amplitude_mul, speed_mul, sigma_mul, warm_bonus)
_CANDLE_PRESETS: dict = {
"default": (1.0, 1.0, 1.0, 0.0),
"taper": (0.5, 1.3, 0.8, 0.0), # tall, steady
"votive": (1.5, 1.0, 0.7, 0.0), # small, flickery
"bonfire": (2.0, 1.0, 1.5, 0.12), # chaotic, warmer shift
}
_VALID_CANDLE_TYPES = frozenset(_CANDLE_PRESETS)
class CandlelightColorStripStream(ColorStripStream):
"""Color strip stream simulating realistic candle flickering.
@@ -59,7 +76,12 @@ class CandlelightColorStripStream(ColorStripStream):
self._s_bright: Optional[np.ndarray] = None
self._s_noise: Optional[np.ndarray] = None
self._s_x: Optional[np.ndarray] = None
self._s_drip: Optional[np.ndarray] = None
self._pool_n = 0
# Wax drip events: [pos, brightness, phase(0=dim,1=recover)]
self._drip_events: List[List[float]] = []
self._drip_rng = np.random.RandomState(seed=42)
self._last_drip_t = 0.0
self._update_from_source(source)
def _update_from_source(self, source) -> None:
@@ -68,6 +90,9 @@ class CandlelightColorStripStream(ColorStripStream):
self._intensity = float(getattr(source, "intensity", 1.0))
self._num_candles = max(1, int(getattr(source, "num_candles", 3)))
self._speed = float(getattr(source, "speed", 1.0))
self._wind_strength = float(getattr(source, "wind_strength", 0.0))
raw_type = getattr(source, "candle_type", "default")
self._candle_type = raw_type if raw_type in _VALID_CANDLE_TYPES else "default"
_lc = getattr(source, "led_count", 0)
self._auto_size = not _lc
self._led_count = _lc if _lc and _lc > 0 else 1
@@ -161,11 +186,13 @@ class CandlelightColorStripStream(ColorStripStream):
self._s_bright = np.empty(n, dtype=np.float32)
self._s_noise = np.empty(n, dtype=np.float32)
self._s_x = np.arange(n, dtype=np.float32)
self._s_drip = np.ones(n, dtype=np.float32)
buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a
self._render_candlelight(buf, n, t, speed)
self._update_drip_events(n, wall_start, frame_time)
self._render_candlelight(buf, n, t, speed, wall_start)
with self._colors_lock:
self._colors = buf
@@ -179,26 +206,68 @@ class CandlelightColorStripStream(ColorStripStream):
finally:
self._running = False
def _render_candlelight(self, buf: np.ndarray, n: int, t: float, speed: float) -> None:
"""Render candle flickering into buf (n, 3) uint8.
# ── Drip management ─────────────────────────────────────────────
Algorithm:
- Place num_candles evenly along the strip
- Each candle has independent layered-sine flicker
- Spatial falloff: LEDs near a candle are brighter
- Per-LED noise adds individual variation
- Final brightness modulates the base warm color
"""
# Scale speed so that speed=1 gives a gentle ~1.3 Hz dominant flicker
speed = speed * 0.35
def _update_drip_events(self, n: int, wall_t: float, dt: float) -> None:
"""Spawn new wax drip events and advance existing ones."""
intensity = self._intensity
spawn_interval = max(0.3, 1.0 / max(intensity, 0.01))
if wall_t - self._last_drip_t >= spawn_interval and len(self._drip_events) < 5:
self._last_drip_t = wall_t
pos = float(self._drip_rng.randint(0, max(n, 1)))
self._drip_events.append([pos, 1.0, 0])
surviving = []
for drip in self._drip_events:
pos, bright, phase = drip
if phase == 0:
bright -= dt / 0.2 * 0.7
if bright <= 0.3:
bright = 0.3
drip[2] = 1
else:
bright += dt / 0.5 * 0.7
if bright >= 1.0:
continue # drip complete
drip[1] = bright
surviving.append(drip)
self._drip_events = surviving
# Build per-LED drip factor
drip_arr = self._s_drip
drip_arr[:n] = 1.0
x = self._s_x[:n]
for drip in self._drip_events:
pos, bright, _phase = drip
dist = x - pos
falloff = np.exp(-0.5 * dist * dist / 4.0)
drip_arr[:n] *= 1.0 - (1.0 - bright) * falloff
# ── Render ──────────────────────────────────────────────────────
def _render_candlelight(self, buf: np.ndarray, n: int, t: float, speed: float, wall_t: float) -> None:
"""Render candle flickering into buf (n, 3) uint8."""
amp_mul, spd_mul, sigma_mul, warm_bonus = _CANDLE_PRESETS[self._candle_type]
eff_speed = speed * 0.35 * spd_mul
intensity = self._intensity
num_candles = self._num_candles
base_r, base_g, base_b = self._color[0], self._color[1], self._color[2]
bright = self._s_bright
bright[:] = 0.0
# Wind modulation
wind_strength = self._wind_strength
if wind_strength > 0.0:
wind_raw = (
0.6 * math.sin(2.0 * math.pi * 0.15 * wall_t)
+ 0.4 * math.sin(2.0 * math.pi * 0.27 * wall_t + 1.1)
)
wind_mod = max(0.0, wind_raw)
else:
wind_mod = 0.0
bright = self._s_bright
bright[:n] = 0.0
# Candle positions: evenly distributed
if num_candles == 1:
positions = [n / 2.0]
else:
@@ -207,42 +276,42 @@ class CandlelightColorStripStream(ColorStripStream):
x = self._s_x[:n]
for ci, pos in enumerate(positions):
# Independent flicker for this candle: layered sines at different frequencies
# Use candle index as phase offset for independence
offset = ci * 137.5 # golden-angle offset for non-repeating
offset = ci * 137.5
flicker = (
0.40 * math.sin(2 * math.pi * speed * t * 3.7 + offset)
+ 0.25 * math.sin(2 * math.pi * speed * t * 7.3 + offset * 0.7)
+ 0.15 * math.sin(2 * math.pi * speed * t * 13.1 + offset * 1.3)
+ 0.10 * math.sin(2 * math.pi * speed * t * 1.9 + offset * 0.3)
0.40 * math.sin(2.0 * math.pi * eff_speed * t * 3.7 + offset)
+ 0.25 * math.sin(2.0 * math.pi * eff_speed * t * 7.3 + offset * 0.7)
+ 0.15 * math.sin(2.0 * math.pi * eff_speed * t * 13.1 + offset * 1.3)
+ 0.10 * math.sin(2.0 * math.pi * eff_speed * t * 1.9 + offset * 0.3)
)
# Normalize flicker to [0.3, 1.0] range (candles never fully go dark)
candle_brightness = 0.65 + 0.35 * flicker * intensity
# Spatial falloff: Gaussian centered on candle position
# sigma proportional to strip length / num_candles
sigma = max(n / (num_candles * 2.0), 2.0)
candle_brightness = 0.65 + 0.35 * flicker * intensity * amp_mul
if wind_strength > 0.0:
candle_brightness *= (1.0 - wind_strength * wind_mod * 0.4)
candle_brightness = max(0.1, candle_brightness)
sigma = max(n / (num_candles * 2.0), 2.0) * sigma_mul
dist = x - pos
falloff = np.exp(-0.5 * (dist * dist) / (sigma * sigma))
bright += candle_brightness * falloff
bright[:n] += candle_brightness * falloff
# Per-LED noise for individual variation
noise_x = x * 0.3 + t * speed * 5.0
# Per-LED noise
noise_x = x * 0.3 + t * eff_speed * 5.0
noise = _noise1d(noise_x)
# Modulate brightness with noise (±15%)
bright *= (0.85 + 0.30 * noise)
bright[:n] *= (0.85 + 0.30 * noise)
# Clamp to [0, 1]
np.clip(bright, 0.0, 1.0, out=bright)
# Wax drip factor
bright[:n] *= self._s_drip[:n]
# Apply base color with brightness modulation
# Candles emit warmer (more red, less blue) at lower brightness
# Add slight color variation: dimmer = warmer
warm_shift = (1.0 - bright) * 0.3
r = bright * base_r
g = bright * base_g * (1.0 - warm_shift * 0.5)
b = bright * base_b * (1.0 - warm_shift)
np.clip(bright[:n], 0.0, 1.0, out=bright[:n])
# Colour mapping: dimmer = warmer
warm_shift = (1.0 - bright[:n]) * (0.3 + warm_bonus)
r = bright[:n] * base_r
g = bright[:n] * base_g * (1.0 - warm_shift * 0.5)
b = bright[:n] * base_b * (1.0 - warm_shift)
buf[:, 0] = np.clip(r, 0, 255).astype(np.uint8)
buf[:, 1] = np.clip(g, 0, 255).astype(np.uint8)

View File

@@ -24,6 +24,27 @@ from wled_controller.core.capture.screen_capture import extract_border_pixels
from wled_controller.utils import get_logger
from wled_controller.utils.timer import high_resolution_timer
class _SimpleNoise1D:
"""Minimal 1-D value noise for gradient perturbation (avoids circular import)."""
def __init__(self, seed: int = 99):
rng = np.random.RandomState(seed)
self._table = rng.random(512).astype(np.float32)
def noise(self, x: np.ndarray) -> np.ndarray:
size = len(self._table)
xi = np.floor(x).astype(np.int64)
frac = x - np.floor(x)
t = frac * frac * (3.0 - 2.0 * frac)
a = self._table[xi % size]
b = self._table[(xi + 1) % size]
return a + t * (b - a)
# Module-level noise for gradient perturbation
_gradient_noise = _SimpleNoise1D(seed=99)
logger = get_logger(__name__)
@@ -348,7 +369,7 @@ class PictureColorStripStream(ColorStripStream):
self._running = False
def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
def _compute_gradient_colors(stops: list, led_count: int, easing: str = "linear") -> np.ndarray:
"""Compute an (led_count, 3) uint8 array from gradient color stops.
Each stop: {"position": float 01, "color": [R,G,B], "color_right": [R,G,B] | absent}
@@ -361,7 +382,7 @@ def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
left_color = A["color_right"] if present, else A["color"]
right_color = B["color"]
t = (p - A.pos) / (B.pos - A.pos)
color = lerp(left_color, right_color, t)
color = lerp(left_color, right_color, eased(t))
"""
if led_count <= 0:
led_count = 1
@@ -412,6 +433,15 @@ def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
span = b_pos - a_pos
t = np.where(span > 0, (between_pos - a_pos) / span, 0.0)
# Apply easing to interpolation parameter
if easing == "ease_in_out":
t = t * t * (3.0 - 2.0 * t)
elif easing == "cubic":
t = np.where(t < 0.5, 4.0 * t * t * t, 1.0 - (-2.0 * t + 2.0) ** 3 / 2.0)
elif easing == "step":
steps = float(max(2, n_stops))
t = np.round(t * steps) / steps
a_colors = right_colors[idx] # A's right color
b_colors = left_colors[idx + 1] # B's left color
result[mask_between] = a_colors + t[:, np.newaxis] * (b_colors - a_colors)
@@ -830,10 +860,11 @@ class GradientColorStripStream(ColorStripStream):
led_count = _lc if _lc and _lc > 0 else 1
self._led_count = led_count
self._animation = source.animation # dict or None; read atomically by _animate_loop
self._easing = getattr(source, "easing", "linear") or "linear"
self._rebuild_colors()
def _rebuild_colors(self) -> None:
colors = _compute_gradient_colors(self._stops, self._led_count)
colors = _compute_gradient_colors(self._stops, self._led_count, self._easing)
with self._colors_lock:
self._colors = colors
@@ -919,6 +950,7 @@ class GradientColorStripStream(ColorStripStream):
_cached_base: Optional[np.ndarray] = None
_cached_n: int = 0
_cached_stops: Optional[list] = None
_cached_easing: str = ""
# Double-buffer pool + uint16 scratch for brightness math
_pool_n = 0
_buf_a = _buf_b = _scratch_u16 = None
@@ -950,11 +982,13 @@ class GradientColorStripStream(ColorStripStream):
stops = self._stops
colors = None
# Recompute base gradient only when stops or led_count change
if _cached_base is None or _cached_n != n or _cached_stops is not stops:
_cached_base = _compute_gradient_colors(stops, n)
# Recompute base gradient only when stops, led_count, or easing change
easing = self._easing
if _cached_base is None or _cached_n != n or _cached_stops is not stops or _cached_easing != easing:
_cached_base = _compute_gradient_colors(stops, n, easing)
_cached_n = n
_cached_stops = stops
_cached_easing = easing
base = _cached_base
# Re-allocate pool only when LED count changes
@@ -1099,6 +1133,70 @@ class GradientColorStripStream(ColorStripStream):
buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8)
colors = buf
elif atype == "noise_perturb":
# Perturb gradient stop positions with value noise
perturbed = []
for si, s in enumerate(stops):
noise_val = _gradient_noise.noise(
np.array([si * 10.0 + t * speed], dtype=np.float32)
)[0]
new_pos = min(1.0, max(0.0,
float(s.get("position", 0)) + (noise_val - 0.5) * 0.2
))
perturbed.append(dict(s, position=new_pos))
buf[:] = _compute_gradient_colors(perturbed, n, easing)
colors = buf
elif atype == "hue_rotate":
# Rotate hue while preserving original S/V
h_shift = (speed * t * 0.1) % 1.0
rgb_f = base.astype(np.float32) * (1.0 / 255.0)
r_f = rgb_f[:, 0]
g_f = rgb_f[:, 1]
b_f = rgb_f[:, 2]
cmax = np.maximum(np.maximum(r_f, g_f), b_f)
cmin = np.minimum(np.minimum(r_f, g_f), b_f)
delta = cmax - cmin
# Hue
h_arr = np.zeros(n, dtype=np.float32)
mask_r = (delta > 0) & (cmax == r_f)
mask_g = (delta > 0) & (cmax == g_f) & ~mask_r
mask_b = (delta > 0) & ~mask_r & ~mask_g
h_arr[mask_r] = ((g_f[mask_r] - b_f[mask_r]) / delta[mask_r]) % 6.0
h_arr[mask_g] = ((b_f[mask_g] - r_f[mask_g]) / delta[mask_g]) + 2.0
h_arr[mask_b] = ((r_f[mask_b] - g_f[mask_b]) / delta[mask_b]) + 4.0
h_arr *= (1.0 / 6.0)
h_arr %= 1.0
# S and V — preserve original values (no clamping)
s_arr = np.where(cmax > 0, delta / cmax, np.float32(0))
v_arr = cmax
# Shift hue
h_arr += h_shift
h_arr %= 1.0
# HSV->RGB
h6 = h_arr * 6.0
hi = h6.astype(np.int32) % 6
f_arr = h6 - np.floor(h6)
p = v_arr * (1.0 - s_arr)
q = v_arr * (1.0 - s_arr * f_arr)
tt = v_arr * (1.0 - s_arr * (1.0 - f_arr))
ro = np.empty(n, dtype=np.float32)
go = np.empty(n, dtype=np.float32)
bo = np.empty(n, dtype=np.float32)
for sxt, rv, gv, bv in (
(0, v_arr, tt, p), (1, q, v_arr, p),
(2, p, v_arr, tt), (3, p, q, v_arr),
(4, tt, p, v_arr), (5, v_arr, p, q),
):
m = hi == sxt
ro[m] = rv[m]
go[m] = gv[m]
bo[m] = bv[m]
buf[:, 0] = np.clip(ro * 255.0, 0, 255).astype(np.uint8)
buf[:, 1] = np.clip(go * 255.0, 0, 255).astype(np.uint8)
buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8)
colors = buf
if colors is not None:
with self._colors_lock:
self._colors = colors

View File

@@ -3,8 +3,14 @@
Implements DaylightColorStripStream which produces a uniform LED color array
that transitions through dawn, daylight, sunset, and night over a continuous
24-hour cycle. Can use real wall-clock time or a configurable simulation speed.
When latitude and longitude are provided, sunrise/sunset times are computed
via simplified NOAA solar equations so the daylight curve automatically adapts
to the user's location and the current season.
"""
import datetime
import math
import threading
import time
from typing import Optional
@@ -19,9 +25,9 @@ logger = get_logger(__name__)
# ── Daylight color table ────────────────────────────────────────────────
#
# Maps hour-of-day (024) to RGB color. Interpolated linearly between
# control points. Colors approximate natural daylight color temperature
# from warm sunrise tones through cool midday to warm sunset and dim night.
# Canonical hour control points (024) RGB. Designed for a default
# sunrise of 6 h and sunset of 19 h. At render time the curve is remapped
# to the actual solar times for the location.
#
# Format: (hour, R, G, B)
_DAYLIGHT_CURVE = [
@@ -44,66 +50,171 @@ _DAYLIGHT_CURVE = [
(24.0, 10, 10, 30), # midnight (wrap)
]
# Pre-build a (1440, 3) uint8 LUT — one entry per minute of the day
# Reference solar times the canonical curve was designed around
_DEFAULT_SUNRISE = 6.0
_DEFAULT_SUNSET = 19.0
# Global cache of the default static LUT (lazy-built once)
_daylight_lut: Optional[np.ndarray] = None
def _get_daylight_lut() -> np.ndarray:
global _daylight_lut
if _daylight_lut is not None:
return _daylight_lut
# ── Solar position helpers ──────────────────────────────────────────────
def _compute_solar_times(
latitude: float, longitude: float, day_of_year: int
) -> tuple:
"""Return (sunrise_hour, sunset_hour) in local solar time.
Uses simplified NOAA solar equations:
- declination: decl = 23.45 * sin(2π * (284 + doy) / 365)
- hour angle: cos(ha) = -tan(lat) * tan(decl)
- sunrise/sunset: 12 ∓ ha/15, shifted by longitude
Polar day and polar night are clamped to visible ranges.
"""
deg2rad = math.pi / 180.0
decl_deg = 23.45 * math.sin(2.0 * math.pi * (284 + day_of_year) / 365.0)
decl_rad = decl_deg * deg2rad
lat_rad = latitude * deg2rad
cos_ha = -math.tan(lat_rad) * math.tan(decl_rad)
if cos_ha <= -1.0:
# Polar day — sun never sets
sunrise = 3.0
sunset = 21.0
elif cos_ha >= 1.0:
# Polar night — sun never rises
sunrise = 12.0
sunset = 12.0
else:
ha_hours = math.acos(cos_ha) / (deg2rad * 15.0)
lon_offset = longitude / 15.0
solar_noon = 12.0 - lon_offset
sunrise = solar_noon - ha_hours
sunset = solar_noon + ha_hours
# Clamp to sane ranges
sunrise = max(3.0, min(10.0, sunrise))
sunset = max(14.0, min(21.0, sunset))
return sunrise, sunset
def _build_lut_for_solar_times(sunrise: float, sunset: float) -> np.ndarray:
"""Build a 1440-entry uint8 RGB LUT scaled to the given sunrise/sunset hours.
The canonical _DAYLIGHT_CURVE is remapped so that:
- Night before dawn: 0 h → sunrise maps to 0 h → _DEFAULT_SUNRISE
- Daylight window: sunrise → sunset maps to _DEFAULT_SUNRISE → _DEFAULT_SUNSET
- Night after dusk: sunset → 24 h maps to _DEFAULT_SUNSET → 24 h
"""
default_night_before = _DEFAULT_SUNRISE
default_day_len = _DEFAULT_SUNSET - _DEFAULT_SUNRISE
default_night_after = 24.0 - _DEFAULT_SUNSET
actual_night_before = max(sunrise, 0.01)
actual_day_len = max(sunset - sunrise, 0.25)
actual_night_after = max(24.0 - sunset, 0.01)
lut = np.zeros((1440, 3), dtype=np.uint8)
for minute in range(1440):
hour = minute / 60.0
# Find surrounding control points
if hour < sunrise:
frac = hour / actual_night_before
canon_hour = frac * default_night_before
elif hour < sunset:
frac = (hour - sunrise) / actual_day_len
canon_hour = _DEFAULT_SUNRISE + frac * default_day_len
else:
frac = (hour - sunset) / actual_night_after
canon_hour = _DEFAULT_SUNSET + frac * default_night_after
canon_hour = max(0.0, min(23.99, canon_hour))
# Locate surrounding curve control points
prev = _DAYLIGHT_CURVE[0]
nxt = _DAYLIGHT_CURVE[-1]
for i in range(len(_DAYLIGHT_CURVE) - 1):
if _DAYLIGHT_CURVE[i][0] <= hour <= _DAYLIGHT_CURVE[i + 1][0]:
if _DAYLIGHT_CURVE[i][0] <= canon_hour <= _DAYLIGHT_CURVE[i + 1][0]:
prev = _DAYLIGHT_CURVE[i]
nxt = _DAYLIGHT_CURVE[i + 1]
break
span = nxt[0] - prev[0]
t = (hour - prev[0]) / span if span > 0 else 0.0
# Smooth interpolation (smoothstep)
t = t * t * (3 - 2 * t)
for ch in range(3):
lut[minute, ch] = int(prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5)
_daylight_lut = lut
span = nxt[0] - prev[0]
t = (canon_hour - prev[0]) / span if span > 0 else 0.0
t = t * t * (3.0 - 2.0 * t) # smoothstep
for ch in range(3):
lut[minute, ch] = int(
prev[ch + 1] + (nxt[ch + 1] - prev[ch + 1]) * t + 0.5
)
return lut
def _get_daylight_lut() -> np.ndarray:
"""Return the static default LUT (built once, cached globally)."""
global _daylight_lut
if _daylight_lut is None:
_daylight_lut = _build_lut_for_solar_times(_DEFAULT_SUNRISE, _DEFAULT_SUNSET)
return _daylight_lut
# ── Stream class ────────────────────────────────────────────────────────
class DaylightColorStripStream(ColorStripStream):
"""Color strip stream simulating a 24-hour daylight cycle.
All LEDs display the same color at any moment. The color smoothly
transitions through a pre-defined daylight curve.
transitions through a pre-defined daylight curve whose sunrise/sunset
times are computed from latitude, longitude, and day of year.
"""
def __init__(self, source):
self._colors_lock = threading.Lock()
self._running = False
self._thread: Optional[threading.Thread] = None
self._fps = 10 # low FPS — transitions are slow
self._fps = 10
self._frame_time = 1.0 / 10
self._clock = None
self._led_count = 1
self._auto_size = True
self._lut = _get_daylight_lut()
# Per-instance LUT cache: {(sr_min, ss_min): np.ndarray}
self._lut_cache: dict = {}
self._update_from_source(source)
def _update_from_source(self, source) -> None:
self._speed = float(getattr(source, "speed", 1.0))
self._use_real_time = bool(getattr(source, "use_real_time", False))
self._latitude = float(getattr(source, "latitude", 50.0))
self._longitude = float(getattr(source, "longitude", 0.0))
_lc = getattr(source, "led_count", 0)
self._auto_size = not _lc
self._led_count = _lc if _lc and _lc > 0 else 1
self._lut_cache = {}
with self._colors_lock:
self._colors: Optional[np.ndarray] = None
def _get_lut_for_day(self, day_of_year: int) -> np.ndarray:
"""Return a solar-time-aware LUT for the given day (cached)."""
sunrise, sunset = _compute_solar_times(
self._latitude, self._longitude, day_of_year
)
sr_key = int(round(sunrise * 60))
ss_key = int(round(sunset * 60))
cache_key = (sr_key, ss_key)
lut = self._lut_cache.get(cache_key)
if lut is None:
lut = _build_lut_for_solar_times(sunrise, sunset)
if len(self._lut_cache) > 8:
self._lut_cache.clear()
self._lut_cache[cache_key] = lut
return lut
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)
@@ -193,18 +304,20 @@ class DaylightColorStripStream(ColorStripStream):
_use_a = not _use_a
if self._use_real_time:
# Use actual wall-clock time
import datetime
now = datetime.datetime.now()
day_of_year = now.timetuple().tm_yday
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
else:
# Simulated cycle: speed=1.0 → full 24h in ~240s (4 min)
# Simulated: speed=1.0 → full 24h in 240s.
# Use summer solstice (day 172) for maximum day length.
day_of_year = 172
cycle_seconds = 240.0 / max(speed, 0.01)
phase = (t % cycle_seconds) / cycle_seconds # 0..1
phase = (t % cycle_seconds) / cycle_seconds
minute_of_day = phase * 1440.0
lut = self._get_lut_for_day(day_of_year)
idx = int(minute_of_day) % 1440
color = self._lut[idx]
color = lut[idx]
buf[:] = color
with self._colors_lock:

View File

@@ -42,8 +42,18 @@ _PALETTE_DEFS: Dict[str, list] = {
_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."""
def _build_palette_lut(name: str, custom_stops: list = None) -> np.ndarray:
"""Build a (256, 3) uint8 lookup table for the named palette.
When name == "custom" and custom_stops is provided, builds from those
stops without caching (each source gets its own LUT).
"""
if custom_stops and name == "custom":
# Convert [[pos,R,G,B], ...] to [(pos,R,G,B), ...]
points = [(s[0], s[1], s[2], s[3]) for s in custom_stops if len(s) >= 4]
if not points:
points = _PALETTE_DEFS["fire"]
else:
if name in _palette_cache:
return _palette_cache[name]
points = _PALETTE_DEFS.get(name, _PALETTE_DEFS["fire"])
@@ -67,6 +77,7 @@ def _build_palette_lut(name: str) -> np.ndarray:
int(ag + (bg - ag) * frac),
int(ab + (bb - ab) * frac),
)
if name != "custom":
_palette_cache[name] = lut
return lut
@@ -164,6 +175,13 @@ _EFFECT_DEFAULT_PALETTE = {
"plasma": "rainbow",
"noise": "rainbow",
"aurora": "aurora",
"rain": "ocean",
"comet": "fire",
"bouncing_ball": "rainbow",
"fireworks": "rainbow",
"sparkle_rain": "ice",
"lava_lamp": "lava",
"wave_interference": "rainbow",
}
@@ -200,6 +218,16 @@ class EffectColorStripStream(ColorStripStream):
self._s_layer2: Optional[np.ndarray] = None
self._plasma_key = (0, 0.0)
self._plasma_x: Optional[np.ndarray] = None
# Bouncing ball state
self._ball_positions: Optional[np.ndarray] = None
self._ball_velocities: Optional[np.ndarray] = None
self._ball_last_t = 0.0
# Fireworks state
self._fw_particles: list = [] # active particles
self._fw_rockets: list = [] # active rockets
self._fw_last_launch = 0.0
# Sparkle rain state
self._sparkle_state: Optional[np.ndarray] = None # per-LED brightness 0..1
self._update_from_source(source)
def _update_from_source(self, source) -> None:
@@ -208,7 +236,8 @@ class EffectColorStripStream(ColorStripStream):
self._auto_size = not _lc
self._led_count = _lc if _lc and _lc > 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)
custom_palette = getattr(source, "custom_palette", None)
self._palette_lut = _build_palette_lut(self._palette_name, custom_palette)
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))
@@ -290,6 +319,13 @@ class EffectColorStripStream(ColorStripStream):
"plasma": self._render_plasma,
"noise": self._render_noise,
"aurora": self._render_aurora,
"rain": self._render_rain,
"comet": self._render_comet,
"bouncing_ball": self._render_bouncing_ball,
"fireworks": self._render_fireworks,
"sparkle_rain": self._render_sparkle_rain,
"lava_lamp": self._render_lava_lamp,
"wave_interference": self._render_wave_interference,
}
try:
@@ -555,3 +591,329 @@ class EffectColorStripStream(ColorStripStream):
self._s_f32_rgb *= bright[:, np.newaxis]
np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb)
np.copyto(buf, self._s_f32_rgb, casting='unsafe')
# ── Rain ──────────────────────────────────────────────────────────
def _render_rain(self, buf: np.ndarray, n: int, t: float) -> None:
"""Raindrops falling down the strip with trailing tails."""
speed = self._effective_speed
intensity = self._intensity
scale = self._scale
lut = self._palette_lut
# Multiple rain "lanes" at different speeds for depth
bright = self._s_f32_a
bright[:] = 0.0
indices = self._s_arange
num_drops = max(3, int(8 * intensity))
for d in range(num_drops):
drop_speed = speed * (6.0 + d * 2.3) * scale
phase_offset = d * 31.7 # prime-ish offset for independence
# Drop position wraps around the strip
pos = (t * drop_speed + phase_offset) % n
# Tail: exponential falloff behind drop (drop falls downward = decreasing index)
dist = (pos - indices) % n
tail_len = max(3.0, n * 0.08)
trail = np.exp(-dist / tail_len)
# Head brightness boost
head_mask = dist < 2.0
trail[head_mask] = 1.0
bright += trail * (0.3 / max(num_drops * 0.3, 1.0))
np.clip(bright, 0.0, 1.0, out=bright)
np.multiply(bright, 255, out=self._s_f32_b)
np.copyto(self._s_i32, self._s_f32_b, casting='unsafe')
np.clip(self._s_i32, 0, 255, out=self._s_i32)
buf[:] = lut[self._s_i32]
# ── Comet ─────────────────────────────────────────────────────────
def _render_comet(self, buf: np.ndarray, n: int, t: float) -> None:
"""Multiple comets with curved, pulsing tails."""
speed = self._effective_speed
intensity = self._intensity
color = self._color
mirror = self._mirror
indices = self._s_arange
buf[:] = 0
num_comets = 3
for c in range(num_comets):
travel_speed = speed * (5.0 + c * 3.0)
phase = c * 137.5
if mirror:
cycle = 2 * (n - 1) if n > 1 else 1
raw_pos = (t * travel_speed + phase) % cycle
pos = raw_pos if raw_pos < n else cycle - raw_pos
else:
pos = (t * travel_speed + phase) % n
# Tail with pulsing brightness
dist = self._s_f32_a
np.subtract(pos, indices, out=dist)
dist %= n
decay = 0.04 + 0.20 * (1.0 - min(1.0, intensity))
np.multiply(dist, -decay, out=self._s_f32_b)
np.exp(self._s_f32_b, out=self._s_f32_b)
# Pulse modulation on tail
pulse = 0.7 + 0.3 * math.sin(t * speed * 4.0 + c * 2.1)
self._s_f32_b *= pulse
r, g, b = color
for ch_idx, ch_val in enumerate((r, g, b)):
np.multiply(self._s_f32_b, ch_val, out=self._s_f32_c)
np.clip(self._s_f32_c, 0, 255, out=self._s_f32_c)
# Additive blend
self._s_f32_a[:] = buf[:, ch_idx]
self._s_f32_a += self._s_f32_c
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
np.copyto(buf[:, ch_idx], self._s_f32_a, casting='unsafe')
# ── Bouncing Ball ─────────────────────────────────────────────────
def _render_bouncing_ball(self, buf: np.ndarray, n: int, t: float) -> None:
"""Physics-simulated bouncing balls with gravity."""
speed = self._effective_speed
intensity = self._intensity
color = self._color
num_balls = 3
# Initialize ball state on first call or LED count change
if self._ball_positions is None or len(self._ball_positions) != num_balls:
self._ball_positions = np.array([n * 0.3, n * 0.5, n * 0.8], dtype=np.float64)
self._ball_velocities = np.array([0.0, 0.0, 0.0], dtype=np.float64)
self._ball_last_t = t
dt = min(t - self._ball_last_t, 0.1) # cap delta to avoid explosion
self._ball_last_t = t
if dt <= 0:
dt = 1.0 / 30
gravity = 50.0 * intensity * speed
damping = 0.85
for i in range(num_balls):
self._ball_velocities[i] += gravity * dt
self._ball_positions[i] += self._ball_velocities[i] * dt
# Bounce off bottom (index n-1)
if self._ball_positions[i] >= n - 1:
self._ball_positions[i] = n - 1
self._ball_velocities[i] = -abs(self._ball_velocities[i]) * damping
# Re-launch if nearly stopped
if abs(self._ball_velocities[i]) < 2.0:
self._ball_velocities[i] = -30.0 * speed * (0.8 + 0.4 * (i / num_balls))
# Bounce off top
if self._ball_positions[i] < 0:
self._ball_positions[i] = 0
self._ball_velocities[i] = abs(self._ball_velocities[i]) * damping
# Render balls with glow radius
buf[:] = 0
indices = self._s_arange
r, g, b = color
for i in range(num_balls):
pos = self._ball_positions[i]
dist = self._s_f32_a
np.subtract(indices, pos, out=dist)
np.abs(dist, out=dist)
# Gaussian glow, radius ~3 LEDs
np.multiply(dist, dist, out=self._s_f32_b)
self._s_f32_b *= -0.5
np.exp(self._s_f32_b, out=self._s_f32_b)
# Hue offset per ball
hue_shift = i / num_balls
br = r * (1 - hue_shift * 0.5)
bg = g * (0.5 + hue_shift * 0.5)
bb = b * (0.3 + hue_shift * 0.7)
for ch_idx, ch_val in enumerate((br, bg, bb)):
np.multiply(self._s_f32_b, ch_val, out=self._s_f32_c)
self._s_f32_a[:] = buf[:, ch_idx]
self._s_f32_a += self._s_f32_c
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
np.copyto(buf[:, ch_idx], self._s_f32_a, casting='unsafe')
# ── Fireworks ─────────────────────────────────────────────────────
def _render_fireworks(self, buf: np.ndarray, n: int, t: float) -> None:
"""Rockets launch and explode into colorful particle bursts."""
speed = self._effective_speed
intensity = self._intensity
lut = self._palette_lut
dt = 1.0 / max(self._fps, 1)
# Launch rockets periodically
launch_interval = max(0.3, 1.5 / max(intensity, 0.1))
if t - self._fw_last_launch > launch_interval:
self._fw_last_launch = t
# Rocket: [position, velocity, palette_idx]
target = 0.2 + np.random.random() * 0.5 # explode at 20-70% height
rocket_speed = n * (0.8 + np.random.random() * 0.4) * speed
self._fw_rockets.append([float(n - 1), -rocket_speed, target * n])
# Update rockets
new_rockets = []
for rocket in self._fw_rockets:
rocket[0] += rocket[1] * dt
if rocket[0] <= rocket[2]:
# Explode: create particles
num_particles = int(8 + 8 * intensity)
palette_idx = np.random.randint(0, 256)
for _ in range(num_particles):
vel = (np.random.random() - 0.5) * n * 0.5 * speed
# [position, velocity, brightness, palette_idx]
self._fw_particles.append([rocket[0], vel, 1.0, palette_idx])
else:
new_rockets.append(rocket)
self._fw_rockets = new_rockets
# Update particles
new_particles = []
for p in self._fw_particles:
p[0] += p[1] * dt
p[1] *= 0.97 # drag
p[2] -= dt * 1.5 # fade
if p[2] > 0.02 and 0 <= p[0] < n:
new_particles.append(p)
self._fw_particles = new_particles
# Cap active particles
if len(self._fw_particles) > 200:
self._fw_particles = self._fw_particles[-200:]
# Render
buf[:] = 0
for p in self._fw_particles:
pos, _vel, bright, pal_idx = p
idx = int(pos)
if 0 <= idx < n:
color = lut[int(pal_idx) % 256]
for ch in range(3):
val = int(buf[idx, ch] + color[ch] * bright)
buf[idx, ch] = min(255, val)
# Spread to neighbors
for offset in (-1, 1):
ni = idx + offset
if 0 <= ni < n:
for ch in range(3):
val = int(buf[ni, ch] + color[ch] * bright * 0.4)
buf[ni, ch] = min(255, val)
# Render rockets as bright white dots
for rocket in self._fw_rockets:
idx = int(rocket[0])
if 0 <= idx < n:
buf[idx] = (255, 255, 255)
# ── Sparkle Rain ──────────────────────────────────────────────────
def _render_sparkle_rain(self, buf: np.ndarray, n: int, t: float) -> None:
"""Twinkling star field with smooth fade-in/fade-out."""
speed = self._effective_speed
intensity = self._intensity
lut = self._palette_lut
# Initialize/resize sparkle state
if self._sparkle_state is None or len(self._sparkle_state) != n:
self._sparkle_state = np.zeros(n, dtype=np.float32)
dt = 1.0 / max(self._fps, 1)
state = self._sparkle_state
# Fade existing sparkles
fade_rate = 1.5 * speed
state -= fade_rate * dt
np.clip(state, 0.0, 1.0, out=state)
# Spawn new sparkles
spawn_prob = 0.05 * intensity * speed
rng = np.random.random(n)
new_mask = (rng < spawn_prob) & (state < 0.1)
state[new_mask] = 1.0
# Map sparkle brightness to palette
np.multiply(state, 255, out=self._s_f32_a)
np.copyto(self._s_i32, self._s_f32_a, casting='unsafe')
np.clip(self._s_i32, 0, 255, out=self._s_i32)
self._s_f32_rgb[:] = lut[self._s_i32]
# Apply brightness
self._s_f32_rgb *= state[:, np.newaxis]
np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb)
np.copyto(buf, self._s_f32_rgb, casting='unsafe')
# ── Lava Lamp ─────────────────────────────────────────────────────
def _render_lava_lamp(self, buf: np.ndarray, n: int, t: float) -> None:
"""Slow-moving colored blobs that merge and separate."""
speed = self._effective_speed
scale = self._scale
lut = self._palette_lut
# Use noise at very low frequency for blob movement
np.multiply(self._s_arange, scale * 0.03, out=self._s_f32_a)
# Two blob layers at different speeds for organic movement
self._s_f32_a += t * speed * 0.1
layer1 = self._noise.fbm(self._s_f32_a, octaves=3).copy()
np.multiply(self._s_arange, scale * 0.05, out=self._s_f32_a)
self._s_f32_a += t * speed * 0.07 + 100.0
layer2 = self._noise.fbm(self._s_f32_a, octaves=2).copy()
# Combine: create blob-like shapes with soft edges
combined = self._s_f32_a
np.multiply(layer1, 0.6, out=combined)
combined += layer2 * 0.4
# Sharpen to create distinct blobs (sigmoid-like)
combined -= 0.45
combined *= 6.0
# Soft clamp via tanh approximation: x / (1 + |x|)
np.abs(combined, out=self._s_f32_b)
self._s_f32_b += 1.0
np.divide(combined, self._s_f32_b, out=combined)
# Map from [-1,1] to [0,1]
combined += 1.0
combined *= 0.5
np.clip(combined, 0.0, 1.0, out=combined)
# Map to palette
np.multiply(combined, 255, out=self._s_f32_b)
np.copyto(self._s_i32, self._s_f32_b, casting='unsafe')
np.clip(self._s_i32, 0, 255, out=self._s_i32)
buf[:] = lut[self._s_i32]
# ── Wave Interference ─────────────────────────────────────────────
def _render_wave_interference(self, buf: np.ndarray, n: int, t: float) -> None:
"""Two counter-propagating sine waves creating interference patterns."""
speed = self._effective_speed
scale = self._scale
lut = self._palette_lut
# Wave parameters
k = 2.0 * math.pi * scale / max(n, 1) # wavenumber
omega = speed * 2.0 # angular frequency
# Wave 1: right-propagating
indices = self._s_arange
np.multiply(indices, k, out=self._s_f32_a)
self._s_f32_a -= omega * t
np.sin(self._s_f32_a, out=self._s_f32_a)
# Wave 2: left-propagating at slightly different frequency
np.multiply(indices, k * 1.1, out=self._s_f32_b)
self._s_f32_b += omega * t * 0.9
np.sin(self._s_f32_b, out=self._s_f32_b)
# Interference: sum and normalize to [0, 255]
self._s_f32_a += self._s_f32_b
# Range is [-2, 2], map to [0, 255]
self._s_f32_a += 2.0
self._s_f32_a *= (255.0 / 4.0)
np.clip(self._s_f32_a, 0, 255, out=self._s_f32_a)
np.copyto(self._s_i32, self._s_f32_a, casting='unsafe')
buf[:] = lut[self._s_i32]

View File

@@ -118,7 +118,7 @@ import {
// Layer 5: color-strip sources
import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
onCSSTypeChange, onEffectTypeChange, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
colorCycleAddColor, colorCycleRemoveColor,
compositeAddLayer, compositeRemoveLayer,
mappedAddZone, mappedRemoveZone,
@@ -430,6 +430,7 @@ Object.assign(window, {
deleteColorStrip,
onCSSTypeChange,
onEffectTypeChange,
onEffectPaletteChange,
onCSSClockChange,
onAnimationTypeChange,
onDaylightRealTimeChange,

View File

@@ -31,18 +31,19 @@ function _collectPreviewConfig() {
} else if (sourceType === 'gradient') {
const stops = getGradientStops();
if (stops.length < 2) return null;
config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload() };
config = { source_type: 'gradient', stops: stops.map(s => ({ position: s.position, color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}) })), animation: _getAnimationPayload(), easing: (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value };
} else if (sourceType === 'color_cycle') {
const colors = _colorCycleGetColors();
if (colors.length < 2) return null;
config = { source_type: 'color_cycle', colors };
} else if (sourceType === 'effect') {
config = { source_type: 'effect', effect_type: (document.getElementById('css-editor-effect-type') as HTMLInputElement).value, palette: (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value, intensity: parseFloat((document.getElementById('css-editor-effect-intensity') as HTMLInputElement).value), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value), mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked };
if (config.effect_type === 'meteor') { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
if (['meteor', 'comet', 'bouncing_ball'].includes(config.effect_type)) { const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; config.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; }
if (config.palette === 'custom') { const cpText = (document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement)?.value?.trim(); if (cpText) { try { config.custom_palette = JSON.parse(cpText); } catch {} } }
} else if (sourceType === 'daylight') {
config = { source_type: 'daylight', speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value), use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value) };
config = { source_type: 'daylight', speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value), use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value), longitude: parseFloat((document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value) };
} else if (sourceType === 'candlelight') {
config = { source_type: 'candlelight', color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value), intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value), num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3, speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value) };
config = { source_type: 'candlelight', color: hexToRgbArray((document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value), intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value), num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3, speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value), wind_strength: parseFloat((document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value), candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value };
}
const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;
if (clockEl && clockEl.value) config.clock_id = clockEl.value;

View File

@@ -194,11 +194,12 @@ export function onCSSTypeChange() {
_ensureAudioPaletteIconSelect();
onAudioVizChange();
}
if (type === 'gradient') { _ensureGradientPresetIconSelect(); _renderCustomPresetList(); }
if (type === 'gradient') { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); _renderCustomPresetList(); }
if (type === 'notification') {
ensureNotificationEffectIconSelect();
ensureNotificationFilterModeIconSelect();
}
if (type === 'candlelight') _ensureCandleTypeIconSelect();
// Animation section — shown for static/gradient only
const animSection = document.getElementById('css-editor-animation-section') as HTMLElement;
@@ -206,7 +207,7 @@ export function onCSSTypeChange() {
if (type === 'static' || type === 'gradient') {
animSection.style.display = '';
const opts = type === 'gradient'
? ['none','breathing','gradient_shift','wave','strobe','sparkle','pulse','candle','rainbow_fade']
? ['none','breathing','gradient_shift','wave','noise_perturb','hue_rotate','strobe','sparkle','pulse','candle','rainbow_fade']
: ['none','breathing','strobe','sparkle','pulse','candle','rainbow_fade'];
animTypeSelect.innerHTML = opts.map(v =>
`<option value="${v}">${t('color_strip.animation.type.' + v)}</option>`
@@ -373,6 +374,8 @@ let _effectPaletteIconSelect: any = null;
let _audioPaletteIconSelect: any = null;
let _audioVizIconSelect: any = null;
let _gradientPresetIconSelect: any = null;
let _gradientEasingIconSelect: any = null;
let _candleTypeIconSelect: any = null;
const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
function _ensureInterpolationIconSelect() {
@@ -396,6 +399,13 @@ function _ensureEffectTypeIconSelect() {
{ value: 'plasma', icon: _icon(P.rainbow), label: t('color_strip.effect.plasma'), desc: t('color_strip.effect.plasma.desc') },
{ value: 'noise', icon: _icon(P.activity), label: t('color_strip.effect.noise'), desc: t('color_strip.effect.noise.desc') },
{ value: 'aurora', icon: _icon(P.sparkles), label: t('color_strip.effect.aurora'), desc: t('color_strip.effect.aurora.desc') },
{ value: 'rain', icon: _icon(P.cloudSun), label: t('color_strip.effect.rain'), desc: t('color_strip.effect.rain.desc') },
{ value: 'comet', icon: _icon(P.rocket), label: t('color_strip.effect.comet'), desc: t('color_strip.effect.comet.desc') },
{ value: 'bouncing_ball', icon: _icon(P.activity), label: t('color_strip.effect.bouncing_ball'), desc: t('color_strip.effect.bouncing_ball.desc') },
{ value: 'fireworks', icon: _icon(P.sparkles), label: t('color_strip.effect.fireworks'), desc: t('color_strip.effect.fireworks.desc') },
{ value: 'sparkle_rain', icon: _icon(P.star), label: t('color_strip.effect.sparkle_rain'), desc: t('color_strip.effect.sparkle_rain.desc') },
{ value: 'lava_lamp', icon: _icon(P.flame), label: t('color_strip.effect.lava_lamp'), desc: t('color_strip.effect.lava_lamp.desc') },
{ value: 'wave_interference',icon: _icon(P.rainbow), label: t('color_strip.effect.wave_interference'),desc: t('color_strip.effect.wave_interference.desc') },
];
if (_effectTypeIconSelect) { _effectTypeIconSelect.updateItems(items); return; }
_effectTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
@@ -407,10 +417,37 @@ function _ensureEffectPaletteIconSelect() {
const items = Object.entries(_PALETTE_COLORS).map(([key, pts]) => ({
value: key, icon: _gradientStripHTML(pts), label: t(`color_strip.palette.${key}`),
}));
items.push({ value: 'custom', icon: _icon(P.pencil), label: t('color_strip.palette.custom') });
if (_effectPaletteIconSelect) { _effectPaletteIconSelect.updateItems(items); return; }
_effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
function _ensureGradientEasingIconSelect() {
const sel = document.getElementById('css-editor-gradient-easing') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'linear', icon: _icon(P.trendingUp), label: t('color_strip.gradient.easing.linear'), desc: t('color_strip.gradient.easing.linear.desc') },
{ value: 'ease_in_out', icon: _icon(P.activity), label: t('color_strip.gradient.easing.ease_in_out'), desc: t('color_strip.gradient.easing.ease_in_out.desc') },
{ value: 'step', icon: _icon(P.layoutDashboard), label: t('color_strip.gradient.easing.step'), desc: t('color_strip.gradient.easing.step.desc') },
{ value: 'cubic', icon: _icon(P.slidersHorizontal), label: t('color_strip.gradient.easing.cubic'), desc: t('color_strip.gradient.easing.cubic.desc') },
];
if (_gradientEasingIconSelect) { _gradientEasingIconSelect.updateItems(items); return; }
_gradientEasingIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
function _ensureCandleTypeIconSelect() {
const sel = document.getElementById('css-editor-candlelight-type') as HTMLSelectElement | null;
if (!sel) return;
const items = [
{ value: 'default', icon: _icon(P.flame), label: t('color_strip.candlelight.type.default'), desc: t('color_strip.candlelight.type.default.desc') },
{ value: 'taper', icon: _icon(P.activity), label: t('color_strip.candlelight.type.taper'), desc: t('color_strip.candlelight.type.taper.desc') },
{ value: 'votive', icon: _icon(P.lightbulb), label: t('color_strip.candlelight.type.votive'), desc: t('color_strip.candlelight.type.votive.desc') },
{ value: 'bonfire', icon: _icon(P.zap), label: t('color_strip.candlelight.type.bonfire'), desc: t('color_strip.candlelight.type.bonfire.desc') },
];
if (_candleTypeIconSelect) { _candleTypeIconSelect.updateItems(items); return; }
_candleTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
}
function _ensureAudioPaletteIconSelect() {
const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null;
if (!sel) return;
@@ -500,6 +537,8 @@ function _buildAnimationTypeItems(cssType: any) {
items.push(
{ value: 'gradient_shift', icon: _icon(P.fastForward), label: t('color_strip.animation.type.gradient_shift'), desc: t('color_strip.animation.type.gradient_shift.desc') },
{ value: 'wave', icon: _icon(P.rainbow), label: t('color_strip.animation.type.wave'), desc: t('color_strip.animation.type.wave.desc') },
{ value: 'noise_perturb', icon: _icon(P.activity), label: t('color_strip.animation.type.noise_perturb'), desc: t('color_strip.animation.type.noise_perturb.desc') },
{ value: 'hue_rotate', icon: _icon(P.rotateCw), label: t('color_strip.animation.type.hue_rotate'), desc: t('color_strip.animation.type.hue_rotate.desc') },
);
}
items.push(
@@ -572,18 +611,23 @@ const _PALETTE_COLORS = {
// Default palette per effect type
export function onEffectTypeChange() {
const et = (document.getElementById('css-editor-effect-type') as HTMLInputElement).value;
// palette: all except meteor
(document.getElementById('css-editor-effect-palette-group') as HTMLElement).style.display = et !== 'meteor' ? '' : 'none';
// color picker: meteor only
(document.getElementById('css-editor-effect-color-group') as HTMLElement).style.display = et === 'meteor' ? '' : 'none';
// intensity: fire, meteor, aurora
(document.getElementById('css-editor-effect-intensity-group') as HTMLElement).style.display =
['fire', 'meteor', 'aurora'].includes(et) ? '' : 'none';
// scale: plasma, noise, aurora
(document.getElementById('css-editor-effect-scale-group') as HTMLElement).style.display =
['plasma', 'noise', 'aurora'].includes(et) ? '' : 'none';
// mirror: meteor only
(document.getElementById('css-editor-effect-mirror-group') as HTMLElement).style.display = et === 'meteor' ? '' : 'none';
// palette: all except meteor, comet, bouncing_ball (which use color picker)
const usesColorPicker = ['meteor', 'comet', 'bouncing_ball'].includes(et);
(document.getElementById('css-editor-effect-palette-group') as HTMLElement).style.display = !usesColorPicker ? '' : 'none';
// color picker: meteor, comet, bouncing_ball
(document.getElementById('css-editor-effect-color-group') as HTMLElement).style.display = usesColorPicker ? '' : 'none';
// intensity: most effects use it
const usesIntensity = ['fire', 'meteor', 'aurora', 'rain', 'comet', 'bouncing_ball', 'fireworks', 'sparkle_rain'].includes(et);
(document.getElementById('css-editor-effect-intensity-group') as HTMLElement).style.display = usesIntensity ? '' : 'none';
// scale: effects with spatial parameters
const usesScale = ['plasma', 'noise', 'aurora', 'rain', 'lava_lamp', 'wave_interference'].includes(et);
(document.getElementById('css-editor-effect-scale-group') as HTMLElement).style.display = usesScale ? '' : 'none';
// mirror: meteor, comet
(document.getElementById('css-editor-effect-mirror-group') as HTMLElement).style.display =
['meteor', 'comet'].includes(et) ? '' : 'none';
// custom palette visibility
if (!usesColorPicker) onEffectPaletteChange();
else (document.getElementById('css-editor-effect-custom-palette-group') as HTMLElement).style.display = 'none';
// description
const descEl = document.getElementById('css-editor-effect-type-desc') as HTMLElement | null;
if (descEl) {
@@ -594,6 +638,12 @@ export function onEffectTypeChange() {
_autoGenerateCSSName();
}
export function onEffectPaletteChange() {
const palette = (document.getElementById('css-editor-effect-palette') as HTMLInputElement).value;
const customGroup = document.getElementById('css-editor-effect-custom-palette-group') as HTMLElement;
if (customGroup) customGroup.style.display = palette === 'custom' ? '' : 'none';
}
/* ── Color Cycle helpers ──────────────────────────────────────── */
const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff'];
@@ -1074,7 +1124,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
content: `
<div class="card-header">
<div class="card-title" title="${escapeHtml(source.name)}">
${icon} ${escapeHtml(source.name)}
${icon} <span class="card-title-text">${escapeHtml(source.name)}</span>
</div>
</div>
<div class="stream-card-props">
@@ -1166,6 +1216,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
{ position: 1.0, color: [0, 0, 255] },
]);
_loadAnimationState(css.animation);
(document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = css.easing || 'linear';
if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue(css.easing || 'linear');
},
reset() {
(document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = '';
@@ -1174,6 +1226,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
{ position: 1.0, color: [0, 0, 255] },
]);
_loadAnimationState(null);
(document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = 'linear';
if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue('linear');
},
getPayload(name) {
const gStops = getGradientStops();
@@ -1189,6 +1243,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
...(s.colorRight ? { color_right: s.colorRight } : {}),
})),
animation: _getAnimationPayload(),
easing: (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value,
};
},
},
@@ -1205,6 +1260,10 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = css.scale ?? 1.0;
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
// Custom palette
const cpTextarea = document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement;
if (cpTextarea) cpTextarea.value = css.custom_palette ? JSON.stringify(css.custom_palette) : '';
onEffectPaletteChange();
},
reset() {
(document.getElementById('css-editor-effect-type') as HTMLInputElement).value = 'fire';
@@ -1215,6 +1274,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = '1.0';
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
const cpTextarea = document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement;
if (cpTextarea) cpTextarea.value = '';
},
getPayload(name) {
const payload: any = {
@@ -1225,11 +1286,23 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value),
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
};
// Meteor uses a color picker
if (payload.effect_type === 'meteor') {
// Meteor/comet/bouncing_ball use a color picker
if (['meteor', 'comet', 'bouncing_ball'].includes(payload.effect_type)) {
const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value;
payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
}
// Custom palette
if (payload.palette === 'custom') {
const cpText = (document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement).value.trim();
if (cpText) {
try {
payload.custom_palette = JSON.parse(cpText);
} catch {
cssEditorModal.showError('Invalid custom palette JSON');
return null;
}
}
}
return payload;
},
},
@@ -1345,6 +1418,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
(document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked = css.use_real_time || false;
(document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value = css.latitude ?? 50.0;
(document.getElementById('css-editor-daylight-latitude-val') as HTMLElement).textContent = parseFloat(css.latitude ?? 50.0).toFixed(0);
(document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value = css.longitude ?? 0.0;
(document.getElementById('css-editor-daylight-longitude-val') as HTMLElement).textContent = parseFloat(css.longitude ?? 0.0).toFixed(0);
_syncDaylightSpeedVisibility();
},
reset() {
@@ -1353,6 +1428,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
(document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked = false;
(document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value = 50.0 as any;
(document.getElementById('css-editor-daylight-latitude-val') as HTMLElement).textContent = '50';
(document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value = 0.0 as any;
(document.getElementById('css-editor-daylight-longitude-val') as HTMLElement).textContent = '0';
},
getPayload(name) {
return {
@@ -1360,6 +1437,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value),
use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value),
longitude: parseFloat((document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value),
};
},
},
@@ -1371,6 +1449,10 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3;
(document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = css.speed ?? 1.0;
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
(document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = css.wind_strength ?? 0.0;
(document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = parseFloat(css.wind_strength ?? 0.0).toFixed(1);
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = css.candle_type || 'default';
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default');
},
reset() {
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329';
@@ -1379,6 +1461,10 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any;
(document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = '1.0';
(document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = 0.0 as any;
(document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = '0.0';
(document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = 'default';
if (_candleTypeIconSelect) _candleTypeIconSelect.setValue('default');
},
getPayload(name) {
return {
@@ -1387,6 +1473,8 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value),
num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3,
speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value),
wind_strength: parseFloat((document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value),
candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value,
};
},
},

View File

@@ -963,6 +963,16 @@
"color_strip.static_color.hint": "The solid color that will be sent to all LEDs on the strip.",
"color_strip.gradient.preview": "Gradient:",
"color_strip.gradient.preview.hint": "Visual preview. Click the marker track below to add a stop. Drag markers to reposition.",
"color_strip.gradient.easing": "Easing:",
"color_strip.gradient.easing.hint": "Controls how colors blend between gradient stops.",
"color_strip.gradient.easing.linear": "Linear",
"color_strip.gradient.easing.linear.desc": "Constant-rate blending between stops",
"color_strip.gradient.easing.ease_in_out": "Smooth",
"color_strip.gradient.easing.ease_in_out.desc": "S-curve: slow start and end, fast middle",
"color_strip.gradient.easing.step": "Step",
"color_strip.gradient.easing.step.desc": "Hard jumps between colors with no blending",
"color_strip.gradient.easing.cubic": "Cubic",
"color_strip.gradient.easing.cubic.desc": "Cubic ease — accelerating blend curve",
"color_strip.gradient.stops": "Color Stops:",
"color_strip.gradient.stops.hint": "Each stop defines a color at a relative position (0.0 = start, 1.0 = end). The ↔ button adds a right-side color to create a hard edge at that stop.",
"color_strip.gradient.stops_count": "stops",
@@ -1012,6 +1022,10 @@
"color_strip.animation.type.candle.desc": "Warm flickering candle-like glow",
"color_strip.animation.type.rainbow_fade": "Rainbow Fade",
"color_strip.animation.type.rainbow_fade.desc": "Cycles through the entire hue spectrum",
"color_strip.animation.type.noise_perturb": "Noise Perturb",
"color_strip.animation.type.noise_perturb.desc": "Perturbs gradient stop positions with organic noise each frame",
"color_strip.animation.type.hue_rotate": "Hue Rotate",
"color_strip.animation.type.hue_rotate.desc": "Smoothly rotates all pixel hues while preserving saturation and brightness",
"color_strip.animation.speed": "Speed:",
"color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.",
"color_strip.color_cycle.colors": "Colors:",
@@ -1112,6 +1126,8 @@
"color_strip.daylight.real_time": "Real Time",
"color_strip.daylight.latitude": "Latitude:",
"color_strip.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.",
"color_strip.daylight.longitude": "Longitude:",
"color_strip.daylight.longitude.hint": "Your geographic longitude (-180 to 180). Adjusts solar noon offset for accurate sunrise/sunset timing.",
"color_strip.type.candlelight": "Candlelight",
"color_strip.type.candlelight.desc": "Realistic flickering candle simulation",
"color_strip.type.candlelight.hint": "Simulates realistic candle flickering across all LEDs with warm tones and organic flicker patterns.",
@@ -1124,6 +1140,18 @@
"color_strip.candlelight.num_candles.hint": "How many independent candle sources along the strip. Each flickers with its own pattern.",
"color_strip.candlelight.speed": "Flicker Speed:",
"color_strip.candlelight.speed.hint": "Speed of the flicker animation. Higher values produce faster, more restless flames.",
"color_strip.candlelight.wind": "Wind:",
"color_strip.candlelight.wind.hint": "Wind simulation strength. Higher values create correlated gusts that make all candles flicker together.",
"color_strip.candlelight.type": "Candle Type:",
"color_strip.candlelight.type.hint": "Preset that adjusts flicker behavior without changing other settings.",
"color_strip.candlelight.type.default": "Default",
"color_strip.candlelight.type.default.desc": "Standard candle flicker",
"color_strip.candlelight.type.taper": "Taper",
"color_strip.candlelight.type.taper.desc": "Tall, steady candle with reduced flicker",
"color_strip.candlelight.type.votive": "Votive",
"color_strip.candlelight.type.votive.desc": "Small, flickery candle with narrow glow",
"color_strip.candlelight.type.bonfire": "Bonfire",
"color_strip.candlelight.type.bonfire.desc": "Large, chaotic fire with extra warmth",
"color_strip.type.processed": "Processed",
"color_strip.type.processed.desc": "Apply a processing template to another source",
"color_strip.type.processed.hint": "Wraps an existing color strip source and pipes its output through a filter chain.",
@@ -1199,6 +1227,22 @@
"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.rain": "Rain",
"color_strip.effect.rain.desc": "Raindrops fall down the strip with trailing tails",
"color_strip.effect.comet": "Comet",
"color_strip.effect.comet.desc": "Multiple comets with curved, pulsing tails",
"color_strip.effect.bouncing_ball": "Bouncing Ball",
"color_strip.effect.bouncing_ball.desc": "Physics-simulated balls bouncing with gravity",
"color_strip.effect.fireworks": "Fireworks",
"color_strip.effect.fireworks.desc": "Rockets launch and explode into colorful bursts",
"color_strip.effect.sparkle_rain": "Sparkle Rain",
"color_strip.effect.sparkle_rain.desc": "Twinkling star field with smooth fade-in/fade-out",
"color_strip.effect.lava_lamp": "Lava Lamp",
"color_strip.effect.lava_lamp.desc": "Slow-moving colored blobs that merge and separate",
"color_strip.effect.wave_interference": "Wave Interference",
"color_strip.effect.wave_interference.desc": "Two counter-propagating waves creating interference patterns",
"color_strip.effect.custom_palette": "Custom Palette:",
"color_strip.effect.custom_palette.hint": "JSON array of [position, R, G, B] stops, e.g. [[0,0,0,0],[0.5,255,0,0],[1,255,255,0]]",
"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:",
@@ -1219,6 +1263,7 @@
"color_strip.palette.aurora": "Aurora",
"color_strip.palette.sunset": "Sunset",
"color_strip.palette.ice": "Ice",
"color_strip.palette.custom": "Custom",
"audio_source.title": "Audio Sources",
"audio_source.group.multichannel": "Multichannel",
"audio_source.group.mono": "Mono",

View File

@@ -493,18 +493,20 @@ class GradientColorStripSource(ColorStripSource):
{"position": 1.0, "color": [0, 0, 255]},
])
animation: Optional[dict] = None # {"enabled": bool, "type": str, "speed": float} or None
easing: str = "linear" # linear | ease_in_out | step | cubic
def to_dict(self) -> dict:
d = super().to_dict()
d["stops"] = [dict(s) for s in self.stops]
d["animation"] = self.animation
d["easing"] = self.easing
return d
@classmethod
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None,
stops=None, animation=None, **_kwargs):
stops=None, animation=None, easing=None, **_kwargs):
return cls(
id=id, name=name, source_type="gradient",
created_at=created_at, updated_at=updated_at,
@@ -514,6 +516,7 @@ class GradientColorStripSource(ColorStripSource):
{"position": 1.0, "color": [0, 0, 255]},
],
animation=animation,
easing=easing if easing in ("linear", "ease_in_out", "step", "cubic") else "linear",
)
def apply_update(self, **kwargs) -> None:
@@ -522,6 +525,8 @@ class GradientColorStripSource(ColorStripSource):
self.stops = stops
if kwargs.get("animation") is not None:
self.animation = kwargs["animation"]
if kwargs.get("easing") is not None:
self.easing = kwargs["easing"]
@dataclass
@@ -574,12 +579,13 @@ class EffectColorStripSource(ColorStripSource):
LED count auto-sizes from the connected device.
"""
effect_type: str = "fire" # fire | meteor | plasma | noise | aurora
palette: str = "fire" # named color palette
color: list = field(default_factory=lambda: [255, 80, 0]) # [R,G,B] for meteor head
effect_type: str = "fire" # fire | meteor | plasma | noise | aurora + new types
palette: str = "fire" # named color palette or "custom"
color: list = field(default_factory=lambda: [255, 80, 0]) # [R,G,B] for meteor/comet/bouncing_ball 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)
mirror: bool = False # bounce mode (meteor/comet)
custom_palette: Optional[list] = None # [[pos, R, G, B], ...] custom palette stops
def to_dict(self) -> dict:
d = super().to_dict()
@@ -589,6 +595,7 @@ class EffectColorStripSource(ColorStripSource):
d["intensity"] = self.intensity
d["scale"] = self.scale
d["mirror"] = self.mirror
d["custom_palette"] = self.custom_palette
return d
@classmethod
@@ -596,7 +603,8 @@ class EffectColorStripSource(ColorStripSource):
created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None,
effect_type="fire", palette="fire", color=None,
intensity=1.0, scale=1.0, mirror=False, **_kwargs):
intensity=1.0, scale=1.0, mirror=False,
custom_palette=None, **_kwargs):
rgb = _validate_rgb(color, [255, 80, 0])
return cls(
id=id, name=name, source_type="effect",
@@ -607,6 +615,7 @@ class EffectColorStripSource(ColorStripSource):
intensity=float(intensity) if intensity else 1.0,
scale=float(scale) if scale else 1.0,
mirror=bool(mirror),
custom_palette=custom_palette if isinstance(custom_palette, list) else None,
)
def apply_update(self, **kwargs) -> None:
@@ -623,6 +632,9 @@ class EffectColorStripSource(ColorStripSource):
self.scale = float(kwargs["scale"])
if kwargs.get("mirror") is not None:
self.mirror = bool(kwargs["mirror"])
if "custom_palette" in kwargs:
cp = kwargs["custom_palette"]
self.custom_palette = cp if isinstance(cp, list) else None
@dataclass
@@ -914,12 +926,14 @@ class DaylightColorStripSource(ColorStripSource):
speed: float = 1.0 # cycle speed (ignored when use_real_time)
use_real_time: bool = False # use actual time of day
latitude: float = 50.0 # latitude for sunrise/sunset timing (-90..90)
longitude: float = 0.0 # longitude for solar position (-180..180)
def to_dict(self) -> dict:
d = super().to_dict()
d["speed"] = self.speed
d["use_real_time"] = self.use_real_time
d["latitude"] = self.latitude
d["longitude"] = self.longitude
return d
@classmethod
@@ -927,7 +941,7 @@ class DaylightColorStripSource(ColorStripSource):
created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None,
speed=None, use_real_time=None, latitude=None,
**_kwargs):
longitude=None, **_kwargs):
return cls(
id=id, name=name, source_type="daylight",
created_at=created_at, updated_at=updated_at,
@@ -935,6 +949,7 @@ class DaylightColorStripSource(ColorStripSource):
speed=float(speed) if speed is not None else 1.0,
use_real_time=bool(use_real_time) if use_real_time is not None else False,
latitude=float(latitude) if latitude is not None else 50.0,
longitude=float(longitude) if longitude is not None else 0.0,
)
def apply_update(self, **kwargs) -> None:
@@ -944,6 +959,8 @@ class DaylightColorStripSource(ColorStripSource):
self.use_real_time = bool(kwargs["use_real_time"])
if kwargs.get("latitude") is not None:
self.latitude = float(kwargs["latitude"])
if kwargs.get("longitude") is not None:
self.longitude = float(kwargs["longitude"])
@dataclass
@@ -959,6 +976,8 @@ class CandlelightColorStripSource(ColorStripSource):
intensity: float = 1.0 # flicker intensity (0.1-2.0)
num_candles: int = 3 # number of independent candle sources
speed: float = 1.0 # flicker speed multiplier
wind_strength: float = 0.0 # wind effect (0.0-2.0)
candle_type: str = "default" # default | taper | votive | bonfire
def to_dict(self) -> dict:
d = super().to_dict()
@@ -966,6 +985,8 @@ class CandlelightColorStripSource(ColorStripSource):
d["intensity"] = self.intensity
d["num_candles"] = self.num_candles
d["speed"] = self.speed
d["wind_strength"] = self.wind_strength
d["candle_type"] = self.candle_type
return d
@classmethod
@@ -973,7 +994,8 @@ class CandlelightColorStripSource(ColorStripSource):
created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None,
color=None, intensity=1.0, num_candles=None,
speed=None, **_kwargs):
speed=None, wind_strength=None, candle_type=None,
**_kwargs):
rgb = _validate_rgb(color, [255, 147, 41])
return cls(
id=id, name=name, source_type="candlelight",
@@ -983,6 +1005,8 @@ class CandlelightColorStripSource(ColorStripSource):
intensity=float(intensity) if intensity else 1.0,
num_candles=int(num_candles) if num_candles is not None else 3,
speed=float(speed) if speed is not None else 1.0,
wind_strength=float(wind_strength) if wind_strength is not None else 0.0,
candle_type=candle_type if candle_type in {"default", "taper", "votive", "bonfire"} else "default",
)
def apply_update(self, **kwargs) -> None:
@@ -995,6 +1019,11 @@ class CandlelightColorStripSource(ColorStripSource):
self.num_candles = int(kwargs["num_candles"])
if kwargs.get("speed") is not None:
self.speed = float(kwargs["speed"])
if kwargs.get("wind_strength") is not None:
self.wind_strength = float(kwargs["wind_strength"])
ct = kwargs.get("candle_type")
if ct is not None and ct in {"default", "taper", "votive", "bonfire"}:
self.candle_type = ct
@dataclass

View File

@@ -152,6 +152,20 @@
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.stops.hint">Each stop defines a color at a relative position (0.0 = start, 1.0 = end). The ↔ button adds a right-side color to create a hard edge at that stop.</small>
<div id="gradient-stops-list"></div>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-gradient-easing" data-i18n="color_strip.gradient.easing">Easing:</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.easing.hint">Controls how colors blend between stops.</small>
<select id="css-editor-gradient-easing">
<option value="linear" data-i18n="color_strip.gradient.easing.linear">Linear</option>
<option value="ease_in_out" data-i18n="color_strip.gradient.easing.ease_in_out">Smooth (S-curve)</option>
<option value="step" data-i18n="color_strip.gradient.easing.step">Step</option>
<option value="cubic" data-i18n="color_strip.gradient.easing.cubic">Cubic</option>
</select>
</div>
</div>
<!-- Procedural effect fields -->
@@ -168,6 +182,13 @@
<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>
<option value="rain" data-i18n="color_strip.effect.rain">Rain</option>
<option value="comet" data-i18n="color_strip.effect.comet">Comet</option>
<option value="bouncing_ball" data-i18n="color_strip.effect.bouncing_ball">Bouncing Ball</option>
<option value="fireworks" data-i18n="color_strip.effect.fireworks">Fireworks</option>
<option value="sparkle_rain" data-i18n="color_strip.effect.sparkle_rain">Sparkle Rain</option>
<option value="lava_lamp" data-i18n="color_strip.effect.lava_lamp">Lava Lamp</option>
<option value="wave_interference" data-i18n="color_strip.effect.wave_interference">Wave Interference</option>
</select>
<small id="css-editor-effect-type-desc" class="field-desc"></small>
</div>
@@ -178,7 +199,7 @@
<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">
<select id="css-editor-effect-palette" onchange="onEffectPaletteChange()">
<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>
@@ -187,9 +208,20 @@
<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>
<option value="custom" data-i18n="color_strip.palette.custom">Custom</option>
</select>
</div>
<div id="css-editor-effect-custom-palette-group" class="form-group" style="display:none">
<div class="label-row">
<label for="css-editor-effect-custom-palette" data-i18n="color_strip.effect.custom_palette">Custom 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.custom_palette.hint">JSON array of [position, R, G, B] stops.</small>
<textarea id="css-editor-effect-custom-palette" rows="3"
placeholder='[[0,0,0,0],[0.5,255,0,0],[1,255,255,0]]'></textarea>
</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>
@@ -519,6 +551,15 @@
<input type="range" id="css-editor-daylight-latitude" min="-90" max="90" step="1" value="50"
oninput="document.getElementById('css-editor-daylight-latitude-val').textContent = parseInt(this.value)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-daylight-longitude"><span data-i18n="color_strip.daylight.longitude">Longitude:</span> <span id="css-editor-daylight-longitude-val">0</span>&deg;</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.daylight.longitude.hint">Your geographic longitude (-180 to 180). Adjusts solar noon offset for accurate sunrise/sunset timing.</small>
<input type="range" id="css-editor-daylight-longitude" min="-180" max="180" step="1" value="0"
oninput="document.getElementById('css-editor-daylight-longitude-val').textContent = parseInt(this.value)">
</div>
</div>
<!-- Candlelight section -->
@@ -557,6 +598,28 @@
<input type="range" id="css-editor-candlelight-speed" min="0.1" max="5.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-candlelight-wind"><span data-i18n="color_strip.candlelight.wind">Wind:</span> <span id="css-editor-candlelight-wind-val">0.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.candlelight.wind.hint">Wind simulation strength. Higher values create correlated gusts across all candles.</small>
<input type="range" id="css-editor-candlelight-wind" min="0.0" max="2.0" step="0.1" value="0.0"
oninput="document.getElementById('css-editor-candlelight-wind-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-candlelight-type" data-i18n="color_strip.candlelight.type">Candle Type:</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.candlelight.type.hint">Preset that adjusts flicker behavior without changing other settings.</small>
<select id="css-editor-candlelight-type">
<option value="default" data-i18n="color_strip.candlelight.type.default">Default</option>
<option value="taper" data-i18n="color_strip.candlelight.type.taper">Taper</option>
<option value="votive" data-i18n="color_strip.candlelight.type.votive">Votive</option>
<option value="bonfire" data-i18n="color_strip.candlelight.type.bonfire">Bonfire</option>
</select>
</div>
</div>
<!-- Processed type fields -->