254 lines
13 KiB
Markdown
254 lines
13 KiB
Markdown
---
|
|
phase: 03-communication-protocol
|
|
plan: 02
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- lightsync/animations/__init__.py
|
|
- lightsync/animations/base.py
|
|
- lightsync/animations/solid_color.py
|
|
- lightsync/animations/chase.py
|
|
- lightsync/animations/pulse.py
|
|
- lightsync/animations/rainbow.py
|
|
- lightsync/animations/strobe.py
|
|
- lightsync/animations/color_wipe.py
|
|
- lightsync/animations/fire.py
|
|
- tests/test_animations.py
|
|
autonomous: true
|
|
requirements: [ANI-01, ANI-02]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "All 7 animation types render frames as list of RGB tuples with length == led_count"
|
|
- "Each animation accepts speed, color, and type-specific params from a dict"
|
|
- "Fire animation uses Fire2012 algorithm with cooling/sparking params"
|
|
- "Rainbow animation produces distinct hues across the strip"
|
|
- "Strobe alternates between on (color) and off (black) based on duty_cycle"
|
|
- "Chase moves a lit segment along the strip over time"
|
|
- "All animations can be constructed from a params dict via from_params()"
|
|
artifacts:
|
|
- path: "lightsync/animations/base.py"
|
|
provides: "AnimationBase ABC"
|
|
exports: ["AnimationBase"]
|
|
- path: "lightsync/animations/__init__.py"
|
|
provides: "Animation registry and factory"
|
|
exports: ["ANIMATION_REGISTRY", "create_animation"]
|
|
- path: "lightsync/animations/fire.py"
|
|
provides: "Fire2012 animation"
|
|
contains: "class FireAnimation"
|
|
- path: "tests/test_animations.py"
|
|
provides: "Animation render tests"
|
|
min_lines: 60
|
|
key_links:
|
|
- from: "lightsync/animations/__init__.py"
|
|
to: "lightsync/animations/*.py"
|
|
via: "ANIMATION_REGISTRY dict"
|
|
pattern: "ANIMATION_REGISTRY.*=.*{.*solid_color.*chase.*fire"
|
|
- from: "lightsync/animations/base.py"
|
|
to: "all animation subclasses"
|
|
via: "ABC inheritance"
|
|
pattern: "class AnimationBase"
|
|
---
|
|
|
|
<objective>
|
|
Build the animation library with all 7 built-in types. Each animation is a pure renderer: given time t and led_count, it returns a pixel frame. No I/O, no networking — just computation.
|
|
|
|
Purpose: These animations generate the pixel data that the protocol layer (Plan 01) encodes and sends. Phase 4 timeline editor will reference these by name. Phase 5 live execution will call render() in real-time.
|
|
Output: lightsync/animations/ package with 7 animation classes, a registry/factory, and passing tests.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/03-communication-protocol/03-RESEARCH.md
|
|
|
|
<interfaces>
|
|
<!-- Animation parameter shapes from RESEARCH.md that each class must accept -->
|
|
|
|
solid_color: {"color": [r,g,b], "white": 0}
|
|
chase: {"color": [r,g,b], "white": 0, "bg_color": [0,0,0], "speed": 0.5, "size": 3, "spacing": 7, "reverse": False}
|
|
pulse: {"color": [r,g,b], "white": 0, "speed": 0.5, "min_brightness": 0, "max_brightness": 255}
|
|
rainbow: {"speed": 0.5, "period": 1.0}
|
|
strobe: {"color": [r,g,b], "white": 0, "speed": 0.5, "duty_cycle": 0.1}
|
|
color_wipe: {"color": [r,g,b], "white": 0, "speed": 0.5, "reverse": False}
|
|
fire: {"cooling": 55, "sparking": 120, "speed": 0.5}
|
|
|
|
From lightsync/models/show.py:
|
|
```python
|
|
class CueModel(BaseModel):
|
|
animation: str | None = None
|
|
params: dict[str, Any] = Field(default_factory=dict)
|
|
```
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: AnimationBase ABC + 7 animation implementations</name>
|
|
<files>lightsync/animations/__init__.py, lightsync/animations/base.py, lightsync/animations/solid_color.py, lightsync/animations/chase.py, lightsync/animations/pulse.py, lightsync/animations/rainbow.py, lightsync/animations/strobe.py, lightsync/animations/color_wipe.py, lightsync/animations/fire.py, tests/test_animations.py</files>
|
|
<read_first>
|
|
- .planning/phases/03-communication-protocol/03-RESEARCH.md (Animation Parameter Shapes section, Fire2012 algorithm, Pattern 4: Animation Base)
|
|
- lightsync/models/show.py (CueModel.params shape)
|
|
</read_first>
|
|
<behavior>
|
|
- AnimationBase is ABC with abstract render(t, led_count) and from_params(params)
|
|
- SolidColorAnimation.render(0.0, 10) returns 10 identical (r,g,b) tuples matching params["color"]
|
|
- ChaseAnimation.render(t, 10) returns list of 10 tuples, some matching color and some matching bg_color
|
|
- ChaseAnimation with reverse=True moves opposite direction vs reverse=False at same t
|
|
- PulseAnimation.render(0.0, 5) returns 5 tuples with brightness based on sin wave at t=0
|
|
- RainbowAnimation.render(0.0, 10) returns 10 distinct RGB tuples (hue spread across strip)
|
|
- StrobeAnimation with duty_cycle=0.5 at t=0.0 returns color pixels (on-phase), at t where off returns black
|
|
- ColorWipeAnimation.render(t, 10) returns mix of color and black pixels depending on t
|
|
- FireAnimation.render(t, 30) returns 30 RGB tuples, all values in 0-255 range
|
|
- All render() return lists with length == led_count
|
|
- All from_params(params_dict) construct valid animation instances
|
|
- create_animation("chase", {...}) returns a ChaseAnimation instance
|
|
- create_animation("unknown_type", {}) raises ValueError
|
|
</behavior>
|
|
<action>
|
|
Create `lightsync/animations/base.py`:
|
|
```python
|
|
from abc import ABC, abstractmethod
|
|
|
|
class AnimationBase(ABC):
|
|
@abstractmethod
|
|
def render(self, t: float, led_count: int) -> list[tuple]:
|
|
"""Render frame at time t. Returns list of (R,G,B) tuples, len == led_count."""
|
|
...
|
|
|
|
@classmethod
|
|
@abstractmethod
|
|
def from_params(cls, params: dict) -> "AnimationBase":
|
|
"""Construct from CueModel.params dict."""
|
|
...
|
|
```
|
|
|
|
Create `lightsync/animations/solid_color.py` — SolidColorAnimation:
|
|
- `__init__(self, color: tuple[int,int,int], white: int = 0)`
|
|
- `render(t, led_count)` returns `[self.color] * led_count` (time-independent)
|
|
- `from_params(params)` reads `params.get("color", [0,0,0])` and `params.get("white", 0)`
|
|
|
|
Create `lightsync/animations/chase.py` — ChaseAnimation:
|
|
- `__init__(self, color, bg_color, speed, size, spacing, reverse, white)`
|
|
- `render(t, led_count)`: Calculate offset = `int(t * speed * 60)` (60 px/sec at speed=1.0). Pattern repeats every `size + spacing` pixels. For each LED index i, compute `pos = (i - offset) % period` if not reverse, `(i + offset) % period` if reverse. If `pos < size`, pixel = color, else bg_color.
|
|
- `from_params` reads all chase params with defaults: color=[255,255,255], bg_color=[0,0,0], speed=0.5, size=3, spacing=7, reverse=False, white=0
|
|
|
|
Create `lightsync/animations/pulse.py` — PulseAnimation:
|
|
- `__init__(self, color, speed, min_brightness, max_brightness, white)`
|
|
- `render(t, led_count)`: speed maps to period: `period = 0.1 + (1.0 - speed) * 4.9` seconds (speed=1.0 -> 0.1s fast, speed=0.0 -> 5.0s slow). Brightness = `min_b + (max_b - min_b) * (0.5 + 0.5 * math.sin(2 * math.pi * t / period))`. Scale each channel of color by brightness/255. Return uniform list.
|
|
- `from_params` reads pulse params with defaults
|
|
|
|
Create `lightsync/animations/rainbow.py` — RainbowAnimation:
|
|
- `__init__(self, speed, period)`
|
|
- `render(t, led_count)`: For each LED i, hue = `(i / led_count / period + t * speed) % 1.0`. Convert to RGB via `colorsys.hsv_to_rgb(hue, 1.0, 1.0)`, scale to 0-255 int.
|
|
- `from_params` reads speed=0.5, period=1.0
|
|
|
|
Create `lightsync/animations/strobe.py` — StrobeAnimation:
|
|
- `__init__(self, color, speed, duty_cycle, white)`
|
|
- `render(t, led_count)`: `freq = 1 + speed * 19` (1-20 Hz). `phase = (t * freq) % 1.0`. If `phase < duty_cycle` -> color, else -> (0,0,0).
|
|
- `from_params` reads strobe params
|
|
|
|
Create `lightsync/animations/color_wipe.py` — ColorWipeAnimation:
|
|
- `__init__(self, color, speed, reverse, white)`
|
|
- `render(t, led_count)`: `fill_count = min(led_count, int(t * speed * 60))`. If not reverse: first fill_count pixels = color, rest = black. If reverse: last fill_count pixels = color, rest = black.
|
|
- `from_params` reads color_wipe params
|
|
|
|
Create `lightsync/animations/fire.py` — FireAnimation using Fire2012:
|
|
- `__init__(self, cooling, sparking, speed)` — also init `self._heat: list[int] = []` (lazy-init per led_count)
|
|
- `render(t, led_count)`: Lazy-init heat array if empty or wrong length. Compute `steps = max(1, int(speed * 3))` steps per render call (speed multiplier). For each step, run Fire2012: cool, drift up, spark. Map heat to color: heat<85 -> (h*3, 0, 0), heat<170 -> (255, (h-85)*3, 0), else (255, 255, (h-170)*3). Clamp all to 0-255.
|
|
- Use `random` module (import at top). Index 0 = bottom (heat source).
|
|
- `from_params` reads cooling=55, sparking=120, speed=0.5
|
|
|
|
Create `lightsync/animations/__init__.py`:
|
|
```python
|
|
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
|
|
|
|
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:
|
|
cls = ANIMATION_REGISTRY.get(name)
|
|
if cls is None:
|
|
raise ValueError(f"Unknown animation: {name}")
|
|
return cls.from_params(params)
|
|
```
|
|
|
|
Create `tests/test_animations.py` with pytest tests for all behaviors above. Key tests:
|
|
- test_solid_color_uniform: all pixels same color
|
|
- test_chase_pattern: some pixels match color, some bg_color
|
|
- test_chase_reverse: different from non-reverse at same t
|
|
- test_pulse_brightness_range: pixel values between min and max brightness scaled
|
|
- test_rainbow_distinct_hues: 10-LED strip has distinct pixel values
|
|
- test_strobe_on_off: at certain t returns color, at other t returns black
|
|
- test_color_wipe_progressive: more pixels filled at larger t
|
|
- test_fire_output_range: all R,G,B values in 0-255
|
|
- test_fire_length: output length == led_count
|
|
- test_all_render_length: every animation returns list of length == led_count
|
|
- test_create_animation_factory: create_animation returns correct type
|
|
- test_create_animation_unknown: raises ValueError
|
|
- test_from_params_all_types: from_params works for all 7 types with default-ish params
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/claude/led2 && python -m pytest tests/test_animations.py -v</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- lightsync/animations/__init__.py contains "ANIMATION_REGISTRY" and "def create_animation("
|
|
- lightsync/animations/__init__.py contains all 7 names: "solid_color", "chase", "pulse", "rainbow", "strobe", "color_wipe", "fire"
|
|
- lightsync/animations/base.py contains "class AnimationBase(ABC)" and "def render(" and "def from_params("
|
|
- lightsync/animations/solid_color.py contains "class SolidColorAnimation(AnimationBase)"
|
|
- lightsync/animations/chase.py contains "class ChaseAnimation(AnimationBase)"
|
|
- lightsync/animations/pulse.py contains "class PulseAnimation(AnimationBase)" and "math.sin"
|
|
- lightsync/animations/rainbow.py contains "class RainbowAnimation(AnimationBase)" and "colorsys.hsv_to_rgb"
|
|
- lightsync/animations/strobe.py contains "class StrobeAnimation(AnimationBase)"
|
|
- lightsync/animations/color_wipe.py contains "class ColorWipeAnimation(AnimationBase)"
|
|
- lightsync/animations/fire.py contains "class FireAnimation(AnimationBase)" and "cooling" and "sparking"
|
|
- tests/test_animations.py exits 0 with all tests passing
|
|
- tests/test_animations.py contains at least 10 test functions
|
|
</acceptance_criteria>
|
|
<done>All 7 animation types render correctly. Factory function creates animations by name. All RGB values in valid 0-255 range. Output list length always matches led_count. All tests pass.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `python -m pytest tests/test_animations.py -v` — all animation tests pass
|
|
- `python -c "from lightsync.animations import create_animation, ANIMATION_REGISTRY; print(len(ANIMATION_REGISTRY), 'animations')"` outputs "7 animations"
|
|
- `python -c "from lightsync.animations import create_animation; a = create_animation('fire', {'cooling': 55, 'sparking': 120}); f = a.render(1.0, 30); assert len(f) == 30; print('fire OK')"` passes
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- All 7 animation types implemented with correct parameter handling
|
|
- AnimationBase ABC enforces render() and from_params() contract
|
|
- ANIMATION_REGISTRY maps all 7 names to classes
|
|
- create_animation() factory works for all types, raises ValueError for unknown
|
|
- Fire2012 algorithm produces heat-mapped colors
|
|
- All render() outputs have correct length and valid 0-255 RGB values
|
|
- All tests pass
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/03-communication-protocol/03-02-SUMMARY.md`
|
|
</output>
|