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:
@@ -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.capture.screen_capture import get_available_displays
|
||||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
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.color_strip_store import ColorStripStore
|
||||||
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
|
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
|
||||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
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:
|
if isinstance(source, PictureColorStripSource) and source.calibration:
|
||||||
calibration = CalibrationSchema(**calibration_to_dict(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(
|
return ColorStripSourceResponse(
|
||||||
id=source.id,
|
id=source.id,
|
||||||
name=source.name,
|
name=source.name,
|
||||||
@@ -58,6 +68,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
|
|||||||
led_count=getattr(source, "led_count", 0),
|
led_count=getattr(source, "led_count", 0),
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
color=getattr(source, "color", None),
|
color=getattr(source, "color", None),
|
||||||
|
stops=stops,
|
||||||
description=source.description,
|
description=source.description,
|
||||||
overlay_active=overlay_active,
|
overlay_active=overlay_active,
|
||||||
created_at=source.created_at,
|
created_at=source.created_at,
|
||||||
@@ -106,6 +117,8 @@ async def create_color_strip_source(
|
|||||||
if data.calibration is not None:
|
if data.calibration is not None:
|
||||||
calibration = calibration_from_dict(data.calibration.model_dump())
|
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(
|
source = store.create_source(
|
||||||
name=data.name,
|
name=data.name,
|
||||||
source_type=data.source_type,
|
source_type=data.source_type,
|
||||||
@@ -119,6 +132,7 @@ async def create_color_strip_source(
|
|||||||
led_count=data.led_count,
|
led_count=data.led_count,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
color=data.color,
|
color=data.color,
|
||||||
|
stops=stops,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
)
|
)
|
||||||
return _css_to_response(source)
|
return _css_to_response(source)
|
||||||
@@ -159,6 +173,8 @@ async def update_color_strip_source(
|
|||||||
if data.calibration is not None:
|
if data.calibration is not None:
|
||||||
calibration = calibration_from_dict(data.calibration.model_dump())
|
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 = store.update_source(
|
||||||
source_id=source_id,
|
source_id=source_id,
|
||||||
name=data.name,
|
name=data.name,
|
||||||
@@ -172,6 +188,7 @@ async def update_color_strip_source(
|
|||||||
led_count=data.led_count,
|
led_count=data.led_count,
|
||||||
calibration=calibration,
|
calibration=calibration,
|
||||||
color=data.color,
|
color=data.color,
|
||||||
|
stops=stops,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -258,10 +275,10 @@ async def test_css_calibration(
|
|||||||
if body.edges:
|
if body.edges:
|
||||||
try:
|
try:
|
||||||
source = store.get_source(source_id)
|
source = store.get_source(source_id)
|
||||||
if isinstance(source, StaticColorStripSource):
|
if isinstance(source, (StaticColorStripSource, GradientColorStripSource)):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
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:
|
if isinstance(source, PictureColorStripSource) and source.calibration:
|
||||||
calibration = source.calibration
|
calibration = source.calibration
|
||||||
@@ -304,8 +321,8 @@ async def start_css_overlay(
|
|||||||
"""Start screen overlay visualization for a color strip source."""
|
"""Start screen overlay visualization for a color strip source."""
|
||||||
try:
|
try:
|
||||||
source = store.get_source(source_id)
|
source = store.get_source(source_id)
|
||||||
if isinstance(source, StaticColorStripSource):
|
if isinstance(source, (StaticColorStripSource, GradientColorStripSource)):
|
||||||
raise HTTPException(status_code=400, detail="Overlay is not supported for static color strip sources")
|
raise HTTPException(status_code=400, detail="Overlay is not supported for this color strip source type")
|
||||||
if not isinstance(source, PictureColorStripSource):
|
if not isinstance(source, PictureColorStripSource):
|
||||||
raise HTTPException(status_code=400, detail="Overlay only supported for picture color strip sources")
|
raise HTTPException(status_code=400, detail="Overlay only supported for picture color strip sources")
|
||||||
if not source.calibration:
|
if not source.calibration:
|
||||||
|
|||||||
@@ -8,11 +8,22 @@ from pydantic import BaseModel, Field
|
|||||||
from wled_controller.api.schemas.devices import Calibration
|
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.0–1.0)", ge=0.0, le=1.0)
|
||||||
|
color: List[int] = Field(description="Primary RGB color [R, G, B] (0–255 each)")
|
||||||
|
color_right: Optional[List[int]] = Field(
|
||||||
|
None,
|
||||||
|
description="Optional right-side RGB color for a hard edge (bidirectional stop)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ColorStripSourceCreate(BaseModel):
|
class ColorStripSourceCreate(BaseModel):
|
||||||
"""Request to create a color strip source."""
|
"""Request to create a color strip source."""
|
||||||
|
|
||||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
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-type fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
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)
|
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)")
|
calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)")
|
||||||
# static-type fields
|
# static-type fields
|
||||||
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
|
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
|
# shared
|
||||||
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
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)
|
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")
|
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||||
# static-type fields
|
# static-type fields
|
||||||
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
|
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
|
# shared
|
||||||
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
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)
|
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")
|
calibration: Optional[Calibration] = Field(None, description="LED calibration")
|
||||||
# static-type fields
|
# static-type fields
|
||||||
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B]")
|
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
|
# shared
|
||||||
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
|
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
|
||||||
description: Optional[str] = Field(None, description="Description")
|
description: Optional[str] = Field(None, description="Description")
|
||||||
|
|||||||
@@ -333,6 +333,65 @@ class PictureColorStripStream(ColorStripStream):
|
|||||||
time.sleep(remaining)
|
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):
|
class StaticColorStripStream(ColorStripStream):
|
||||||
"""Color strip stream that returns a constant single-color array.
|
"""Color strip stream that returns a constant single-color array.
|
||||||
|
|
||||||
@@ -400,3 +459,68 @@ class StaticColorStripStream(ColorStripStream):
|
|||||||
self._led_count = prev_led_count
|
self._led_count = prev_led_count
|
||||||
self._rebuild_colors()
|
self._rebuild_colors()
|
||||||
logger.info("StaticColorStripStream params updated in-place")
|
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 (
|
from wled_controller.core.processing.color_strip_stream import (
|
||||||
ColorStripStream,
|
ColorStripStream,
|
||||||
|
GradientColorStripStream,
|
||||||
PictureColorStripStream,
|
PictureColorStripStream,
|
||||||
StaticColorStripStream,
|
StaticColorStripStream,
|
||||||
)
|
)
|
||||||
@@ -79,7 +80,11 @@ class ColorStripStreamManager:
|
|||||||
)
|
)
|
||||||
return entry.stream
|
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)
|
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}")
|
logger.info(f"Created static color strip stream for source {css_id}")
|
||||||
return css_stream
|
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):
|
if not isinstance(source, PictureColorStripSource):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Unsupported color strip source type '{source.source_type}' for {css_id}"
|
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_display_index = stream.display_index
|
||||||
self._resolved_target_fps = stream.target_fps
|
self._resolved_target_fps = stream.target_fps
|
||||||
|
|
||||||
# For auto-sized static streams (led_count == 0), size to device LED count
|
# For auto-sized static/gradient streams (led_count == 0), size to device LED count
|
||||||
from wled_controller.core.processing.color_strip_stream import StaticColorStripStream
|
from wled_controller.core.processing.color_strip_stream import (
|
||||||
if isinstance(stream, StaticColorStripStream) and device_info.led_count > 0:
|
GradientColorStripStream,
|
||||||
|
StaticColorStripStream,
|
||||||
|
)
|
||||||
|
if isinstance(stream, (StaticColorStripStream, GradientColorStripStream)) and device_info.led_count > 0:
|
||||||
stream.configure(device_info.led_count)
|
stream.configure(device_info.led_count)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -489,3 +489,113 @@
|
|||||||
box-shadow: 0 0 16px rgba(76, 175, 80, 0.5);
|
box-shadow: 0 0 16px rgba(76, 175, 80, 0.5);
|
||||||
background: rgba(76, 175, 80, 0.12) !important;
|
background: rgba(76, 175, 80, 0.12) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Gradient editor ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.gradient-editor {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#gradient-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
display: block;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
cursor: crosshair;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-markers-track {
|
||||||
|
position: relative;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
cursor: crosshair;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-marker {
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
cursor: grab;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
top: 6px;
|
||||||
|
transition: box-shadow 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-marker.selected {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px var(--primary-color), 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-marker:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-stop-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-stop-row.selected {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-stop-pos {
|
||||||
|
width: 76px;
|
||||||
|
max-width: 76px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-stop-color {
|
||||||
|
width: 38px;
|
||||||
|
height: 28px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
padding: 1px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-stop-bidir-btn {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
flex: 0 0 26px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-stop-remove-btn {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
flex: 0 0 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-stop-bidir-btn.active {
|
||||||
|
background: var(--primary-color);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-stop-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ class CSSEditorModal extends Modal {
|
|||||||
saturation: document.getElementById('css-editor-saturation').value,
|
saturation: document.getElementById('css-editor-saturation').value,
|
||||||
gamma: document.getElementById('css-editor-gamma').value,
|
gamma: document.getElementById('css-editor-gamma').value,
|
||||||
color: document.getElementById('css-editor-color').value,
|
color: document.getElementById('css-editor-color').value,
|
||||||
led_count: type === 'static' ? '0' : document.getElementById('css-editor-led-count').value,
|
led_count: (type === 'static' || type === 'gradient') ? '0' : document.getElementById('css-editor-led-count').value,
|
||||||
|
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,8 +39,13 @@ export function onCSSTypeChange() {
|
|||||||
const type = document.getElementById('css-editor-type').value;
|
const type = document.getElementById('css-editor-type').value;
|
||||||
document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : 'none';
|
document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : 'none';
|
||||||
document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none';
|
document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none';
|
||||||
// LED count is only meaningful for picture sources; static uses device LED count automatically
|
document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none';
|
||||||
document.getElementById('css-editor-led-count-group').style.display = type === 'static' ? 'none' : '';
|
// LED count is only meaningful for picture sources; static/gradient auto-size from device
|
||||||
|
document.getElementById('css-editor-led-count-group').style.display = (type === 'static' || type === 'gradient') ? 'none' : '';
|
||||||
|
|
||||||
|
if (type === 'gradient') {
|
||||||
|
requestAnimationFrame(() => gradientRenderAll());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */
|
/** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */
|
||||||
@@ -58,6 +64,7 @@ function hexToRgbArray(hex) {
|
|||||||
|
|
||||||
export function createColorStripCard(source, pictureSourceMap) {
|
export function createColorStripCard(source, pictureSourceMap) {
|
||||||
const isStatic = source.source_type === 'static';
|
const isStatic = source.source_type === 'static';
|
||||||
|
const isGradient = source.source_type === 'gradient';
|
||||||
|
|
||||||
let propsHtml;
|
let propsHtml;
|
||||||
if (isStatic) {
|
if (isStatic) {
|
||||||
@@ -68,6 +75,26 @@ export function createColorStripCard(source, pictureSourceMap) {
|
|||||||
</span>
|
</span>
|
||||||
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
|
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
|
||||||
`;
|
`;
|
||||||
|
} else if (isGradient) {
|
||||||
|
const stops = source.stops || [];
|
||||||
|
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
|
||||||
|
let cssGradient = '';
|
||||||
|
if (sortedStops.length >= 2) {
|
||||||
|
// Build CSS stops that mirror the interpolation algorithm:
|
||||||
|
// for each stop emit its primary color, then immediately emit color_right
|
||||||
|
// at the same position to produce a hard edge (bidirectional stop).
|
||||||
|
const parts = [];
|
||||||
|
sortedStops.forEach(s => {
|
||||||
|
const pct = Math.round(s.position * 100);
|
||||||
|
parts.push(`${rgbArrayToHex(s.color)} ${pct}%`);
|
||||||
|
if (s.color_right) parts.push(`${rgbArrayToHex(s.color_right)} ${pct}%`);
|
||||||
|
});
|
||||||
|
cssGradient = `linear-gradient(to right, ${parts.join(', ')})`;
|
||||||
|
}
|
||||||
|
propsHtml = `
|
||||||
|
${cssGradient ? `<span style="flex:1 1 100%;height:12px;background:${cssGradient};border-radius:3px;border:1px solid rgba(128,128,128,0.3)"></span>` : ''}
|
||||||
|
<span class="stream-card-prop">🎨 ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
|
||||||
|
`;
|
||||||
} else {
|
} else {
|
||||||
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
|
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
|
||||||
? pictureSourceMap[source.picture_source_id].name
|
? pictureSourceMap[source.picture_source_id].name
|
||||||
@@ -82,8 +109,10 @@ export function createColorStripCard(source, pictureSourceMap) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = isStatic ? '🎨' : '🎞️';
|
const icon = isStatic ? '🎨' : isGradient ? '🌈' : '🎞️';
|
||||||
const calibrationBtn = isStatic ? '' : `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`;
|
const calibrationBtn = (!isStatic && !isGradient)
|
||||||
|
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="card" data-css-id="${source.id}">
|
<div class="card" data-css-id="${source.id}">
|
||||||
@@ -136,6 +165,11 @@ export async function showCSSEditor(cssId = null) {
|
|||||||
|
|
||||||
if (sourceType === 'static') {
|
if (sourceType === 'static') {
|
||||||
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
|
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
|
||||||
|
} else if (sourceType === 'gradient') {
|
||||||
|
gradientInit(css.stops || [
|
||||||
|
{ position: 0.0, color: [255, 0, 0] },
|
||||||
|
{ position: 1.0, color: [0, 0, 255] },
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
sourceSelect.value = css.picture_source_id || '';
|
sourceSelect.value = css.picture_source_id || '';
|
||||||
|
|
||||||
@@ -183,6 +217,10 @@ export async function showCSSEditor(cssId = null) {
|
|||||||
document.getElementById('css-editor-color').value = '#ffffff';
|
document.getElementById('css-editor-color').value = '#ffffff';
|
||||||
document.getElementById('css-editor-led-count').value = 0;
|
document.getElementById('css-editor-led-count').value = 0;
|
||||||
document.getElementById('css-editor-title').textContent = t('color_strip.add');
|
document.getElementById('css-editor-title').textContent = t('color_strip.add');
|
||||||
|
gradientInit([
|
||||||
|
{ position: 0.0, color: [255, 0, 0] },
|
||||||
|
{ position: 1.0, color: [0, 0, 255] },
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('css-editor-error').style.display = 'none';
|
document.getElementById('css-editor-error').style.display = 'none';
|
||||||
@@ -218,9 +256,21 @@ export async function saveCSSEditor() {
|
|||||||
name,
|
name,
|
||||||
color: hexToRgbArray(document.getElementById('css-editor-color').value),
|
color: hexToRgbArray(document.getElementById('css-editor-color').value),
|
||||||
};
|
};
|
||||||
if (!cssId) {
|
if (!cssId) payload.source_type = 'static';
|
||||||
payload.source_type = 'static';
|
} else if (sourceType === 'gradient') {
|
||||||
|
if (_gradientStops.length < 2) {
|
||||||
|
cssEditorModal.showError(t('color_strip.gradient.min_stops'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
payload = {
|
||||||
|
name,
|
||||||
|
stops: _gradientStops.map(s => ({
|
||||||
|
position: s.position,
|
||||||
|
color: s.color,
|
||||||
|
...(s.colorRight ? { color_right: s.colorRight } : {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (!cssId) payload.source_type = 'gradient';
|
||||||
} else {
|
} else {
|
||||||
payload = {
|
payload = {
|
||||||
name,
|
name,
|
||||||
@@ -233,9 +283,7 @@ export async function saveCSSEditor() {
|
|||||||
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
|
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
|
||||||
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
|
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
|
||||||
};
|
};
|
||||||
if (!cssId) {
|
if (!cssId) payload.source_type = 'picture';
|
||||||
payload.source_type = 'picture';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -329,3 +377,269 @@ export async function stopCSSOverlay(cssId) {
|
|||||||
showToast(t('overlay.error.stop'), 'error');
|
showToast(t('overlay.error.stop'), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════════════
|
||||||
|
GRADIENT EDITOR
|
||||||
|
══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal state: array of stop objects.
|
||||||
|
* Each stop: { position: float 0–1, color: [R,G,B], colorRight: [R,G,B]|null }
|
||||||
|
*/
|
||||||
|
let _gradientStops = [];
|
||||||
|
let _gradientSelectedIdx = -1;
|
||||||
|
let _gradientDragging = null; // { idx, trackRect } while dragging
|
||||||
|
|
||||||
|
/* ── Interpolation (mirrors Python backend exactly) ───────────── */
|
||||||
|
|
||||||
|
function _gradientInterpolate(stops, pos) {
|
||||||
|
if (!stops.length) return [128, 128, 128];
|
||||||
|
const sorted = [...stops].sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
if (pos <= sorted[0].position) return sorted[0].color.slice();
|
||||||
|
|
||||||
|
const last = sorted[sorted.length - 1];
|
||||||
|
if (pos >= last.position) return (last.colorRight || last.color).slice();
|
||||||
|
|
||||||
|
for (let i = 0; i < sorted.length - 1; i++) {
|
||||||
|
const a = sorted[i];
|
||||||
|
const b = sorted[i + 1];
|
||||||
|
if (a.position <= pos && pos <= b.position) {
|
||||||
|
const span = b.position - a.position;
|
||||||
|
const t2 = span > 0 ? (pos - a.position) / span : 0;
|
||||||
|
const lc = a.colorRight || a.color;
|
||||||
|
const rc = b.color;
|
||||||
|
return lc.map((c, j) => Math.round(c + t2 * (rc[j] - c)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [128, 128, 128];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Init ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function gradientInit(stops) {
|
||||||
|
_gradientStops = stops.map(s => ({
|
||||||
|
position: parseFloat(s.position ?? 0),
|
||||||
|
color: (Array.isArray(s.color) && s.color.length === 3) ? [...s.color] : [255, 255, 255],
|
||||||
|
colorRight: (Array.isArray(s.color_right) && s.color_right.length === 3) ? [...s.color_right] : null,
|
||||||
|
}));
|
||||||
|
_gradientSelectedIdx = _gradientStops.length > 0 ? 0 : -1;
|
||||||
|
_gradientDragging = null;
|
||||||
|
_gradientSetupTrackClick();
|
||||||
|
gradientRenderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Render ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function gradientRenderAll() {
|
||||||
|
_gradientRenderCanvas();
|
||||||
|
_gradientRenderMarkers();
|
||||||
|
_gradientRenderStopList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _gradientRenderCanvas() {
|
||||||
|
const canvas = document.getElementById('gradient-canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
// Sync canvas pixel width to its CSS display width
|
||||||
|
const W = Math.max(1, Math.round(canvas.offsetWidth || 300));
|
||||||
|
if (canvas.width !== W) canvas.width = W;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const H = canvas.height;
|
||||||
|
const imgData = ctx.createImageData(W, H);
|
||||||
|
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const pos = W > 1 ? x / (W - 1) : 0;
|
||||||
|
const [r, g, b] = _gradientInterpolate(_gradientStops, pos);
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
const idx = (y * W + x) * 4;
|
||||||
|
imgData.data[idx] = r;
|
||||||
|
imgData.data[idx + 1] = g;
|
||||||
|
imgData.data[idx + 2] = b;
|
||||||
|
imgData.data[idx + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.putImageData(imgData, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _gradientRenderMarkers() {
|
||||||
|
const track = document.getElementById('gradient-markers-track');
|
||||||
|
if (!track) return;
|
||||||
|
track.innerHTML = '';
|
||||||
|
|
||||||
|
_gradientStops.forEach((stop, idx) => {
|
||||||
|
const marker = document.createElement('div');
|
||||||
|
marker.className = 'gradient-marker' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||||||
|
marker.style.left = `${stop.position * 100}%`;
|
||||||
|
marker.style.background = rgbArrayToHex(stop.color);
|
||||||
|
marker.title = `${(stop.position * 100).toFixed(0)}%`;
|
||||||
|
|
||||||
|
marker.addEventListener('mousedown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
_gradientSelectedIdx = idx;
|
||||||
|
_gradientStartDrag(e, idx);
|
||||||
|
_gradientRenderMarkers();
|
||||||
|
_gradientRenderStopList();
|
||||||
|
});
|
||||||
|
|
||||||
|
track.appendChild(marker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the selected stop index and reflect it via CSS classes only —
|
||||||
|
* no DOM rebuild, so in-flight click events on child elements are preserved.
|
||||||
|
*/
|
||||||
|
function _gradientSelectStop(idx) {
|
||||||
|
_gradientSelectedIdx = idx;
|
||||||
|
document.querySelectorAll('.gradient-stop-row').forEach((r, i) => r.classList.toggle('selected', i === idx));
|
||||||
|
document.querySelectorAll('.gradient-marker').forEach((m, i) => m.classList.toggle('selected', i === idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _gradientRenderStopList() {
|
||||||
|
const list = document.getElementById('gradient-stops-list');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
_gradientStops.forEach((stop, idx) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'gradient-stop-row' + (idx === _gradientSelectedIdx ? ' selected' : '');
|
||||||
|
|
||||||
|
const hasBidir = !!stop.colorRight;
|
||||||
|
const rightColor = stop.colorRight || stop.color;
|
||||||
|
|
||||||
|
row.innerHTML = `
|
||||||
|
<input type="number" class="gradient-stop-pos" value="${stop.position.toFixed(2)}"
|
||||||
|
min="0" max="1" step="0.01" title="${t('color_strip.gradient.position')}">
|
||||||
|
<input type="color" class="gradient-stop-color" value="${rgbArrayToHex(stop.color)}"
|
||||||
|
title="Left color">
|
||||||
|
<button type="button" class="btn btn-sm gradient-stop-bidir-btn${hasBidir ? ' active' : ''}"
|
||||||
|
title="${t('color_strip.gradient.bidir.hint')}">↔</button>
|
||||||
|
<input type="color" class="gradient-stop-color-right" value="${rgbArrayToHex(rightColor)}"
|
||||||
|
style="display:${hasBidir ? 'inline-block' : 'none'}" title="Right color">
|
||||||
|
<span class="gradient-stop-spacer"></span>
|
||||||
|
<button type="button" class="btn btn-sm btn-danger gradient-stop-remove-btn"
|
||||||
|
title="Remove stop"${_gradientStops.length <= 2 ? ' disabled' : ''}>✕</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Select row on mousedown — CSS-only update so child click events are not interrupted
|
||||||
|
row.addEventListener('mousedown', () => _gradientSelectStop(idx));
|
||||||
|
|
||||||
|
// Position
|
||||||
|
const posInput = row.querySelector('.gradient-stop-pos');
|
||||||
|
posInput.addEventListener('change', (e) => {
|
||||||
|
const val = Math.min(1, Math.max(0, parseFloat(e.target.value) || 0));
|
||||||
|
e.target.value = val.toFixed(2);
|
||||||
|
_gradientStops[idx].position = val;
|
||||||
|
gradientRenderAll();
|
||||||
|
});
|
||||||
|
posInput.addEventListener('focus', () => _gradientSelectStop(idx));
|
||||||
|
|
||||||
|
// Left color
|
||||||
|
row.querySelector('.gradient-stop-color').addEventListener('input', (e) => {
|
||||||
|
_gradientStops[idx].color = hexToRgbArray(e.target.value);
|
||||||
|
const markers = document.querySelectorAll('.gradient-marker');
|
||||||
|
if (markers[idx]) markers[idx].style.background = e.target.value;
|
||||||
|
_gradientRenderCanvas();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bidirectional toggle
|
||||||
|
row.querySelector('.gradient-stop-bidir-btn').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
_gradientStops[idx].colorRight = _gradientStops[idx].colorRight
|
||||||
|
? null
|
||||||
|
: [..._gradientStops[idx].color];
|
||||||
|
_gradientRenderStopList();
|
||||||
|
_gradientRenderCanvas();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Right color
|
||||||
|
row.querySelector('.gradient-stop-color-right').addEventListener('input', (e) => {
|
||||||
|
_gradientStops[idx].colorRight = hexToRgbArray(e.target.value);
|
||||||
|
_gradientRenderCanvas();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove
|
||||||
|
row.querySelector('.btn-danger').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (_gradientStops.length > 2) {
|
||||||
|
_gradientStops.splice(idx, 1);
|
||||||
|
if (_gradientSelectedIdx >= _gradientStops.length) {
|
||||||
|
_gradientSelectedIdx = _gradientStops.length - 1;
|
||||||
|
}
|
||||||
|
gradientRenderAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Add Stop ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function gradientAddStop(position) {
|
||||||
|
if (position === undefined) {
|
||||||
|
// Find the largest gap between adjacent stops and place in the middle
|
||||||
|
const sorted = [..._gradientStops].sort((a, b) => a.position - b.position);
|
||||||
|
let maxGap = 0, gapMid = 0.5;
|
||||||
|
for (let i = 0; i < sorted.length - 1; i++) {
|
||||||
|
const gap = sorted[i + 1].position - sorted[i].position;
|
||||||
|
if (gap > maxGap) {
|
||||||
|
maxGap = gap;
|
||||||
|
gapMid = (sorted[i].position + sorted[i + 1].position) / 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
position = sorted.length >= 2 ? Math.round(gapMid * 100) / 100 : 0.5;
|
||||||
|
}
|
||||||
|
position = Math.min(1, Math.max(0, position));
|
||||||
|
const color = _gradientInterpolate(_gradientStops, position);
|
||||||
|
_gradientStops.push({ position, color, colorRight: null });
|
||||||
|
_gradientSelectedIdx = _gradientStops.length - 1;
|
||||||
|
gradientRenderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drag ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _gradientStartDrag(e, idx) {
|
||||||
|
const track = document.getElementById('gradient-markers-track');
|
||||||
|
if (!track) return;
|
||||||
|
_gradientDragging = { idx, trackRect: track.getBoundingClientRect() };
|
||||||
|
|
||||||
|
const onMove = (me) => {
|
||||||
|
if (!_gradientDragging) return;
|
||||||
|
const { trackRect } = _gradientDragging;
|
||||||
|
const pos = Math.min(1, Math.max(0, (me.clientX - trackRect.left) / trackRect.width));
|
||||||
|
_gradientStops[_gradientDragging.idx].position = Math.round(pos * 100) / 100;
|
||||||
|
gradientRenderAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
_gradientDragging = null;
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Track click → add stop ───────────────────────────────────── */
|
||||||
|
|
||||||
|
function _gradientSetupTrackClick() {
|
||||||
|
const track = document.getElementById('gradient-markers-track');
|
||||||
|
if (!track || track._gradientClickBound) return;
|
||||||
|
track._gradientClickBound = true;
|
||||||
|
|
||||||
|
track.addEventListener('click', (e) => {
|
||||||
|
if (_gradientDragging) return;
|
||||||
|
const rect = track.getBoundingClientRect();
|
||||||
|
const pos = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
||||||
|
// Ignore clicks very close to an existing marker
|
||||||
|
const tooClose = _gradientStops.some(s => Math.abs(s.position - pos) < 0.03);
|
||||||
|
if (!tooClose) {
|
||||||
|
gradientAddStop(Math.round(pos * 100) / 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -571,9 +571,19 @@
|
|||||||
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target",
|
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target",
|
||||||
"color_strip.error.name_required": "Please enter a name",
|
"color_strip.error.name_required": "Please enter a name",
|
||||||
"color_strip.type": "Type:",
|
"color_strip.type": "Type:",
|
||||||
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color.",
|
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color. Gradient distributes a color gradient across all LEDs.",
|
||||||
"color_strip.type.picture": "Picture Source",
|
"color_strip.type.picture": "Picture Source",
|
||||||
"color_strip.type.static": "Static Color",
|
"color_strip.type.static": "Static Color",
|
||||||
|
"color_strip.type.gradient": "Gradient",
|
||||||
"color_strip.static_color": "Color:",
|
"color_strip.static_color": "Color:",
|
||||||
"color_strip.static_color.hint": "The solid color that will be sent to all LEDs on the strip."
|
"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.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",
|
||||||
|
"color_strip.gradient.add_stop": "+ Add Stop",
|
||||||
|
"color_strip.gradient.position": "Position (0.0–1.0)",
|
||||||
|
"color_strip.gradient.bidir.hint": "Add a second color on the right side of this stop to create a hard edge in the gradient.",
|
||||||
|
"color_strip.gradient.min_stops": "Gradient must have at least 2 stops"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -571,9 +571,19 @@
|
|||||||
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
|
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
|
||||||
"color_strip.error.name_required": "Введите название",
|
"color_strip.error.name_required": "Введите название",
|
||||||
"color_strip.type": "Тип:",
|
"color_strip.type": "Тип:",
|
||||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом.",
|
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам.",
|
||||||
"color_strip.type.picture": "Источник изображения",
|
"color_strip.type.picture": "Источник изображения",
|
||||||
"color_strip.type.static": "Статический цвет",
|
"color_strip.type.static": "Статический цвет",
|
||||||
|
"color_strip.type.gradient": "Градиент",
|
||||||
"color_strip.static_color": "Цвет:",
|
"color_strip.static_color": "Цвет:",
|
||||||
"color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы."
|
"color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы.",
|
||||||
|
"color_strip.gradient.preview": "Градиент:",
|
||||||
|
"color_strip.gradient.preview.hint": "Предпросмотр градиента. Нажмите на дорожку маркеров чтобы добавить остановку. Перетащите маркеры для изменения позиции.",
|
||||||
|
"color_strip.gradient.stops": "Цветовые остановки:",
|
||||||
|
"color_strip.gradient.stops.hint": "Каждая остановка задаёт цвет в относительной позиции (0.0 = начало, 1.0 = конец). Кнопка ↔ добавляет цвет справа для создания резкого перехода.",
|
||||||
|
"color_strip.gradient.stops_count": "остановок",
|
||||||
|
"color_strip.gradient.add_stop": "+ Добавить",
|
||||||
|
"color_strip.gradient.position": "Позиция (0.0–1.0)",
|
||||||
|
"color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.",
|
||||||
|
"color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ calibration, color correction, smoothing, and FPS.
|
|||||||
Current types:
|
Current types:
|
||||||
PictureColorStripSource — derives LED colors from a PictureSource (screen capture)
|
PictureColorStripSource — derives LED colors from a PictureSource (screen capture)
|
||||||
StaticColorStripSource — constant solid color fills all LEDs
|
StaticColorStripSource — constant solid color fills all LEDs
|
||||||
|
GradientColorStripSource — linear gradient across all LEDs from user-defined color stops
|
||||||
Future types (not yet implemented):
|
|
||||||
GradientColorStripSource — animated gradient
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -54,6 +52,7 @@ class ColorStripSource:
|
|||||||
"calibration": None,
|
"calibration": None,
|
||||||
"led_count": None,
|
"led_count": None,
|
||||||
"color": None,
|
"color": None,
|
||||||
|
"stops": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -99,6 +98,16 @@ class ColorStripSource:
|
|||||||
led_count=data.get("led_count") or 0,
|
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
|
# Default: "picture" type
|
||||||
return PictureColorStripSource(
|
return PictureColorStripSource(
|
||||||
id=sid, name=name, source_type=source_type,
|
id=sid, name=name, source_type=source_type,
|
||||||
@@ -166,3 +175,28 @@ class StaticColorStripSource(ColorStripSource):
|
|||||||
d["color"] = list(self.color)
|
d["color"] = list(self.color)
|
||||||
d["led_count"] = self.led_count
|
d["led_count"] = self.led_count
|
||||||
return d
|
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.0–1.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
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import Dict, List, Optional
|
|||||||
from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict
|
from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict
|
||||||
from wled_controller.storage.color_strip_source import (
|
from wled_controller.storage.color_strip_source import (
|
||||||
ColorStripSource,
|
ColorStripSource,
|
||||||
|
GradientColorStripSource,
|
||||||
PictureColorStripSource,
|
PictureColorStripSource,
|
||||||
StaticColorStripSource,
|
StaticColorStripSource,
|
||||||
)
|
)
|
||||||
@@ -101,6 +102,7 @@ class ColorStripStore:
|
|||||||
calibration=None,
|
calibration=None,
|
||||||
led_count: int = 0,
|
led_count: int = 0,
|
||||||
color: Optional[list] = None,
|
color: Optional[list] = None,
|
||||||
|
stops: Optional[list] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
) -> ColorStripSource:
|
) -> ColorStripSource:
|
||||||
"""Create a new color strip source.
|
"""Create a new color strip source.
|
||||||
@@ -130,6 +132,20 @@ class ColorStripStore:
|
|||||||
color=rgb,
|
color=rgb,
|
||||||
led_count=led_count,
|
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:
|
else:
|
||||||
if calibration is None:
|
if calibration is None:
|
||||||
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
|
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
|
||||||
@@ -171,6 +187,7 @@ class ColorStripStore:
|
|||||||
calibration=None,
|
calibration=None,
|
||||||
led_count: Optional[int] = None,
|
led_count: Optional[int] = None,
|
||||||
color: Optional[list] = None,
|
color: Optional[list] = None,
|
||||||
|
stops: Optional[list] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
) -> ColorStripSource:
|
) -> ColorStripSource:
|
||||||
"""Update an existing color strip source.
|
"""Update an existing color strip source.
|
||||||
@@ -217,6 +234,11 @@ class ColorStripStore:
|
|||||||
source.color = color
|
source.color = color
|
||||||
if led_count is not None:
|
if led_count is not None:
|
||||||
source.led_count = led_count
|
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()
|
source.updated_at = datetime.utcnow()
|
||||||
self._save()
|
self._save()
|
||||||
|
|||||||
@@ -19,10 +19,11 @@
|
|||||||
<label for="css-editor-type" data-i18n="color_strip.type">Type:</label>
|
<label for="css-editor-type" data-i18n="color_strip.type">Type:</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</div>
|
||||||
<small class="input-hint" style="display:none" data-i18n="color_strip.type.hint">Picture Source derives colors from a screen capture. Static Color fills all LEDs with one constant color.</small>
|
<small class="input-hint" style="display:none" data-i18n="color_strip.type.hint">Picture Source derives colors from a screen capture. Static Color fills all LEDs with one constant color. Gradient distributes a color gradient across all LEDs.</small>
|
||||||
<select id="css-editor-type" onchange="onCSSTypeChange()">
|
<select id="css-editor-type" onchange="onCSSTypeChange()">
|
||||||
<option value="picture" data-i18n="color_strip.type.picture">Picture Source</option>
|
<option value="picture" data-i18n="color_strip.type.picture">Picture Source</option>
|
||||||
<option value="static" data-i18n="color_strip.type.static">Static Color</option>
|
<option value="static" data-i18n="color_strip.type.static">Static Color</option>
|
||||||
|
<option value="gradient" data-i18n="color_strip.type.gradient">Gradient</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,7 +132,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- LED count — picture type only (auto-sized from device for static) -->
|
<!-- Gradient-specific fields -->
|
||||||
|
<div id="css-editor-gradient-section" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="color_strip.gradient.preview">Gradient:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="color_strip.gradient.preview.hint">Visual preview. Click the marker track below to add a stop. Drag markers to reposition.</small>
|
||||||
|
<div class="gradient-editor">
|
||||||
|
<canvas id="gradient-canvas" height="44"></canvas>
|
||||||
|
<div id="gradient-markers-track" class="gradient-markers-track"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="label-row">
|
||||||
|
<label data-i18n="color_strip.gradient.stops">Color Stops:</label>
|
||||||
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
|
</div>
|
||||||
|
<small class="input-hint" style="display:none" data-i18n="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.</small>
|
||||||
|
<div id="gradient-stops-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- LED count — picture type only (auto-sized from device for static/gradient) -->
|
||||||
<div id="css-editor-led-count-group" class="form-group">
|
<div id="css-editor-led-count-group" class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label for="css-editor-led-count" data-i18n="color_strip.led_count">LED Count:</label>
|
<label for="css-editor-led-count" data-i18n="color_strip.led_count">LED Count:</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user