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. **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) ## Pre-Commit Checks (MANDATORY)
Before every commit, run the relevant linters and fix any issues: Before every commit, run the relevant linters and fix any issues:

View File

@@ -64,11 +64,14 @@ class ColorStripSourceCreate(BaseModel):
# color_cycle-type fields # color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)") colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
# effect-type fields # 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: 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)") 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) 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) 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 # composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
# mapped-type fields # 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) 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") 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) 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 # candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20) 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 # processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)") 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)") 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 # color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)") colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
# effect-type fields # 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") 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) 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) 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") 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 # composite-type fields
layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type") layers: Optional[List[CompositeLayer]] = Field(None, description="Layers for composite type")
# mapped-type fields # 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) 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") 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) 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 # candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources (1-20)", ge=1, le=20) 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 # processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID (for processed type)") 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)") 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") intensity: Optional[float] = Field(None, description="Effect intensity")
scale: Optional[float] = Field(None, description="Spatial scale") scale: Optional[float] = Field(None, description="Spatial scale")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode") 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 # composite-type fields
layers: Optional[List[dict]] = Field(None, description="Layers for composite type") layers: Optional[List[dict]] = Field(None, description="Layers for composite type")
# mapped-type fields # mapped-type fields
@@ -217,8 +232,11 @@ class ColorStripSourceResponse(BaseModel):
speed: Optional[float] = Field(None, description="Cycle/flicker speed multiplier") 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") 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") latitude: Optional[float] = Field(None, description="Latitude for daylight timing")
longitude: Optional[float] = Field(None, description="Longitude for daylight timing")
# candlelight-type fields # candlelight-type fields
num_candles: Optional[int] = Field(None, description="Number of independent candle sources") 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 # processed-type fields
input_source_id: Optional[str] = Field(None, description="Input color strip source ID") 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") 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. flickering across all LEDs using layered sine waves and value noise.
Each "candle" is an independent flicker source that illuminates Each "candle" is an independent flicker source that illuminates
nearby LEDs with smooth falloff. 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 math
import threading import threading
import time import time
from typing import Optional from typing import List, Optional
import numpy as np import numpy as np
@@ -38,6 +43,18 @@ def _noise1d(x: np.ndarray) -> np.ndarray:
return a + u * (b - a) 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): class CandlelightColorStripStream(ColorStripStream):
"""Color strip stream simulating realistic candle flickering. """Color strip stream simulating realistic candle flickering.
@@ -59,7 +76,12 @@ class CandlelightColorStripStream(ColorStripStream):
self._s_bright: Optional[np.ndarray] = None self._s_bright: Optional[np.ndarray] = None
self._s_noise: Optional[np.ndarray] = None self._s_noise: Optional[np.ndarray] = None
self._s_x: Optional[np.ndarray] = None self._s_x: Optional[np.ndarray] = None
self._s_drip: Optional[np.ndarray] = None
self._pool_n = 0 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) self._update_from_source(source)
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
@@ -68,6 +90,9 @@ class CandlelightColorStripStream(ColorStripStream):
self._intensity = float(getattr(source, "intensity", 1.0)) self._intensity = float(getattr(source, "intensity", 1.0))
self._num_candles = max(1, int(getattr(source, "num_candles", 3))) self._num_candles = max(1, int(getattr(source, "num_candles", 3)))
self._speed = float(getattr(source, "speed", 1.0)) 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) _lc = getattr(source, "led_count", 0)
self._auto_size = not _lc self._auto_size = not _lc
self._led_count = _lc if _lc and _lc > 0 else 1 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_bright = np.empty(n, dtype=np.float32)
self._s_noise = 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_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 buf = _buf_a if _use_a else _buf_b
_use_a = not _use_a _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: with self._colors_lock:
self._colors = buf self._colors = buf
@@ -179,26 +206,68 @@ class CandlelightColorStripStream(ColorStripStream):
finally: finally:
self._running = False self._running = False
def _render_candlelight(self, buf: np.ndarray, n: int, t: float, speed: float) -> None: # ── Drip management ─────────────────────────────────────────────
"""Render candle flickering into buf (n, 3) uint8.
Algorithm: def _update_drip_events(self, n: int, wall_t: float, dt: float) -> None:
- Place num_candles evenly along the strip """Spawn new wax drip events and advance existing ones."""
- Each candle has independent layered-sine flicker intensity = self._intensity
- Spatial falloff: LEDs near a candle are brighter spawn_interval = max(0.3, 1.0 / max(intensity, 0.01))
- Per-LED noise adds individual variation if wall_t - self._last_drip_t >= spawn_interval and len(self._drip_events) < 5:
- Final brightness modulates the base warm color self._last_drip_t = wall_t
""" pos = float(self._drip_rng.randint(0, max(n, 1)))
# Scale speed so that speed=1 gives a gentle ~1.3 Hz dominant flicker self._drip_events.append([pos, 1.0, 0])
speed = speed * 0.35
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 intensity = self._intensity
num_candles = self._num_candles num_candles = self._num_candles
base_r, base_g, base_b = self._color[0], self._color[1], self._color[2] base_r, base_g, base_b = self._color[0], self._color[1], self._color[2]
bright = self._s_bright # Wind modulation
bright[:] = 0.0 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: if num_candles == 1:
positions = [n / 2.0] positions = [n / 2.0]
else: else:
@@ -207,42 +276,42 @@ class CandlelightColorStripStream(ColorStripStream):
x = self._s_x[:n] x = self._s_x[:n]
for ci, pos in enumerate(positions): for ci, pos in enumerate(positions):
# Independent flicker for this candle: layered sines at different frequencies offset = ci * 137.5
# Use candle index as phase offset for independence
offset = ci * 137.5 # golden-angle offset for non-repeating
flicker = ( flicker = (
0.40 * math.sin(2 * math.pi * speed * t * 3.7 + offset) 0.40 * math.sin(2.0 * math.pi * eff_speed * t * 3.7 + offset)
+ 0.25 * math.sin(2 * math.pi * speed * t * 7.3 + offset * 0.7) + 0.25 * math.sin(2.0 * math.pi * eff_speed * t * 7.3 + offset * 0.7)
+ 0.15 * math.sin(2 * math.pi * speed * t * 13.1 + offset * 1.3) + 0.15 * math.sin(2.0 * math.pi * eff_speed * t * 13.1 + offset * 1.3)
+ 0.10 * math.sin(2 * math.pi * speed * t * 1.9 + offset * 0.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 candle_brightness = 0.65 + 0.35 * flicker * intensity * amp_mul
# sigma proportional to strip length / num_candles
sigma = max(n / (num_candles * 2.0), 2.0) 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 dist = x - pos
falloff = np.exp(-0.5 * (dist * dist) / (sigma * sigma)) falloff = np.exp(-0.5 * (dist * dist) / (sigma * sigma))
bright += candle_brightness * falloff bright[:n] += candle_brightness * falloff
# Per-LED noise for individual variation # Per-LED noise
noise_x = x * 0.3 + t * speed * 5.0 noise_x = x * 0.3 + t * eff_speed * 5.0
noise = _noise1d(noise_x) noise = _noise1d(noise_x)
# Modulate brightness with noise (±15%) bright[:n] *= (0.85 + 0.30 * noise)
bright *= (0.85 + 0.30 * noise)
# Clamp to [0, 1] # Wax drip factor
np.clip(bright, 0.0, 1.0, out=bright) bright[:n] *= self._s_drip[:n]
# Apply base color with brightness modulation np.clip(bright[:n], 0.0, 1.0, out=bright[:n])
# Candles emit warmer (more red, less blue) at lower brightness
# Add slight color variation: dimmer = warmer # Colour mapping: dimmer = warmer
warm_shift = (1.0 - bright) * 0.3 warm_shift = (1.0 - bright[:n]) * (0.3 + warm_bonus)
r = bright * base_r r = bright[:n] * base_r
g = bright * base_g * (1.0 - warm_shift * 0.5) g = bright[:n] * base_g * (1.0 - warm_shift * 0.5)
b = bright * base_b * (1.0 - warm_shift) b = bright[:n] * base_b * (1.0 - warm_shift)
buf[:, 0] = np.clip(r, 0, 255).astype(np.uint8) buf[:, 0] = np.clip(r, 0, 255).astype(np.uint8)
buf[:, 1] = np.clip(g, 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 import get_logger
from wled_controller.utils.timer import high_resolution_timer 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__) logger = get_logger(__name__)
@@ -348,7 +369,7 @@ class PictureColorStripStream(ColorStripStream):
self._running = False 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. """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} 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"] left_color = A["color_right"] if present, else A["color"]
right_color = B["color"] right_color = B["color"]
t = (p - A.pos) / (B.pos - A.pos) 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: if led_count <= 0:
led_count = 1 led_count = 1
@@ -412,6 +433,15 @@ def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
span = b_pos - a_pos span = b_pos - a_pos
t = np.where(span > 0, (between_pos - a_pos) / span, 0.0) 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 a_colors = right_colors[idx] # A's right color
b_colors = left_colors[idx + 1] # B's left color b_colors = left_colors[idx + 1] # B's left color
result[mask_between] = a_colors + t[:, np.newaxis] * (b_colors - a_colors) 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 led_count = _lc if _lc and _lc > 0 else 1
self._led_count = led_count self._led_count = led_count
self._animation = source.animation # dict or None; read atomically by _animate_loop self._animation = source.animation # dict or None; read atomically by _animate_loop
self._easing = getattr(source, "easing", "linear") or "linear"
self._rebuild_colors() self._rebuild_colors()
def _rebuild_colors(self) -> None: 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: with self._colors_lock:
self._colors = colors self._colors = colors
@@ -919,6 +950,7 @@ class GradientColorStripStream(ColorStripStream):
_cached_base: Optional[np.ndarray] = None _cached_base: Optional[np.ndarray] = None
_cached_n: int = 0 _cached_n: int = 0
_cached_stops: Optional[list] = None _cached_stops: Optional[list] = None
_cached_easing: str = ""
# Double-buffer pool + uint16 scratch for brightness math # Double-buffer pool + uint16 scratch for brightness math
_pool_n = 0 _pool_n = 0
_buf_a = _buf_b = _scratch_u16 = None _buf_a = _buf_b = _scratch_u16 = None
@@ -950,11 +982,13 @@ class GradientColorStripStream(ColorStripStream):
stops = self._stops stops = self._stops
colors = None colors = None
# Recompute base gradient only when stops or led_count change # Recompute base gradient only when stops, led_count, or easing change
if _cached_base is None or _cached_n != n or _cached_stops is not stops: easing = self._easing
_cached_base = _compute_gradient_colors(stops, n) 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_n = n
_cached_stops = stops _cached_stops = stops
_cached_easing = easing
base = _cached_base base = _cached_base
# Re-allocate pool only when LED count changes # 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) buf[:, 2] = np.clip(bo * 255.0, 0, 255).astype(np.uint8)
colors = buf 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: if colors is not None:
with self._colors_lock: with self._colors_lock:
self._colors = colors self._colors = colors

View File

@@ -3,8 +3,14 @@
Implements DaylightColorStripStream which produces a uniform LED color array Implements DaylightColorStripStream which produces a uniform LED color array
that transitions through dawn, daylight, sunset, and night over a continuous 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. 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 threading
import time import time
from typing import Optional from typing import Optional
@@ -19,9 +25,9 @@ logger = get_logger(__name__)
# ── Daylight color table ──────────────────────────────────────────────── # ── Daylight color table ────────────────────────────────────────────────
# #
# Maps hour-of-day (024) to RGB color. Interpolated linearly between # Canonical hour control points (024) RGB. Designed for a default
# control points. Colors approximate natural daylight color temperature # sunrise of 6 h and sunset of 19 h. At render time the curve is remapped
# from warm sunrise tones through cool midday to warm sunset and dim night. # to the actual solar times for the location.
# #
# Format: (hour, R, G, B) # Format: (hour, R, G, B)
_DAYLIGHT_CURVE = [ _DAYLIGHT_CURVE = [
@@ -44,66 +50,171 @@ _DAYLIGHT_CURVE = [
(24.0, 10, 10, 30), # midnight (wrap) (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 _daylight_lut: Optional[np.ndarray] = None
def _get_daylight_lut() -> np.ndarray: # ── Solar position helpers ──────────────────────────────────────────────
global _daylight_lut
if _daylight_lut is not None:
return _daylight_lut 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) lut = np.zeros((1440, 3), dtype=np.uint8)
for minute in range(1440): for minute in range(1440):
hour = minute / 60.0 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] prev = _DAYLIGHT_CURVE[0]
nxt = _DAYLIGHT_CURVE[-1] nxt = _DAYLIGHT_CURVE[-1]
for i in range(len(_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] prev = _DAYLIGHT_CURVE[i]
nxt = _DAYLIGHT_CURVE[i + 1] nxt = _DAYLIGHT_CURVE[i + 1]
break 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 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): class DaylightColorStripStream(ColorStripStream):
"""Color strip stream simulating a 24-hour daylight cycle. """Color strip stream simulating a 24-hour daylight cycle.
All LEDs display the same color at any moment. The color smoothly 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): def __init__(self, source):
self._colors_lock = threading.Lock() self._colors_lock = threading.Lock()
self._running = False self._running = False
self._thread: Optional[threading.Thread] = None self._thread: Optional[threading.Thread] = None
self._fps = 10 # low FPS — transitions are slow self._fps = 10
self._frame_time = 1.0 / 10 self._frame_time = 1.0 / 10
self._clock = None self._clock = None
self._led_count = 1 self._led_count = 1
self._auto_size = True 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) self._update_from_source(source)
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
self._speed = float(getattr(source, "speed", 1.0)) self._speed = float(getattr(source, "speed", 1.0))
self._use_real_time = bool(getattr(source, "use_real_time", False)) self._use_real_time = bool(getattr(source, "use_real_time", False))
self._latitude = float(getattr(source, "latitude", 50.0)) self._latitude = float(getattr(source, "latitude", 50.0))
self._longitude = float(getattr(source, "longitude", 0.0))
_lc = getattr(source, "led_count", 0) _lc = getattr(source, "led_count", 0)
self._auto_size = not _lc self._auto_size = not _lc
self._led_count = _lc if _lc and _lc > 0 else 1 self._led_count = _lc if _lc and _lc > 0 else 1
self._lut_cache = {}
with self._colors_lock: with self._colors_lock:
self._colors: Optional[np.ndarray] = None 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: def configure(self, device_led_count: int) -> None:
if self._auto_size and device_led_count > 0: if self._auto_size and device_led_count > 0:
new_count = max(self._led_count, device_led_count) new_count = max(self._led_count, device_led_count)
@@ -193,18 +304,20 @@ class DaylightColorStripStream(ColorStripStream):
_use_a = not _use_a _use_a = not _use_a
if self._use_real_time: if self._use_real_time:
# Use actual wall-clock time
import datetime
now = datetime.datetime.now() now = datetime.datetime.now()
day_of_year = now.timetuple().tm_yday
minute_of_day = now.hour * 60 + now.minute + now.second / 60.0 minute_of_day = now.hour * 60 + now.minute + now.second / 60.0
else: 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) 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 minute_of_day = phase * 1440.0
lut = self._get_lut_for_day(day_of_year)
idx = int(minute_of_day) % 1440 idx = int(minute_of_day) % 1440
color = self._lut[idx] color = lut[idx]
buf[:] = color buf[:] = color
with self._colors_lock: with self._colors_lock:

View File

@@ -42,8 +42,18 @@ _PALETTE_DEFS: Dict[str, list] = {
_palette_cache: Dict[str, np.ndarray] = {} _palette_cache: Dict[str, np.ndarray] = {}
def _build_palette_lut(name: str) -> np.ndarray: def _build_palette_lut(name: str, custom_stops: list = None) -> np.ndarray:
"""Build a (256, 3) uint8 lookup table for the named palette.""" """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: if name in _palette_cache:
return _palette_cache[name] return _palette_cache[name]
points = _PALETTE_DEFS.get(name, _PALETTE_DEFS["fire"]) 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(ag + (bg - ag) * frac),
int(ab + (bb - ab) * frac), int(ab + (bb - ab) * frac),
) )
if name != "custom":
_palette_cache[name] = lut _palette_cache[name] = lut
return lut return lut
@@ -164,6 +175,13 @@ _EFFECT_DEFAULT_PALETTE = {
"plasma": "rainbow", "plasma": "rainbow",
"noise": "rainbow", "noise": "rainbow",
"aurora": "aurora", "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._s_layer2: Optional[np.ndarray] = None
self._plasma_key = (0, 0.0) self._plasma_key = (0, 0.0)
self._plasma_x: Optional[np.ndarray] = None 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) self._update_from_source(source)
def _update_from_source(self, source) -> None: def _update_from_source(self, source) -> None:
@@ -208,7 +236,8 @@ class EffectColorStripStream(ColorStripStream):
self._auto_size = not _lc self._auto_size = not _lc
self._led_count = _lc if _lc and _lc > 0 else 1 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_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) color = getattr(source, "color", None)
self._color = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0] self._color = color if isinstance(color, list) and len(color) == 3 else [255, 80, 0]
self._intensity = float(getattr(source, "intensity", 1.0)) self._intensity = float(getattr(source, "intensity", 1.0))
@@ -290,6 +319,13 @@ class EffectColorStripStream(ColorStripStream):
"plasma": self._render_plasma, "plasma": self._render_plasma,
"noise": self._render_noise, "noise": self._render_noise,
"aurora": self._render_aurora, "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: try:
@@ -555,3 +591,329 @@ class EffectColorStripStream(ColorStripStream):
self._s_f32_rgb *= bright[:, np.newaxis] self._s_f32_rgb *= bright[:, np.newaxis]
np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb) np.clip(self._s_f32_rgb, 0, 255, out=self._s_f32_rgb)
np.copyto(buf, self._s_f32_rgb, casting='unsafe') 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 // Layer 5: color-strip sources
import { import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip, showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange, onEffectTypeChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange, onCSSTypeChange, onEffectTypeChange, onEffectPaletteChange, onAnimationTypeChange, onCSSClockChange, onDaylightRealTimeChange,
colorCycleAddColor, colorCycleRemoveColor, colorCycleAddColor, colorCycleRemoveColor,
compositeAddLayer, compositeRemoveLayer, compositeAddLayer, compositeRemoveLayer,
mappedAddZone, mappedRemoveZone, mappedAddZone, mappedRemoveZone,
@@ -430,6 +430,7 @@ Object.assign(window, {
deleteColorStrip, deleteColorStrip,
onCSSTypeChange, onCSSTypeChange,
onEffectTypeChange, onEffectTypeChange,
onEffectPaletteChange,
onCSSClockChange, onCSSClockChange,
onAnimationTypeChange, onAnimationTypeChange,
onDaylightRealTimeChange, onDaylightRealTimeChange,

View File

@@ -31,18 +31,19 @@ function _collectPreviewConfig() {
} else if (sourceType === 'gradient') { } else if (sourceType === 'gradient') {
const stops = getGradientStops(); const stops = getGradientStops();
if (stops.length < 2) return null; 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') { } else if (sourceType === 'color_cycle') {
const colors = _colorCycleGetColors(); const colors = _colorCycleGetColors();
if (colors.length < 2) return null; if (colors.length < 2) return null;
config = { source_type: 'color_cycle', colors }; config = { source_type: 'color_cycle', colors };
} else if (sourceType === 'effect') { } 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 }; 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') { } 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') { } 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; const clockEl = document.getElementById('css-editor-clock') as HTMLSelectElement | null;
if (clockEl && clockEl.value) config.clock_id = clockEl.value; if (clockEl && clockEl.value) config.clock_id = clockEl.value;

View File

@@ -194,11 +194,12 @@ export function onCSSTypeChange() {
_ensureAudioPaletteIconSelect(); _ensureAudioPaletteIconSelect();
onAudioVizChange(); onAudioVizChange();
} }
if (type === 'gradient') { _ensureGradientPresetIconSelect(); _renderCustomPresetList(); } if (type === 'gradient') { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); _renderCustomPresetList(); }
if (type === 'notification') { if (type === 'notification') {
ensureNotificationEffectIconSelect(); ensureNotificationEffectIconSelect();
ensureNotificationFilterModeIconSelect(); ensureNotificationFilterModeIconSelect();
} }
if (type === 'candlelight') _ensureCandleTypeIconSelect();
// Animation section — shown for static/gradient only // Animation section — shown for static/gradient only
const animSection = document.getElementById('css-editor-animation-section') as HTMLElement; const animSection = document.getElementById('css-editor-animation-section') as HTMLElement;
@@ -206,7 +207,7 @@ export function onCSSTypeChange() {
if (type === 'static' || type === 'gradient') { if (type === 'static' || type === 'gradient') {
animSection.style.display = ''; animSection.style.display = '';
const opts = type === 'gradient' 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']; : ['none','breathing','strobe','sparkle','pulse','candle','rainbow_fade'];
animTypeSelect.innerHTML = opts.map(v => animTypeSelect.innerHTML = opts.map(v =>
`<option value="${v}">${t('color_strip.animation.type.' + v)}</option>` `<option value="${v}">${t('color_strip.animation.type.' + v)}</option>`
@@ -373,6 +374,8 @@ let _effectPaletteIconSelect: any = null;
let _audioPaletteIconSelect: any = null; let _audioPaletteIconSelect: any = null;
let _audioVizIconSelect: any = null; let _audioVizIconSelect: any = null;
let _gradientPresetIconSelect: 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>`; const _icon = (d: any) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
function _ensureInterpolationIconSelect() { 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: '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: '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: '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; } if (_effectTypeIconSelect) { _effectTypeIconSelect.updateItems(items); return; }
_effectTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 }); _effectTypeIconSelect = new IconSelect({ target: sel, items, columns: 2 });
@@ -407,10 +417,37 @@ function _ensureEffectPaletteIconSelect() {
const items = Object.entries(_PALETTE_COLORS).map(([key, pts]) => ({ const items = Object.entries(_PALETTE_COLORS).map(([key, pts]) => ({
value: key, icon: _gradientStripHTML(pts), label: t(`color_strip.palette.${key}`), 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; } if (_effectPaletteIconSelect) { _effectPaletteIconSelect.updateItems(items); return; }
_effectPaletteIconSelect = new IconSelect({ target: sel, items, columns: 2 }); _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() { function _ensureAudioPaletteIconSelect() {
const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null; const sel = document.getElementById('css-editor-audio-palette') as HTMLSelectElement | null;
if (!sel) return; if (!sel) return;
@@ -500,6 +537,8 @@ function _buildAnimationTypeItems(cssType: any) {
items.push( 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: '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: '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( items.push(
@@ -572,18 +611,23 @@ const _PALETTE_COLORS = {
// Default palette per effect type // Default palette per effect type
export function onEffectTypeChange() { export function onEffectTypeChange() {
const et = (document.getElementById('css-editor-effect-type') as HTMLInputElement).value; const et = (document.getElementById('css-editor-effect-type') as HTMLInputElement).value;
// palette: all except meteor // palette: all except meteor, comet, bouncing_ball (which use color picker)
(document.getElementById('css-editor-effect-palette-group') as HTMLElement).style.display = et !== 'meteor' ? '' : 'none'; const usesColorPicker = ['meteor', 'comet', 'bouncing_ball'].includes(et);
// color picker: meteor only (document.getElementById('css-editor-effect-palette-group') as HTMLElement).style.display = !usesColorPicker ? '' : 'none';
(document.getElementById('css-editor-effect-color-group') as HTMLElement).style.display = et === 'meteor' ? '' : 'none'; // color picker: meteor, comet, bouncing_ball
// intensity: fire, meteor, aurora (document.getElementById('css-editor-effect-color-group') as HTMLElement).style.display = usesColorPicker ? '' : 'none';
(document.getElementById('css-editor-effect-intensity-group') as HTMLElement).style.display = // intensity: most effects use it
['fire', 'meteor', 'aurora'].includes(et) ? '' : 'none'; const usesIntensity = ['fire', 'meteor', 'aurora', 'rain', 'comet', 'bouncing_ball', 'fireworks', 'sparkle_rain'].includes(et);
// scale: plasma, noise, aurora (document.getElementById('css-editor-effect-intensity-group') as HTMLElement).style.display = usesIntensity ? '' : 'none';
(document.getElementById('css-editor-effect-scale-group') as HTMLElement).style.display = // scale: effects with spatial parameters
['plasma', 'noise', 'aurora'].includes(et) ? '' : 'none'; const usesScale = ['plasma', 'noise', 'aurora', 'rain', 'lava_lamp', 'wave_interference'].includes(et);
// mirror: meteor only (document.getElementById('css-editor-effect-scale-group') as HTMLElement).style.display = usesScale ? '' : 'none';
(document.getElementById('css-editor-effect-mirror-group') as HTMLElement).style.display = et === 'meteor' ? '' : '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 // description
const descEl = document.getElementById('css-editor-effect-type-desc') as HTMLElement | null; const descEl = document.getElementById('css-editor-effect-type-desc') as HTMLElement | null;
if (descEl) { if (descEl) {
@@ -594,6 +638,12 @@ export function onEffectTypeChange() {
_autoGenerateCSSName(); _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 ──────────────────────────────────────── */ /* ── Color Cycle helpers ──────────────────────────────────────── */
const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']; const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff'];
@@ -1074,7 +1124,7 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
content: ` content: `
<div class="card-header"> <div class="card-header">
<div class="card-title" title="${escapeHtml(source.name)}"> <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> </div>
<div class="stream-card-props"> <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] }, { position: 1.0, color: [0, 0, 255] },
]); ]);
_loadAnimationState(css.animation); _loadAnimationState(css.animation);
(document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = css.easing || 'linear';
if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue(css.easing || 'linear');
}, },
reset() { reset() {
(document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = ''; (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] }, { position: 1.0, color: [0, 0, 255] },
]); ]);
_loadAnimationState(null); _loadAnimationState(null);
(document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = 'linear';
if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue('linear');
}, },
getPayload(name) { getPayload(name) {
const gStops = getGradientStops(); const gStops = getGradientStops();
@@ -1189,6 +1243,7 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
...(s.colorRight ? { color_right: s.colorRight } : {}), ...(s.colorRight ? { color_right: s.colorRight } : {}),
})), })),
animation: _getAnimationPayload(), 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') 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-scale-val') as HTMLElement).textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false; (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() { reset() {
(document.getElementById('css-editor-effect-type') as HTMLInputElement).value = 'fire'; (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') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = '1.0'; (document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = '1.0';
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false; (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) { getPayload(name) {
const payload: any = { 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), scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value),
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked, mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
}; };
// Meteor uses a color picker // Meteor/comet/bouncing_ball use a color picker
if (payload.effect_type === 'meteor') { if (['meteor', 'comet', 'bouncing_ball'].includes(payload.effect_type)) {
const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value; 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)]; 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; 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-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') 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-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(); _syncDaylightSpeedVisibility();
}, },
reset() { 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-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') as HTMLInputElement).value = 50.0 as any;
(document.getElementById('css-editor-daylight-latitude-val') as HTMLElement).textContent = '50'; (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) { getPayload(name) {
return { 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), speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value),
use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked, use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value), 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-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') 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-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() { reset() {
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329'; (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-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') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = '1.0'; (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) { getPayload(name) {
return { 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), 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, 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), 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.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": "Gradient:",
"color_strip.gradient.preview.hint": "Visual preview. Click the marker track below to add a stop. Drag markers to reposition.", "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": "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.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", "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.candle.desc": "Warm flickering candle-like glow",
"color_strip.animation.type.rainbow_fade": "Rainbow Fade", "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.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": "Speed:",
"color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.", "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:", "color_strip.color_cycle.colors": "Colors:",
@@ -1112,6 +1126,8 @@
"color_strip.daylight.real_time": "Real Time", "color_strip.daylight.real_time": "Real Time",
"color_strip.daylight.latitude": "Latitude:", "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.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": "Candlelight",
"color_strip.type.candlelight.desc": "Realistic flickering candle simulation", "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.", "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.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": "Flicker Speed:",
"color_strip.candlelight.speed.hint": "Speed of the flicker animation. Higher values produce faster, more restless flames.", "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": "Processed",
"color_strip.type.processed.desc": "Apply a processing template to another source", "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.", "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.noise.desc": "Scrolling fractal value noise mapped to a palette",
"color_strip.effect.aurora": "Aurora", "color_strip.effect.aurora": "Aurora",
"color_strip.effect.aurora.desc": "Layered noise bands that drift and blend — northern lights style", "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": "Speed:",
"color_strip.effect.speed.hint": "Speed multiplier for the effect animation (0.1 = very slow, 10.0 = very fast).", "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": "Palette:",
@@ -1219,6 +1263,7 @@
"color_strip.palette.aurora": "Aurora", "color_strip.palette.aurora": "Aurora",
"color_strip.palette.sunset": "Sunset", "color_strip.palette.sunset": "Sunset",
"color_strip.palette.ice": "Ice", "color_strip.palette.ice": "Ice",
"color_strip.palette.custom": "Custom",
"audio_source.title": "Audio Sources", "audio_source.title": "Audio Sources",
"audio_source.group.multichannel": "Multichannel", "audio_source.group.multichannel": "Multichannel",
"audio_source.group.mono": "Mono", "audio_source.group.mono": "Mono",

View File

@@ -493,18 +493,20 @@ class GradientColorStripSource(ColorStripSource):
{"position": 1.0, "color": [0, 0, 255]}, {"position": 1.0, "color": [0, 0, 255]},
]) ])
animation: Optional[dict] = None # {"enabled": bool, "type": str, "speed": float} or None 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: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["stops"] = [dict(s) for s in self.stops] d["stops"] = [dict(s) for s in self.stops]
d["animation"] = self.animation d["animation"] = self.animation
d["easing"] = self.easing
return d return d
@classmethod @classmethod
def create_from_kwargs(cls, *, id: str, name: str, source_type: str, def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
created_at: datetime, updated_at: datetime, created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None, description=None, clock_id=None, tags=None,
stops=None, animation=None, **_kwargs): stops=None, animation=None, easing=None, **_kwargs):
return cls( return cls(
id=id, name=name, source_type="gradient", id=id, name=name, source_type="gradient",
created_at=created_at, updated_at=updated_at, created_at=created_at, updated_at=updated_at,
@@ -514,6 +516,7 @@ class GradientColorStripSource(ColorStripSource):
{"position": 1.0, "color": [0, 0, 255]}, {"position": 1.0, "color": [0, 0, 255]},
], ],
animation=animation, animation=animation,
easing=easing if easing in ("linear", "ease_in_out", "step", "cubic") else "linear",
) )
def apply_update(self, **kwargs) -> None: def apply_update(self, **kwargs) -> None:
@@ -522,6 +525,8 @@ class GradientColorStripSource(ColorStripSource):
self.stops = stops self.stops = stops
if kwargs.get("animation") is not None: if kwargs.get("animation") is not None:
self.animation = kwargs["animation"] self.animation = kwargs["animation"]
if kwargs.get("easing") is not None:
self.easing = kwargs["easing"]
@dataclass @dataclass
@@ -574,12 +579,13 @@ class EffectColorStripSource(ColorStripSource):
LED count auto-sizes from the connected device. LED count auto-sizes from the connected device.
""" """
effect_type: str = "fire" # fire | meteor | plasma | noise | aurora effect_type: str = "fire" # fire | meteor | plasma | noise | aurora + new types
palette: str = "fire" # named color palette palette: str = "fire" # named color palette or "custom"
color: list = field(default_factory=lambda: [255, 80, 0]) # [R,G,B] for meteor head 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) intensity: float = 1.0 # effect-specific intensity (0.1-2.0)
scale: float = 1.0 # spatial scale / zoom (0.5-5.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: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
@@ -589,6 +595,7 @@ class EffectColorStripSource(ColorStripSource):
d["intensity"] = self.intensity d["intensity"] = self.intensity
d["scale"] = self.scale d["scale"] = self.scale
d["mirror"] = self.mirror d["mirror"] = self.mirror
d["custom_palette"] = self.custom_palette
return d return d
@classmethod @classmethod
@@ -596,7 +603,8 @@ class EffectColorStripSource(ColorStripSource):
created_at: datetime, updated_at: datetime, created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None, description=None, clock_id=None, tags=None,
effect_type="fire", palette="fire", color=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]) rgb = _validate_rgb(color, [255, 80, 0])
return cls( return cls(
id=id, name=name, source_type="effect", id=id, name=name, source_type="effect",
@@ -607,6 +615,7 @@ class EffectColorStripSource(ColorStripSource):
intensity=float(intensity) if intensity else 1.0, intensity=float(intensity) if intensity else 1.0,
scale=float(scale) if scale else 1.0, scale=float(scale) if scale else 1.0,
mirror=bool(mirror), mirror=bool(mirror),
custom_palette=custom_palette if isinstance(custom_palette, list) else None,
) )
def apply_update(self, **kwargs) -> None: def apply_update(self, **kwargs) -> None:
@@ -623,6 +632,9 @@ class EffectColorStripSource(ColorStripSource):
self.scale = float(kwargs["scale"]) self.scale = float(kwargs["scale"])
if kwargs.get("mirror") is not None: if kwargs.get("mirror") is not None:
self.mirror = bool(kwargs["mirror"]) 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 @dataclass
@@ -914,12 +926,14 @@ class DaylightColorStripSource(ColorStripSource):
speed: float = 1.0 # cycle speed (ignored when use_real_time) speed: float = 1.0 # cycle speed (ignored when use_real_time)
use_real_time: bool = False # use actual time of day use_real_time: bool = False # use actual time of day
latitude: float = 50.0 # latitude for sunrise/sunset timing (-90..90) 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: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
d["speed"] = self.speed d["speed"] = self.speed
d["use_real_time"] = self.use_real_time d["use_real_time"] = self.use_real_time
d["latitude"] = self.latitude d["latitude"] = self.latitude
d["longitude"] = self.longitude
return d return d
@classmethod @classmethod
@@ -927,7 +941,7 @@ class DaylightColorStripSource(ColorStripSource):
created_at: datetime, updated_at: datetime, created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None, description=None, clock_id=None, tags=None,
speed=None, use_real_time=None, latitude=None, speed=None, use_real_time=None, latitude=None,
**_kwargs): longitude=None, **_kwargs):
return cls( return cls(
id=id, name=name, source_type="daylight", id=id, name=name, source_type="daylight",
created_at=created_at, updated_at=updated_at, 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, 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, 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, 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: def apply_update(self, **kwargs) -> None:
@@ -944,6 +959,8 @@ class DaylightColorStripSource(ColorStripSource):
self.use_real_time = bool(kwargs["use_real_time"]) self.use_real_time = bool(kwargs["use_real_time"])
if kwargs.get("latitude") is not None: if kwargs.get("latitude") is not None:
self.latitude = float(kwargs["latitude"]) self.latitude = float(kwargs["latitude"])
if kwargs.get("longitude") is not None:
self.longitude = float(kwargs["longitude"])
@dataclass @dataclass
@@ -959,6 +976,8 @@ class CandlelightColorStripSource(ColorStripSource):
intensity: float = 1.0 # flicker intensity (0.1-2.0) intensity: float = 1.0 # flicker intensity (0.1-2.0)
num_candles: int = 3 # number of independent candle sources num_candles: int = 3 # number of independent candle sources
speed: float = 1.0 # flicker speed multiplier 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: def to_dict(self) -> dict:
d = super().to_dict() d = super().to_dict()
@@ -966,6 +985,8 @@ class CandlelightColorStripSource(ColorStripSource):
d["intensity"] = self.intensity d["intensity"] = self.intensity
d["num_candles"] = self.num_candles d["num_candles"] = self.num_candles
d["speed"] = self.speed d["speed"] = self.speed
d["wind_strength"] = self.wind_strength
d["candle_type"] = self.candle_type
return d return d
@classmethod @classmethod
@@ -973,7 +994,8 @@ class CandlelightColorStripSource(ColorStripSource):
created_at: datetime, updated_at: datetime, created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None, description=None, clock_id=None, tags=None,
color=None, intensity=1.0, num_candles=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]) rgb = _validate_rgb(color, [255, 147, 41])
return cls( return cls(
id=id, name=name, source_type="candlelight", id=id, name=name, source_type="candlelight",
@@ -983,6 +1005,8 @@ class CandlelightColorStripSource(ColorStripSource):
intensity=float(intensity) if intensity else 1.0, intensity=float(intensity) if intensity else 1.0,
num_candles=int(num_candles) if num_candles is not None else 3, num_candles=int(num_candles) if num_candles is not None else 3,
speed=float(speed) if speed is not None else 1.0, 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: def apply_update(self, **kwargs) -> None:
@@ -995,6 +1019,11 @@ class CandlelightColorStripSource(ColorStripSource):
self.num_candles = int(kwargs["num_candles"]) self.num_candles = int(kwargs["num_candles"])
if kwargs.get("speed") is not None: if kwargs.get("speed") is not None:
self.speed = float(kwargs["speed"]) 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 @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> <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 id="gradient-stops-list"></div>
</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> </div>
<!-- Procedural effect fields --> <!-- Procedural effect fields -->
@@ -168,6 +182,13 @@
<option value="plasma" data-i18n="color_strip.effect.plasma">Plasma</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="noise" data-i18n="color_strip.effect.noise">Perlin Noise</option>
<option value="aurora" data-i18n="color_strip.effect.aurora">Aurora</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> </select>
<small id="css-editor-effect-type-desc" class="field-desc"></small> <small id="css-editor-effect-type-desc" class="field-desc"></small>
</div> </div>
@@ -178,7 +199,7 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.effect.palette.hint">Color palette used by the effect.</small> <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="fire" data-i18n="color_strip.palette.fire">Fire</option>
<option value="ocean" data-i18n="color_strip.palette.ocean">Ocean</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="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="aurora" data-i18n="color_strip.palette.aurora">Aurora</option>
<option value="sunset" data-i18n="color_strip.palette.sunset">Sunset</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="ice" data-i18n="color_strip.palette.ice">Ice</option>
<option value="custom" data-i18n="color_strip.palette.custom">Custom</option>
</select> </select>
</div> </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 id="css-editor-effect-color-group" class="form-group" style="display:none">
<div class="label-row"> <div class="label-row">
<label for="css-editor-effect-color" data-i18n="color_strip.effect.color">Color:</label> <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" <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)"> oninput="document.getElementById('css-editor-daylight-latitude-val').textContent = parseInt(this.value)">
</div> </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> </div>
<!-- Candlelight section --> <!-- 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" <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)"> oninput="document.getElementById('css-editor-candlelight-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div> </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> </div>
<!-- Processed type fields --> <!-- Processed type fields -->