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) => ` +