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:
48
lightsync/animations/__init__.py
Normal file
48
lightsync/animations/__init__.py
Normal 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)
|
||||
28
lightsync/animations/base.py
Normal file
28
lightsync/animations/base.py
Normal 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.
|
||||
"""
|
||||
...
|
||||
58
lightsync/animations/chase.py
Normal file
58
lightsync/animations/chase.py
Normal 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),
|
||||
)
|
||||
44
lightsync/animations/color_wipe.py
Normal file
44
lightsync/animations/color_wipe.py
Normal 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),
|
||||
)
|
||||
80
lightsync/animations/fire.py
Normal file
80
lightsync/animations/fire.py
Normal 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),
|
||||
)
|
||||
53
lightsync/animations/pulse.py
Normal file
53
lightsync/animations/pulse.py
Normal 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),
|
||||
)
|
||||
38
lightsync/animations/rainbow.py
Normal file
38
lightsync/animations/rainbow.py
Normal 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),
|
||||
)
|
||||
23
lightsync/animations/solid_color.py
Normal file
23
lightsync/animations/solid_color.py
Normal 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]
|
||||
42
lightsync/animations/strobe.py
Normal file
42
lightsync/animations/strobe.py
Normal 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),
|
||||
)
|
||||
@@ -108,15 +108,20 @@ def test_chase_pattern():
|
||||
|
||||
|
||||
def test_chase_reverse():
|
||||
"""Chase with reverse=True differs from reverse=False at same t (non-trivial t)."""
|
||||
"""Chase with reverse=True differs from reverse=False at same t (non-trivial t).
|
||||
|
||||
Choose t such that offset is not a multiple of (size+spacing) to avoid symmetry.
|
||||
"""
|
||||
color = (255, 0, 0)
|
||||
bg = (0, 0, 0)
|
||||
# period = size + spacing = 3 + 7 = 10
|
||||
# offset = int(t * speed * 60); use t=0.05 -> offset=3, not multiple of 10
|
||||
anim_fwd = ChaseAnimation(color=color, bg_color=bg, speed=1.0, size=3, spacing=7, reverse=False, white=0)
|
||||
anim_rev = ChaseAnimation(color=color, bg_color=bg, speed=1.0, size=3, spacing=7, reverse=True, white=0)
|
||||
t = 1.5 # Non-zero t to show movement
|
||||
t = 0.05 # offset=3, not a multiple of period=10, so fwd and rev patterns differ
|
||||
frame_fwd = anim_fwd.render(t, 20)
|
||||
frame_rev = anim_rev.render(t, 20)
|
||||
assert frame_fwd != frame_rev, "Forward and reverse chase should differ at t=1.5"
|
||||
assert frame_fwd != frame_rev, "Forward and reverse chase should differ when offset is not a multiple of period"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user