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.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:
|
||||
|
||||
@@ -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.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):
|
||||
"""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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -489,3 +489,113 @@
|
||||
box-shadow: 0 0 16px rgba(76, 175, 80, 0.5);
|
||||
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,
|
||||
gamma: document.getElementById('css-editor-gamma').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;
|
||||
document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : '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-led-count-group').style.display = type === 'static' ? 'none' : '';
|
||||
document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : '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". */
|
||||
@@ -58,6 +64,7 @@ function hexToRgbArray(hex) {
|
||||
|
||||
export function createColorStripCard(source, pictureSourceMap) {
|
||||
const isStatic = source.source_type === 'static';
|
||||
const isGradient = source.source_type === 'gradient';
|
||||
|
||||
let propsHtml;
|
||||
if (isStatic) {
|
||||
@@ -68,6 +75,26 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
</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 {
|
||||
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
|
||||
? pictureSourceMap[source.picture_source_id].name
|
||||
@@ -82,8 +109,10 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
`;
|
||||
}
|
||||
|
||||
const icon = isStatic ? '🎨' : '🎞️';
|
||||
const calibrationBtn = isStatic ? '' : `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`;
|
||||
const icon = isStatic ? '🎨' : isGradient ? '🌈' : '🎞️';
|
||||
const calibrationBtn = (!isStatic && !isGradient)
|
||||
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="card" data-css-id="${source.id}">
|
||||
@@ -136,6 +165,11 @@ export async function showCSSEditor(cssId = null) {
|
||||
|
||||
if (sourceType === 'static') {
|
||||
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 {
|
||||
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-led-count').value = 0;
|
||||
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';
|
||||
@@ -218,9 +256,21 @@ export async function saveCSSEditor() {
|
||||
name,
|
||||
color: hexToRgbArray(document.getElementById('css-editor-color').value),
|
||||
};
|
||||
if (!cssId) {
|
||||
payload.source_type = 'static';
|
||||
if (!cssId) 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 {
|
||||
payload = {
|
||||
name,
|
||||
@@ -233,9 +283,7 @@ export async function saveCSSEditor() {
|
||||
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
|
||||
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
|
||||
};
|
||||
if (!cssId) {
|
||||
payload.source_type = 'picture';
|
||||
}
|
||||
if (!cssId) payload.source_type = 'picture';
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -329,3 +377,269 @@ export async function stopCSSOverlay(cssId) {
|
||||
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.error.name_required": "Please enter a name",
|
||||
"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.static": "Static Color",
|
||||
"color_strip.type.gradient": "Gradient",
|
||||
"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.error.name_required": "Введите название",
|
||||
"color_strip.type": "Тип:",
|
||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом.",
|
||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам.",
|
||||
"color_strip.type.picture": "Источник изображения",
|
||||
"color_strip.type.static": "Статический цвет",
|
||||
"color_strip.type.gradient": "Градиент",
|
||||
"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:
|
||||
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.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.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()
|
||||
|
||||
@@ -19,10 +19,11 @@
|
||||
<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>
|
||||
</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()">
|
||||
<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="gradient" data-i18n="color_strip.type.gradient">Gradient</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +132,31 @@
|
||||
</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 class="label-row">
|
||||
<label for="css-editor-led-count" data-i18n="color_strip.led_count">LED Count:</label>
|
||||
|
||||
Reference in New Issue
Block a user