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:
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user