feat: expand color strip sources with new effects, gradient improvements, and daylight/candlelight enhancements
Some checks failed
Lint & Test / test (push) Failing after 29s
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:
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 0–1, "color": [R,G,B], "color_right": [R,G,B] | absent}
|
Each stop: {"position": float 0–1, "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
|
||||||
|
|||||||
@@ -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 (0–24) to RGB color. Interpolated linearly between
|
# Canonical hour control points (0–24) → 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:
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>°</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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user