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

@@ -7,9 +7,7 @@ calibration, color correction, smoothing, and FPS.
Current types:
PictureColorStripSource — derives LED colors from a PictureSource (screen capture)
StaticColorStripSource — constant solid color fills all LEDs
Future types (not yet implemented):
GradientColorStripSource — animated gradient
GradientColorStripSource — linear gradient across all LEDs from user-defined color stops
"""
from dataclasses import dataclass, field
@@ -54,6 +52,7 @@ class ColorStripSource:
"calibration": None,
"led_count": None,
"color": None,
"stops": None,
}
@staticmethod
@@ -99,6 +98,16 @@ class ColorStripSource:
led_count=data.get("led_count") or 0,
)
if source_type == "gradient":
raw_stops = data.get("stops")
stops = raw_stops if isinstance(raw_stops, list) else []
return GradientColorStripSource(
id=sid, name=name, source_type="gradient",
created_at=created_at, updated_at=updated_at, description=description,
stops=stops,
led_count=data.get("led_count") or 0,
)
# Default: "picture" type
return PictureColorStripSource(
id=sid, name=name, source_type=source_type,
@@ -166,3 +175,28 @@ class StaticColorStripSource(ColorStripSource):
d["color"] = list(self.color)
d["led_count"] = self.led_count
return d
@dataclass
class GradientColorStripSource(ColorStripSource):
"""Color strip source that produces a linear gradient across all LEDs.
The gradient is defined by color stops at relative positions (0.01.0).
Each stop has a primary color; optionally a second "right" color to create
a hard discontinuity (bidirectional stop) at that position.
LED count auto-sizes from the connected device when led_count == 0.
"""
# Each stop: {"position": float, "color": [R,G,B], "color_right": [R,G,B] | null}
stops: list = field(default_factory=lambda: [
{"position": 0.0, "color": [255, 0, 0]},
{"position": 1.0, "color": [0, 0, 255]},
])
led_count: int = 0 # 0 = use device LED count
def to_dict(self) -> dict:
d = super().to_dict()
d["stops"] = [dict(s) for s in self.stops]
d["led_count"] = self.led_count
return d

View File

@@ -9,6 +9,7 @@ from typing import Dict, List, Optional
from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict
from wled_controller.storage.color_strip_source import (
ColorStripSource,
GradientColorStripSource,
PictureColorStripSource,
StaticColorStripSource,
)
@@ -101,6 +102,7 @@ class ColorStripStore:
calibration=None,
led_count: int = 0,
color: Optional[list] = None,
stops: Optional[list] = None,
description: Optional[str] = None,
) -> ColorStripSource:
"""Create a new color strip source.
@@ -130,6 +132,20 @@ class ColorStripStore:
color=rgb,
led_count=led_count,
)
elif source_type == "gradient":
source = GradientColorStripSource(
id=source_id,
name=name,
source_type="gradient",
created_at=now,
updated_at=now,
description=description,
stops=stops if isinstance(stops, list) else [
{"position": 0.0, "color": [255, 0, 0]},
{"position": 1.0, "color": [0, 0, 255]},
],
led_count=led_count,
)
else:
if calibration is None:
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
@@ -171,6 +187,7 @@ class ColorStripStore:
calibration=None,
led_count: Optional[int] = None,
color: Optional[list] = None,
stops: Optional[list] = None,
description: Optional[str] = None,
) -> ColorStripSource:
"""Update an existing color strip source.
@@ -217,6 +234,11 @@ class ColorStripStore:
source.color = color
if led_count is not None:
source.led_count = led_count
elif isinstance(source, GradientColorStripSource):
if stops is not None and isinstance(stops, list):
source.stops = stops
if led_count is not None:
source.led_count = led_count
source.updated_at = datetime.utcnow()
self._save()