@@ -1166,6 +1216,8 @@ const _typeHandlers: Record
any; reset: (...
{ position: 1.0, color: [0, 0, 255] },
]);
_loadAnimationState(css.animation);
+ (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = css.easing || 'linear';
+ if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue(css.easing || 'linear');
},
reset() {
(document.getElementById('css-editor-gradient-preset') as HTMLInputElement).value = '';
@@ -1174,6 +1226,8 @@ const _typeHandlers: Record any; reset: (...
{ position: 1.0, color: [0, 0, 255] },
]);
_loadAnimationState(null);
+ (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value = 'linear';
+ if (_gradientEasingIconSelect) _gradientEasingIconSelect.setValue('linear');
},
getPayload(name) {
const gStops = getGradientStops();
@@ -1189,6 +1243,7 @@ const _typeHandlers: Record any; reset: (...
...(s.colorRight ? { color_right: s.colorRight } : {}),
})),
animation: _getAnimationPayload(),
+ easing: (document.getElementById('css-editor-gradient-easing') as HTMLSelectElement).value,
};
},
},
@@ -1205,6 +1260,10 @@ const _typeHandlers: Record any; reset: (...
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = css.scale ?? 1.0;
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = parseFloat(css.scale ?? 1.0).toFixed(1);
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = css.mirror || false;
+ // Custom palette
+ const cpTextarea = document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement;
+ if (cpTextarea) cpTextarea.value = css.custom_palette ? JSON.stringify(css.custom_palette) : '';
+ onEffectPaletteChange();
},
reset() {
(document.getElementById('css-editor-effect-type') as HTMLInputElement).value = 'fire';
@@ -1215,6 +1274,8 @@ const _typeHandlers: Record any; reset: (...
(document.getElementById('css-editor-effect-scale') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-effect-scale-val') as HTMLElement).textContent = '1.0';
(document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked = false;
+ const cpTextarea = document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement;
+ if (cpTextarea) cpTextarea.value = '';
},
getPayload(name) {
const payload: any = {
@@ -1225,11 +1286,23 @@ const _typeHandlers: Record any; reset: (...
scale: parseFloat((document.getElementById('css-editor-effect-scale') as HTMLInputElement).value),
mirror: (document.getElementById('css-editor-effect-mirror') as HTMLInputElement).checked,
};
- // Meteor uses a color picker
- if (payload.effect_type === 'meteor') {
+ // Meteor/comet/bouncing_ball use a color picker
+ if (['meteor', 'comet', 'bouncing_ball'].includes(payload.effect_type)) {
const hex = (document.getElementById('css-editor-effect-color') as HTMLInputElement).value;
payload.color = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
}
+ // Custom palette
+ if (payload.palette === 'custom') {
+ const cpText = (document.getElementById('css-editor-effect-custom-palette') as HTMLTextAreaElement).value.trim();
+ if (cpText) {
+ try {
+ payload.custom_palette = JSON.parse(cpText);
+ } catch {
+ cssEditorModal.showError('Invalid custom palette JSON');
+ return null;
+ }
+ }
+ }
return payload;
},
},
@@ -1345,6 +1418,8 @@ const _typeHandlers: Record any; reset: (...
(document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked = css.use_real_time || false;
(document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value = css.latitude ?? 50.0;
(document.getElementById('css-editor-daylight-latitude-val') as HTMLElement).textContent = parseFloat(css.latitude ?? 50.0).toFixed(0);
+ (document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value = css.longitude ?? 0.0;
+ (document.getElementById('css-editor-daylight-longitude-val') as HTMLElement).textContent = parseFloat(css.longitude ?? 0.0).toFixed(0);
_syncDaylightSpeedVisibility();
},
reset() {
@@ -1353,6 +1428,8 @@ const _typeHandlers: Record any; reset: (...
(document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked = false;
(document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value = 50.0 as any;
(document.getElementById('css-editor-daylight-latitude-val') as HTMLElement).textContent = '50';
+ (document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value = 0.0 as any;
+ (document.getElementById('css-editor-daylight-longitude-val') as HTMLElement).textContent = '0';
},
getPayload(name) {
return {
@@ -1360,6 +1437,7 @@ const _typeHandlers: Record any; reset: (...
speed: parseFloat((document.getElementById('css-editor-daylight-speed') as HTMLInputElement).value),
use_real_time: (document.getElementById('css-editor-daylight-real-time') as HTMLInputElement).checked,
latitude: parseFloat((document.getElementById('css-editor-daylight-latitude') as HTMLInputElement).value),
+ longitude: parseFloat((document.getElementById('css-editor-daylight-longitude') as HTMLInputElement).value),
};
},
},
@@ -1371,6 +1449,10 @@ const _typeHandlers: Record any; reset: (...
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = css.num_candles ?? 3;
(document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = css.speed ?? 1.0;
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
+ (document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = css.wind_strength ?? 0.0;
+ (document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = parseFloat(css.wind_strength ?? 0.0).toFixed(1);
+ (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = css.candle_type || 'default';
+ if (_candleTypeIconSelect) _candleTypeIconSelect.setValue(css.candle_type || 'default');
},
reset() {
(document.getElementById('css-editor-candlelight-color') as HTMLInputElement).value = '#ff9329';
@@ -1379,6 +1461,10 @@ const _typeHandlers: Record any; reset: (...
(document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value = 3 as any;
(document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-candlelight-speed-val') as HTMLElement).textContent = '1.0';
+ (document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value = 0.0 as any;
+ (document.getElementById('css-editor-candlelight-wind-val') as HTMLElement).textContent = '0.0';
+ (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value = 'default';
+ if (_candleTypeIconSelect) _candleTypeIconSelect.setValue('default');
},
getPayload(name) {
return {
@@ -1387,6 +1473,8 @@ const _typeHandlers: Record any; reset: (...
intensity: parseFloat((document.getElementById('css-editor-candlelight-intensity') as HTMLInputElement).value),
num_candles: parseInt((document.getElementById('css-editor-candlelight-num-candles') as HTMLInputElement).value) || 3,
speed: parseFloat((document.getElementById('css-editor-candlelight-speed') as HTMLInputElement).value),
+ wind_strength: parseFloat((document.getElementById('css-editor-candlelight-wind') as HTMLInputElement).value),
+ candle_type: (document.getElementById('css-editor-candlelight-type') as HTMLSelectElement).value,
};
},
},
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json
index 36a7ec7..fab4d8d 100644
--- a/server/src/wled_controller/static/locales/en.json
+++ b/server/src/wled_controller/static/locales/en.json
@@ -963,6 +963,16 @@
"color_strip.static_color.hint": "The solid color that will be sent to all LEDs on the strip.",
"color_strip.gradient.preview": "Gradient:",
"color_strip.gradient.preview.hint": "Visual preview. Click the marker track below to add a stop. Drag markers to reposition.",
+ "color_strip.gradient.easing": "Easing:",
+ "color_strip.gradient.easing.hint": "Controls how colors blend between gradient stops.",
+ "color_strip.gradient.easing.linear": "Linear",
+ "color_strip.gradient.easing.linear.desc": "Constant-rate blending between stops",
+ "color_strip.gradient.easing.ease_in_out": "Smooth",
+ "color_strip.gradient.easing.ease_in_out.desc": "S-curve: slow start and end, fast middle",
+ "color_strip.gradient.easing.step": "Step",
+ "color_strip.gradient.easing.step.desc": "Hard jumps between colors with no blending",
+ "color_strip.gradient.easing.cubic": "Cubic",
+ "color_strip.gradient.easing.cubic.desc": "Cubic ease — accelerating blend curve",
"color_strip.gradient.stops": "Color Stops:",
"color_strip.gradient.stops.hint": "Each stop defines a color at a relative position (0.0 = start, 1.0 = end). The ↔ button adds a right-side color to create a hard edge at that stop.",
"color_strip.gradient.stops_count": "stops",
@@ -1012,6 +1022,10 @@
"color_strip.animation.type.candle.desc": "Warm flickering candle-like glow",
"color_strip.animation.type.rainbow_fade": "Rainbow Fade",
"color_strip.animation.type.rainbow_fade.desc": "Cycles through the entire hue spectrum",
+ "color_strip.animation.type.noise_perturb": "Noise Perturb",
+ "color_strip.animation.type.noise_perturb.desc": "Perturbs gradient stop positions with organic noise each frame",
+ "color_strip.animation.type.hue_rotate": "Hue Rotate",
+ "color_strip.animation.type.hue_rotate.desc": "Smoothly rotates all pixel hues while preserving saturation and brightness",
"color_strip.animation.speed": "Speed:",
"color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.",
"color_strip.color_cycle.colors": "Colors:",
@@ -1112,6 +1126,8 @@
"color_strip.daylight.real_time": "Real Time",
"color_strip.daylight.latitude": "Latitude:",
"color_strip.daylight.latitude.hint": "Your geographic latitude (-90 to 90). Affects sunrise/sunset timing in real-time mode.",
+ "color_strip.daylight.longitude": "Longitude:",
+ "color_strip.daylight.longitude.hint": "Your geographic longitude (-180 to 180). Adjusts solar noon offset for accurate sunrise/sunset timing.",
"color_strip.type.candlelight": "Candlelight",
"color_strip.type.candlelight.desc": "Realistic flickering candle simulation",
"color_strip.type.candlelight.hint": "Simulates realistic candle flickering across all LEDs with warm tones and organic flicker patterns.",
@@ -1124,6 +1140,18 @@
"color_strip.candlelight.num_candles.hint": "How many independent candle sources along the strip. Each flickers with its own pattern.",
"color_strip.candlelight.speed": "Flicker Speed:",
"color_strip.candlelight.speed.hint": "Speed of the flicker animation. Higher values produce faster, more restless flames.",
+ "color_strip.candlelight.wind": "Wind:",
+ "color_strip.candlelight.wind.hint": "Wind simulation strength. Higher values create correlated gusts that make all candles flicker together.",
+ "color_strip.candlelight.type": "Candle Type:",
+ "color_strip.candlelight.type.hint": "Preset that adjusts flicker behavior without changing other settings.",
+ "color_strip.candlelight.type.default": "Default",
+ "color_strip.candlelight.type.default.desc": "Standard candle flicker",
+ "color_strip.candlelight.type.taper": "Taper",
+ "color_strip.candlelight.type.taper.desc": "Tall, steady candle with reduced flicker",
+ "color_strip.candlelight.type.votive": "Votive",
+ "color_strip.candlelight.type.votive.desc": "Small, flickery candle with narrow glow",
+ "color_strip.candlelight.type.bonfire": "Bonfire",
+ "color_strip.candlelight.type.bonfire.desc": "Large, chaotic fire with extra warmth",
"color_strip.type.processed": "Processed",
"color_strip.type.processed.desc": "Apply a processing template to another source",
"color_strip.type.processed.hint": "Wraps an existing color strip source and pipes its output through a filter chain.",
@@ -1199,6 +1227,22 @@
"color_strip.effect.noise.desc": "Scrolling fractal value noise mapped to a palette",
"color_strip.effect.aurora": "Aurora",
"color_strip.effect.aurora.desc": "Layered noise bands that drift and blend — northern lights style",
+ "color_strip.effect.rain": "Rain",
+ "color_strip.effect.rain.desc": "Raindrops fall down the strip with trailing tails",
+ "color_strip.effect.comet": "Comet",
+ "color_strip.effect.comet.desc": "Multiple comets with curved, pulsing tails",
+ "color_strip.effect.bouncing_ball": "Bouncing Ball",
+ "color_strip.effect.bouncing_ball.desc": "Physics-simulated balls bouncing with gravity",
+ "color_strip.effect.fireworks": "Fireworks",
+ "color_strip.effect.fireworks.desc": "Rockets launch and explode into colorful bursts",
+ "color_strip.effect.sparkle_rain": "Sparkle Rain",
+ "color_strip.effect.sparkle_rain.desc": "Twinkling star field with smooth fade-in/fade-out",
+ "color_strip.effect.lava_lamp": "Lava Lamp",
+ "color_strip.effect.lava_lamp.desc": "Slow-moving colored blobs that merge and separate",
+ "color_strip.effect.wave_interference": "Wave Interference",
+ "color_strip.effect.wave_interference.desc": "Two counter-propagating waves creating interference patterns",
+ "color_strip.effect.custom_palette": "Custom Palette:",
+ "color_strip.effect.custom_palette.hint": "JSON array of [position, R, G, B] stops, e.g. [[0,0,0,0],[0.5,255,0,0],[1,255,255,0]]",
"color_strip.effect.speed": "Speed:",
"color_strip.effect.speed.hint": "Speed multiplier for the effect animation (0.1 = very slow, 10.0 = very fast).",
"color_strip.effect.palette": "Palette:",
@@ -1219,6 +1263,7 @@
"color_strip.palette.aurora": "Aurora",
"color_strip.palette.sunset": "Sunset",
"color_strip.palette.ice": "Ice",
+ "color_strip.palette.custom": "Custom",
"audio_source.title": "Audio Sources",
"audio_source.group.multichannel": "Multichannel",
"audio_source.group.mono": "Mono",
diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py
index a24bac7..ffabe2a 100644
--- a/server/src/wled_controller/storage/color_strip_source.py
+++ b/server/src/wled_controller/storage/color_strip_source.py
@@ -493,18 +493,20 @@ class GradientColorStripSource(ColorStripSource):
{"position": 1.0, "color": [0, 0, 255]},
])
animation: Optional[dict] = None # {"enabled": bool, "type": str, "speed": float} or None
+ easing: str = "linear" # linear | ease_in_out | step | cubic
def to_dict(self) -> dict:
d = super().to_dict()
d["stops"] = [dict(s) for s in self.stops]
d["animation"] = self.animation
+ d["easing"] = self.easing
return d
@classmethod
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None,
- stops=None, animation=None, **_kwargs):
+ stops=None, animation=None, easing=None, **_kwargs):
return cls(
id=id, name=name, source_type="gradient",
created_at=created_at, updated_at=updated_at,
@@ -514,6 +516,7 @@ class GradientColorStripSource(ColorStripSource):
{"position": 1.0, "color": [0, 0, 255]},
],
animation=animation,
+ easing=easing if easing in ("linear", "ease_in_out", "step", "cubic") else "linear",
)
def apply_update(self, **kwargs) -> None:
@@ -522,6 +525,8 @@ class GradientColorStripSource(ColorStripSource):
self.stops = stops
if kwargs.get("animation") is not None:
self.animation = kwargs["animation"]
+ if kwargs.get("easing") is not None:
+ self.easing = kwargs["easing"]
@dataclass
@@ -574,12 +579,13 @@ class EffectColorStripSource(ColorStripSource):
LED count auto-sizes from the connected device.
"""
- effect_type: str = "fire" # fire | meteor | plasma | noise | aurora
- palette: str = "fire" # named color palette
- color: list = field(default_factory=lambda: [255, 80, 0]) # [R,G,B] for meteor head
+ effect_type: str = "fire" # fire | meteor | plasma | noise | aurora + new types
+ palette: str = "fire" # named color palette or "custom"
+ color: list = field(default_factory=lambda: [255, 80, 0]) # [R,G,B] for meteor/comet/bouncing_ball head
intensity: float = 1.0 # effect-specific intensity (0.1-2.0)
scale: float = 1.0 # spatial scale / zoom (0.5-5.0)
- mirror: bool = False # bounce mode (meteor)
+ mirror: bool = False # bounce mode (meteor/comet)
+ custom_palette: Optional[list] = None # [[pos, R, G, B], ...] custom palette stops
def to_dict(self) -> dict:
d = super().to_dict()
@@ -589,6 +595,7 @@ class EffectColorStripSource(ColorStripSource):
d["intensity"] = self.intensity
d["scale"] = self.scale
d["mirror"] = self.mirror
+ d["custom_palette"] = self.custom_palette
return d
@classmethod
@@ -596,7 +603,8 @@ class EffectColorStripSource(ColorStripSource):
created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None,
effect_type="fire", palette="fire", color=None,
- intensity=1.0, scale=1.0, mirror=False, **_kwargs):
+ intensity=1.0, scale=1.0, mirror=False,
+ custom_palette=None, **_kwargs):
rgb = _validate_rgb(color, [255, 80, 0])
return cls(
id=id, name=name, source_type="effect",
@@ -607,6 +615,7 @@ class EffectColorStripSource(ColorStripSource):
intensity=float(intensity) if intensity else 1.0,
scale=float(scale) if scale else 1.0,
mirror=bool(mirror),
+ custom_palette=custom_palette if isinstance(custom_palette, list) else None,
)
def apply_update(self, **kwargs) -> None:
@@ -623,6 +632,9 @@ class EffectColorStripSource(ColorStripSource):
self.scale = float(kwargs["scale"])
if kwargs.get("mirror") is not None:
self.mirror = bool(kwargs["mirror"])
+ if "custom_palette" in kwargs:
+ cp = kwargs["custom_palette"]
+ self.custom_palette = cp if isinstance(cp, list) else None
@dataclass
@@ -914,12 +926,14 @@ class DaylightColorStripSource(ColorStripSource):
speed: float = 1.0 # cycle speed (ignored when use_real_time)
use_real_time: bool = False # use actual time of day
latitude: float = 50.0 # latitude for sunrise/sunset timing (-90..90)
+ longitude: float = 0.0 # longitude for solar position (-180..180)
def to_dict(self) -> dict:
d = super().to_dict()
d["speed"] = self.speed
d["use_real_time"] = self.use_real_time
d["latitude"] = self.latitude
+ d["longitude"] = self.longitude
return d
@classmethod
@@ -927,7 +941,7 @@ class DaylightColorStripSource(ColorStripSource):
created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None,
speed=None, use_real_time=None, latitude=None,
- **_kwargs):
+ longitude=None, **_kwargs):
return cls(
id=id, name=name, source_type="daylight",
created_at=created_at, updated_at=updated_at,
@@ -935,6 +949,7 @@ class DaylightColorStripSource(ColorStripSource):
speed=float(speed) if speed is not None else 1.0,
use_real_time=bool(use_real_time) if use_real_time is not None else False,
latitude=float(latitude) if latitude is not None else 50.0,
+ longitude=float(longitude) if longitude is not None else 0.0,
)
def apply_update(self, **kwargs) -> None:
@@ -944,6 +959,8 @@ class DaylightColorStripSource(ColorStripSource):
self.use_real_time = bool(kwargs["use_real_time"])
if kwargs.get("latitude") is not None:
self.latitude = float(kwargs["latitude"])
+ if kwargs.get("longitude") is not None:
+ self.longitude = float(kwargs["longitude"])
@dataclass
@@ -959,6 +976,8 @@ class CandlelightColorStripSource(ColorStripSource):
intensity: float = 1.0 # flicker intensity (0.1-2.0)
num_candles: int = 3 # number of independent candle sources
speed: float = 1.0 # flicker speed multiplier
+ wind_strength: float = 0.0 # wind effect (0.0-2.0)
+ candle_type: str = "default" # default | taper | votive | bonfire
def to_dict(self) -> dict:
d = super().to_dict()
@@ -966,6 +985,8 @@ class CandlelightColorStripSource(ColorStripSource):
d["intensity"] = self.intensity
d["num_candles"] = self.num_candles
d["speed"] = self.speed
+ d["wind_strength"] = self.wind_strength
+ d["candle_type"] = self.candle_type
return d
@classmethod
@@ -973,7 +994,8 @@ class CandlelightColorStripSource(ColorStripSource):
created_at: datetime, updated_at: datetime,
description=None, clock_id=None, tags=None,
color=None, intensity=1.0, num_candles=None,
- speed=None, **_kwargs):
+ speed=None, wind_strength=None, candle_type=None,
+ **_kwargs):
rgb = _validate_rgb(color, [255, 147, 41])
return cls(
id=id, name=name, source_type="candlelight",
@@ -983,6 +1005,8 @@ class CandlelightColorStripSource(ColorStripSource):
intensity=float(intensity) if intensity else 1.0,
num_candles=int(num_candles) if num_candles is not None else 3,
speed=float(speed) if speed is not None else 1.0,
+ wind_strength=float(wind_strength) if wind_strength is not None else 0.0,
+ candle_type=candle_type if candle_type in {"default", "taper", "votive", "bonfire"} else "default",
)
def apply_update(self, **kwargs) -> None:
@@ -995,6 +1019,11 @@ class CandlelightColorStripSource(ColorStripSource):
self.num_candles = int(kwargs["num_candles"])
if kwargs.get("speed") is not None:
self.speed = float(kwargs["speed"])
+ if kwargs.get("wind_strength") is not None:
+ self.wind_strength = float(kwargs["wind_strength"])
+ ct = kwargs.get("candle_type")
+ if ct is not None and ct in {"default", "taper", "votive", "bonfire"}:
+ self.candle_type = ct
@dataclass
diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html
index b9c91f0..feaf78a 100644
--- a/server/src/wled_controller/templates/modals/css-editor.html
+++ b/server/src/wled_controller/templates/modals/css-editor.html
@@ -152,6 +152,20 @@
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.
+
+