chore(pipeline): improve pipeline architecture

- Add capability-based dependency resolution with prefix matching
- Add EffectPluginStage with sensor binding support
- Add CameraStage adapter for camera integration
- Add DisplayStage adapter for display integration
- Add Pipeline metrics collection
- Add deprecation notices to legacy modules
- Update app.py with pipeline integration
This commit is contained in:
2026-03-16 13:56:22 -07:00
parent 1a42fca507
commit ce9d888cf5
11 changed files with 328 additions and 32 deletions

View File

@@ -352,7 +352,18 @@ def pick_effects_config():
def run_demo_mode(): def run_demo_mode():
"""Run demo mode - showcases effects and camera modes with real content.""" """Run demo mode - showcases effects and camera modes with real content.
.. deprecated::
This is legacy code. Use run_pipeline_mode() instead.
"""
import warnings
warnings.warn(
"run_demo_mode is deprecated. Use run_pipeline_mode() instead.",
DeprecationWarning,
stacklevel=2,
)
import random import random
from engine import config from engine import config
@@ -559,7 +570,18 @@ def run_demo_mode():
def run_pipeline_demo(): def run_pipeline_demo():
"""Run pipeline visualization demo mode - shows ASCII pipeline animation.""" """Run pipeline visualization demo mode - shows ASCII pipeline animation.
.. deprecated::
This demo mode uses legacy rendering. Use run_pipeline_mode() instead.
"""
import warnings
warnings.warn(
"run_pipeline_demo is deprecated. Use run_pipeline_mode() instead.",
DeprecationWarning,
stacklevel=2,
)
import time import time
from engine import config from engine import config
@@ -700,7 +722,18 @@ def run_pipeline_demo():
def run_preset_mode(preset_name: str): def run_preset_mode(preset_name: str):
"""Run mode using animation presets.""" """Run mode using animation presets.
.. deprecated::
Use run_pipeline_mode() with preset parameter instead.
"""
import warnings
warnings.warn(
"run_preset_mode is deprecated. Use run_pipeline_mode() instead.",
DeprecationWarning,
stacklevel=2,
)
from engine import config from engine import config
from engine.animation import ( from engine.animation import (
create_demo_preset, create_demo_preset,
@@ -839,28 +872,41 @@ def run_preset_mode(preset_name: str):
def main(): def main():
from engine import config from engine import config
from engine.pipeline import list_presets
# Show pipeline diagram if requested
if config.PIPELINE_DIAGRAM: if config.PIPELINE_DIAGRAM:
try:
from engine.pipeline import generate_pipeline_diagram from engine.pipeline import generate_pipeline_diagram
except ImportError:
print("Error: pipeline diagram not available")
return
print(generate_pipeline_diagram()) print(generate_pipeline_diagram())
return return
if config.PIPELINE_MODE: # Unified preset-based entry point
run_pipeline_mode(config.PIPELINE_PRESET) # All modes are now just presets
return preset_name = None
if config.PIPELINE_DEMO:
run_pipeline_demo()
return
# Check for --preset flag first
if config.PRESET: if config.PRESET:
run_preset_mode(config.PRESET) preset_name = config.PRESET
return # Check for legacy --pipeline flag (mapped to demo preset)
elif config.PIPELINE_MODE:
preset_name = config.PIPELINE_PRESET
# Default to demo if no preset specified
else:
preset_name = "demo"
if config.DEMO: # Validate preset exists
run_demo_mode() available = list_presets()
return if preset_name not in available:
print(f"Error: Unknown preset '{preset_name}'")
print(f"Available presets: {', '.join(available)}")
sys.exit(1)
# Run with the selected preset
run_pipeline_mode(preset_name)
atexit.register(lambda: print(CURSOR_ON, end="", flush=True)) atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
@@ -1079,6 +1125,11 @@ def run_pipeline_mode(preset_name: str = "demo"):
if result.success: if result.success:
display.show(result.data) display.show(result.data)
if hasattr(display, "is_quit_requested") and display.is_quit_requested():
if hasattr(display, "clear_quit_request"):
display.clear_quit_request()
raise KeyboardInterrupt()
time.sleep(1 / 60) time.sleep(1 / 60)
frame += 1 frame += 1

View File

@@ -26,8 +26,20 @@ class Display(Protocol):
- clear(): Clear the display - clear(): Clear the display
- cleanup(): Shutdown the display - cleanup(): Shutdown the display
Optional methods for keyboard input:
- is_quit_requested(): Returns True if user pressed Ctrl+C/Q or Escape
- clear_quit_request(): Clears the quit request flag
The reuse flag allows attaching to an existing display instance The reuse flag allows attaching to an existing display instance
rather than creating a new window/connection. rather than creating a new window/connection.
Keyboard input support by backend:
- terminal: No native input (relies on signal handler for Ctrl+C)
- pygame: Supports Ctrl+C, Ctrl+Q, Escape for graceful shutdown
- websocket: No native input (relies on signal handler for Ctrl+C)
- sixel: No native input (relies on signal handler for Ctrl+C)
- null: No native input
- kitty: Supports Ctrl+C, Ctrl+Q, Escape (via pygame-like handling)
""" """
width: int width: int

View File

@@ -37,6 +37,7 @@ class PygameDisplay:
self._screen = None self._screen = None
self._font = None self._font = None
self._resized = False self._resized = False
self._quit_requested = False
def _get_font_path(self) -> str | None: def _get_font_path(self) -> str | None:
"""Get font path for rendering.""" """Get font path for rendering."""
@@ -130,8 +131,6 @@ class PygameDisplay:
self._initialized = True self._initialized = True
def show(self, buffer: list[str]) -> None: def show(self, buffer: list[str]) -> None:
import sys
if not self._initialized or not self._pygame: if not self._initialized or not self._pygame:
return return
@@ -139,7 +138,15 @@ class PygameDisplay:
for event in self._pygame.event.get(): for event in self._pygame.event.get():
if event.type == self._pygame.QUIT: if event.type == self._pygame.QUIT:
sys.exit(0) self._quit_requested = True
elif event.type == self._pygame.KEYDOWN:
if event.key in (self._pygame.K_ESCAPE, self._pygame.K_c):
if event.key == self._pygame.K_c and not (
event.mod & self._pygame.KMOD_LCTRL
or event.mod & self._pygame.KMOD_RCTRL
):
continue
self._quit_requested = True
elif event.type == self._pygame.VIDEORESIZE: elif event.type == self._pygame.VIDEORESIZE:
self.window_width = event.w self.window_width = event.w
self.window_height = event.h self.window_height = event.h
@@ -210,3 +217,20 @@ class PygameDisplay:
def reset_state(cls) -> None: def reset_state(cls) -> None:
"""Reset pygame state - useful for testing.""" """Reset pygame state - useful for testing."""
cls._pygame_initialized = False cls._pygame_initialized = False
def is_quit_requested(self) -> bool:
"""Check if user requested quit (Ctrl+C, Ctrl+Q, or Escape).
Returns True if the user pressed Ctrl+C, Ctrl+Q, or Escape.
The main loop should check this and raise KeyboardInterrupt.
"""
return self._quit_requested
def clear_quit_request(self) -> bool:
"""Clear the quit request flag after handling.
Returns the previous quit request state.
"""
was_requested = self._quit_requested
self._quit_requested = False
return was_requested

View File

@@ -35,6 +35,26 @@ class EffectContext:
frame_number: int = 0 frame_number: int = 0
has_message: bool = False has_message: bool = False
items: list = field(default_factory=list) items: list = field(default_factory=list)
_state: dict[str, Any] = field(default_factory=dict, repr=False)
def get_sensor_value(self, sensor_name: str) -> float | None:
"""Get a sensor value from context state.
Args:
sensor_name: Name of the sensor (e.g., "mic", "camera")
Returns:
Sensor value as float, or None if not available.
"""
return self._state.get(f"sensor.{sensor_name}")
def set_state(self, key: str, value: Any) -> None:
"""Set a state value in the context."""
self._state[key] = value
def get_state(self, key: str, default: Any = None) -> Any:
"""Get a state value from the context."""
return self._state.get(key, default)
@dataclass @dataclass
@@ -51,6 +71,14 @@ class EffectPlugin(ABC):
- name: str - unique identifier for the effect - name: str - unique identifier for the effect
- config: EffectConfig - current configuration - config: EffectConfig - current configuration
Optional class attribute:
- param_bindings: dict - Declarative sensor-to-param bindings
Example:
param_bindings = {
"intensity": {"sensor": "mic", "transform": "linear"},
"rate": {"sensor": "mic", "transform": "exponential"},
}
And implement: And implement:
- process(buf, ctx) -> list[str] - process(buf, ctx) -> list[str]
- configure(config) -> None - configure(config) -> None
@@ -63,10 +91,16 @@ class EffectPlugin(ABC):
Effects should handle missing or zero context values gracefully by Effects should handle missing or zero context values gracefully by
returning the input buffer unchanged rather than raising errors. returning the input buffer unchanged rather than raising errors.
The param_bindings system enables PureData-style signal routing:
- Sensors emit values that effects can bind to
- Transform functions map sensor values to param ranges
- Effects read bound values from context.state["sensor.{name}"]
""" """
name: str name: str
config: EffectConfig config: EffectConfig
param_bindings: dict[str, dict[str, str | float]] = {}
@abstractmethod @abstractmethod
def process(self, buf: list[str], ctx: EffectContext) -> list[str]: def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
@@ -120,3 +154,58 @@ def create_effect_context(
class PipelineConfig: class PipelineConfig:
order: list[str] = field(default_factory=list) order: list[str] = field(default_factory=list)
effects: dict[str, EffectConfig] = field(default_factory=dict) effects: dict[str, EffectConfig] = field(default_factory=dict)
def apply_param_bindings(
effect: "EffectPlugin",
ctx: EffectContext,
) -> EffectConfig:
"""Apply sensor bindings to effect config.
This resolves param_bindings declarations by reading sensor values
from the context and applying transform functions.
Args:
effect: The effect with param_bindings to apply
ctx: EffectContext containing sensor values
Returns:
Modified EffectConfig with sensor-driven values applied.
"""
import copy
if not effect.param_bindings:
return effect.config
config = copy.deepcopy(effect.config)
for param_name, binding in effect.param_bindings.items():
sensor_name: str = binding.get("sensor", "")
transform: str = binding.get("transform", "linear")
if not sensor_name:
continue
sensor_value = ctx.get_sensor_value(sensor_name)
if sensor_value is None:
continue
if transform == "linear":
applied_value: float = sensor_value
elif transform == "exponential":
applied_value = sensor_value**2
elif transform == "threshold":
threshold = float(binding.get("threshold", 0.5))
applied_value = 1.0 if sensor_value > threshold else 0.0
elif transform == "inverse":
applied_value = 1.0 - sensor_value
else:
applied_value = sensor_value
config.params[f"{param_name}_sensor"] = applied_value
if param_name == "intensity":
base_intensity = effect.config.intensity
config.intensity = base_intensity * (0.5 + applied_value * 0.5)
return config

View File

@@ -1,6 +1,11 @@
""" """
Layer compositing — message overlay, ticker zone, firehose, noise. Layer compositing — message overlay, ticker zone, firehose, noise.
Depends on: config, render, effects. Depends on: config, render, effects.
.. deprecated::
This module contains legacy rendering code. New pipeline code should
use the Stage-based pipeline architecture instead. This module is
maintained for backwards compatibility with the demo mode.
""" """
import random import random

View File

@@ -1,6 +1,10 @@
""" """
Microphone input monitor — standalone, no internal dependencies. Microphone input monitor — standalone, no internal dependencies.
Gracefully degrades if sounddevice/numpy are unavailable. Gracefully degrades if sounddevice/numpy are unavailable.
.. deprecated::
For pipeline integration, use :class:`engine.sensors.mic.MicSensor` instead.
MicMonitor is still used as the backend for MicSensor.
""" """
import atexit import atexit
@@ -20,7 +24,11 @@ from engine.events import MicLevelEvent
class MicMonitor: class MicMonitor:
"""Background mic stream that exposes current RMS dB level.""" """Background mic stream that exposes current RMS dB level.
.. deprecated::
For pipeline integration, use :class:`engine.sensors.mic.MicSensor` instead.
"""
def __init__(self, threshold_db=50): def __init__(self, threshold_db=50):
self.threshold_db = threshold_db self.threshold_db = threshold_db

View File

@@ -56,7 +56,7 @@ class RenderStage(Stage):
@property @property
def dependencies(self) -> set[str]: def dependencies(self) -> set[str]:
return {"source.items"} return {"source"}
def init(self, ctx: PipelineContext) -> bool: def init(self, ctx: PipelineContext) -> bool:
random.shuffle(self._pool) random.shuffle(self._pool)
@@ -142,7 +142,7 @@ class EffectPluginStage(Stage):
"""Process data through the effect.""" """Process data through the effect."""
if data is None: if data is None:
return None return None
from engine.effects import EffectContext from engine.effects.types import EffectContext, apply_param_bindings
w = ctx.params.viewport_width if ctx.params else 80 w = ctx.params.viewport_width if ctx.params else 80
h = ctx.params.viewport_height if ctx.params else 24 h = ctx.params.viewport_height if ctx.params else 24
@@ -160,6 +160,17 @@ class EffectPluginStage(Stage):
has_message=False, has_message=False,
items=ctx.get("items", []), items=ctx.get("items", []),
) )
# Copy sensor state from PipelineContext to EffectContext
for key, value in ctx.state.items():
if key.startswith("sensor."):
effect_ctx.set_state(key, value)
# Apply sensor param bindings if effect has them
if hasattr(self._effect, "param_bindings") and self._effect.param_bindings:
bound_config = apply_param_bindings(self._effect, effect_ctx)
self._effect.configure(bound_config)
return self._effect.process(data, effect_ctx) return self._effect.process(data, effect_ctx)
@@ -221,9 +232,21 @@ class DataSourceStage(Stage):
class ItemsStage(Stage): class ItemsStage(Stage):
"""Stage that holds pre-fetched items and provides them to the pipeline.""" """Stage that holds pre-fetched items and provides them to the pipeline.
.. deprecated::
Use DataSourceStage with a proper DataSource instead.
ItemsStage is a legacy bootstrap mechanism.
"""
def __init__(self, items, name: str = "headlines"): def __init__(self, items, name: str = "headlines"):
import warnings
warnings.warn(
"ItemsStage is deprecated. Use DataSourceStage with a DataSource instead.",
DeprecationWarning,
stacklevel=2,
)
self._items = items self._items = items
self.name = name self.name = name
self.category = "source" self.category = "source"

View File

@@ -83,12 +83,60 @@ class Pipeline:
def build(self) -> "Pipeline": def build(self) -> "Pipeline":
"""Build execution order based on dependencies.""" """Build execution order based on dependencies."""
self._capability_map = self._build_capability_map()
self._execution_order = self._resolve_dependencies() self._execution_order = self._resolve_dependencies()
self._validate_dependencies()
self._initialized = True self._initialized = True
return self return self
def _build_capability_map(self) -> dict[str, list[str]]:
"""Build a map of capabilities to stage names.
Returns:
Dict mapping capability -> list of stage names that provide it
"""
capability_map: dict[str, list[str]] = {}
for name, stage in self._stages.items():
for cap in stage.capabilities:
if cap not in capability_map:
capability_map[cap] = []
capability_map[cap].append(name)
return capability_map
def _find_stage_with_capability(self, capability: str) -> str | None:
"""Find a stage that provides the given capability.
Supports wildcard matching:
- "source" matches "source.headlines" (prefix match)
- "source.*" matches "source.headlines"
- "source.headlines" matches exactly
Args:
capability: The capability to find
Returns:
Stage name that provides the capability, or None if not found
"""
# Exact match
if capability in self._capability_map:
return self._capability_map[capability][0]
# Prefix match (e.g., "source" -> "source.headlines")
for cap, stages in self._capability_map.items():
if cap.startswith(capability + "."):
return stages[0]
# Wildcard match (e.g., "source.*" -> "source.headlines")
if ".*" in capability:
prefix = capability[:-2] # Remove ".*"
for cap in self._capability_map:
if cap.startswith(prefix + "."):
return self._capability_map[cap][0]
return None
def _resolve_dependencies(self) -> list[str]: def _resolve_dependencies(self) -> list[str]:
"""Resolve stage execution order using topological sort.""" """Resolve stage execution order using topological sort with capability matching."""
ordered = [] ordered = []
visited = set() visited = set()
temp_mark = set() temp_mark = set()
@@ -103,9 +151,10 @@ class Pipeline:
stage = self._stages.get(name) stage = self._stages.get(name)
if stage: if stage:
for dep in stage.dependencies: for dep in stage.dependencies:
dep_stage = self._stages.get(dep) # Find a stage that provides this capability
if dep_stage: dep_stage_name = self._find_stage_with_capability(dep)
visit(dep) if dep_stage_name:
visit(dep_stage_name)
temp_mark.remove(name) temp_mark.remove(name)
visited.add(name) visited.add(name)
@@ -117,6 +166,25 @@ class Pipeline:
return ordered return ordered
def _validate_dependencies(self) -> None:
"""Validate that all dependencies can be satisfied.
Raises StageError if any dependency cannot be resolved.
"""
missing: list[tuple[str, str]] = [] # (stage_name, capability)
for name, stage in self._stages.items():
for dep in stage.dependencies:
if not self._find_stage_with_capability(dep):
missing.append((name, dep))
if missing:
msgs = [f" - {stage} needs {cap}" for stage, cap in missing]
raise StageError(
"validation",
"Missing capabilities:\n" + "\n".join(msgs),
)
def initialize(self) -> bool: def initialize(self) -> bool:
"""Initialize all stages in execution order.""" """Initialize all stages in execution order."""
for name in self._execution_order: for name in self._execution_order:

View File

@@ -6,18 +6,25 @@ Provides a single registry for sources, effects, displays, and cameras.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any, TypeVar
from engine.pipeline.core import Stage from engine.pipeline.core import Stage
if TYPE_CHECKING:
from engine.pipeline.core import Stage
T = TypeVar("T")
class StageRegistry: class StageRegistry:
"""Unified registry for all pipeline stage types.""" """Unified registry for all pipeline stage types."""
_categories: dict[str, dict[str, type[Stage]]] = {} _categories: dict[str, dict[str, type[Any]]] = {}
_discovered: bool = False _discovered: bool = False
_instances: dict[str, Stage] = {} _instances: dict[str, Stage] = {}
@classmethod @classmethod
def register(cls, category: str, stage_class: type[Stage]) -> None: def register(cls, category: str, stage_class: type[Any]) -> None:
"""Register a stage class in a category. """Register a stage class in a category.
Args: Args:
@@ -27,12 +34,11 @@ class StageRegistry:
if category not in cls._categories: if category not in cls._categories:
cls._categories[category] = {} cls._categories[category] = {}
# Use class name as key
key = getattr(stage_class, "__name__", stage_class.__class__.__name__) key = getattr(stage_class, "__name__", stage_class.__class__.__name__)
cls._categories[category][key] = stage_class cls._categories[category][key] = stage_class
@classmethod @classmethod
def get(cls, category: str, name: str) -> type[Stage] | None: def get(cls, category: str, name: str) -> type[Any] | None:
"""Get a stage class by category and name.""" """Get a stage class by category and name."""
return cls._categories.get(category, {}).get(name) return cls._categories.get(category, {}).get(name)

View File

@@ -2,6 +2,11 @@
OTF → terminal half-block rendering pipeline. OTF → terminal half-block rendering pipeline.
Font loading, text rasterization, word-wrap, gradient coloring, headline block assembly. Font loading, text rasterization, word-wrap, gradient coloring, headline block assembly.
Depends on: config, terminal, sources, translate. Depends on: config, terminal, sources, translate.
.. deprecated::
This module contains legacy rendering code. New pipeline code should
use the Stage-based pipeline architecture instead. This module is
maintained for backwards compatibility with the demo mode.
""" """
import random import random

View File

@@ -1,6 +1,11 @@
""" """
Render engine — ticker content, scroll motion, message panel, and firehose overlay. Render engine — ticker content, scroll motion, message panel, and firehose overlay.
Orchestrates viewport, frame timing, and layers. Orchestrates viewport, frame timing, and layers.
.. deprecated::
This module contains legacy rendering/orchestration code. New pipeline code should
use the Stage-based pipeline architecture instead. This module is
maintained for backwards compatibility with the demo mode.
""" """
import random import random