CSS: add GradientColorStripSource with visual editor
- Backend: GradientColorStripSource storage model, GradientColorStripStream with numpy interpolation (bidirectional stops, auto-size from device LED count), ColorStop Pydantic schema, API create/update/guard routes - Frontend: gradient editor modal (canvas preview, draggable markers, stop rows), CSS hard-edge card swatch, locale keys (en + ru) - Fixes: stop row mousedown no longer rebuilds DOM (buttons now clickable), position input max-width, bidir/remove button static width Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -333,6 +333,65 @@ class PictureColorStripStream(ColorStripStream):
|
||||
time.sleep(remaining)
|
||||
|
||||
|
||||
def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
|
||||
"""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}
|
||||
|
||||
Interpolation:
|
||||
Sort stops by position. For each LED at relative position p = i/(N-1):
|
||||
p ≤ first stop → first stop primary color
|
||||
p ≥ last stop → last stop right color (if bidirectional) else primary
|
||||
else find surrounding stops A (≤p) and B (>p):
|
||||
left_color = A["color_right"] if present, else A["color"]
|
||||
right_color = B["color"]
|
||||
t = (p - A.pos) / (B.pos - A.pos)
|
||||
color = lerp(left_color, right_color, t)
|
||||
"""
|
||||
if led_count <= 0:
|
||||
led_count = 1
|
||||
|
||||
if not stops:
|
||||
return np.zeros((led_count, 3), dtype=np.uint8)
|
||||
|
||||
sorted_stops = sorted(stops, key=lambda s: float(s.get("position", 0)))
|
||||
|
||||
def _color(stop: dict, side: str = "left") -> np.ndarray:
|
||||
if side == "right":
|
||||
cr = stop.get("color_right")
|
||||
if cr and isinstance(cr, list) and len(cr) == 3:
|
||||
return np.array(cr, dtype=np.float32)
|
||||
c = stop.get("color", [255, 255, 255])
|
||||
return np.array(c if isinstance(c, list) and len(c) == 3 else [255, 255, 255], dtype=np.float32)
|
||||
|
||||
result = np.zeros((led_count, 3), dtype=np.float32)
|
||||
|
||||
for i in range(led_count):
|
||||
p = i / (led_count - 1) if led_count > 1 else 0.0
|
||||
|
||||
if p <= float(sorted_stops[0].get("position", 0)):
|
||||
result[i] = _color(sorted_stops[0], "left")
|
||||
continue
|
||||
|
||||
last = sorted_stops[-1]
|
||||
if p >= float(last.get("position", 1)):
|
||||
result[i] = _color(last, "right")
|
||||
continue
|
||||
|
||||
for j in range(len(sorted_stops) - 1):
|
||||
a = sorted_stops[j]
|
||||
b = sorted_stops[j + 1]
|
||||
a_pos = float(a.get("position", 0))
|
||||
b_pos = float(b.get("position", 1))
|
||||
if a_pos <= p <= b_pos:
|
||||
span = b_pos - a_pos
|
||||
t = (p - a_pos) / span if span > 0 else 0.0
|
||||
result[i] = _color(a, "right") + t * (_color(b, "left") - _color(a, "right"))
|
||||
break
|
||||
|
||||
return np.clip(result, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
class StaticColorStripStream(ColorStripStream):
|
||||
"""Color strip stream that returns a constant single-color array.
|
||||
|
||||
@@ -400,3 +459,68 @@ class StaticColorStripStream(ColorStripStream):
|
||||
self._led_count = prev_led_count
|
||||
self._rebuild_colors()
|
||||
logger.info("StaticColorStripStream params updated in-place")
|
||||
|
||||
|
||||
class GradientColorStripStream(ColorStripStream):
|
||||
"""Color strip stream that distributes a gradient across all LEDs.
|
||||
|
||||
Produces a pre-computed (led_count, 3) uint8 array from user-defined
|
||||
color stops. No background thread needed — output is constant until
|
||||
stops are changed.
|
||||
|
||||
LED count auto-sizes from the connected device when led_count == 0 in
|
||||
the source config; configure(device_led_count) is called by
|
||||
WledTargetProcessor on start.
|
||||
"""
|
||||
|
||||
def __init__(self, source):
|
||||
self._update_from_source(source)
|
||||
|
||||
def _update_from_source(self, source) -> None:
|
||||
self._stops = list(source.stops) if source.stops else []
|
||||
self._auto_size = not source.led_count
|
||||
led_count = source.led_count if source.led_count and source.led_count > 0 else 1
|
||||
self._led_count = led_count
|
||||
self._rebuild_colors()
|
||||
|
||||
def _rebuild_colors(self) -> None:
|
||||
self._colors = _compute_gradient_colors(self._stops, self._led_count)
|
||||
|
||||
def configure(self, device_led_count: int) -> None:
|
||||
"""Size to device LED count when led_count was 0 (auto-size).
|
||||
|
||||
Only takes effect when the source was configured with led_count==0.
|
||||
Silently ignored when an explicit led_count was set.
|
||||
"""
|
||||
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
|
||||
self._led_count = device_led_count
|
||||
self._rebuild_colors()
|
||||
logger.debug(f"GradientColorStripStream auto-sized to {device_led_count} LEDs")
|
||||
|
||||
@property
|
||||
def target_fps(self) -> int:
|
||||
return 30 # static output; any reasonable value is fine
|
||||
|
||||
@property
|
||||
def led_count(self) -> int:
|
||||
return self._led_count
|
||||
|
||||
def start(self) -> None:
|
||||
logger.info(f"GradientColorStripStream started (leds={self._led_count}, stops={len(self._stops)})")
|
||||
|
||||
def stop(self) -> None:
|
||||
logger.info("GradientColorStripStream stopped")
|
||||
|
||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||
return self._colors
|
||||
|
||||
def update_source(self, source) -> None:
|
||||
from wled_controller.storage.color_strip_source import GradientColorStripSource
|
||||
if isinstance(source, GradientColorStripSource):
|
||||
prev_led_count = self._led_count if self._auto_size else None
|
||||
self._update_from_source(source)
|
||||
# Preserve runtime LED count across hot-updates when auto-sized
|
||||
if prev_led_count and self._auto_size:
|
||||
self._led_count = prev_led_count
|
||||
self._rebuild_colors()
|
||||
logger.info("GradientColorStripStream params updated in-place")
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import Dict, Optional
|
||||
|
||||
from wled_controller.core.processing.color_strip_stream import (
|
||||
ColorStripStream,
|
||||
GradientColorStripStream,
|
||||
PictureColorStripStream,
|
||||
StaticColorStripStream,
|
||||
)
|
||||
@@ -79,7 +80,11 @@ class ColorStripStreamManager:
|
||||
)
|
||||
return entry.stream
|
||||
|
||||
from wled_controller.storage.color_strip_source import PictureColorStripSource, StaticColorStripSource
|
||||
from wled_controller.storage.color_strip_source import (
|
||||
GradientColorStripSource,
|
||||
PictureColorStripSource,
|
||||
StaticColorStripSource,
|
||||
)
|
||||
|
||||
source = self._color_strip_store.get_source(css_id)
|
||||
|
||||
@@ -94,6 +99,17 @@ class ColorStripStreamManager:
|
||||
logger.info(f"Created static color strip stream for source {css_id}")
|
||||
return css_stream
|
||||
|
||||
if isinstance(source, GradientColorStripSource):
|
||||
css_stream = GradientColorStripStream(source)
|
||||
css_stream.start()
|
||||
self._streams[css_id] = _ColorStripEntry(
|
||||
stream=css_stream,
|
||||
ref_count=1,
|
||||
picture_source_id="", # no live stream to manage
|
||||
)
|
||||
logger.info(f"Created gradient color strip stream for source {css_id}")
|
||||
return css_stream
|
||||
|
||||
if not isinstance(source, PictureColorStripSource):
|
||||
raise ValueError(
|
||||
f"Unsupported color strip source type '{source.source_type}' for {css_id}"
|
||||
|
||||
@@ -115,9 +115,12 @@ class WledTargetProcessor(TargetProcessor):
|
||||
self._resolved_display_index = stream.display_index
|
||||
self._resolved_target_fps = stream.target_fps
|
||||
|
||||
# For auto-sized static streams (led_count == 0), size to device LED count
|
||||
from wled_controller.core.processing.color_strip_stream import StaticColorStripStream
|
||||
if isinstance(stream, StaticColorStripStream) and device_info.led_count > 0:
|
||||
# For auto-sized static/gradient streams (led_count == 0), size to device LED count
|
||||
from wled_controller.core.processing.color_strip_stream import (
|
||||
GradientColorStripStream,
|
||||
StaticColorStripStream,
|
||||
)
|
||||
if isinstance(stream, (StaticColorStripStream, GradientColorStripStream)) and device_info.led_count > 0:
|
||||
stream.configure(device_info.led_count)
|
||||
|
||||
logger.info(
|
||||
|
||||
Reference in New Issue
Block a user