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 a8ed6e2..1ee964b 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 GradientColorStripSource, PictureColorStripSource, StaticColorStripSource +from wled_controller.storage.color_strip_source import ColorCycleColorStripSource, 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 @@ -69,8 +69,11 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe calibration=calibration, color=getattr(source, "color", None), stops=stops, + colors=getattr(source, "colors", None), + cycle_speed=getattr(source, "cycle_speed", None), description=source.description, frame_interpolation=getattr(source, "frame_interpolation", None), + animation=getattr(source, "animation", None), overlay_active=overlay_active, created_at=source.created_at, updated_at=source.updated_at, @@ -136,6 +139,9 @@ async def create_color_strip_source( stops=stops, description=data.description, frame_interpolation=data.frame_interpolation, + animation=data.animation.model_dump() if data.animation else None, + colors=data.colors, + cycle_speed=data.cycle_speed, ) return _css_to_response(source) @@ -193,6 +199,9 @@ async def update_color_strip_source( stops=stops, description=data.description, frame_interpolation=data.frame_interpolation, + animation=data.animation.model_dump() if data.animation else None, + colors=data.colors, + cycle_speed=data.cycle_speed, ) # Hot-reload running stream (no restart needed for in-place param changes) @@ -278,7 +287,7 @@ async def test_css_calibration( if body.edges: try: source = store.get_source(source_id) - if isinstance(source, (StaticColorStripSource, GradientColorStripSource)): + if isinstance(source, (StaticColorStripSource, GradientColorStripSource, ColorCycleColorStripSource)): raise HTTPException( status_code=400, detail="Calibration test is not applicable for this color strip source type", @@ -324,7 +333,7 @@ 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, GradientColorStripSource)): + if isinstance(source, (StaticColorStripSource, GradientColorStripSource, ColorCycleColorStripSource)): 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") 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 df796cb..6a1f6cd 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -8,6 +8,14 @@ from pydantic import BaseModel, Field from wled_controller.api.schemas.devices import Calibration +class AnimationConfig(BaseModel): + """Procedural animation configuration for static/gradient color strip sources.""" + + enabled: bool = True + type: str = "breathing" # breathing | color_cycle | gradient_shift | wave + speed: float = Field(1.0, ge=0.1, le=10.0, description="Speed multiplier (0.1–10.0)") + + class ColorStop(BaseModel): """A single color stop in a gradient.""" @@ -23,7 +31,7 @@ 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", "gradient"] = Field(default="picture", description="Source type") + source_type: Literal["picture", "static", "gradient", "color_cycle"] = 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) @@ -37,10 +45,14 @@ class ColorStripSourceCreate(BaseModel): 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") + # color_cycle-type fields + colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)") + cycle_speed: Optional[float] = Field(None, description="Cycle speed multiplier 0.1–10.0 (color_cycle type)", ge=0.1, le=10.0) # 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) frame_interpolation: bool = Field(default=False, description="Blend between consecutive captured frames for smoother output") + animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)") class ColorStripSourceUpdate(BaseModel): @@ -60,10 +72,14 @@ class ColorStripSourceUpdate(BaseModel): 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") + # color_cycle-type fields + colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)") + cycle_speed: Optional[float] = Field(None, description="Cycle speed multiplier 0.1–10.0 (color_cycle type)", ge=0.1, le=10.0) # 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) frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames") + animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)") class ColorStripSourceResponse(BaseModel): @@ -85,10 +101,14 @@ class ColorStripSourceResponse(BaseModel): 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") + # color_cycle-type fields + colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)") + cycle_speed: Optional[float] = Field(None, description="Cycle speed multiplier (color_cycle type)") # shared led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)") description: Optional[str] = Field(None, description="Description") frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames") + animation: Optional[AnimationConfig] = Field(None, description="Procedural animation config (static/gradient only)") overlay_active: bool = Field(False, description="Whether the screen overlay is currently active") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") 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 74c1e8f..24059d6 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -10,6 +10,7 @@ processing — border extraction, pixel mapping, color correction — runs only even when multiple devices share the same source configuration. """ +import math import threading import time from abc import ABC, abstractmethod @@ -447,9 +448,8 @@ def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray: class StaticColorStripStream(ColorStripStream): """Color strip stream that returns a constant single-color array. - No background thread needed — every call to get_latest_colors() returns - the same pre-built numpy array. Parameters can be hot-updated via - update_source(). + When animation is enabled a 30 fps background thread updates _colors with + the animated result. Parameters can be hot-updated via update_source(). """ def __init__(self, source): @@ -457,6 +457,9 @@ class StaticColorStripStream(ColorStripStream): Args: source: StaticColorStripSource config """ + self._colors_lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None self._update_from_source(source) def _update_from_source(self, source) -> None: @@ -465,13 +468,16 @@ class StaticColorStripStream(ColorStripStream): self._auto_size = not source.led_count # True when led_count == 0 led_count = source.led_count if source.led_count and source.led_count > 0 else 1 self._led_count = led_count + self._animation = source.animation # dict or None; read atomically by _animate_loop self._rebuild_colors() def _rebuild_colors(self) -> None: - self._colors = np.tile( + colors = np.tile( np.array(self._source_color, dtype=np.uint8), (self._led_count, 1), ) + with self._colors_lock: + self._colors = colors def configure(self, device_led_count: int) -> None: """Set LED count from the target device (called by WledTargetProcessor on start). @@ -486,20 +492,36 @@ class StaticColorStripStream(ColorStripStream): @property def target_fps(self) -> int: - return 30 # static output; any reasonable value is fine + return 30 @property def led_count(self) -> int: return self._led_count def start(self) -> None: + if self._running: + return + self._running = True + self._thread = threading.Thread( + target=self._animate_loop, + name="css-static-animate", + daemon=True, + ) + self._thread.start() logger.info(f"StaticColorStripStream started (leds={self._led_count})") def stop(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=5.0) + if self._thread.is_alive(): + logger.warning("StaticColorStripStream animate thread did not terminate within 5s") + self._thread = None logger.info("StaticColorStripStream stopped") def get_latest_colors(self) -> Optional[np.ndarray]: - return self._colors + with self._colors_lock: + return self._colors def update_source(self, source) -> None: from wled_controller.storage.color_strip_source import StaticColorStripSource @@ -512,13 +534,38 @@ class StaticColorStripStream(ColorStripStream): self._rebuild_colors() logger.info("StaticColorStripStream params updated in-place") + def _animate_loop(self) -> None: + """Background thread: compute animated colors at ~30 fps when animation is active.""" + frame_time = 1.0 / 30 + while self._running: + loop_start = time.time() + anim = self._animation + if anim and anim.get("enabled"): + speed = float(anim.get("speed", 1.0)) + atype = anim.get("type", "breathing") + t = loop_start + n = self._led_count + colors = None -class GradientColorStripStream(ColorStripStream): - """Color strip stream that distributes a gradient across all LEDs. + if atype == "breathing": + factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) + base = np.array(self._source_color, dtype=np.float32) + pixel = np.clip(base * factor, 0, 255).astype(np.uint8) + colors = np.tile(pixel, (n, 1)) - 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. + if colors is not None: + with self._colors_lock: + self._colors = colors + + elapsed = time.time() - loop_start + time.sleep(max(frame_time - elapsed, 0.001)) + + +class ColorCycleColorStripStream(ColorStripStream): + """Color strip stream that smoothly cycles through a user-defined color list. + + All LEDs receive the same solid color at any moment, continuously interpolating + between the configured colors in a loop at 30 fps. LED count auto-sizes from the connected device when led_count == 0 in the source config; configure(device_led_count) is called by @@ -526,6 +573,122 @@ class GradientColorStripStream(ColorStripStream): """ def __init__(self, source): + self._colors_lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None + self._update_from_source(source) + + def _update_from_source(self, source) -> None: + raw = source.colors if isinstance(source.colors, list) else [] + default = [ + [255, 0, 0], [255, 255, 0], [0, 255, 0], + [0, 255, 255], [0, 0, 255], [255, 0, 255], + ] + self._color_list = [ + c for c in raw if isinstance(c, list) and len(c) == 3 + ] or default + self._cycle_speed = float(source.cycle_speed) if source.cycle_speed else 1.0 + self._auto_size = not source.led_count + self._led_count = source.led_count if source.led_count > 0 else 1 + self._rebuild_colors() + + def _rebuild_colors(self) -> None: + pixel = np.array(self._color_list[0], dtype=np.uint8) + colors = np.tile(pixel, (self._led_count, 1)) + with self._colors_lock: + self._colors = colors + + def configure(self, device_led_count: int) -> None: + """Size to device LED count when led_count was 0 (auto-size).""" + 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"ColorCycleColorStripStream auto-sized to {device_led_count} LEDs") + + @property + def target_fps(self) -> int: + return 30 + + @property + def led_count(self) -> int: + return self._led_count + + def start(self) -> None: + if self._running: + return + self._running = True + self._thread = threading.Thread( + target=self._animate_loop, + name="css-color-cycle", + daemon=True, + ) + self._thread.start() + logger.info(f"ColorCycleColorStripStream started (leds={self._led_count}, colors={len(self._color_list)})") + + def stop(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=5.0) + if self._thread.is_alive(): + logger.warning("ColorCycleColorStripStream animate thread did not terminate within 5s") + self._thread = None + logger.info("ColorCycleColorStripStream stopped") + + def get_latest_colors(self) -> Optional[np.ndarray]: + with self._colors_lock: + return self._colors + + def update_source(self, source) -> None: + from wled_controller.storage.color_strip_source import ColorCycleColorStripSource + if isinstance(source, ColorCycleColorStripSource): + prev_led_count = self._led_count if self._auto_size else None + self._update_from_source(source) + if prev_led_count and self._auto_size: + self._led_count = prev_led_count + self._rebuild_colors() + logger.info("ColorCycleColorStripStream params updated in-place") + + def _animate_loop(self) -> None: + """Background thread: interpolate between colors at ~30 fps.""" + frame_time = 1.0 / 30 + while self._running: + loop_start = time.time() + color_list = self._color_list + speed = self._cycle_speed + n = self._led_count + num = len(color_list) + if num >= 2: + # 0.05 factor → one full cycle every 20s at speed=1.0 + cycle_pos = (speed * loop_start * 0.05) % 1.0 + seg = cycle_pos * num + idx = int(seg) % num + t_interp = seg - int(seg) + c1 = np.array(color_list[idx], dtype=np.float32) + c2 = np.array(color_list[(idx + 1) % num], dtype=np.float32) + pixel = np.clip(c1 * (1 - t_interp) + c2 * t_interp, 0, 255).astype(np.uint8) + led_colors = np.tile(pixel, (n, 1)) + with self._colors_lock: + self._colors = led_colors + elapsed = time.time() - loop_start + time.sleep(max(frame_time - elapsed, 0.001)) + + +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. When animation is enabled a 30 fps background thread applies + dynamic effects (breathing, gradient_shift, wave). + + 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._colors_lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None self._update_from_source(source) def _update_from_source(self, source) -> None: @@ -533,10 +696,13 @@ class GradientColorStripStream(ColorStripStream): 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._animation = source.animation # dict or None; read atomically by _animate_loop self._rebuild_colors() def _rebuild_colors(self) -> None: - self._colors = _compute_gradient_colors(self._stops, self._led_count) + colors = _compute_gradient_colors(self._stops, self._led_count) + with self._colors_lock: + self._colors = colors def configure(self, device_led_count: int) -> None: """Size to device LED count when led_count was 0 (auto-size). @@ -551,20 +717,36 @@ class GradientColorStripStream(ColorStripStream): @property def target_fps(self) -> int: - return 30 # static output; any reasonable value is fine + return 30 @property def led_count(self) -> int: return self._led_count def start(self) -> None: + if self._running: + return + self._running = True + self._thread = threading.Thread( + target=self._animate_loop, + name="css-gradient-animate", + daemon=True, + ) + self._thread.start() logger.info(f"GradientColorStripStream started (leds={self._led_count}, stops={len(self._stops)})") def stop(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=5.0) + if self._thread.is_alive(): + logger.warning("GradientColorStripStream animate thread did not terminate within 5s") + self._thread = None logger.info("GradientColorStripStream stopped") def get_latest_colors(self) -> Optional[np.ndarray]: - return self._colors + with self._colors_lock: + return self._colors def update_source(self, source) -> None: from wled_controller.storage.color_strip_source import GradientColorStripSource @@ -576,3 +758,54 @@ class GradientColorStripStream(ColorStripStream): self._led_count = prev_led_count self._rebuild_colors() logger.info("GradientColorStripStream params updated in-place") + + def _animate_loop(self) -> None: + """Background thread: apply animation effects at ~30 fps when animation is active.""" + frame_time = 1.0 / 30 + _cached_base: Optional[np.ndarray] = None + _cached_n: int = 0 + _cached_stops: Optional[list] = None + while self._running: + loop_start = time.time() + anim = self._animation + if anim and anim.get("enabled"): + speed = float(anim.get("speed", 1.0)) + atype = anim.get("type", "breathing") + t = loop_start + n = self._led_count + stops = self._stops + colors = None + + # Recompute base gradient only when stops or led_count change + if _cached_base is None or _cached_n != n or _cached_stops is not stops: + _cached_base = _compute_gradient_colors(stops, n) + _cached_n = n + _cached_stops = stops + base = _cached_base + + if atype == "breathing": + factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) + colors = np.clip(base.astype(np.float32) * factor, 0, 255).astype(np.uint8) + + elif atype == "gradient_shift": + shift = int(speed * t * 10) % max(n, 1) + colors = np.roll(base, shift, axis=0) + + elif atype == "wave": + if n > 1: + i_arr = np.arange(n, dtype=np.float32) + factor = 0.5 * (1 + np.sin( + 2 * math.pi * i_arr / n - 2 * math.pi * speed * t + )) + colors = np.clip( + base.astype(np.float32) * factor[:, None], 0, 255 + ).astype(np.uint8) + else: + colors = base + + if colors is not None: + with self._colors_lock: + self._colors = colors + + elapsed = time.time() - loop_start + time.sleep(max(frame_time - elapsed, 0.001)) 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 9b75b39..29cce62 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 @@ -13,6 +13,7 @@ from dataclasses import dataclass from typing import Dict, Optional from wled_controller.core.processing.color_strip_stream import ( + ColorCycleColorStripStream, ColorStripStream, GradientColorStripStream, PictureColorStripStream, @@ -81,6 +82,7 @@ class ColorStripStreamManager: return entry.stream from wled_controller.storage.color_strip_source import ( + ColorCycleColorStripSource, GradientColorStripSource, PictureColorStripSource, StaticColorStripSource, @@ -88,6 +90,17 @@ class ColorStripStreamManager: source = self._color_strip_store.get_source(css_id) + if isinstance(source, ColorCycleColorStripSource): + css_stream = ColorCycleColorStripStream(source) + css_stream.start() + self._streams[css_id] = _ColorStripEntry( + stream=css_stream, + ref_count=1, + picture_source_id="", + ) + logger.info(f"Created color cycle stream for source {css_id}") + return css_stream + if isinstance(source, StaticColorStripSource): css_stream = StaticColorStripStream(source) css_stream.start() diff --git a/server/src/wled_controller/static/css/modal.css b/server/src/wled_controller/static/css/modal.css index af20755..096878d 100644 --- a/server/src/wled_controller/static/css/modal.css +++ b/server/src/wled_controller/static/css/modal.css @@ -599,3 +599,39 @@ .gradient-stop-spacer { flex: 1; } + +/* ── Color Cycle editor ──────────────────────────────────────── */ + +#color-cycle-colors-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.color-cycle-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} + +.color-cycle-item input[type="color"] { + width: 36px; + height: 28px; + border: 1px solid var(--border-color); + border-radius: 4px; + padding: 1px; + cursor: pointer; + background: transparent; + flex-shrink: 0; +} + +.color-cycle-remove-btn { + font-size: 0.6rem; + padding: 0; + width: 36px; + height: 14px; + min-width: unset; + line-height: 1; +} diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index b7c7004..54d38f2 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -90,7 +90,7 @@ import { // Layer 5: color-strip sources import { showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip, - onCSSTypeChange, + onCSSTypeChange, colorCycleAddColor, colorCycleRemoveColor, } from './features/color-strips.js'; // Layer 5: calibration @@ -276,6 +276,8 @@ Object.assign(window, { saveCSSEditor, deleteColorStrip, onCSSTypeChange, + colorCycleAddColor, + colorCycleRemoveColor, // calibration showCalibration, 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 445f63b..7cee700 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -26,8 +26,13 @@ class CSSEditorModal extends Modal { gamma: document.getElementById('css-editor-gamma').value, color: document.getElementById('css-editor-color').value, frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked, - led_count: (type === 'static' || type === 'gradient') ? '0' : document.getElementById('css-editor-led-count').value, + led_count: (type === 'static' || type === 'gradient' || type === 'color_cycle') ? '0' : document.getElementById('css-editor-led-count').value, gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]', + animation_enabled: document.getElementById('css-editor-animation-enabled').checked, + animation_type: document.getElementById('css-editor-animation-type').value, + animation_speed: document.getElementById('css-editor-animation-speed').value, + cycle_speed: document.getElementById('css-editor-cycle-speed').value, + cycle_colors: JSON.stringify(_colorCycleColors), }; } } @@ -40,15 +45,114 @@ 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'; + document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : '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' : ''; + // LED count is only meaningful for picture sources; static/gradient/color_cycle auto-size from device + document.getElementById('css-editor-led-count-group').style.display = + (type === 'static' || type === 'gradient' || type === 'color_cycle') ? 'none' : ''; + + // Animation section — shown for static/gradient only (color_cycle is always animating) + const animSection = document.getElementById('css-editor-animation-section'); + const animTypeSelect = document.getElementById('css-editor-animation-type'); + if (type === 'static') { + animSection.style.display = ''; + animTypeSelect.innerHTML = + ``; + } else if (type === 'gradient') { + animSection.style.display = ''; + animTypeSelect.innerHTML = + `` + + `` + + ``; + } else { + animSection.style.display = 'none'; + } if (type === 'gradient') { requestAnimationFrame(() => gradientRenderAll()); } } +function _getAnimationPayload() { + return { + enabled: document.getElementById('css-editor-animation-enabled').checked, + type: document.getElementById('css-editor-animation-type').value, + speed: parseFloat(document.getElementById('css-editor-animation-speed').value), + }; +} + +function _loadAnimationState(anim) { + document.getElementById('css-editor-animation-enabled').checked = !!(anim && anim.enabled); + const speedEl = document.getElementById('css-editor-animation-speed'); + speedEl.value = (anim && anim.speed != null) ? anim.speed : 1.0; + document.getElementById('css-editor-animation-speed-val').textContent = + parseFloat(speedEl.value).toFixed(1); + // Set type after onCSSTypeChange() has populated the dropdown + if (anim && anim.type) { + document.getElementById('css-editor-animation-type').value = anim.type; + } +} + +/* ── Color Cycle helpers ──────────────────────────────────────── */ + +const _DEFAULT_CYCLE_COLORS = ['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff']; +let _colorCycleColors = [..._DEFAULT_CYCLE_COLORS]; + +function _syncColorCycleFromDom() { + const inputs = document.querySelectorAll('#color-cycle-colors-list input[type=color]'); + if (inputs.length > 0) { + _colorCycleColors = Array.from(inputs).map(el => el.value); + } +} + +function _colorCycleRenderList() { + const list = document.getElementById('color-cycle-colors-list'); + if (!list) return; + const canRemove = _colorCycleColors.length > 2; + list.innerHTML = _colorCycleColors.map((hex, i) => ` +
+ + ${canRemove + ? `` + : `
`} +
+ `).join(''); +} + +export function colorCycleAddColor() { + _syncColorCycleFromDom(); + _colorCycleColors.push('#ffffff'); + _colorCycleRenderList(); +} + +export function colorCycleRemoveColor(i) { + _syncColorCycleFromDom(); + if (_colorCycleColors.length <= 2) return; + _colorCycleColors.splice(i, 1); + _colorCycleRenderList(); +} + +function _colorCycleGetColors() { + const inputs = document.querySelectorAll('#color-cycle-colors-list input[type=color]'); + return Array.from(inputs).map(el => hexToRgbArray(el.value)); +} + +function _loadColorCycleState(css) { + const raw = css && css.colors; + _colorCycleColors = (raw && raw.length >= 2) + ? raw.map(c => rgbArrayToHex(c)) + : [..._DEFAULT_CYCLE_COLORS]; + _colorCycleRenderList(); + const speed = (css && css.cycle_speed != null) ? css.cycle_speed : 1.0; + const speedEl = document.getElementById('css-editor-cycle-speed'); + if (speedEl) { + speedEl.value = speed; + document.getElementById('css-editor-cycle-speed-val').textContent = + parseFloat(speed).toFixed(1); + } +} + /** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */ function rgbArrayToHex(rgb) { if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff'; @@ -66,6 +170,11 @@ function hexToRgbArray(hex) { export function createColorStripCard(source, pictureSourceMap) { const isStatic = source.source_type === 'static'; const isGradient = source.source_type === 'gradient'; + const isColorCycle = source.source_type === 'color_cycle'; + + const animBadge = ((isStatic || isGradient) && source.animation && source.animation.enabled) + ? `✨ ${t('color_strip.animation.type.' + source.animation.type) || source.animation.type}` + : ''; let propsHtml; if (isStatic) { @@ -75,6 +184,17 @@ export function createColorStripCard(source, pictureSourceMap) { ${hexColor.toUpperCase()} ${source.led_count ? `💡 ${source.led_count}` : ''} + ${animBadge} + `; + } else if (isColorCycle) { + const colors = source.colors || []; + const swatches = colors.slice(0, 8).map(c => + `` + ).join(''); + propsHtml = ` + ${swatches} + 🔄 ${(source.cycle_speed || 1.0).toFixed(1)}× + ${source.led_count ? `💡 ${source.led_count}` : ''} `; } else if (isGradient) { const stops = source.stops || []; @@ -95,6 +215,7 @@ export function createColorStripCard(source, pictureSourceMap) { propsHtml = ` ${cssGradient ? `` : ''} 🎨 ${stops.length} ${t('color_strip.gradient.stops_count')} + ${animBadge} `; } else { const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id]) @@ -110,8 +231,8 @@ export function createColorStripCard(source, pictureSourceMap) { `; } - const icon = isStatic ? '🎨' : isGradient ? '🌈' : '🎞️'; - const calibrationBtn = (!isStatic && !isGradient) + const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : '🎞️'; + const calibrationBtn = (!isStatic && !isGradient && !isColorCycle) ? `` : ''; @@ -166,11 +287,15 @@ export async function showCSSEditor(cssId = null) { if (sourceType === 'static') { document.getElementById('css-editor-color').value = rgbArrayToHex(css.color); + _loadAnimationState(css.animation); + } else if (sourceType === 'color_cycle') { + _loadColorCycleState(css); } else if (sourceType === 'gradient') { gradientInit(css.stops || [ { position: 0.0, color: [255, 0, 0] }, { position: 1.0, color: [0, 0, 255] }, ]); + _loadAnimationState(css.animation); } else { sourceSelect.value = css.picture_source_id || ''; @@ -220,6 +345,8 @@ export async function showCSSEditor(cssId = null) { document.getElementById('css-editor-frame-interpolation').checked = false; document.getElementById('css-editor-color').value = '#ffffff'; document.getElementById('css-editor-led-count').value = 0; + _loadAnimationState(null); + _loadColorCycleState(null); document.getElementById('css-editor-title').textContent = t('color_strip.add'); gradientInit([ { position: 0.0, color: [255, 0, 0] }, @@ -259,8 +386,21 @@ export async function saveCSSEditor() { payload = { name, color: hexToRgbArray(document.getElementById('css-editor-color').value), + animation: _getAnimationPayload(), }; if (!cssId) payload.source_type = 'static'; + } else if (sourceType === 'color_cycle') { + const cycleColors = _colorCycleGetColors(); + if (cycleColors.length < 2) { + cssEditorModal.showError(t('color_strip.color_cycle.min_colors')); + return; + } + payload = { + name, + colors: cycleColors, + cycle_speed: parseFloat(document.getElementById('css-editor-cycle-speed').value), + }; + if (!cssId) payload.source_type = 'color_cycle'; } else if (sourceType === 'gradient') { if (_gradientStops.length < 2) { cssEditorModal.showError(t('color_strip.gradient.min_stops')); @@ -273,6 +413,7 @@ export async function saveCSSEditor() { color: s.color, ...(s.colorRight ? { color_right: s.colorRight } : {}), })), + animation: _getAnimationPayload(), }; if (!cssId) payload.source_type = 'gradient'; } else { diff --git a/server/src/wled_controller/static/js/features/kc-targets.js b/server/src/wled_controller/static/js/features/kc-targets.js index e439bb3..f6c27f3 100644 --- a/server/src/wled_controller/static/js/features/kc-targets.js +++ b/server/src/wled_controller/static/js/features/kc-targets.js @@ -102,10 +102,6 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap) {
${t('device.metrics.target_fps')}
${state.fps_target || 0}
-
-
${t('device.metrics.potential_fps')}
-
${state.fps_potential?.toFixed(0) || '-'}
-
${t('device.metrics.frames')}
${metrics.frames_processed || 0}
diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 7a5e825..7f59ad2 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -466,10 +466,6 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
${t('device.metrics.target_fps')}
${state.fps_target || 0}
-
-
${t('device.metrics.potential_fps')}
-
${state.fps_potential?.toFixed(0) || '-'}
-
${t('device.metrics.frames')}
${metrics.frames_processed || 0}
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index ef51e4e..e827824 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -573,10 +573,11 @@ "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. Gradient distributes a color gradient across all LEDs.", + "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 Cycle smoothly cycles through a user-defined list of colors.", "color_strip.type.picture": "Picture Source", "color_strip.type.static": "Static Color", "color_strip.type.gradient": "Gradient", + "color_strip.type.color_cycle": "Color Cycle", "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.gradient.preview": "Gradient:", @@ -587,5 +588,22 @@ "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" + "color_strip.gradient.min_stops": "Gradient must have at least 2 stops", + "color_strip.animation": "Animation", + "color_strip.animation.enabled": "Enable Animation:", + "color_strip.animation.enabled.hint": "Enables procedural animation. The LEDs will update at 30 fps driven by the selected effect.", + "color_strip.animation.type": "Effect:", + "color_strip.animation.type.hint": "The animation effect to apply. Breathing works for both static and gradient sources; Gradient Shift and Wave work for gradient sources only.", + "color_strip.animation.type.breathing": "Breathing", + "color_strip.animation.type.color_cycle": "Color Cycle", + "color_strip.animation.type.gradient_shift": "Gradient Shift", + "color_strip.animation.type.wave": "Wave", + "color_strip.animation.speed": "Speed:", + "color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.", + "color_strip.color_cycle.colors": "Colors:", + "color_strip.color_cycle.colors.hint": "List of colors to cycle through smoothly. At least 2 required. Default is a full rainbow spectrum.", + "color_strip.color_cycle.add_color": "+ Add Color", + "color_strip.color_cycle.speed": "Speed:", + "color_strip.color_cycle.speed.hint": "Cycle speed multiplier. 1.0 ≈ one full cycle every 20 seconds; higher values cycle faster.", + "color_strip.color_cycle.min_colors": "Color cycle must have at least 2 colors" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 4345161..a5d2693 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -573,10 +573,11 @@ "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.type.color_cycle": "Смена цвета", "color_strip.static_color": "Цвет:", "color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы.", "color_strip.gradient.preview": "Градиент:", @@ -587,5 +588,22 @@ "color_strip.gradient.add_stop": "+ Добавить", "color_strip.gradient.position": "Позиция (0.0–1.0)", "color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.", - "color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок" + "color_strip.gradient.min_stops": "Градиент должен содержать не менее 2 остановок", + "color_strip.animation": "Анимация", + "color_strip.animation.enabled": "Включить анимацию:", + "color_strip.animation.enabled.hint": "Включает процедурную анимацию. Светодиоды обновляются со скоростью 30 кадров в секунду по выбранному эффекту.", + "color_strip.animation.type": "Эффект:", + "color_strip.animation.type.hint": "Эффект анимации. Дыхание работает для статичного цвета и градиента; сдвиг градиента и волна — только для градиентных источников.", + "color_strip.animation.type.breathing": "Дыхание", + "color_strip.animation.type.color_cycle": "Смена цвета", + "color_strip.animation.type.gradient_shift": "Сдвиг градиента", + "color_strip.animation.type.wave": "Волна", + "color_strip.animation.speed": "Скорость:", + "color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.", + "color_strip.color_cycle.colors": "Цвета:", + "color_strip.color_cycle.colors.hint": "Список цветов для плавной циклической смены. Минимум 2 цвета. По умолчанию — полный радужный спектр.", + "color_strip.color_cycle.add_color": "+ Добавить цвет", + "color_strip.color_cycle.speed": "Скорость:", + "color_strip.color_cycle.speed.hint": "Множитель скорости смены. 1.0 ≈ один полный цикл за 20 секунд; большие значения ускоряют смену.", + "color_strip.color_cycle.min_colors": "Смена цвета должна содержать не менее 2 цветов" } diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index d6a3b37..bd0b242 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -5,9 +5,10 @@ from some input, encapsulating everything needed to drive a physical LED strip: calibration, color correction, smoothing, and FPS. Current types: - PictureColorStripSource — derives LED colors from a PictureSource (screen capture) - StaticColorStripSource — constant solid color fills all LEDs - GradientColorStripSource — linear gradient across all LEDs from user-defined color stops + PictureColorStripSource — derives LED colors from a PictureSource (screen capture) + StaticColorStripSource — constant solid color fills all LEDs + GradientColorStripSource — linear gradient across all LEDs from user-defined color stops + ColorCycleColorStripSource — smoothly cycles through a user-defined list of colors """ from dataclasses import dataclass, field @@ -53,6 +54,9 @@ class ColorStripSource: "led_count": None, "color": None, "stops": None, + "animation": None, + "colors": None, + "cycle_speed": None, } @staticmethod @@ -96,6 +100,7 @@ class ColorStripSource: created_at=created_at, updated_at=updated_at, description=description, color=color, led_count=data.get("led_count") or 0, + animation=data.get("animation"), ) if source_type == "gradient": @@ -106,6 +111,18 @@ class ColorStripSource: created_at=created_at, updated_at=updated_at, description=description, stops=stops, led_count=data.get("led_count") or 0, + animation=data.get("animation"), + ) + + if source_type == "color_cycle": + raw_colors = data.get("colors") + colors = raw_colors if isinstance(raw_colors, list) else [] + return ColorCycleColorStripSource( + id=sid, name=name, source_type="color_cycle", + created_at=created_at, updated_at=updated_at, description=description, + colors=colors, + cycle_speed=float(data.get("cycle_speed") or 1.0), + led_count=data.get("led_count") or 0, ) # Default: "picture" type @@ -172,11 +189,13 @@ class StaticColorStripSource(ColorStripSource): color: list = field(default_factory=lambda: [255, 255, 255]) # [R, G, B] led_count: int = 0 # 0 = use device LED count + animation: Optional[dict] = None # {"enabled": bool, "type": str, "speed": float} or None def to_dict(self) -> dict: d = super().to_dict() d["color"] = list(self.color) d["led_count"] = self.led_count + d["animation"] = self.animation return d @@ -197,9 +216,35 @@ class GradientColorStripSource(ColorStripSource): {"position": 1.0, "color": [0, 0, 255]}, ]) led_count: int = 0 # 0 = use device LED count + animation: Optional[dict] = None # {"enabled": bool, "type": str, "speed": float} or None def to_dict(self) -> dict: d = super().to_dict() d["stops"] = [dict(s) for s in self.stops] d["led_count"] = self.led_count + d["animation"] = self.animation + return d + + +@dataclass +class ColorCycleColorStripSource(ColorStripSource): + """Color strip source that smoothly cycles through a user-defined list of colors. + + All LEDs receive the same solid color at any point in time, smoothly + interpolating between the configured color stops in a continuous loop. + LED count auto-sizes from the connected device when led_count == 0. + """ + + colors: list = field(default_factory=lambda: [ + [255, 0, 0], [255, 255, 0], [0, 255, 0], + [0, 255, 255], [0, 0, 255], [255, 0, 255], + ]) + cycle_speed: float = 1.0 # speed multiplier; 1.0 ≈ one full cycle every 20 seconds + led_count: int = 0 # 0 = use device LED count + + def to_dict(self) -> dict: + d = super().to_dict() + d["colors"] = [list(c) for c in self.colors] + d["cycle_speed"] = self.cycle_speed + 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 b0e8b34..196892c 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -8,6 +8,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 ( + ColorCycleColorStripSource, ColorStripSource, GradientColorStripSource, PictureColorStripSource, @@ -105,6 +106,9 @@ class ColorStripStore: stops: Optional[list] = None, description: Optional[str] = None, frame_interpolation: bool = False, + animation: Optional[dict] = None, + colors: Optional[list] = None, + cycle_speed: float = 1.0, ) -> ColorStripSource: """Create a new color strip source. @@ -132,6 +136,7 @@ class ColorStripStore: description=description, color=rgb, led_count=led_count, + animation=animation, ) elif source_type == "gradient": source = GradientColorStripSource( @@ -146,6 +151,23 @@ class ColorStripStore: {"position": 1.0, "color": [0, 0, 255]}, ], led_count=led_count, + animation=animation, + ) + elif source_type == "color_cycle": + default_colors = [ + [255, 0, 0], [255, 255, 0], [0, 255, 0], + [0, 255, 255], [0, 0, 255], [255, 0, 255], + ] + source = ColorCycleColorStripSource( + id=source_id, + name=name, + source_type="color_cycle", + created_at=now, + updated_at=now, + description=description, + colors=colors if isinstance(colors, list) and len(colors) >= 2 else default_colors, + cycle_speed=float(cycle_speed) if cycle_speed else 1.0, + led_count=led_count, ) else: if calibration is None: @@ -192,6 +214,9 @@ class ColorStripStore: stops: Optional[list] = None, description: Optional[str] = None, frame_interpolation: Optional[bool] = None, + animation: Optional[dict] = None, + colors: Optional[list] = None, + cycle_speed: Optional[float] = None, ) -> ColorStripSource: """Update an existing color strip source. @@ -239,11 +264,22 @@ class ColorStripStore: source.color = color if led_count is not None: source.led_count = led_count + if animation is not None: + source.animation = animation 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 + if animation is not None: + source.animation = animation + elif isinstance(source, ColorCycleColorStripSource): + if colors is not None and isinstance(colors, list) and len(colors) >= 2: + source.colors = colors + if cycle_speed is not None: + source.cycle_speed = float(cycle_speed) + 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 1a0fc5a..a98a7b1 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -24,6 +24,7 @@ +
@@ -84,7 +85,10 @@ - +
@@ -141,6 +145,31 @@ + + + + + +