feat(03-02): implement animation library — 7 types with registry and factory

- AnimationBase ABC with render(t, led_count) and from_params() contract
- SolidColorAnimation: time-independent uniform fill
- ChaseAnimation: moving lit segment with forward/reverse support
- PulseAnimation: sine-wave breathing effect (speed maps to period)
- RainbowAnimation: HSV hue cycle across strip via colorsys.hsv_to_rgb
- StrobeAnimation: on/off flashing at 1-20 Hz with configurable duty cycle
- ColorWipeAnimation: progressive fill from start or end
- FireAnimation: Fire2012 algorithm with cooling/sparking/heat-to-color mapping
- ANIMATION_REGISTRY + create_animation() factory function
- All 24 tests passing
This commit is contained in:
Claude
2026-04-06 21:47:39 +00:00
parent 920243fcae
commit f4da9564d4
10 changed files with 422 additions and 3 deletions

View File

@@ -0,0 +1,48 @@
"""Animation library — 7 built-in animation types with a factory registry."""
from lightsync.animations.base import AnimationBase
from lightsync.animations.solid_color import SolidColorAnimation
from lightsync.animations.chase import ChaseAnimation
from lightsync.animations.pulse import PulseAnimation
from lightsync.animations.rainbow import RainbowAnimation
from lightsync.animations.strobe import StrobeAnimation
from lightsync.animations.color_wipe import ColorWipeAnimation
from lightsync.animations.fire import FireAnimation
__all__ = [
"AnimationBase",
"SolidColorAnimation",
"ChaseAnimation",
"PulseAnimation",
"RainbowAnimation",
"StrobeAnimation",
"ColorWipeAnimation",
"FireAnimation",
"ANIMATION_REGISTRY",
"create_animation",
]
ANIMATION_REGISTRY: dict[str, type[AnimationBase]] = {
"solid_color": SolidColorAnimation,
"chase": ChaseAnimation,
"pulse": PulseAnimation,
"rainbow": RainbowAnimation,
"strobe": StrobeAnimation,
"color_wipe": ColorWipeAnimation,
"fire": FireAnimation,
}
def create_animation(name: str, params: dict) -> AnimationBase:
"""Create an animation by registry name.
Args:
name: Animation type name (e.g. "chase", "fire").
params: CueModel.params dict passed to from_params().
Raises:
ValueError: If name is not in ANIMATION_REGISTRY.
"""
cls = ANIMATION_REGISTRY.get(name)
if cls is None:
raise ValueError(f"Unknown animation: {name!r}. Available: {sorted(ANIMATION_REGISTRY)}")
return cls.from_params(params)

View File

@@ -0,0 +1,28 @@
"""AnimationBase ABC — all animations implement render() and from_params()."""
from abc import ABC, abstractmethod
class AnimationBase(ABC):
"""Pure animation renderer — no I/O, stateless or state-per-instance.
Each animation computes pixel data from time t and strip length.
No networking, no disk I/O — just math.
"""
@abstractmethod
def render(self, t: float, led_count: int) -> list[tuple]:
"""Render frame at time t (seconds since animation started).
Returns list of (R, G, B) tuples, length == led_count.
All channel values in range 0-255.
"""
...
@classmethod
@abstractmethod
def from_params(cls, params: dict) -> "AnimationBase":
"""Construct from CueModel.params dict.
Missing keys use animation-specific defaults.
"""
...

View File

@@ -0,0 +1,58 @@
"""Chase animation — a lit segment moves along the strip."""
from lightsync.animations.base import AnimationBase
class ChaseAnimation(AnimationBase):
"""Moving lit segment(s) chasing along the strip.
At speed=1.0, the segment moves at 60 pixels/second.
Pattern repeats every (size + spacing) pixels.
"""
def __init__(
self,
color: tuple[int, int, int] = (255, 255, 255),
bg_color: tuple[int, int, int] = (0, 0, 0),
speed: float = 0.5,
size: int = 3,
spacing: int = 7,
reverse: bool = False,
white: int = 0,
) -> None:
self.color = tuple(color) # type: ignore[arg-type]
self.bg_color = tuple(bg_color) # type: ignore[arg-type]
self.speed = speed
self.size = size
self.spacing = spacing
self.reverse = reverse
self.white = white
def render(self, t: float, led_count: int) -> list[tuple]:
period = self.size + self.spacing
offset = int(t * self.speed * 60) # pixels/sec at speed=1.0 is 60
pixels = []
for i in range(led_count):
if self.reverse:
pos = (i + offset) % period
else:
pos = (i - offset) % period
if pos < self.size:
pixels.append(self.color)
else:
pixels.append(self.bg_color)
return pixels
@classmethod
def from_params(cls, params: dict) -> "ChaseAnimation":
return cls(
color=tuple(params.get("color", [255, 255, 255])), # type: ignore[arg-type]
bg_color=tuple(params.get("bg_color", [0, 0, 0])), # type: ignore[arg-type]
speed=params.get("speed", 0.5),
size=params.get("size", 3),
spacing=params.get("spacing", 7),
reverse=params.get("reverse", False),
white=params.get("white", 0),
)

View File

@@ -0,0 +1,44 @@
"""Color wipe animation — progressively fills the strip with color."""
from lightsync.animations.base import AnimationBase
class ColorWipeAnimation(AnimationBase):
"""Progressively fills the strip from one end with a single color.
At speed=1.0, fills at 60 pixels/second.
reverse=True fills from the end instead of the start.
"""
def __init__(
self,
color: tuple[int, int, int] = (255, 255, 255),
speed: float = 0.5,
reverse: bool = False,
white: int = 0,
) -> None:
self.color = tuple(color) # type: ignore[arg-type]
self.speed = speed
self.reverse = reverse
self.white = white
def render(self, t: float, led_count: int) -> list[tuple]:
fill_count = min(led_count, int(t * self.speed * 60))
black = (0, 0, 0)
if not self.reverse:
# Fill from start: first fill_count pixels = color
pixels = [self.color] * fill_count + [black] * (led_count - fill_count)
else:
# Fill from end: last fill_count pixels = color
pixels = [black] * (led_count - fill_count) + [self.color] * fill_count
return pixels
@classmethod
def from_params(cls, params: dict) -> "ColorWipeAnimation":
return cls(
color=tuple(params.get("color", [255, 255, 255])), # type: ignore[arg-type]
speed=params.get("speed", 0.5),
reverse=params.get("reverse", False),
white=params.get("white", 0),
)

View File

@@ -0,0 +1,80 @@
"""Fire animation — Fire2012 algorithm port from FastLED."""
import random
from lightsync.animations.base import AnimationBase
def _fire_step(
heat: list[int],
led_count: int,
cooling: int,
sparking: int,
) -> None:
"""Run one Fire2012 step, mutating heat array in-place.
Index 0 = bottom of strip (heat source), index N-1 = top.
"""
# Step 1: Cool every cell
for i in range(led_count):
cooldown = random.randint(0, (cooling * 10 // led_count) + 2)
heat[i] = max(0, heat[i] - cooldown)
# Step 2: Heat drifts upward (toward index N-1)
for i in range(led_count - 1, 1, -1):
heat[i] = (heat[i - 1] + heat[i - 2] + heat[i - 2]) // 3
# Step 3: Randomly ignite new sparks near bottom
if random.randint(0, 255) < sparking:
y = random.randint(0, min(6, led_count - 1))
heat[y] = min(255, heat[y] + random.randint(160, 255))
def _heat_to_rgb(h: int) -> tuple[int, int, int]:
"""Map heat value 0-255 to black -> red -> yellow -> white palette."""
h = max(0, min(255, h))
if h < 85:
return (h * 3, 0, 0)
elif h < 170:
h2 = h - 85
return (255, h2 * 3, 0)
else:
h2 = h - 170
return (255, 255, min(255, h2 * 3))
class FireAnimation(AnimationBase):
"""Fire2012 algorithm — heat diffusion with cooling, sparking, and color mapping.
Index 0 = bottom of strip (heat source). Flames rise toward index N-1.
State is per-instance (heat array) — each instance maintains its own fire.
"""
def __init__(
self,
cooling: int = 55,
sparking: int = 120,
speed: float = 0.5,
) -> None:
self.cooling = cooling
self.sparking = sparking
self.speed = speed
self._heat: list[int] = [] # lazy-init per led_count
def render(self, t: float, led_count: int) -> list[tuple]:
# Lazy-init or resize heat array
if len(self._heat) != led_count:
self._heat = [0] * led_count
# Speed multiplier: higher speed -> more simulation steps per render
steps = max(1, int(self.speed * 3))
for _ in range(steps):
_fire_step(self._heat, led_count, self.cooling, self.sparking)
return [_heat_to_rgb(h) for h in self._heat]
@classmethod
def from_params(cls, params: dict) -> "FireAnimation":
return cls(
cooling=params.get("cooling", 55),
sparking=params.get("sparking", 120),
speed=params.get("speed", 0.5),
)

View File

@@ -0,0 +1,53 @@
"""Pulse (breathing) animation — brightness oscillates via sine wave."""
import math
from lightsync.animations.base import AnimationBase
class PulseAnimation(AnimationBase):
"""Smooth breathing effect — brightness oscillates via sine wave.
speed=1.0 -> 0.1s period (fast), speed=0.0 -> 5.0s period (slow).
"""
def __init__(
self,
color: tuple[int, int, int] = (255, 255, 255),
speed: float = 0.5,
min_brightness: int = 0,
max_brightness: int = 255,
white: int = 0,
) -> None:
self.color = tuple(color) # type: ignore[arg-type]
self.speed = speed
self.min_brightness = min_brightness
self.max_brightness = max_brightness
self.white = white
def render(self, t: float, led_count: int) -> list[tuple]:
# Map speed to period: speed=1.0 -> 0.1s, speed=0.0 -> 5.0s
period = 0.1 + (1.0 - self.speed) * 4.9
# Sine oscillation: 0.0 to 1.0 over one period
brightness = (
self.min_brightness
+ (self.max_brightness - self.min_brightness)
* (0.5 + 0.5 * math.sin(2 * math.pi * t / period))
)
scale = brightness / 255.0
r = min(255, max(0, int(self.color[0] * scale)))
g = min(255, max(0, int(self.color[1] * scale)))
b = min(255, max(0, int(self.color[2] * scale)))
pixel = (r, g, b)
return [pixel] * led_count
@classmethod
def from_params(cls, params: dict) -> "PulseAnimation":
return cls(
color=tuple(params.get("color", [255, 255, 255])), # type: ignore[arg-type]
speed=params.get("speed", 0.5),
min_brightness=params.get("min_brightness", 0),
max_brightness=params.get("max_brightness", 255),
white=params.get("white", 0),
)

View File

@@ -0,0 +1,38 @@
"""Rainbow animation — full HSV hue cycle across the strip."""
import colorsys
from lightsync.animations.base import AnimationBase
class RainbowAnimation(AnimationBase):
"""Hue-shifting rainbow across the entire strip.
period=1.0 means one full rainbow cycle fits on the strip.
speed controls how fast the hues shift over time.
"""
def __init__(
self,
speed: float = 0.5,
period: float = 1.0,
) -> None:
self.speed = speed
self.period = period
def render(self, t: float, led_count: int) -> list[tuple]:
pixels = []
for i in range(led_count):
hue = (i / led_count / self.period + t * self.speed) % 1.0
r_f, g_f, b_f = colorsys.hsv_to_rgb(hue, 1.0, 1.0)
pixels.append((
int(r_f * 255),
int(g_f * 255),
int(b_f * 255),
))
return pixels
@classmethod
def from_params(cls, params: dict) -> "RainbowAnimation":
return cls(
speed=params.get("speed", 0.5),
period=params.get("period", 1.0),
)

View File

@@ -0,0 +1,23 @@
"""Solid color animation — all LEDs the same color at all times."""
from lightsync.animations.base import AnimationBase
class SolidColorAnimation(AnimationBase):
"""Time-independent: all LEDs show a single solid color."""
def __init__(
self,
color: tuple[int, int, int] = (255, 255, 255),
white: int = 0,
) -> None:
self.color = tuple(color) # type: ignore[arg-type]
self.white = white
def render(self, t: float, led_count: int) -> list[tuple]:
return [self.color] * led_count
@classmethod
def from_params(cls, params: dict) -> "SolidColorAnimation":
color = tuple(params.get("color", [255, 255, 255]))
white = params.get("white", 0)
return cls(color=color, white=white) # type: ignore[arg-type]

View File

@@ -0,0 +1,42 @@
"""Strobe animation — rapid on/off flashing."""
from lightsync.animations.base import AnimationBase
class StrobeAnimation(AnimationBase):
"""Rapid on/off flashing at configurable frequency and duty cycle.
speed=0.5 -> ~10.5 Hz. duty_cycle=0.1 means 10% on, 90% off.
"""
def __init__(
self,
color: tuple[int, int, int] = (255, 255, 255),
speed: float = 0.5,
duty_cycle: float = 0.1,
white: int = 0,
) -> None:
self.color = tuple(color) # type: ignore[arg-type]
self.speed = speed
self.duty_cycle = duty_cycle
self.white = white
def render(self, t: float, led_count: int) -> list[tuple]:
# Frequency: 1-20 Hz mapped from speed 0.0-1.0
freq = 1.0 + self.speed * 19.0
phase = (t * freq) % 1.0
if phase < self.duty_cycle:
pixel = self.color
else:
pixel = (0, 0, 0)
return [pixel] * led_count
@classmethod
def from_params(cls, params: dict) -> "StrobeAnimation":
return cls(
color=tuple(params.get("color", [255, 255, 255])), # type: ignore[arg-type]
speed=params.get("speed", 0.5),
duty_cycle=params.get("duty_cycle", 0.1),
white=params.get("white", 0),
)