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 `