From 7479b1fb8d2a8d878278c73a6bb53cd470e07887 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 20 Feb 2026 19:35:41 +0300 Subject: [PATCH] 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 --- .../api/routes/color_strip_sources.py | 27 +- .../api/schemas/color_strip_sources.py | 19 +- .../core/processing/color_strip_stream.py | 124 +++++++ .../processing/color_strip_stream_manager.py | 18 +- .../core/processing/wled_target_processor.py | 9 +- .../src/wled_controller/static/css/modal.css | 110 ++++++ .../static/js/features/color-strips.js | 334 +++++++++++++++++- .../wled_controller/static/locales/en.json | 14 +- .../wled_controller/static/locales/ru.json | 14 +- .../storage/color_strip_source.py | 40 ++- .../storage/color_strip_store.py | 22 ++ .../templates/modals/css-editor.html | 29 +- 12 files changed, 731 insertions(+), 29 deletions(-) diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index 3d9ae3f..586b2db 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -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: diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index f7c2950..cfc7852 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -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") diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index 4fc91a6..d7c5a1c 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -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") diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index b084a36..9b75b39 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -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}" diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index f8ae9a6..e34d63a 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -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( diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index ab03a92..af20755 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -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; +} diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index ba01979..eb42528 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -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) { ${source.led_count ? `💡 ${source.led_count}` : ''} `; + } 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 ? `` : ''} + 🎨 ${stops.length} ${t('color_strip.gradient.stops_count')} + `; } 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 ? '' : ``; + const icon = isStatic ? '🎨' : isGradient ? '🌈' : '🎞️'; + const calibrationBtn = (!isStatic && !isGradient) + ? `` + : ''; return `
@@ -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 = ` + + + + + + + `; + + // 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); + } + }); +} diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 2d0ff05..ab7d472 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -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" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index f27b73e..68a11f4 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -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 остановок" } diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 91233e7..33bdccd 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -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 diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index 5044acd..0217dfa 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -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() diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index bd473bd..fce64a2 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -19,10 +19,11 @@
- + @@ -131,7 +132,31 @@ - + + + +