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

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