feat/figment: periodic SVG glyph overlays with CLI flag #34

Merged
genewildish merged 14 commits from feat/figment into main 2026-03-19 20:42:11 +00:00
Showing only changes of commit 2cc8dbfc02 - Show all commits

View File

@@ -8,10 +8,27 @@ Figment mode displays a randomly selected SVG from the `figments/` directory as
The feature is designed for extensibility: a generic input protocol allows MQTT, ntfy, serial, or any other control surface to trigger figments and adjust parameters in real time. The feature is designed for extensibility: a generic input protocol allows MQTT, ntfy, serial, or any other control surface to trigger figments and adjust parameters in real time.
## Goals
- Display SVG figments as half-block terminal art overlaid on the running ticker
- Three-phase animation: progressive reveal, strobing hold, dissolve
- Random color from existing theme gradients (green, orange, purple)
- Configurable interval and duration via C&C
- Extensible input abstraction for physical device control (MQTT, serial, etc.)
## Out of Scope
- Multi-figment simultaneous display (one at a time)
- SVG animation support (static SVGs only; animation comes from the overlay phases)
- Custom color palettes beyond existing themes
- MQTT and serial adapters (v1 ships with ntfy C&C only; protocol is ready for future adapters)
## Architecture: Hybrid Plugin + Overlay ## Architecture: Hybrid Plugin + Overlay
The figment is an **EffectPlugin** for lifecycle, discovery, and configuration, but delegates rendering to a **layers-style overlay helper**. This avoids stretching the `EffectPlugin.process()` contract (which transforms line buffers) while still benefiting from the plugin system for C&C, auto-discovery, and config management. The figment is an **EffectPlugin** for lifecycle, discovery, and configuration, but delegates rendering to a **layers-style overlay helper**. This avoids stretching the `EffectPlugin.process()` contract (which transforms line buffers) while still benefiting from the plugin system for C&C, auto-discovery, and config management.
**Important**: The plugin class is named `FigmentEffect` (not `FigmentPlugin`) to match the `*Effect` naming convention required by `discover_plugins()` in `effects_plugins/__init__.py`. The plugin is **not** added to the `EffectChain` order list — its `process()` is a no-op that returns the buffer unchanged. The chain only processes effects that transform buffers (noise, fade, glitch, firehose). Figment's rendering happens via the overlay path in `scroll.py`, outside the chain.
### Component Diagram ### Component Diagram
``` ```
@@ -25,7 +42,7 @@ The figment is an **EffectPlugin** for lifecycle, discovery, and configuration,
| FigmentCommand | FigmentCommand
v v
+------------------+ +-----------------+ +----------------------+ +------------------+ +-----------------+ +----------------------+
| figment_render |<---| FigmentPlugin |--->| render_figment_ | | figment_render |<---| FigmentEffect |--->| render_figment_ |
| .py | | (EffectPlugin) | | overlay() in | | .py | | (EffectPlugin) | | overlay() in |
| | | | | layers.py | | | | | | layers.py |
| SVG -> PIL -> | | Timer, state | | | | SVG -> PIL -> | | Timer, state | | |
@@ -37,7 +54,6 @@ The figment is an **EffectPlugin** for lifecycle, discovery, and configuration,
v v
+-------------------+ +-------------------+
| scroll.py | | scroll.py |
| (5-line block) |
+-------------------+ +-------------------+
``` ```
@@ -57,7 +73,7 @@ Reuses the same PIL-based half-block encoding that `engine/render.py` uses for O
### Dependency ### Dependency
`cairosvg` added as an optional dependency in `pyproject.toml` (like `sounddevice`). Figment mode gracefully degrades if unavailable. `cairosvg` added as an optional dependency in `pyproject.toml` (like `sounddevice`). If `cairosvg` is not installed, the `FigmentEffect` class will fail to import, and `discover_plugins()` will silently skip it (the existing `except Exception: pass` in discovery handles this). The plugin simply won't appear in the registry.
### Key Function ### Key Function
@@ -72,6 +88,21 @@ def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
New function following the `render_message_overlay()` pattern. New function following the `render_message_overlay()` pattern.
### FigmentState Dataclass
Defined in `effects_plugins/figment.py`, passed between the plugin and the overlay renderer:
```python
@dataclass
class FigmentState:
phase: FigmentPhase # enum: REVEAL, HOLD, DISSOLVE
progress: float # 0.0 to 1.0 within current phase
rows: list[str] # rasterized half-block rows (uncolored)
gradient: list[int] # 12-color ANSI 256 gradient from chosen theme
center_row: int # top row for centering in viewport
center_col: int # left column for centering in viewport
```
### Function Signature ### Function Signature
```python ```python
@@ -81,11 +112,13 @@ def render_figment_overlay(figment_state: FigmentState, w: int, h: int) -> list[
### Animation Phases (~4.5 seconds total) ### Animation Phases (~4.5 seconds total)
Progress advances each frame as: `progress += config.FRAME_DT / phase_duration`. At 20 FPS (FRAME_DT=0.05s), a 1.5s phase takes 30 frames to complete.
| Phase | Duration | Behavior | | Phase | Duration | Behavior |
|-------|----------|----------| |-------|----------|----------|
| **Reveal** | ~1.5s | Progressive scanline fill. Each frame, a percentage of the figment's non-empty cells become visible in random block order. | | **Reveal** | ~1.5s | Progressive scanline fill. Each frame, a percentage of the figment's non-empty cells become visible in random block order. Intensity scales reveal speed. |
| **Hold** | ~1.5s | Full figment visible. Strobes between full brightness and dimmed/partial visibility every few frames. | | **Hold** | ~1.5s | Full figment visible. Strobes between full brightness and dimmed/partial visibility every few frames. Intensity scales strobe frequency. |
| **Dissolve** | ~1.5s | Inverse of reveal. Cells randomly drop out, replaced by spaces. | | **Dissolve** | ~1.5s | Inverse of reveal. Cells randomly drop out, replaced by spaces. Intensity scales dissolve speed. |
### Color ### Color
@@ -95,15 +128,19 @@ A random theme gradient is selected from `THEME_REGISTRY` at trigger time. Appli
Figment is centered in the viewport. Each visible row is an ANSI `\033[row;colH` command appended to the buffer, identical to how the message overlay works. Figment is centered in the viewport. Each visible row is an ANSI `\033[row;colH` command appended to the buffer, identical to how the message overlay works.
## Section 3: FigmentPlugin (Effect Plugin) ## Section 3: FigmentEffect (Effect Plugin)
**File: `effects_plugins/figment.py`** **File: `effects_plugins/figment.py`**
An `EffectPlugin(ABC)` subclass that owns the figment lifecycle. An `EffectPlugin(ABC)` subclass named `FigmentEffect` to match the `*Effect` discovery convention.
### Chain Exclusion
`FigmentEffect` is registered in the `EffectRegistry` (for C&C access and config management) but is **not** added to the `EffectChain` order list. Its `process()` returns the buffer unchanged. The `enabled` flag is checked directly by `scroll.py` when deciding whether to call `get_figment_state()`, not by the chain.
### Responsibilities ### Responsibilities
- **Timer**: Tracks elapsed frames. At the configured interval (default 60s), triggers a new figment. - **Timer**: Tracks elapsed time via `config.FRAME_DT` accumulation. At the configured interval (default 60s), triggers a new figment.
- **SVG selection**: Randomly picks from `figments/*.svg`. Avoids repeating the last shown. - **SVG selection**: Randomly picks from `figments/*.svg`. Avoids repeating the last shown.
- **State machine**: `idle -> reveal -> hold -> dissolve -> idle`. Tracks phase progress (0.0 to 1.0). - **State machine**: `idle -> reveal -> hold -> dissolve -> idle`. Tracks phase progress (0.0 to 1.0).
- **Color selection**: Picks a random theme key (`"green"`, `"orange"`, `"purple"`) at trigger time. - **Color selection**: Picks a random theme key (`"green"`, `"orange"`, `"purple"`) at trigger time.
@@ -120,7 +157,7 @@ dissolve ──(progress >= 1.0)──> idle
### Interface ### Interface
The `process()` method is a no-op (returns buffer unchanged) since rendering is handled by the overlay. The plugin exposes state via: The `process()` method returns the buffer unchanged (no-op). The plugin exposes state via:
```python ```python
def get_figment_state(self, frame_number: int) -> FigmentState | None: def get_figment_state(self, frame_number: int) -> FigmentState | None:
@@ -129,6 +166,19 @@ def get_figment_state(self, frame_number: int) -> FigmentState | None:
This mirrors the `ntfy_poller.get_active_message()` pattern. This mirrors the `ntfy_poller.get_active_message()` pattern.
### Scroll Loop Access
`scroll.py` imports `FigmentEffect` directly and uses `isinstance()` to safely downcast from the registry:
```python
from effects_plugins.figment import FigmentEffect
plugin = registry.get("figment")
figment = plugin if isinstance(plugin, FigmentEffect) else None
```
This is a one-time setup check, not per-frame. If `cairosvg` is missing, the import is wrapped in a try/except and `figment` stays `None`.
### EffectConfig ### EffectConfig
- `enabled`: bool (default `False` — opt-in) - `enabled`: bool (default `False` — opt-in)
@@ -154,12 +204,21 @@ class FigmentTrigger(Protocol):
### FigmentCommand ### FigmentCommand
```python ```python
class FigmentAction(Enum):
TRIGGER = "trigger"
SET_INTENSITY = "set_intensity"
SET_INTERVAL = "set_interval"
SET_COLOR = "set_color"
STOP = "stop"
@dataclass @dataclass
class FigmentCommand: class FigmentCommand:
action: str # "trigger" | "set_intensity" | "set_interval" | "set_color" | "stop" action: FigmentAction
value: float | str | None = None value: float | str | None = None
``` ```
Uses an enum for consistency with `EventType` in `engine/events.py`.
### Adapters ### Adapters
| Adapter | Transport | Dependency | Status | | Adapter | Transport | Dependency | Status |
@@ -168,25 +227,32 @@ class FigmentCommand:
| `MqttTrigger` | MQTT broker | `paho-mqtt` (optional) | Future | | `MqttTrigger` | MQTT broker | `paho-mqtt` (optional) | Future |
| `SerialTrigger` | USB serial | `pyserial` (optional) | Future | | `SerialTrigger` | USB serial | `pyserial` (optional) | Future |
**NtfyTrigger v1**: Subscribes as a callback on the existing `NtfyPoller`. Parses messages with a `/figment` prefix (e.g., `/figment trigger`, `/figment intensity 0.8`). This is separate from the `/effects figment on` C&C path — the trigger protocol allows external devices to send commands without knowing the effects controller API.
### Integration ### Integration
The `FigmentPlugin` accepts a list of triggers. Each frame, it polls all triggers and acts on commands. Triggers are optional — if none are configured, the plugin runs on its internal timer alone. The `FigmentEffect` accepts a list of triggers. Each frame, it polls all triggers and acts on commands. Triggers are optional — if none are configured, the plugin runs on its internal timer alone.
### EventBus Bridge ### EventBus Bridge
Any trigger can publish a `FIGMENT_TRIGGER` event to the EventBus, allowing other components to react (logging, multi-display sync). A new `FIGMENT_TRIGGER` variant is added to the `EventType` enum in `engine/events.py`, with a corresponding `FigmentTriggerEvent` dataclass. Triggers publish to the EventBus for other components to react (logging, multi-display sync).
## Section 5: Scroll Loop Integration ## Section 5: Scroll Loop Integration
Minimal change to `engine/scroll.py` — 5 lines following the ntfy overlay pattern: Minimal change to `engine/scroll.py`:
```python ```python
# In stream() setup: # In stream() setup (with safe import):
figment_plugin = registry.get("figment") try:
from effects_plugins.figment import FigmentEffect
_plugin = registry.get("figment")
figment = _plugin if isinstance(_plugin, FigmentEffect) else None
except ImportError:
figment = None
# In frame loop, after effects processing, before display.show(): # In frame loop, after effects processing, before ntfy message overlay:
if figment_plugin and figment_plugin.config.enabled: if figment and figment.config.enabled:
figment_state = figment_plugin.get_figment_state(frame_number) figment_state = figment.get_figment_state(frame_number)
if figment_state is not None: if figment_state is not None:
figment_overlay = render_figment_overlay(figment_state, w, h) figment_overlay = render_figment_overlay(figment_state, w, h)
buf.extend(figment_overlay) buf.extend(figment_overlay)
@@ -198,28 +264,45 @@ Figment overlay appends **after** effects processing but **before** the ntfy mes
- Ntfy messages always appear on top of figments (higher priority) - Ntfy messages always appear on top of figments (higher priority)
- Existing glitch/noise effects run over the ticker underneath the figment - Existing glitch/noise effects run over the ticker underneath the figment
## Section 6: File Summary Note: If more overlay types are added in the future, a priority-based overlay system should replace the current positional ordering.
## Section 6: Error Handling
| Scenario | Behavior |
|----------|----------|
| `cairosvg` not installed | `FigmentEffect` fails to import; `discover_plugins()` silently skips it; `scroll.py` import guard sets `figment = None` |
| `figments/` directory missing | Plugin logs warning at startup, stays in permanent `idle` state |
| `figments/` contains zero `.svg` files | Same as above: warning, permanent `idle` |
| Malformed SVG | `cairosvg` raises exception; plugin catches it, skips that SVG, picks another. If all SVGs fail, enters permanent `idle` with warning |
| Terminal resize during animation | Re-rasterize on next frame using new dimensions. Cache miss triggers fresh rasterization. Animation phase/progress are preserved; only the rendered rows update |
## Section 7: File Summary
### New Files ### New Files
| File | Purpose | | File | Purpose |
|------|---------| |------|---------|
| `effects_plugins/figment.py` | FigmentPlugin — lifecycle, timer, state machine, SVG selection | | `effects_plugins/figment.py` | FigmentEffect — lifecycle, timer, state machine, SVG selection, FigmentState/FigmentPhase |
| `engine/figment_render.py` | SVG to half-block rasterization pipeline | | `engine/figment_render.py` | SVG to half-block rasterization pipeline |
| `engine/figment_trigger.py` | FigmentTrigger protocol, FigmentCommand, NtfyTrigger adapter | | `engine/figment_trigger.py` | FigmentTrigger protocol, FigmentAction enum, FigmentCommand, NtfyTrigger adapter |
| `tests/test_figment.py` | Plugin lifecycle, state machine, timer, overlay rendering | | `figments/` | SVG source directory (ships with sample SVGs) |
| `tests/fixtures/test.svg` | Minimal SVG for testing rasterization | | `tests/test_figment.py` | FigmentEffect lifecycle, state machine transitions, timer |
| `tests/test_figment_render.py` | SVG rasterization, caching, edge cases |
| `tests/test_figment_trigger.py` | FigmentCommand parsing, NtfyTrigger adapter |
| `tests/fixtures/test.svg` | Minimal SVG for deterministic rasterization tests |
### Modified Files ### Modified Files
| File | Change | | File | Change |
|------|--------| |------|--------|
| `engine/scroll.py` | 5-line figment overlay integration | | `engine/scroll.py` | Figment overlay integration (setup + per-frame block) |
| `engine/layers.py` | Add `render_figment_overlay()` function | | `engine/layers.py` | Add `render_figment_overlay()` function |
| `engine/events.py` | Add `FIGMENT_TRIGGER` to `EventType` enum, add `FigmentTriggerEvent` dataclass |
| `pyproject.toml` | Add `cairosvg` as optional dependency | | `pyproject.toml` | Add `cairosvg` as optional dependency |
## Testing Strategy ## Testing Strategy
- **Unit**: State machine transitions, timer accuracy, SVG rasterization output, FigmentCommand parsing - **Unit**: State machine transitions (idle→reveal→hold→dissolve→idle), timer accuracy (fires at interval_secs), SVG rasterization output dimensions, FigmentCommand parsing, FigmentAction enum coverage
- **Integration**: Plugin discovery, overlay rendering with mock terminal dimensions, C&C command handling - **Integration**: Plugin discovery (verify `FigmentEffect` is found by `discover_plugins()`), overlay rendering with mock terminal dimensions, C&C command handling via `/effects figment on`
- **Fixture**: Minimal `test.svg` (simple shape) for deterministic rasterization tests - **Edge cases**: Missing figments dir, empty dir, malformed SVG, cairosvg unavailable, terminal resize mid-animation
- **Fixture**: Minimal `test.svg` (simple rectangle) for deterministic rasterization tests