Add 5 procedural LED effects, gradient presets, auto-crop min aspect ratio, static source polling optimization

New features:
- Procedural effect source type with fire, meteor, plasma, noise, and aurora algorithms
  using palette LUT system and 1D value noise generator
- 12 predefined gradient presets (rainbow, sunset, ocean, forest, fire, lava, aurora,
  ice, warm, cool, neon, pastel) selectable from a dropdown in the gradient editor
- Auto-crop filter: min aspect ratio parameter to prevent false-positive cropping
  in dark scenes on ultrawide displays

Optimization:
- Static/gradient sources without animation: stream thread sleeps 0.25s instead of
  frame_time; processor repolls at frame_time instead of 5ms (~40x fewer iterations)
- Inverted isinstance checks in routes to test for PictureColorStripSource only

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 01:03:16 +03:00
parent 9392741f08
commit a4083764fb
14 changed files with 1033 additions and 19 deletions

View File

@@ -26,7 +26,7 @@ from wled_controller.core.capture.calibration import (
)
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import ColorCycleColorStripSource, GradientColorStripSource, PictureColorStripSource, StaticColorStripSource
from wled_controller.storage.color_strip_source import PictureColorStripSource
from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore
@@ -70,6 +70,12 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
stops=stops,
colors=getattr(source, "colors", None),
cycle_speed=getattr(source, "cycle_speed", None),
effect_type=getattr(source, "effect_type", None),
speed=getattr(source, "speed", None),
palette=getattr(source, "palette", None),
intensity=getattr(source, "intensity", None),
scale=getattr(source, "scale", None),
mirror=getattr(source, "mirror", None),
description=source.description,
frame_interpolation=getattr(source, "frame_interpolation", None),
animation=getattr(source, "animation", None),
@@ -140,6 +146,12 @@ async def create_color_strip_source(
animation=data.animation.model_dump() if data.animation else None,
colors=data.colors,
cycle_speed=data.cycle_speed,
effect_type=data.effect_type,
speed=data.speed,
palette=data.palette,
intensity=data.intensity,
scale=data.scale,
mirror=data.mirror,
)
return _css_to_response(source)
@@ -199,6 +211,12 @@ async def update_color_strip_source(
animation=data.animation.model_dump() if data.animation else None,
colors=data.colors,
cycle_speed=data.cycle_speed,
effect_type=data.effect_type,
speed=data.speed,
palette=data.palette,
intensity=data.intensity,
scale=data.scale,
mirror=data.mirror,
)
# Hot-reload running stream (no restart needed for in-place param changes)
@@ -284,12 +302,12 @@ async def test_css_calibration(
if body.edges:
try:
source = store.get_source(source_id)
if isinstance(source, (StaticColorStripSource, GradientColorStripSource, ColorCycleColorStripSource)):
if not isinstance(source, PictureColorStripSource):
raise HTTPException(
status_code=400,
detail="Calibration test is not applicable for this color strip source type",
detail="Calibration test is only available for picture color strip sources",
)
if isinstance(source, PictureColorStripSource) and source.calibration:
if source.calibration:
calibration = source.calibration
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@@ -330,10 +348,8 @@ async def start_css_overlay(
"""Start screen overlay visualization for a color strip source."""
try:
source = store.get_source(source_id)
if isinstance(source, (StaticColorStripSource, GradientColorStripSource, ColorCycleColorStripSource)):
raise HTTPException(status_code=400, detail="Overlay is not supported for this color strip source type")
if not isinstance(source, PictureColorStripSource):
raise HTTPException(status_code=400, detail="Overlay only supported for picture color strip sources")
raise HTTPException(status_code=400, detail="Overlay is only supported for picture color strip sources")
if not source.calibration:
raise HTTPException(status_code=400, detail="Color strip source has no calibration configured")

View File

@@ -31,7 +31,7 @@ class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["picture", "static", "gradient", "color_cycle"] = Field(default="picture", description="Source type")
source_type: Literal["picture", "static", "gradient", "color_cycle", "effect"] = Field(default="picture", description="Source type")
# picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
@@ -47,6 +47,13 @@ class ColorStripSourceCreate(BaseModel):
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
cycle_speed: Optional[float] = Field(None, description="Cycle speed multiplier 0.110.0 (color_cycle type)", ge=0.1, le=10.0)
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
speed: Optional[float] = Field(None, description="Effect speed multiplier 0.1-10.0", ge=0.1, le=10.0)
palette: Optional[str] = Field(None, description="Named palette (fire/ocean/lava/forest/rainbow/aurora/sunset/ice)")
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode (meteor)")
# shared
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -73,6 +80,13 @@ class ColorStripSourceUpdate(BaseModel):
# color_cycle-type fields
colors: Optional[List[List[int]]] = Field(None, description="List of [R,G,B] colors to cycle (color_cycle type)")
cycle_speed: Optional[float] = Field(None, description="Cycle speed multiplier 0.110.0 (color_cycle type)", ge=0.1, le=10.0)
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm: fire|meteor|plasma|noise|aurora")
speed: Optional[float] = Field(None, description="Effect speed multiplier 0.1-10.0", ge=0.1, le=10.0)
palette: Optional[str] = Field(None, description="Named palette")
intensity: Optional[float] = Field(None, description="Effect intensity 0.1-2.0", ge=0.1, le=2.0)
scale: Optional[float] = Field(None, description="Spatial scale 0.5-5.0", ge=0.5, le=5.0)
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
# shared
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -101,6 +115,13 @@ class ColorStripSourceResponse(BaseModel):
# 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)")
# effect-type fields
effect_type: Optional[str] = Field(None, description="Effect algorithm")
speed: Optional[float] = Field(None, description="Effect speed multiplier")
palette: Optional[str] = Field(None, description="Named palette")
intensity: Optional[float] = Field(None, description="Effect intensity")
scale: Optional[float] = Field(None, description="Spatial scale")
mirror: Optional[bool] = Field(None, description="Mirror/bounce mode")
# shared
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
description: Optional[str] = Field(None, description="Description")