CSS: add GradientColorStripSource with visual editor

- Backend: GradientColorStripSource storage model, GradientColorStripStream
  with numpy interpolation (bidirectional stops, auto-size from device LED count),
  ColorStop Pydantic schema, API create/update/guard routes
- Frontend: gradient editor modal (canvas preview, draggable markers, stop rows),
  CSS hard-edge card swatch, locale keys (en + ru)
- Fixes: stop row mousedown no longer rebuilds DOM (buttons now clickable),
  position input max-width, bidir/remove button static width

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 19:35:41 +03:00
parent 2a8e2daefc
commit 7479b1fb8d
12 changed files with 731 additions and 29 deletions

View File

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

View File

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

View File

@@ -333,6 +333,65 @@ class PictureColorStripStream(ColorStripStream):
time.sleep(remaining)
def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
"""Compute an (led_count, 3) uint8 array from gradient color stops.
Each stop: {"position": float 01, "color": [R,G,B], "color_right": [R,G,B] | absent}
Interpolation:
Sort stops by position. For each LED at relative position p = i/(N-1):
p ≤ first stop → first stop primary color
p ≥ last stop → last stop right color (if bidirectional) else primary
else find surrounding stops A (≤p) and B (>p):
left_color = A["color_right"] if present, else A["color"]
right_color = B["color"]
t = (p - A.pos) / (B.pos - A.pos)
color = lerp(left_color, right_color, t)
"""
if led_count <= 0:
led_count = 1
if not stops:
return np.zeros((led_count, 3), dtype=np.uint8)
sorted_stops = sorted(stops, key=lambda s: float(s.get("position", 0)))
def _color(stop: dict, side: str = "left") -> np.ndarray:
if side == "right":
cr = stop.get("color_right")
if cr and isinstance(cr, list) and len(cr) == 3:
return np.array(cr, dtype=np.float32)
c = stop.get("color", [255, 255, 255])
return np.array(c if isinstance(c, list) and len(c) == 3 else [255, 255, 255], dtype=np.float32)
result = np.zeros((led_count, 3), dtype=np.float32)
for i in range(led_count):
p = i / (led_count - 1) if led_count > 1 else 0.0
if p <= float(sorted_stops[0].get("position", 0)):
result[i] = _color(sorted_stops[0], "left")
continue
last = sorted_stops[-1]
if p >= float(last.get("position", 1)):
result[i] = _color(last, "right")
continue
for j in range(len(sorted_stops) - 1):
a = sorted_stops[j]
b = sorted_stops[j + 1]
a_pos = float(a.get("position", 0))
b_pos = float(b.get("position", 1))
if a_pos <= p <= b_pos:
span = b_pos - a_pos
t = (p - a_pos) / span if span > 0 else 0.0
result[i] = _color(a, "right") + t * (_color(b, "left") - _color(a, "right"))
break
return np.clip(result, 0, 255).astype(np.uint8)
class StaticColorStripStream(ColorStripStream):
"""Color strip stream that returns a constant single-color array.
@@ -400,3 +459,68 @@ class StaticColorStripStream(ColorStripStream):
self._led_count = prev_led_count
self._rebuild_colors()
logger.info("StaticColorStripStream params updated in-place")
class GradientColorStripStream(ColorStripStream):
"""Color strip stream that distributes a gradient across all LEDs.
Produces a pre-computed (led_count, 3) uint8 array from user-defined
color stops. No background thread needed — output is constant until
stops are changed.
LED count auto-sizes from the connected device when led_count == 0 in
the source config; configure(device_led_count) is called by
WledTargetProcessor on start.
"""
def __init__(self, source):
self._update_from_source(source)
def _update_from_source(self, source) -> None:
self._stops = list(source.stops) if source.stops else []
self._auto_size = not source.led_count
led_count = source.led_count if source.led_count and source.led_count > 0 else 1
self._led_count = led_count
self._rebuild_colors()
def _rebuild_colors(self) -> None:
self._colors = _compute_gradient_colors(self._stops, self._led_count)
def configure(self, device_led_count: int) -> None:
"""Size to device LED count when led_count was 0 (auto-size).
Only takes effect when the source was configured with led_count==0.
Silently ignored when an explicit led_count was set.
"""
if self._auto_size and device_led_count > 0 and device_led_count != self._led_count:
self._led_count = device_led_count
self._rebuild_colors()
logger.debug(f"GradientColorStripStream auto-sized to {device_led_count} LEDs")
@property
def target_fps(self) -> int:
return 30 # static output; any reasonable value is fine
@property
def led_count(self) -> int:
return self._led_count
def start(self) -> None:
logger.info(f"GradientColorStripStream started (leds={self._led_count}, stops={len(self._stops)})")
def stop(self) -> None:
logger.info("GradientColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
return self._colors
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import GradientColorStripSource
if isinstance(source, GradientColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
# Preserve runtime LED count across hot-updates when auto-sized
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
self._rebuild_colors()
logger.info("GradientColorStripStream params updated in-place")

View File

@@ -14,6 +14,7 @@ from typing import Dict, Optional
from wled_controller.core.processing.color_strip_stream import (
ColorStripStream,
GradientColorStripStream,
PictureColorStripStream,
StaticColorStripStream,
)
@@ -79,7 +80,11 @@ class ColorStripStreamManager:
)
return entry.stream
from wled_controller.storage.color_strip_source import PictureColorStripSource, StaticColorStripSource
from wled_controller.storage.color_strip_source import (
GradientColorStripSource,
PictureColorStripSource,
StaticColorStripSource,
)
source = self._color_strip_store.get_source(css_id)
@@ -94,6 +99,17 @@ class ColorStripStreamManager:
logger.info(f"Created static color strip stream for source {css_id}")
return css_stream
if isinstance(source, GradientColorStripSource):
css_stream = GradientColorStripStream(source)
css_stream.start()
self._streams[css_id] = _ColorStripEntry(
stream=css_stream,
ref_count=1,
picture_source_id="", # no live stream to manage
)
logger.info(f"Created gradient color strip stream for source {css_id}")
return css_stream
if not isinstance(source, PictureColorStripSource):
raise ValueError(
f"Unsupported color strip source type '{source.source_type}' for {css_id}"

View File

@@ -115,9 +115,12 @@ class WledTargetProcessor(TargetProcessor):
self._resolved_display_index = stream.display_index
self._resolved_target_fps = stream.target_fps
# For auto-sized static streams (led_count == 0), size to device LED count
from wled_controller.core.processing.color_strip_stream import StaticColorStripStream
if isinstance(stream, StaticColorStripStream) and device_info.led_count > 0:
# For auto-sized static/gradient streams (led_count == 0), size to device LED count
from wled_controller.core.processing.color_strip_stream import (
GradientColorStripStream,
StaticColorStripStream,
)
if isinstance(stream, (StaticColorStripStream, GradientColorStripStream)) and device_info.led_count > 0:
stream.configure(device_info.led_count)
logger.info(

View File

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

View File

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

View File

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

View File

@@ -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.01.0)",
"color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.",
"color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок"
}

View File

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

View File

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

View File

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