forked from genewildish/Mainline
Implement a Read-Eval-Print Loop (REPL) effect that provides a HUD-style overlay for interactive pipeline control. ## New Files - engine/effects/plugins/repl.py - REPL effect plugin with command processor - engine/display/backends/terminal.py - Added raw mode and input handling - examples/repl_simple.py - Simple demonstration script - tests/test_repl_effect.py - 18 comprehensive tests ## Features ### REPL Interface - HUD-style overlay showing FPS, command history, output buffer size - Command history navigation (Up/Down arrows) - Command execution (Enter) - Character input and backspace support - Output buffer with scrolling ### Commands - help - Show available commands - status - Show pipeline status and metrics - effects - List all effects in pipeline - effect <name> <on|off> - Toggle effect - param <effect> <param> <value> - Set parameter - pipeline - Show current pipeline order - clear - Clear output buffer - quit - Show exit message ### Terminal Input Support - Added set_raw_mode() to TerminalDisplay for capturing keystrokes - Added get_input_keys() to read keyboard input - Proper terminal state restoration on cleanup ## Usage Add 'repl' to effects in your configuration: ## Testing All 18 REPL tests pass, covering: - Effect registration - Command processing - Navigation (history, editing) - Configuration - Rendering ## Integration The REPL effect integrates with the existing pipeline system: - Uses EffectPlugin interface - Supports partial updates - Reads metrics from EffectContext - Can be controlled via keyboard when terminal display is in raw mode Next steps: - Integrate REPL input handling into pipeline_runner.py - Add keyboard event processing loop - Create full demo with interactive features
283 lines
8.4 KiB
Python
283 lines
8.4 KiB
Python
"""Hybrid Preset-Graph Configuration System
|
|
|
|
This module provides a configuration format that combines the simplicity
|
|
of presets with the flexibility of graphs.
|
|
|
|
Example:
|
|
[pipeline]
|
|
source = "headlines"
|
|
camera = { mode = "scroll", speed = 1.0 }
|
|
effects = [
|
|
{ name = "noise", intensity = 0.3 },
|
|
{ name = "fade", intensity = 0.5 }
|
|
]
|
|
display = { backend = "terminal" }
|
|
|
|
This is much more concise than the verbose node-based graph DSL while
|
|
providing the same flexibility.
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Optional
|
|
from pathlib import Path
|
|
|
|
from engine.pipeline.graph import Graph, NodeType
|
|
from engine.pipeline.graph_adapter import graph_to_pipeline
|
|
|
|
|
|
@dataclass
|
|
class EffectConfig:
|
|
"""Configuration for a single effect."""
|
|
|
|
name: str
|
|
intensity: float = 1.0
|
|
enabled: bool = True
|
|
params: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class CameraConfig:
|
|
"""Configuration for camera."""
|
|
|
|
mode: str = "scroll"
|
|
speed: float = 1.0
|
|
|
|
|
|
@dataclass
|
|
class DisplayConfig:
|
|
"""Configuration for display."""
|
|
|
|
backend: str = "terminal"
|
|
positioning: str = "mixed"
|
|
|
|
|
|
@dataclass
|
|
class PipelineConfig:
|
|
"""Hybrid pipeline configuration combining preset simplicity with graph flexibility.
|
|
|
|
This format provides a concise way to define pipelines that's 70% smaller
|
|
than the verbose node-based DSL while maintaining full flexibility.
|
|
|
|
Example:
|
|
[pipeline]
|
|
source = "headlines"
|
|
camera = { mode = "scroll", speed = 1.0 }
|
|
effects = [
|
|
{ name = "noise", intensity = 0.3 },
|
|
{ name = "fade", intensity = 0.5 }
|
|
]
|
|
display = { backend = "terminal", positioning = "mixed" }
|
|
"""
|
|
|
|
source: str = "headlines"
|
|
camera: Optional[CameraConfig] = None
|
|
effects: List[EffectConfig] = field(default_factory=list)
|
|
display: Optional[DisplayConfig] = None
|
|
viewport_width: int = 80
|
|
viewport_height: int = 24
|
|
|
|
@classmethod
|
|
def from_preset(cls, preset_name: str) -> "PipelineConfig":
|
|
"""Create PipelineConfig from a preset name.
|
|
|
|
Args:
|
|
preset_name: Name of preset (e.g., "upstream-default")
|
|
|
|
Returns:
|
|
PipelineConfig instance
|
|
"""
|
|
from engine.pipeline import get_preset
|
|
|
|
preset = get_preset(preset_name)
|
|
if not preset:
|
|
raise ValueError(f"Preset '{preset_name}' not found")
|
|
|
|
# Convert preset to PipelineConfig
|
|
effects = [EffectConfig(name=e, intensity=1.0) for e in preset.effects]
|
|
|
|
return cls(
|
|
source=preset.source,
|
|
camera=CameraConfig(mode=preset.camera, speed=preset.camera_speed),
|
|
effects=effects,
|
|
display=DisplayConfig(
|
|
backend=preset.display, positioning=preset.positioning
|
|
),
|
|
viewport_width=preset.viewport_width,
|
|
viewport_height=preset.viewport_height,
|
|
)
|
|
|
|
def to_graph(self) -> Graph:
|
|
"""Convert hybrid config to Graph representation."""
|
|
graph = Graph()
|
|
|
|
# Add source node
|
|
graph.node("source", NodeType.SOURCE, source=self.source)
|
|
|
|
# Add camera node if configured
|
|
if self.camera:
|
|
graph.node(
|
|
"camera",
|
|
NodeType.CAMERA,
|
|
mode=self.camera.mode,
|
|
speed=self.camera.speed,
|
|
)
|
|
|
|
# Add effect nodes
|
|
for effect in self.effects:
|
|
# Handle both EffectConfig objects and dictionaries
|
|
if isinstance(effect, dict):
|
|
name = effect.get("name", "")
|
|
intensity = effect.get("intensity", 1.0)
|
|
enabled = effect.get("enabled", True)
|
|
params = effect.get("params", {})
|
|
else:
|
|
name = effect.name
|
|
intensity = effect.intensity
|
|
enabled = effect.enabled
|
|
params = effect.params
|
|
|
|
if name:
|
|
graph.node(
|
|
name,
|
|
NodeType.EFFECT,
|
|
effect=name,
|
|
intensity=intensity,
|
|
enabled=enabled,
|
|
**params,
|
|
)
|
|
|
|
# Add display node
|
|
if isinstance(self.display, dict):
|
|
display_backend = self.display.get("backend", "terminal")
|
|
display_positioning = self.display.get("positioning", "mixed")
|
|
elif self.display:
|
|
display_backend = self.display.backend
|
|
display_positioning = self.display.positioning
|
|
else:
|
|
display_backend = "terminal"
|
|
display_positioning = "mixed"
|
|
|
|
graph.node(
|
|
"display",
|
|
NodeType.DISPLAY,
|
|
backend=display_backend,
|
|
positioning=display_positioning,
|
|
)
|
|
|
|
# Create linear connections
|
|
# Build chain: source -> camera -> effects... -> display
|
|
chain = ["source"]
|
|
|
|
if self.camera:
|
|
chain.append("camera")
|
|
|
|
# Add all effects in order
|
|
for effect in self.effects:
|
|
name = effect.get("name", "") if isinstance(effect, dict) else effect.name
|
|
if name:
|
|
chain.append(name)
|
|
|
|
chain.append("display")
|
|
|
|
# Connect all nodes in chain
|
|
for i in range(len(chain) - 1):
|
|
graph.connect(chain[i], chain[i + 1])
|
|
|
|
return graph
|
|
|
|
def to_pipeline(self, viewport_width: int = 80, viewport_height: int = 24):
|
|
"""Convert to Pipeline instance."""
|
|
graph = self.to_graph()
|
|
return graph_to_pipeline(graph, viewport_width, viewport_height)
|
|
|
|
|
|
def load_hybrid_config(toml_path: str | Path) -> PipelineConfig:
|
|
"""Load hybrid configuration from TOML file.
|
|
|
|
Args:
|
|
toml_path: Path to TOML file
|
|
|
|
Returns:
|
|
PipelineConfig instance
|
|
"""
|
|
import tomllib
|
|
|
|
with open(toml_path, "rb") as f:
|
|
data = tomllib.load(f)
|
|
|
|
return parse_hybrid_config(data)
|
|
|
|
|
|
def parse_hybrid_config(data: Dict[str, Any]) -> PipelineConfig:
|
|
"""Parse hybrid configuration from dictionary.
|
|
|
|
Expected format:
|
|
{
|
|
"pipeline": {
|
|
"source": "headlines",
|
|
"camera": {"mode": "scroll", "speed": 1.0},
|
|
"effects": [
|
|
{"name": "noise", "intensity": 0.3},
|
|
{"name": "fade", "intensity": 0.5}
|
|
],
|
|
"display": {"backend": "terminal"}
|
|
}
|
|
}
|
|
"""
|
|
pipeline_data = data.get("pipeline", {})
|
|
|
|
# Parse camera config
|
|
camera = None
|
|
if "camera" in pipeline_data:
|
|
camera_data = pipeline_data["camera"]
|
|
if isinstance(camera_data, dict):
|
|
camera = CameraConfig(
|
|
mode=camera_data.get("mode", "scroll"),
|
|
speed=camera_data.get("speed", 1.0),
|
|
)
|
|
elif isinstance(camera_data, str):
|
|
camera = CameraConfig(mode=camera_data)
|
|
|
|
# Parse effects list
|
|
effects = []
|
|
if "effects" in pipeline_data:
|
|
effects_data = pipeline_data["effects"]
|
|
if isinstance(effects_data, list):
|
|
for effect_item in effects_data:
|
|
if isinstance(effect_item, dict):
|
|
effects.append(
|
|
EffectConfig(
|
|
name=effect_item.get("name", ""),
|
|
intensity=effect_item.get("intensity", 1.0),
|
|
enabled=effect_item.get("enabled", True),
|
|
params=effect_item.get("params", {}),
|
|
)
|
|
)
|
|
elif isinstance(effect_item, str):
|
|
effects.append(EffectConfig(name=effect_item))
|
|
|
|
# Parse display config
|
|
display = None
|
|
if "display" in pipeline_data:
|
|
display_data = pipeline_data["display"]
|
|
if isinstance(display_data, dict):
|
|
display = DisplayConfig(
|
|
backend=display_data.get("backend", "terminal"),
|
|
positioning=display_data.get("positioning", "mixed"),
|
|
)
|
|
elif isinstance(display_data, str):
|
|
display = DisplayConfig(backend=display_data)
|
|
|
|
# Parse viewport settings
|
|
viewport_width = pipeline_data.get("viewport_width", 80)
|
|
viewport_height = pipeline_data.get("viewport_height", 24)
|
|
|
|
return PipelineConfig(
|
|
source=pipeline_data.get("source", "headlines"),
|
|
camera=camera,
|
|
effects=effects,
|
|
display=display,
|
|
viewport_width=viewport_width,
|
|
viewport_height=viewport_height,
|
|
)
|