# Specification: Full Review — Animation Pipeline Performance

**Track ID:** review-perf_20260318
**Type:** Refactor
**Created:** 2026-03-18
**Status:** Draft

## Summary

Optimize the performance-critical animation pipeline to reduce per-frame object allocations and eliminate bottlenecks in the hot path, enabling sustained 30+ FPS delivery for multi-tile LIFX matrix devices.

## Context

The comprehensive code review identified the animation pipeline as the primary performance bottleneck. At 30 FPS with a 128-pixel device, the current implementation creates ~26,880 HSBK objects per second in the frame generation loop, runs nested Python loops for canvas-to-device pixel mapping, and flattens pixel data via 64 `.extend()` calls per packet. The animation layer was explicitly designed for high-frequency delivery and these allocations are the main impediment.

## User Story

As a developer building real-time lighting effects, I want the animation pipeline to deliver 30+ FPS with minimal GC pressure so that effects run smoothly on resource-constrained hardware.

## Acceptance Criteria

- [ ] `FrameBuffer._apply_canvas` pre-computes the canvas-to-device index lookup table at init time — per-frame work is a single list comprehension with no index arithmetic
- [ ] `update_colors()` in `MatrixPacketGenerator` and `MultiZonePacketGenerator` replaces per-pixel `.extend()` with a flat indexing approach
- [ ] A `generate_protocol_frame()` method on the effects base class provides an optional protocol-direct path returning `list[tuple[int,int,int,int]]` without HSBK object construction
- [ ] `asdict()` in `Packet.unpack()` is gated behind a `_LOGGER.isEnabledFor(logging.DEBUG)` check
- [ ] Benchmark test suite exists verifying `_apply_canvas` completes in <5ms for a 5-tile (320px) setup
- [ ] All quality gates pass: ruff format, ruff check, pyright, pytest

## Dependencies

- review-critical_20260318 — fix `time.time()` → `time.monotonic()` first (shared files)

## Out of Scope

- Rewriting effect `generate_frame()` methods to use the new protocol-direct path (opt-in, effects authors can migrate gradually)
- `bytes +=` → `b"".join()` in generated protocol files (requires generator change — separate task)
- HSBK validation bypass for internal callers (deferred — needs careful API design)
- Multi-process or C extension optimizations

## Technical Notes

### FrameBuffer LUT pre-computation (PERF-H1)

Current `_apply_canvas` at `framebuffer.py:382-416` iterates `tiles × rows × cols` on every frame to build the output list. The mapping from canvas position to output index is static — computed once at `__init__` time and stored as a `list[int]` of canvas indices in output order. Per-frame work becomes:

```python
# Pre-computed at init:
self._lut: list[int] = [canvas_w * (tile_y + row) + (tile_x + col) for ...]

# Per frame (single comprehension):
def apply(self, canvas: list[tuple]) -> list[tuple]:
    return [canvas[i] for i in self._lut]
```

### `update_colors` flattening (PERF-H2)

Current `packets.py:398-404` uses `for h, s, b, k in hsbk: flat.extend([h, s, b, k])`. Replace with indexed struct packs or a pre-allocated list:

```python
# Use struct.pack_into directly into the pre-allocated bytearray:
for i, (h, s, b, k) in enumerate(hsbk):
    struct.pack_into("<HHHH", self._buf, COLORS_OFFSET + i * 8, h, s, b, k)
```

### Debug logging guard (PERF-L3)

`base.py:114` calls `dataclasses.asdict(packet)` unconditionally in every `Packet.unpack()`. This deep-copies the entire packet for a debug log that is almost never active:

```python
if _LOGGER.isEnabledFor(logging.DEBUG):
    _LOGGER.debug({"class": "Packet", "method": "unpack", "packet": dataclasses.asdict(packet)})
```

---

_Generated by Conductor. Review and edit as needed._
