feat(integration): Complete feature rewrite with pipeline architecture, effects system, and display improvements

Major changes:
- Pipeline architecture with capability-based dependency resolution
- Effects plugin system with performance monitoring
- Display abstraction with multiple backends (terminal, null, websocket)
- Camera system for viewport scrolling
- Sensor framework for real-time input
- Command-and-control system via ntfy
- WebSocket display backend for browser clients
- Comprehensive test suite and documentation

Issue #48: ADR for preset scripting language included

This commit consolidates 110 individual commits into a single
feature integration that can be reviewed and tested before
further refinement.
This commit is contained in:
2026-03-20 04:41:23 -07:00
parent 42aa6f16cc
commit ef98add0c5
179 changed files with 27649 additions and 6552 deletions

View File

@@ -0,0 +1,174 @@
"""
Frame buffer stage - stores previous frames for temporal effects.
Provides (per-instance, using instance name):
- framebuffer.{name}.history: list of previous buffers (most recent first)
- framebuffer.{name}.intensity_history: list of corresponding intensity maps
- framebuffer.{name}.current_intensity: intensity map for current frame
Capability: "framebuffer.history.{name}"
"""
import threading
from dataclasses import dataclass
from typing import Any
from engine.display import _strip_ansi
from engine.pipeline.core import DataType, PipelineContext, Stage
@dataclass
class FrameBufferConfig:
"""Configuration for FrameBufferStage."""
history_depth: int = 2 # Number of previous frames to keep
name: str = "default" # Unique instance name for capability and context keys
class FrameBufferStage(Stage):
"""Stores frame history and computes intensity maps.
Supports multiple instances with unique capabilities and context keys.
"""
name = "framebuffer"
category = "effect" # It's an effect that enriches context with frame history
def __init__(
self,
config: FrameBufferConfig | None = None,
history_depth: int = 2,
name: str = "default",
):
self.config = config or FrameBufferConfig(
history_depth=history_depth, name=name
)
self._lock = threading.Lock()
@property
def capabilities(self) -> set[str]:
return {f"framebuffer.history.{self.config.name}"}
@property
def dependencies(self) -> set[str]:
# Depends on rendered output (since we want to capture final buffer)
return {"render.output"}
@property
def inlet_types(self) -> set:
return {DataType.TEXT_BUFFER}
@property
def outlet_types(self) -> set:
return {DataType.TEXT_BUFFER} # Pass through unchanged
def init(self, ctx: PipelineContext) -> bool:
"""Initialize framebuffer state in context."""
prefix = f"framebuffer.{self.config.name}"
ctx.set(f"{prefix}.history", [])
ctx.set(f"{prefix}.intensity_history", [])
return True
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Store frame in history and compute intensity.
Args:
data: Current text buffer (list[str])
ctx: Pipeline context
Returns:
Same buffer (pass-through)
"""
if not isinstance(data, list):
return data
prefix = f"framebuffer.{self.config.name}"
# Compute intensity map for current buffer (per-row, length = buffer rows)
intensity_map = self._compute_buffer_intensity(data, len(data))
# Store in context
ctx.set(f"{prefix}.current_intensity", intensity_map)
with self._lock:
# Get existing histories
history = ctx.get(f"{prefix}.history", [])
intensity_hist = ctx.get(f"{prefix}.intensity_history", [])
# Prepend current frame to history
history.insert(0, data.copy())
intensity_hist.insert(0, intensity_map)
# Trim to configured depth
max_depth = self.config.history_depth
ctx.set(f"{prefix}.history", history[:max_depth])
ctx.set(f"{prefix}.intensity_history", intensity_hist[:max_depth])
return data
def _compute_buffer_intensity(
self, buf: list[str], max_rows: int = 24
) -> list[float]:
"""Compute average intensity per row in buffer.
Uses ANSI color if available; falls back to character density.
Args:
buf: Text buffer (list of strings)
max_rows: Maximum number of rows to process
Returns:
List of intensity values (0.0-1.0) per row
"""
intensities = []
# Limit to viewport height
lines = buf[:max_rows]
for line in lines:
# Strip ANSI codes for length calc
plain = _strip_ansi(line)
if not plain:
intensities.append(0.0)
continue
# Simple heuristic: ratio of non-space characters
# More sophisticated version could parse ANSI RGB brightness
filled = sum(1 for c in plain if c not in (" ", "\t"))
total = len(plain)
intensity = filled / total if total > 0 else 0.0
intensities.append(max(0.0, min(1.0, intensity)))
# Pad to max_rows if needed
while len(intensities) < max_rows:
intensities.append(0.0)
return intensities
def get_frame(
self, index: int = 0, ctx: PipelineContext | None = None
) -> list[str] | None:
"""Get frame from history by index (0 = current, 1 = previous, etc)."""
if ctx is None:
return None
prefix = f"framebuffer.{self.config.name}"
history = ctx.get(f"{prefix}.history", [])
if 0 <= index < len(history):
return history[index]
return None
def get_intensity(
self, index: int = 0, ctx: PipelineContext | None = None
) -> list[float] | None:
"""Get intensity map from history by index."""
if ctx is None:
return None
prefix = f"framebuffer.{self.config.name}"
intensity_hist = ctx.get(f"{prefix}.intensity_history", [])
if 0 <= index < len(intensity_hist):
return intensity_hist[index]
return None
def cleanup(self) -> None:
"""Cleanup resources."""
pass