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:
@@ -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.capture.screen_capture import get_available_displays
|
||||||
from wled_controller.core.processing.processor_manager import ProcessorManager
|
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.color_strip_store import ColorStripStore
|
||||||
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
|
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
|
||||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
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,
|
calibration=calibration,
|
||||||
color=getattr(source, "color", None),
|
color=getattr(source, "color", None),
|
||||||
stops=stops,
|
stops=stops,
|
||||||
|
colors=getattr(source, "colors", None),
|
||||||
|
cycle_speed=getattr(source, "cycle_speed", None),
|
||||||
description=source.description,
|
description=source.description,
|
||||||
frame_interpolation=getattr(source, "frame_interpolation", None),
|
frame_interpolation=getattr(source, "frame_interpolation", None),
|
||||||
|
animation=getattr(source, "animation", None),
|
||||||
overlay_active=overlay_active,
|
overlay_active=overlay_active,
|
||||||
created_at=source.created_at,
|
created_at=source.created_at,
|
||||||
updated_at=source.updated_at,
|
updated_at=source.updated_at,
|
||||||
@@ -136,6 +139,9 @@ async def create_color_strip_source(
|
|||||||
stops=stops,
|
stops=stops,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
frame_interpolation=data.frame_interpolation,
|
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)
|
return _css_to_response(source)
|
||||||
|
|
||||||
@@ -193,6 +199,9 @@ async def update_color_strip_source(
|
|||||||
stops=stops,
|
stops=stops,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
frame_interpolation=data.frame_interpolation,
|
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)
|
# Hot-reload running stream (no restart needed for in-place param changes)
|
||||||
@@ -278,7 +287,7 @@ async def test_css_calibration(
|
|||||||
if body.edges:
|
if body.edges:
|
||||||
try:
|
try:
|
||||||
source = store.get_source(source_id)
|
source = store.get_source(source_id)
|
||||||
if isinstance(source, (StaticColorStripSource, GradientColorStripSource)):
|
if isinstance(source, (StaticColorStripSource, GradientColorStripSource, ColorCycleColorStripSource)):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Calibration test is not applicable for this color strip source type",
|
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."""
|
"""Start screen overlay visualization for a color strip source."""
|
||||||
try:
|
try:
|
||||||
source = store.get_source(source_id)
|
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")
|
raise HTTPException(status_code=400, detail="Overlay is not supported for this color strip source type")
|
||||||
if not isinstance(source, PictureColorStripSource):
|
if not isinstance(source, PictureColorStripSource):
|
||||||
raise HTTPException(status_code=400, detail="Overlay only supported for picture color strip sources")
|
raise HTTPException(status_code=400, detail="Overlay only supported for picture color strip sources")
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ from pydantic import BaseModel, Field
|
|||||||
from wled_controller.api.schemas.devices import Calibration
|
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):
|
class ColorStop(BaseModel):
|
||||||
"""A single color stop in a gradient."""
|
"""A single color stop in a gradient."""
|
||||||
|
|
||||||
@@ -23,7 +31,7 @@ class ColorStripSourceCreate(BaseModel):
|
|||||||
"""Request to create a color strip source."""
|
"""Request to create a color strip source."""
|
||||||
|
|
||||||
name: str = Field(description="Source name", min_length=1, max_length=100)
|
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-type fields
|
||||||
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
|
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)
|
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)")
|
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
|
||||||
# gradient-type fields
|
# gradient-type fields
|
||||||
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
|
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
|
# shared
|
||||||
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
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)
|
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")
|
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):
|
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)")
|
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
|
||||||
# gradient-type fields
|
# gradient-type fields
|
||||||
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
|
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
|
# shared
|
||||||
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
|
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)
|
description: Optional[str] = Field(None, description="Optional description", max_length=500)
|
||||||
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
|
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):
|
class ColorStripSourceResponse(BaseModel):
|
||||||
@@ -85,10 +101,14 @@ class ColorStripSourceResponse(BaseModel):
|
|||||||
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B]")
|
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B]")
|
||||||
# gradient-type fields
|
# gradient-type fields
|
||||||
stops: Optional[List[ColorStop]] = Field(None, description="Color stops for gradient type")
|
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
|
# shared
|
||||||
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
|
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
|
||||||
description: Optional[str] = Field(None, description="Description")
|
description: Optional[str] = Field(None, description="Description")
|
||||||
frame_interpolation: Optional[bool] = Field(None, description="Blend between consecutive captured frames")
|
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")
|
overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
|
||||||
created_at: datetime = Field(description="Creation timestamp")
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
updated_at: datetime = Field(description="Last update timestamp")
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ processing — border extraction, pixel mapping, color correction — runs only
|
|||||||
even when multiple devices share the same source configuration.
|
even when multiple devices share the same source configuration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
@@ -447,9 +448,8 @@ def _compute_gradient_colors(stops: list, led_count: int) -> np.ndarray:
|
|||||||
class StaticColorStripStream(ColorStripStream):
|
class StaticColorStripStream(ColorStripStream):
|
||||||
"""Color strip stream that returns a constant single-color array.
|
"""Color strip stream that returns a constant single-color array.
|
||||||
|
|
||||||
No background thread needed — every call to get_latest_colors() returns
|
When animation is enabled a 30 fps background thread updates _colors with
|
||||||
the same pre-built numpy array. Parameters can be hot-updated via
|
the animated result. Parameters can be hot-updated via update_source().
|
||||||
update_source().
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, source):
|
def __init__(self, source):
|
||||||
@@ -457,6 +457,9 @@ class StaticColorStripStream(ColorStripStream):
|
|||||||
Args:
|
Args:
|
||||||
source: StaticColorStripSource config
|
source: StaticColorStripSource config
|
||||||
"""
|
"""
|
||||||
|
self._colors_lock = threading.Lock()
|
||||||
|
self._running = False
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
self._update_from_source(source)
|
self._update_from_source(source)
|
||||||
|
|
||||||
def _update_from_source(self, source) -> None:
|
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
|
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
|
led_count = source.led_count if source.led_count and source.led_count > 0 else 1
|
||||||
self._led_count = led_count
|
self._led_count = led_count
|
||||||
|
self._animation = source.animation # dict or None; read atomically by _animate_loop
|
||||||
self._rebuild_colors()
|
self._rebuild_colors()
|
||||||
|
|
||||||
def _rebuild_colors(self) -> None:
|
def _rebuild_colors(self) -> None:
|
||||||
self._colors = np.tile(
|
colors = np.tile(
|
||||||
np.array(self._source_color, dtype=np.uint8),
|
np.array(self._source_color, dtype=np.uint8),
|
||||||
(self._led_count, 1),
|
(self._led_count, 1),
|
||||||
)
|
)
|
||||||
|
with self._colors_lock:
|
||||||
|
self._colors = colors
|
||||||
|
|
||||||
def configure(self, device_led_count: int) -> None:
|
def configure(self, device_led_count: int) -> None:
|
||||||
"""Set LED count from the target device (called by WledTargetProcessor on start).
|
"""Set LED count from the target device (called by WledTargetProcessor on start).
|
||||||
@@ -486,20 +492,36 @@ class StaticColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def target_fps(self) -> int:
|
def target_fps(self) -> int:
|
||||||
return 30 # static output; any reasonable value is fine
|
return 30
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def led_count(self) -> int:
|
def led_count(self) -> int:
|
||||||
return self._led_count
|
return self._led_count
|
||||||
|
|
||||||
def start(self) -> None:
|
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})")
|
logger.info(f"StaticColorStripStream started (leds={self._led_count})")
|
||||||
|
|
||||||
def stop(self) -> None:
|
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")
|
logger.info("StaticColorStripStream stopped")
|
||||||
|
|
||||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||||
return self._colors
|
with self._colors_lock:
|
||||||
|
return self._colors
|
||||||
|
|
||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
from wled_controller.storage.color_strip_source import StaticColorStripSource
|
from wled_controller.storage.color_strip_source import StaticColorStripSource
|
||||||
@@ -512,13 +534,38 @@ class StaticColorStripStream(ColorStripStream):
|
|||||||
self._rebuild_colors()
|
self._rebuild_colors()
|
||||||
logger.info("StaticColorStripStream params updated in-place")
|
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):
|
if atype == "breathing":
|
||||||
"""Color strip stream that distributes a gradient across all LEDs.
|
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
|
if colors is not None:
|
||||||
color stops. No background thread needed — output is constant until
|
with self._colors_lock:
|
||||||
stops are changed.
|
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
|
LED count auto-sizes from the connected device when led_count == 0 in
|
||||||
the source config; configure(device_led_count) is called by
|
the source config; configure(device_led_count) is called by
|
||||||
@@ -526,6 +573,122 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, source):
|
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)
|
self._update_from_source(source)
|
||||||
|
|
||||||
def _update_from_source(self, source) -> None:
|
def _update_from_source(self, source) -> None:
|
||||||
@@ -533,10 +696,13 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
self._auto_size = not source.led_count
|
self._auto_size = not source.led_count
|
||||||
led_count = source.led_count if source.led_count and source.led_count > 0 else 1
|
led_count = source.led_count if source.led_count and source.led_count > 0 else 1
|
||||||
self._led_count = led_count
|
self._led_count = led_count
|
||||||
|
self._animation = source.animation # dict or None; read atomically by _animate_loop
|
||||||
self._rebuild_colors()
|
self._rebuild_colors()
|
||||||
|
|
||||||
def _rebuild_colors(self) -> None:
|
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:
|
def configure(self, device_led_count: int) -> None:
|
||||||
"""Size to device LED count when led_count was 0 (auto-size).
|
"""Size to device LED count when led_count was 0 (auto-size).
|
||||||
@@ -551,20 +717,36 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def target_fps(self) -> int:
|
def target_fps(self) -> int:
|
||||||
return 30 # static output; any reasonable value is fine
|
return 30
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def led_count(self) -> int:
|
def led_count(self) -> int:
|
||||||
return self._led_count
|
return self._led_count
|
||||||
|
|
||||||
def start(self) -> None:
|
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)})")
|
logger.info(f"GradientColorStripStream started (leds={self._led_count}, stops={len(self._stops)})")
|
||||||
|
|
||||||
def stop(self) -> None:
|
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")
|
logger.info("GradientColorStripStream stopped")
|
||||||
|
|
||||||
def get_latest_colors(self) -> Optional[np.ndarray]:
|
def get_latest_colors(self) -> Optional[np.ndarray]:
|
||||||
return self._colors
|
with self._colors_lock:
|
||||||
|
return self._colors
|
||||||
|
|
||||||
def update_source(self, source) -> None:
|
def update_source(self, source) -> None:
|
||||||
from wled_controller.storage.color_strip_source import GradientColorStripSource
|
from wled_controller.storage.color_strip_source import GradientColorStripSource
|
||||||
@@ -576,3 +758,54 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
self._led_count = prev_led_count
|
self._led_count = prev_led_count
|
||||||
self._rebuild_colors()
|
self._rebuild_colors()
|
||||||
logger.info("GradientColorStripStream params updated in-place")
|
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 typing import Dict, Optional
|
||||||
|
|
||||||
from wled_controller.core.processing.color_strip_stream import (
|
from wled_controller.core.processing.color_strip_stream import (
|
||||||
|
ColorCycleColorStripStream,
|
||||||
ColorStripStream,
|
ColorStripStream,
|
||||||
GradientColorStripStream,
|
GradientColorStripStream,
|
||||||
PictureColorStripStream,
|
PictureColorStripStream,
|
||||||
@@ -81,6 +82,7 @@ class ColorStripStreamManager:
|
|||||||
return entry.stream
|
return entry.stream
|
||||||
|
|
||||||
from wled_controller.storage.color_strip_source import (
|
from wled_controller.storage.color_strip_source import (
|
||||||
|
ColorCycleColorStripSource,
|
||||||
GradientColorStripSource,
|
GradientColorStripSource,
|
||||||
PictureColorStripSource,
|
PictureColorStripSource,
|
||||||
StaticColorStripSource,
|
StaticColorStripSource,
|
||||||
@@ -88,6 +90,17 @@ class ColorStripStreamManager:
|
|||||||
|
|
||||||
source = self._color_strip_store.get_source(css_id)
|
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):
|
if isinstance(source, StaticColorStripSource):
|
||||||
css_stream = StaticColorStripStream(source)
|
css_stream = StaticColorStripStream(source)
|
||||||
css_stream.start()
|
css_stream.start()
|
||||||
|
|||||||
@@ -599,3 +599,39 @@
|
|||||||
.gradient-stop-spacer {
|
.gradient-stop-spacer {
|
||||||
flex: 1;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ import {
|
|||||||
// Layer 5: color-strip sources
|
// Layer 5: color-strip sources
|
||||||
import {
|
import {
|
||||||
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
|
||||||
onCSSTypeChange,
|
onCSSTypeChange, colorCycleAddColor, colorCycleRemoveColor,
|
||||||
} from './features/color-strips.js';
|
} from './features/color-strips.js';
|
||||||
|
|
||||||
// Layer 5: calibration
|
// Layer 5: calibration
|
||||||
@@ -276,6 +276,8 @@ Object.assign(window, {
|
|||||||
saveCSSEditor,
|
saveCSSEditor,
|
||||||
deleteColorStrip,
|
deleteColorStrip,
|
||||||
onCSSTypeChange,
|
onCSSTypeChange,
|
||||||
|
colorCycleAddColor,
|
||||||
|
colorCycleRemoveColor,
|
||||||
|
|
||||||
// calibration
|
// calibration
|
||||||
showCalibration,
|
showCalibration,
|
||||||
|
|||||||
@@ -26,8 +26,13 @@ class CSSEditorModal extends Modal {
|
|||||||
gamma: document.getElementById('css-editor-gamma').value,
|
gamma: document.getElementById('css-editor-gamma').value,
|
||||||
color: document.getElementById('css-editor-color').value,
|
color: document.getElementById('css-editor-color').value,
|
||||||
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
|
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) : '[]',
|
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;
|
const type = document.getElementById('css-editor-type').value;
|
||||||
document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : 'none';
|
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-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';
|
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
|
// 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') ? 'none' : '';
|
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') {
|
if (type === 'gradient') {
|
||||||
requestAnimationFrame(() => gradientRenderAll());
|
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})">✕</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". */
|
/** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */
|
||||||
function rgbArrayToHex(rgb) {
|
function rgbArrayToHex(rgb) {
|
||||||
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
|
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
|
||||||
@@ -66,6 +170,11 @@ function hexToRgbArray(hex) {
|
|||||||
export function createColorStripCard(source, pictureSourceMap) {
|
export function createColorStripCard(source, pictureSourceMap) {
|
||||||
const isStatic = source.source_type === 'static';
|
const isStatic = source.source_type === 'static';
|
||||||
const isGradient = source.source_type === 'gradient';
|
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;
|
let propsHtml;
|
||||||
if (isStatic) {
|
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 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>
|
</span>
|
||||||
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</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) {
|
} else if (isGradient) {
|
||||||
const stops = source.stops || [];
|
const stops = source.stops || [];
|
||||||
@@ -95,6 +215,7 @@ export function createColorStripCard(source, pictureSourceMap) {
|
|||||||
propsHtml = `
|
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>` : ''}
|
${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>
|
<span class="stream-card-prop">🎨 ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
|
||||||
|
${animBadge}
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
|
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
|
||||||
@@ -110,8 +231,8 @@ export function createColorStripCard(source, pictureSourceMap) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = isStatic ? '🎨' : isGradient ? '🌈' : '🎞️';
|
const icon = isStatic ? '🎨' : isColorCycle ? '🔄' : isGradient ? '🌈' : '🎞️';
|
||||||
const calibrationBtn = (!isStatic && !isGradient)
|
const calibrationBtn = (!isStatic && !isGradient && !isColorCycle)
|
||||||
? `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`
|
? `<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') {
|
if (sourceType === 'static') {
|
||||||
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
|
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
|
||||||
|
_loadAnimationState(css.animation);
|
||||||
|
} else if (sourceType === 'color_cycle') {
|
||||||
|
_loadColorCycleState(css);
|
||||||
} else if (sourceType === 'gradient') {
|
} else if (sourceType === 'gradient') {
|
||||||
gradientInit(css.stops || [
|
gradientInit(css.stops || [
|
||||||
{ position: 0.0, color: [255, 0, 0] },
|
{ position: 0.0, color: [255, 0, 0] },
|
||||||
{ position: 1.0, color: [0, 0, 255] },
|
{ position: 1.0, color: [0, 0, 255] },
|
||||||
]);
|
]);
|
||||||
|
_loadAnimationState(css.animation);
|
||||||
} else {
|
} else {
|
||||||
sourceSelect.value = css.picture_source_id || '';
|
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-frame-interpolation').checked = false;
|
||||||
document.getElementById('css-editor-color').value = '#ffffff';
|
document.getElementById('css-editor-color').value = '#ffffff';
|
||||||
document.getElementById('css-editor-led-count').value = 0;
|
document.getElementById('css-editor-led-count').value = 0;
|
||||||
|
_loadAnimationState(null);
|
||||||
|
_loadColorCycleState(null);
|
||||||
document.getElementById('css-editor-title').textContent = t('color_strip.add');
|
document.getElementById('css-editor-title').textContent = t('color_strip.add');
|
||||||
gradientInit([
|
gradientInit([
|
||||||
{ position: 0.0, color: [255, 0, 0] },
|
{ position: 0.0, color: [255, 0, 0] },
|
||||||
@@ -259,8 +386,21 @@ export async function saveCSSEditor() {
|
|||||||
payload = {
|
payload = {
|
||||||
name,
|
name,
|
||||||
color: hexToRgbArray(document.getElementById('css-editor-color').value),
|
color: hexToRgbArray(document.getElementById('css-editor-color').value),
|
||||||
|
animation: _getAnimationPayload(),
|
||||||
};
|
};
|
||||||
if (!cssId) payload.source_type = 'static';
|
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') {
|
} else if (sourceType === 'gradient') {
|
||||||
if (_gradientStops.length < 2) {
|
if (_gradientStops.length < 2) {
|
||||||
cssEditorModal.showError(t('color_strip.gradient.min_stops'));
|
cssEditorModal.showError(t('color_strip.gradient.min_stops'));
|
||||||
@@ -273,6 +413,7 @@ export async function saveCSSEditor() {
|
|||||||
color: s.color,
|
color: s.color,
|
||||||
...(s.colorRight ? { color_right: s.colorRight } : {}),
|
...(s.colorRight ? { color_right: s.colorRight } : {}),
|
||||||
})),
|
})),
|
||||||
|
animation: _getAnimationPayload(),
|
||||||
};
|
};
|
||||||
if (!cssId) payload.source_type = 'gradient';
|
if (!cssId) payload.source_type = 'gradient';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -102,10 +102,6 @@ export function createKCTargetCard(target, sourceMap, patternTemplateMap) {
|
|||||||
<div class="metric-label">${t('device.metrics.target_fps')}</div>
|
<div class="metric-label">${t('device.metrics.target_fps')}</div>
|
||||||
<div class="metric-value">${state.fps_target || 0}</div>
|
<div class="metric-value">${state.fps_target || 0}</div>
|
||||||
</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">
|
||||||
<div class="metric-label">${t('device.metrics.frames')}</div>
|
<div class="metric-label">${t('device.metrics.frames')}</div>
|
||||||
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
||||||
|
|||||||
@@ -466,10 +466,6 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
|||||||
<div class="metric-label">${t('device.metrics.target_fps')}</div>
|
<div class="metric-label">${t('device.metrics.target_fps')}</div>
|
||||||
<div class="metric-value">${state.fps_target || 0}</div>
|
<div class="metric-value">${state.fps_target || 0}</div>
|
||||||
</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">
|
||||||
<div class="metric-label">${t('device.metrics.frames')}</div>
|
<div class="metric-label">${t('device.metrics.frames')}</div>
|
||||||
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
<div class="metric-value">${metrics.frames_processed || 0}</div>
|
||||||
|
|||||||
@@ -573,10 +573,11 @@
|
|||||||
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target",
|
"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.error.name_required": "Please enter a name",
|
||||||
"color_strip.type": "Type:",
|
"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.picture": "Picture Source",
|
||||||
"color_strip.type.static": "Static Color",
|
"color_strip.type.static": "Static Color",
|
||||||
"color_strip.type.gradient": "Gradient",
|
"color_strip.type.gradient": "Gradient",
|
||||||
|
"color_strip.type.color_cycle": "Color Cycle",
|
||||||
"color_strip.static_color": "Color:",
|
"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.static_color.hint": "The solid color that will be sent to all LEDs on the strip.",
|
||||||
"color_strip.gradient.preview": "Gradient:",
|
"color_strip.gradient.preview": "Gradient:",
|
||||||
@@ -587,5 +588,22 @@
|
|||||||
"color_strip.gradient.add_stop": "+ Add Stop",
|
"color_strip.gradient.add_stop": "+ Add Stop",
|
||||||
"color_strip.gradient.position": "Position (0.0–1.0)",
|
"color_strip.gradient.position": "Position (0.0–1.0)",
|
||||||
"color_strip.gradient.bidir.hint": "Add a second color on the right side of this stop to create a hard edge in the gradient.",
|
"color_strip.gradient.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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -573,10 +573,11 @@
|
|||||||
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
|
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
|
||||||
"color_strip.error.name_required": "Введите название",
|
"color_strip.error.name_required": "Введите название",
|
||||||
"color_strip.type": "Тип:",
|
"color_strip.type": "Тип:",
|
||||||
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам.",
|
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом. Градиент распределяет цветовой градиент по всем светодиодам. Смена цвета плавно циклически переключается между заданными цветами.",
|
||||||
"color_strip.type.picture": "Источник изображения",
|
"color_strip.type.picture": "Источник изображения",
|
||||||
"color_strip.type.static": "Статический цвет",
|
"color_strip.type.static": "Статический цвет",
|
||||||
"color_strip.type.gradient": "Градиент",
|
"color_strip.type.gradient": "Градиент",
|
||||||
|
"color_strip.type.color_cycle": "Смена цвета",
|
||||||
"color_strip.static_color": "Цвет:",
|
"color_strip.static_color": "Цвет:",
|
||||||
"color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы.",
|
"color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы.",
|
||||||
"color_strip.gradient.preview": "Градиент:",
|
"color_strip.gradient.preview": "Градиент:",
|
||||||
@@ -587,5 +588,22 @@
|
|||||||
"color_strip.gradient.add_stop": "+ Добавить",
|
"color_strip.gradient.add_stop": "+ Добавить",
|
||||||
"color_strip.gradient.position": "Позиция (0.0–1.0)",
|
"color_strip.gradient.position": "Позиция (0.0–1.0)",
|
||||||
"color_strip.gradient.bidir.hint": "Добавить второй цвет справа от этой остановки для создания резкого перехода в градиенте.",
|
"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 цветов"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ from some input, encapsulating everything needed to drive a physical LED strip:
|
|||||||
calibration, color correction, smoothing, and FPS.
|
calibration, color correction, smoothing, and FPS.
|
||||||
|
|
||||||
Current types:
|
Current types:
|
||||||
PictureColorStripSource — derives LED colors from a PictureSource (screen capture)
|
PictureColorStripSource — derives LED colors from a PictureSource (screen capture)
|
||||||
StaticColorStripSource — constant solid color fills all LEDs
|
StaticColorStripSource — constant solid color fills all LEDs
|
||||||
GradientColorStripSource — linear gradient across all LEDs from user-defined color stops
|
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
|
from dataclasses import dataclass, field
|
||||||
@@ -53,6 +54,9 @@ class ColorStripSource:
|
|||||||
"led_count": None,
|
"led_count": None,
|
||||||
"color": None,
|
"color": None,
|
||||||
"stops": None,
|
"stops": None,
|
||||||
|
"animation": None,
|
||||||
|
"colors": None,
|
||||||
|
"cycle_speed": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -96,6 +100,7 @@ class ColorStripSource:
|
|||||||
created_at=created_at, updated_at=updated_at, description=description,
|
created_at=created_at, updated_at=updated_at, description=description,
|
||||||
color=color,
|
color=color,
|
||||||
led_count=data.get("led_count") or 0,
|
led_count=data.get("led_count") or 0,
|
||||||
|
animation=data.get("animation"),
|
||||||
)
|
)
|
||||||
|
|
||||||
if source_type == "gradient":
|
if source_type == "gradient":
|
||||||
@@ -106,6 +111,18 @@ class ColorStripSource:
|
|||||||
created_at=created_at, updated_at=updated_at, description=description,
|
created_at=created_at, updated_at=updated_at, description=description,
|
||||||
stops=stops,
|
stops=stops,
|
||||||
led_count=data.get("led_count") or 0,
|
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
|
# Default: "picture" type
|
||||||
@@ -172,11 +189,13 @@ class StaticColorStripSource(ColorStripSource):
|
|||||||
|
|
||||||
color: list = field(default_factory=lambda: [255, 255, 255]) # [R, G, B]
|
color: list = field(default_factory=lambda: [255, 255, 255]) # [R, G, B]
|
||||||
led_count: int = 0 # 0 = use device LED count
|
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:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["color"] = list(self.color)
|
d["color"] = list(self.color)
|
||||||
d["led_count"] = self.led_count
|
d["led_count"] = self.led_count
|
||||||
|
d["animation"] = self.animation
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -197,9 +216,35 @@ class GradientColorStripSource(ColorStripSource):
|
|||||||
{"position": 1.0, "color": [0, 0, 255]},
|
{"position": 1.0, "color": [0, 0, 255]},
|
||||||
])
|
])
|
||||||
led_count: int = 0 # 0 = use device LED count
|
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:
|
def to_dict(self) -> dict:
|
||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
d["stops"] = [dict(s) for s in self.stops]
|
d["stops"] = [dict(s) for s in self.stops]
|
||||||
d["led_count"] = self.led_count
|
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
|
return d
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Dict, List, Optional
|
|||||||
|
|
||||||
from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict
|
from wled_controller.core.capture.calibration import CalibrationConfig, calibration_to_dict
|
||||||
from wled_controller.storage.color_strip_source import (
|
from wled_controller.storage.color_strip_source import (
|
||||||
|
ColorCycleColorStripSource,
|
||||||
ColorStripSource,
|
ColorStripSource,
|
||||||
GradientColorStripSource,
|
GradientColorStripSource,
|
||||||
PictureColorStripSource,
|
PictureColorStripSource,
|
||||||
@@ -105,6 +106,9 @@ class ColorStripStore:
|
|||||||
stops: Optional[list] = None,
|
stops: Optional[list] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
frame_interpolation: bool = False,
|
frame_interpolation: bool = False,
|
||||||
|
animation: Optional[dict] = None,
|
||||||
|
colors: Optional[list] = None,
|
||||||
|
cycle_speed: float = 1.0,
|
||||||
) -> ColorStripSource:
|
) -> ColorStripSource:
|
||||||
"""Create a new color strip source.
|
"""Create a new color strip source.
|
||||||
|
|
||||||
@@ -132,6 +136,7 @@ class ColorStripStore:
|
|||||||
description=description,
|
description=description,
|
||||||
color=rgb,
|
color=rgb,
|
||||||
led_count=led_count,
|
led_count=led_count,
|
||||||
|
animation=animation,
|
||||||
)
|
)
|
||||||
elif source_type == "gradient":
|
elif source_type == "gradient":
|
||||||
source = GradientColorStripSource(
|
source = GradientColorStripSource(
|
||||||
@@ -146,6 +151,23 @@ class ColorStripStore:
|
|||||||
{"position": 1.0, "color": [0, 0, 255]},
|
{"position": 1.0, "color": [0, 0, 255]},
|
||||||
],
|
],
|
||||||
led_count=led_count,
|
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:
|
else:
|
||||||
if calibration is None:
|
if calibration is None:
|
||||||
@@ -192,6 +214,9 @@ class ColorStripStore:
|
|||||||
stops: Optional[list] = None,
|
stops: Optional[list] = None,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
frame_interpolation: Optional[bool] = None,
|
frame_interpolation: Optional[bool] = None,
|
||||||
|
animation: Optional[dict] = None,
|
||||||
|
colors: Optional[list] = None,
|
||||||
|
cycle_speed: Optional[float] = None,
|
||||||
) -> ColorStripSource:
|
) -> ColorStripSource:
|
||||||
"""Update an existing color strip source.
|
"""Update an existing color strip source.
|
||||||
|
|
||||||
@@ -239,11 +264,22 @@ class ColorStripStore:
|
|||||||
source.color = color
|
source.color = color
|
||||||
if led_count is not None:
|
if led_count is not None:
|
||||||
source.led_count = led_count
|
source.led_count = led_count
|
||||||
|
if animation is not None:
|
||||||
|
source.animation = animation
|
||||||
elif isinstance(source, GradientColorStripSource):
|
elif isinstance(source, GradientColorStripSource):
|
||||||
if stops is not None and isinstance(stops, list):
|
if stops is not None and isinstance(stops, list):
|
||||||
source.stops = stops
|
source.stops = stops
|
||||||
if led_count is not None:
|
if led_count is not None:
|
||||||
source.led_count = led_count
|
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()
|
source.updated_at = datetime.utcnow()
|
||||||
self._save()
|
self._save()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<option value="picture" data-i18n="color_strip.type.picture">Picture Source</option>
|
<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="static" data-i18n="color_strip.type.static">Static Color</option>
|
||||||
<option value="gradient" data-i18n="color_strip.type.gradient">Gradient</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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -84,7 +85,10 @@
|
|||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
|
||||||
</div>
|
</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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<details class="form-collapse">
|
<details class="form-collapse">
|
||||||
@@ -141,6 +145,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Gradient-specific fields -->
|
||||||
<div id="css-editor-gradient-section" style="display:none">
|
<div id="css-editor-gradient-section" style="display:none">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -165,6 +194,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- LED count — picture type only (auto-sized from device for static/gradient) -->
|
||||||
<div id="css-editor-led-count-group" class="form-group">
|
<div id="css-editor-led-count-group" class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
|
|||||||
Reference in New Issue
Block a user