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:
2026-02-20 19:35:41 +03:00
parent 2a8e2daefc
commit 7479b1fb8d
12 changed files with 731 additions and 29 deletions

View File

@@ -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 01, "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")

View File

@@ -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}"

View File

@@ -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(