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

@@ -26,7 +26,7 @@ from wled_controller.core.capture.calibration import (
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import PictureColorStripSource, StaticColorStripSource
from wled_controller.storage.color_strip_source import GradientColorStripSource, PictureColorStripSource, StaticColorStripSource
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
@@ -44,6 +44,16 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
if isinstance(source, PictureColorStripSource) and source.calibration:
calibration = CalibrationSchema(**calibration_to_dict(source.calibration))
# Convert raw stop dicts to ColorStop schema objects for gradient sources
from wled_controller.api.schemas.color_strip_sources import ColorStop as ColorStopSchema
raw_stops = getattr(source, "stops", None)
stops = None
if raw_stops is not None:
try:
stops = [ColorStopSchema(**s) for s in raw_stops]
except Exception:
stops = None
return ColorStripSourceResponse(
id=source.id,
name=source.name,
@@ -58,6 +68,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
led_count=getattr(source, "led_count", 0),
calibration=calibration,
color=getattr(source, "color", None),
stops=stops,
description=source.description,
overlay_active=overlay_active,
created_at=source.created_at,
@@ -106,6 +117,8 @@ async def create_color_strip_source(
if data.calibration is not None:
calibration = calibration_from_dict(data.calibration.model_dump())
stops = [s.model_dump() for s in data.stops] if data.stops is not None else None
source = store.create_source(
name=data.name,
source_type=data.source_type,
@@ -119,6 +132,7 @@ async def create_color_strip_source(
led_count=data.led_count,
calibration=calibration,
color=data.color,
stops=stops,
description=data.description,
)
return _css_to_response(source)
@@ -159,6 +173,8 @@ async def update_color_strip_source(
if data.calibration is not None:
calibration = calibration_from_dict(data.calibration.model_dump())
stops = [s.model_dump() for s in data.stops] if data.stops is not None else None
source = store.update_source(
source_id=source_id,
name=data.name,
@@ -172,6 +188,7 @@ async def update_color_strip_source(
led_count=data.led_count,
calibration=calibration,
color=data.color,
stops=stops,
description=data.description,
)
@@ -258,10 +275,10 @@ async def test_css_calibration(
if body.edges:
try:
source = store.get_source(source_id)
if isinstance(source, StaticColorStripSource):
if isinstance(source, (StaticColorStripSource, GradientColorStripSource)):
raise HTTPException(
status_code=400,
detail="Calibration test is not applicable for static color strip sources",
detail="Calibration test is not applicable for this color strip source type",
)
if isinstance(source, PictureColorStripSource) and source.calibration:
calibration = source.calibration
@@ -304,8 +321,8 @@ async def start_css_overlay(
"""Start screen overlay visualization for a color strip source."""
try:
source = store.get_source(source_id)
if isinstance(source, StaticColorStripSource):
raise HTTPException(status_code=400, detail="Overlay is not supported for static color strip sources")
if isinstance(source, (StaticColorStripSource, GradientColorStripSource)):
raise HTTPException(status_code=400, detail="Overlay is not supported for this color strip source type")
if not isinstance(source, PictureColorStripSource):
raise HTTPException(status_code=400, detail="Overlay only supported for picture color strip sources")
if not source.calibration:

View File

@@ -8,11 +8,22 @@ from pydantic import BaseModel, Field
from wled_controller.api.schemas.devices import Calibration
class ColorStop(BaseModel):
"""A single color stop in a gradient."""
position: float = Field(description="Relative position along the strip (0.01.0)", ge=0.0, le=1.0)
color: List[int] = Field(description="Primary RGB color [R, G, B] (0255 each)")
color_right: Optional[List[int]] = Field(
None,
description="Optional right-side RGB color for a hard edge (bidirectional stop)",
)
class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["picture", "static"] = Field(default="picture", description="Source type")
source_type: Literal["picture", "static", "gradient"] = Field(default="picture", description="Source type")
# picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
@@ -24,6 +35,8 @@ class ColorStripSourceCreate(BaseModel):
calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)")
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
# gradient-type fields
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# shared
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -44,6 +57,8 @@ class ColorStripSourceUpdate(BaseModel):
calibration: Optional[Calibration] = Field(None, description="LED calibration")
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
# gradient-type fields
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# shared
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -66,6 +81,8 @@ class ColorStripSourceResponse(BaseModel):
calibration: Optional[Calibration] = Field(None, description="LED calibration")
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B]")
# gradient-type fields
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
# shared
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
description: Optional[str] = Field(None, description="Description")