Add color_cycle as standalone source type; UI polish

- color_cycle is now a top-level source type (alongside picture/static/gradient)
  with a configurable color list and cycle_speed; defaults to full rainbow spectrum
- ColorCycleColorStripSource + ColorCycleColorStripStream: smooth 30 fps interpolation
  between user-defined colors, one full cycle every 20s at speed=1.0
- Removed color_cycle animation sub-type from StaticColorStripStream
- Color cycle editor: compact horizontal swatch layout, proper module-scope fix
  (colorCycleAdd/Remove now exposed on window, DOM-synced before mutations)
- Animation enabled + Frame interpolation checkboxes use toggle-switch style
- Removed Potential FPS metric from targets and KC targets metric grids

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 22:14:42 +03:00
parent 872949a7e1
commit c31818a20d
14 changed files with 674 additions and 40 deletions

View File

@@ -26,7 +26,7 @@ from wled_controller.core.capture.calibration import (
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import 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")

View File

@@ -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.110.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.110.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.110.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")

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 =
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>`;
} else if (type === 'gradient') {
animSection.style.display = '';
animTypeSelect.innerHTML =
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>` +
`<option value="gradient_shift">${t('color_strip.animation.type.gradient_shift')}</option>` +
`<option value="wave">${t('color_strip.animation.type.wave')}</option>`;
} 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) => `
<div class="color-cycle-item">
<input type="color" value="${hex}">
${canRemove
? `<button type="button" class="btn btn-secondary color-cycle-remove-btn"
onclick="colorCycleRemoveColor(${i})">&#x2715;</button>`
: `<div style="height:14px"></div>`}
</div>
`).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)
? `<span class="stream-card-prop" title="${t('color_strip.animation')}">✨ ${t('color_strip.animation.type.' + source.animation.type) || source.animation.type}</span>`
: '';
let propsHtml;
if (isStatic) {
@@ -75,6 +184,17 @@ export function createColorStripCard(source, pictureSourceMap) {
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
${animBadge}
`;
} else if (isColorCycle) {
const colors = source.colors || [];
const swatches = colors.slice(0, 8).map(c =>
`<span style="display:inline-block;width:12px;height:12px;background:${rgbArrayToHex(c)};border:1px solid #888;border-radius:2px;margin-right:2px"></span>`
).join('');
propsHtml = `
<span class="stream-card-prop">${swatches}</span>
<span class="stream-card-prop" title="${t('color_strip.color_cycle.speed')}">🔄 ${(source.cycle_speed || 1.0).toFixed(1)}×</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`;
} else if (isGradient) {
const stops = source.stops || [];
@@ -95,6 +215,7 @@ export function createColorStripCard(source, pictureSourceMap) {
propsHtml = `
${cssGradient ? `<span style="flex:1 1 100%;height:12px;background:${cssGradient};border-radius:3px;border:1px solid rgba(128,128,128,0.3)"></span>` : ''}
<span class="stream-card-prop">🎨 ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
${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)
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
: '';
@@ -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 {

View File

@@ -102,10 +102,6 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap) {
<div class="metric-label">${t('device.metrics.target_fps')}</div>
<div class="metric-value">${state.fps_target || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.potential_fps')}</div>
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value">${metrics.frames_processed || 0}</div>

View File

@@ -466,10 +466,6 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
<div class="metric-label">${t('device.metrics.target_fps')}</div>
<div class="metric-value">${state.fps_target || 0}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.potential_fps')}</div>
<div class="metric-value">${state.fps_potential?.toFixed(0) || '-'}</div>
</div>
<div class="metric">
<div class="metric-label">${t('device.metrics.frames')}</div>
<div class="metric-value">${metrics.frames_processed || 0}</div>

View File

@@ -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.01.0)",
"color_strip.gradient.bidir.hint": "Add a second color on the right side of this stop to create a hard edge in the gradient.",
"color_strip.gradient.min_stops": "Gradient must have at least 2 stops"
"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"
}

View File

@@ -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.01.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 цветов"
}

View File

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

View File

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

View File

@@ -24,6 +24,7 @@
<option value="picture" data-i18n="color_strip.type.picture">Picture Source</option>
<option value="static" data-i18n="color_strip.type.static">Static Color</option>
<option value="gradient" data-i18n="color_strip.type.gradient">Gradient</option>
<option value="color_cycle" data-i18n="color_strip.type.color_cycle">Color Cycle</option>
</select>
</div>
@@ -84,7 +85,10 @@
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.frame_interpolation.hint">Blends between consecutive captured frames to produce output at the full target FPS even when capture rate is lower. Reduces visible stepping on slow ambient transitions.</small>
<input type="checkbox" id="css-editor-frame-interpolation">
<label class="settings-toggle">
<input type="checkbox" id="css-editor-frame-interpolation">
<span class="settings-toggle-slider"></span>
</label>
</div>
<details class="form-collapse">
@@ -141,6 +145,31 @@
</div>
</div>
<!-- Color-cycle-specific fields -->
<div id="css-editor-color-cycle-section" style="display:none">
<div class="form-group">
<div class="label-row">
<label data-i18n="color_strip.color_cycle.colors">Colors:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.color_cycle.colors.hint">Colors to cycle through smoothly. At least 2 required.</small>
<div id="color-cycle-colors-list"></div>
<button type="button" class="btn btn-secondary" onclick="colorCycleAddColor()" data-i18n="color_strip.color_cycle.add_color">+ Add Color</button>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-cycle-speed">
<span data-i18n="color_strip.color_cycle.speed">Speed:</span>
<span id="css-editor-cycle-speed-val">1.0</span>×
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.color_cycle.speed.hint">Cycle speed multiplier. 1.0 ≈ one full cycle every 20 seconds.</small>
<input type="range" id="css-editor-cycle-speed" min="0.1" max="10.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-cycle-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
</div>
<!-- Gradient-specific fields -->
<div id="css-editor-gradient-section" style="display:none">
<div class="form-group">
@@ -165,6 +194,48 @@
</div>
</div>
<!-- Animation — shown for static/gradient, hidden for picture -->
<div id="css-editor-animation-section" style="display:none">
<details class="form-collapse">
<summary><span data-i18n="color_strip.animation">Animation</span></summary>
<div class="form-collapse-body">
<div class="form-group">
<div class="label-row">
<label for="css-editor-animation-enabled" data-i18n="color_strip.animation.enabled">Enable Animation:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.animation.enabled.hint">Enables procedural animation. The LEDs will update at 30 fps driven by the selected effect.</small>
<label class="settings-toggle">
<input type="checkbox" id="css-editor-animation-enabled">
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-animation-type" data-i18n="color_strip.animation.type">Effect:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.animation.type.hint">The animation effect to apply. Available effects depend on source type.</small>
<select id="css-editor-animation-type">
<!-- populated by onCSSTypeChange() -->
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-animation-speed">
<span data-i18n="color_strip.animation.speed">Speed:</span>
<span id="css-editor-animation-speed-val">1.0</span>×
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.animation.speed.hint">Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.</small>
<input type="range" id="css-editor-animation-speed" min="0.1" max="10.0" step="0.1" value="1.0"
oninput="document.getElementById('css-editor-animation-speed-val').textContent = parseFloat(this.value).toFixed(1)">
</div>
</div>
</details>
</div>
<!-- LED count — picture type only (auto-sized from device for static/gradient) -->
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row">