feat(pipeline): add unified pipeline architecture with Stage abstraction

- Add engine/pipeline/ module with Stage ABC, PipelineContext, PipelineParams
- Stage provides unified interface for sources, effects, displays, cameras
- Pipeline class handles DAG-based execution with dependency resolution
- PipelinePreset for pre-configured pipelines (demo, poetry, pipeline, etc.)
- Add PipelineParams as params layer for animation-driven config
- Add StageRegistry for unified stage registration
- Add sources_v2.py with DataSource.is_dynamic property
- Add animation.py with Preset and AnimationController
- Skip ntfy integration tests by default (require -m integration)
- Skip e2e tests by default (require -m e2e)
- Update pipeline.py with comprehensive introspection methods
This commit is contained in:
2026-03-16 03:11:24 -07:00
parent 996ba14b1d
commit bcb4ef0cfe
17 changed files with 2356 additions and 48 deletions

340
engine/animation.py Normal file
View File

@@ -0,0 +1,340 @@
"""
Animation system - Clock, events, triggers, durations, and animation controller.
"""
import time
from collections.abc import Callable
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Any
class Clock:
"""High-resolution clock for animation timing."""
def __init__(self):
self._start_time = time.perf_counter()
self._paused = False
self._pause_offset = 0.0
self._pause_start = 0.0
def reset(self) -> None:
self._start_time = time.perf_counter()
self._paused = False
self._pause_offset = 0.0
self._pause_start = 0.0
def elapsed(self) -> float:
if self._paused:
return self._pause_start - self._start_time - self._pause_offset
return time.perf_counter() - self._start_time - self._pause_offset
def elapsed_ms(self) -> float:
return self.elapsed() * 1000
def elapsed_frames(self, fps: float = 60.0) -> int:
return int(self.elapsed() * fps)
def pause(self) -> None:
if not self._paused:
self._paused = True
self._pause_start = time.perf_counter()
def resume(self) -> None:
if self._paused:
self._pause_offset += time.perf_counter() - self._pause_start
self._paused = False
class TriggerType(Enum):
TIME = auto() # Trigger after elapsed time
FRAME = auto() # Trigger after N frames
CYCLE = auto() # Trigger on cycle repeat
CONDITION = auto() # Trigger when condition is met
MANUAL = auto() # Trigger manually
@dataclass
class Trigger:
"""Event trigger configuration."""
type: TriggerType
value: float | int = 0
condition: Callable[["AnimationController"], bool] | None = None
repeat: bool = False
repeat_interval: float = 0.0
@dataclass
class Event:
"""An event with trigger, duration, and action."""
name: str
trigger: Trigger
action: Callable[["AnimationController", float], None]
duration: float = 0.0
ease: Callable[[float], float] | None = None
def __post_init__(self):
if self.ease is None:
self.ease = linear_ease
def linear_ease(t: float) -> float:
return t
def ease_in_out(t: float) -> float:
return t * t * (3 - 2 * t)
def ease_out_bounce(t: float) -> float:
if t < 1 / 2.75:
return 7.5625 * t * t
elif t < 2 / 2.75:
t -= 1.5 / 2.75
return 7.5625 * t * t + 0.75
elif t < 2.5 / 2.75:
t -= 2.25 / 2.75
return 7.5625 * t * t + 0.9375
else:
t -= 2.625 / 2.75
return 7.5625 * t * t + 0.984375
class AnimationController:
"""Controls animation parameters with clock and events."""
def __init__(self, fps: float = 60.0):
self.clock = Clock()
self.fps = fps
self.frame = 0
self._events: list[Event] = []
self._active_events: dict[str, float] = {}
self._params: dict[str, Any] = {}
self._cycled = 0
def add_event(self, event: Event) -> "AnimationController":
self._events.append(event)
return self
def set_param(self, key: str, value: Any) -> None:
self._params[key] = value
def get_param(self, key: str, default: Any = None) -> Any:
return self._params.get(key, default)
def update(self) -> dict[str, Any]:
"""Update animation state, return current params."""
elapsed = self.clock.elapsed()
for event in self._events:
triggered = False
if event.trigger.type == TriggerType.TIME:
if self.clock.elapsed() >= event.trigger.value:
triggered = True
elif event.trigger.type == TriggerType.FRAME:
if self.frame >= event.trigger.value:
triggered = True
elif event.trigger.type == TriggerType.CYCLE:
cycle_duration = event.trigger.value
if cycle_duration > 0:
current_cycle = int(elapsed / cycle_duration)
if current_cycle > self._cycled:
self._cycled = current_cycle
triggered = True
elif event.trigger.type == TriggerType.CONDITION:
if event.trigger.condition and event.trigger.condition(self):
triggered = True
elif event.trigger.type == TriggerType.MANUAL:
pass
if triggered:
if event.name not in self._active_events:
self._active_events[event.name] = 0.0
progress = 0.0
if event.duration > 0:
self._active_events[event.name] += 1 / self.fps
progress = min(
1.0, self._active_events[event.name] / event.duration
)
eased_progress = event.ease(progress)
event.action(self, eased_progress)
if progress >= 1.0:
if event.trigger.repeat:
self._active_events[event.name] = 0.0
else:
del self._active_events[event.name]
else:
event.action(self, 1.0)
if not event.trigger.repeat:
del self._active_events[event.name]
else:
self._active_events[event.name] = 0.0
self.frame += 1
return dict(self._params)
@dataclass
class PipelineParams:
"""Snapshot of pipeline parameters for animation."""
effect_enabled: dict[str, bool] = field(default_factory=dict)
effect_intensity: dict[str, float] = field(default_factory=dict)
camera_mode: str = "vertical"
camera_speed: float = 1.0
camera_x: int = 0
camera_y: int = 0
display_backend: str = "terminal"
scroll_speed: float = 1.0
class Preset:
"""Packages a starting pipeline config + Animation controller."""
def __init__(
self,
name: str,
description: str = "",
initial_params: PipelineParams | None = None,
animation: AnimationController | None = None,
):
self.name = name
self.description = description
self.initial_params = initial_params or PipelineParams()
self.animation = animation or AnimationController()
def create_controller(self) -> AnimationController:
controller = AnimationController()
for key, value in self.initial_params.__dict__.items():
controller.set_param(key, value)
for event in self.animation._events:
controller.add_event(event)
return controller
def create_demo_preset() -> Preset:
"""Create the demo preset with effect cycling and camera modes."""
animation = AnimationController(fps=60)
effects = ["noise", "fade", "glitch", "firehose"]
camera_modes = ["vertical", "horizontal", "omni", "floating", "trace"]
def make_effect_action(eff):
def action(ctrl, t):
ctrl.set_param("current_effect", eff)
ctrl.set_param("effect_intensity", t)
return action
def make_camera_action(cam_mode):
def action(ctrl, t):
ctrl.set_param("camera_mode", cam_mode)
return action
for i, effect in enumerate(effects):
effect_duration = 5.0
animation.add_event(
Event(
name=f"effect_{effect}",
trigger=Trigger(
type=TriggerType.TIME,
value=i * effect_duration,
repeat=True,
repeat_interval=len(effects) * effect_duration,
),
duration=effect_duration,
action=make_effect_action(effect),
ease=ease_in_out,
)
)
for i, mode in enumerate(camera_modes):
camera_duration = 10.0
animation.add_event(
Event(
name=f"camera_{mode}",
trigger=Trigger(
type=TriggerType.TIME,
value=i * camera_duration,
repeat=True,
repeat_interval=len(camera_modes) * camera_duration,
),
duration=0.5,
action=make_camera_action(mode),
)
)
animation.add_event(
Event(
name="pulse",
trigger=Trigger(type=TriggerType.CYCLE, value=2.0, repeat=True),
duration=1.0,
action=lambda ctrl, t: ctrl.set_param("pulse", t),
ease=ease_out_bounce,
)
)
return Preset(
name="demo",
description="Demo mode with effect cycling and camera modes",
initial_params=PipelineParams(
effect_enabled={
"noise": False,
"fade": False,
"glitch": False,
"firehose": False,
"hud": True,
},
effect_intensity={
"noise": 0.0,
"fade": 0.0,
"glitch": 0.0,
"firehose": 0.0,
},
camera_mode="vertical",
camera_speed=1.0,
display_backend="pygame",
),
animation=animation,
)
def create_pipeline_preset() -> Preset:
"""Create preset for pipeline visualization."""
animation = AnimationController(fps=60)
animation.add_event(
Event(
name="camera_trace",
trigger=Trigger(type=TriggerType.CYCLE, value=8.0, repeat=True),
duration=8.0,
action=lambda ctrl, t: ctrl.set_param("camera_mode", "trace"),
)
)
animation.add_event(
Event(
name="highlight_path",
trigger=Trigger(type=TriggerType.CYCLE, value=4.0, repeat=True),
duration=4.0,
action=lambda ctrl, t: ctrl.set_param("path_progress", t),
)
)
return Preset(
name="pipeline",
description="Pipeline visualization with trace camera",
initial_params=PipelineParams(
camera_mode="trace",
camera_speed=1.0,
display_backend="pygame",
),
animation=animation,
)