forked from genewildish/Mainline
feat(pipeline): improve new pipeline architecture
- Add TransformDataSource for filtering/mapping source items - Add MetricsDataSource for rendering live pipeline metrics as ASCII art - Fix display stage registration in StageRegistry - Register sources with both class name and simple name aliases - Fix DisplayStage.init() to pass reuse parameter - Simplify create_default_pipeline to use DataSourceStage wrapper - Set pygame as default display - Remove old pipeline tasks from mise.toml - Add tests for new pipeline architecture
This commit is contained in:
@@ -129,7 +129,7 @@ class Config:
|
||||
|
||||
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
||||
|
||||
display: str = "terminal"
|
||||
display: str = "pygame"
|
||||
websocket: bool = False
|
||||
websocket_port: int = 8765
|
||||
|
||||
@@ -237,7 +237,7 @@ GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||
|
||||
# ─── WEBSOCKET ─────────────────────────────────────────────
|
||||
DISPLAY = _arg_value("--display", sys.argv) or "terminal"
|
||||
DISPLAY = _arg_value("--display", sys.argv) or "pygame"
|
||||
WEBSOCKET = "--websocket" in sys.argv
|
||||
WEBSOCKET_PORT = _arg_int("--websocket-port", 8765)
|
||||
|
||||
|
||||
@@ -183,7 +183,7 @@ class DisplayStage(Stage):
|
||||
def init(self, ctx: PipelineContext) -> bool:
|
||||
w = ctx.params.viewport_width if ctx.params else 80
|
||||
h = ctx.params.viewport_height if ctx.params else 24
|
||||
result = self._display.init(w, h)
|
||||
result = self._display.init(w, h, reuse=False)
|
||||
return result is not False
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
|
||||
@@ -303,18 +303,14 @@ def create_pipeline_from_params(params: PipelineParams) -> Pipeline:
|
||||
|
||||
def create_default_pipeline() -> Pipeline:
|
||||
"""Create a default pipeline with all standard components."""
|
||||
from engine.pipeline.adapters import DataSourceStage
|
||||
from engine.sources_v2 import HeadlinesDataSource
|
||||
|
||||
pipeline = Pipeline()
|
||||
|
||||
# Add source stage
|
||||
source = StageRegistry.create("source", "headlines")
|
||||
if source:
|
||||
pipeline.add_stage("source", source)
|
||||
|
||||
# Add effect stages
|
||||
for effect_name in ["noise", "fade", "glitch", "firehose", "hud"]:
|
||||
effect = StageRegistry.create("effect", effect_name)
|
||||
if effect:
|
||||
pipeline.add_stage(f"effect_{effect_name}", effect)
|
||||
# Add source stage (wrapped as Stage)
|
||||
source = HeadlinesDataSource()
|
||||
pipeline.add_stage("source", DataSourceStage(source, name="headlines"))
|
||||
|
||||
# Add display stage
|
||||
display = StageRegistry.create("display", "terminal")
|
||||
|
||||
@@ -28,7 +28,7 @@ class StageRegistry:
|
||||
cls._categories[category] = {}
|
||||
|
||||
# Use class name as key
|
||||
key = stage_class.__name__
|
||||
key = getattr(stage_class, "__name__", stage_class.__class__.__name__)
|
||||
cls._categories[category][key] = stage_class
|
||||
|
||||
@classmethod
|
||||
@@ -90,6 +90,10 @@ def discover_stages() -> None:
|
||||
StageRegistry.register("source", HeadlinesDataSource)
|
||||
StageRegistry.register("source", PoetryDataSource)
|
||||
StageRegistry.register("source", PipelineDataSource)
|
||||
|
||||
StageRegistry._categories["source"]["headlines"] = HeadlinesDataSource
|
||||
StageRegistry._categories["source"]["poetry"] = PoetryDataSource
|
||||
StageRegistry._categories["source"]["pipeline"] = PipelineDataSource
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -98,14 +102,48 @@ def discover_stages() -> None:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from engine.display import Display # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
# Register display stages
|
||||
_register_display_stages()
|
||||
|
||||
StageRegistry._discovered = True
|
||||
|
||||
|
||||
def _register_display_stages() -> None:
|
||||
"""Register display backends as stages."""
|
||||
try:
|
||||
from engine.display import DisplayRegistry
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
DisplayRegistry.initialize()
|
||||
|
||||
for backend_name in DisplayRegistry.list_backends():
|
||||
factory = _DisplayStageFactory(backend_name)
|
||||
StageRegistry._categories.setdefault("display", {})[backend_name] = factory
|
||||
|
||||
|
||||
class _DisplayStageFactory:
|
||||
"""Factory that creates DisplayStage instances for a specific backend."""
|
||||
|
||||
def __init__(self, backend_name: str):
|
||||
self._backend_name = backend_name
|
||||
|
||||
def __call__(self):
|
||||
from engine.display import DisplayRegistry
|
||||
from engine.pipeline.adapters import DisplayStage
|
||||
|
||||
display = DisplayRegistry.create(self._backend_name)
|
||||
if display is None:
|
||||
raise RuntimeError(
|
||||
f"Failed to create display backend: {self._backend_name}"
|
||||
)
|
||||
return DisplayStage(display, name=self._backend_name)
|
||||
|
||||
@property
|
||||
def __name__(self) -> str:
|
||||
return self._backend_name.capitalize() + "Stage"
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def register_source(stage_class: type[Stage]) -> None:
|
||||
"""Register a source stage."""
|
||||
|
||||
@@ -9,6 +9,7 @@ Each data source implements a common interface:
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
@@ -125,6 +126,115 @@ class PipelineDataSource(DataSource):
|
||||
return self.fetch()
|
||||
|
||||
|
||||
class MetricsDataSource(DataSource):
|
||||
"""Data source that renders live pipeline metrics as ASCII art.
|
||||
|
||||
Wraps a Pipeline and displays active stages with their average execution
|
||||
time and approximate FPS impact. Updates lazily when camera is about to
|
||||
focus on a new node (frame % 15 == 12).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pipeline: Any,
|
||||
viewport_width: int = 80,
|
||||
viewport_height: int = 24,
|
||||
):
|
||||
self.pipeline = pipeline
|
||||
self.viewport_width = viewport_width
|
||||
self.viewport_height = viewport_height
|
||||
self.frame = 0
|
||||
self._cached_metrics: dict | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "metrics"
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return True
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
if self.frame % 15 == 12:
|
||||
self._cached_metrics = None
|
||||
|
||||
if self._cached_metrics is None:
|
||||
self._cached_metrics = self._fetch_metrics()
|
||||
|
||||
buffer = self._render_metrics(self._cached_metrics)
|
||||
self.frame += 1
|
||||
content = "\n".join(buffer)
|
||||
return [
|
||||
SourceItem(content=content, source="metrics", timestamp=f"f{self.frame}")
|
||||
]
|
||||
|
||||
def _fetch_metrics(self) -> dict:
|
||||
if hasattr(self.pipeline, "get_metrics_summary"):
|
||||
metrics = self.pipeline.get_metrics_summary()
|
||||
if "error" not in metrics:
|
||||
return metrics
|
||||
return {"stages": {}, "pipeline": {"avg_ms": 0}}
|
||||
|
||||
def _render_metrics(self, metrics: dict) -> list[str]:
|
||||
stages = metrics.get("stages", {})
|
||||
|
||||
if not stages:
|
||||
return self._render_empty()
|
||||
|
||||
active_stages = {
|
||||
name: stats for name, stats in stages.items() if stats.get("avg_ms", 0) > 0
|
||||
}
|
||||
|
||||
if not active_stages:
|
||||
return self._render_empty()
|
||||
|
||||
total_avg = sum(s["avg_ms"] for s in active_stages.values())
|
||||
if total_avg == 0:
|
||||
total_avg = 1
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("═" * self.viewport_width)
|
||||
lines.append(" PIPELINE METRICS ".center(self.viewport_width, "─"))
|
||||
lines.append("─" * self.viewport_width)
|
||||
|
||||
header = f"{'STAGE':<20} {'AVG_MS':>8} {'FPS %':>8}"
|
||||
lines.append(header)
|
||||
lines.append("─" * self.viewport_width)
|
||||
|
||||
for name, stats in sorted(active_stages.items()):
|
||||
avg_ms = stats.get("avg_ms", 0)
|
||||
fps_impact = (avg_ms / 16.67) * 100 if avg_ms > 0 else 0
|
||||
|
||||
row = f"{name:<20} {avg_ms:>7.2f} {fps_impact:>7.1f}%"
|
||||
lines.append(row[: self.viewport_width])
|
||||
|
||||
lines.append("─" * self.viewport_width)
|
||||
total_row = (
|
||||
f"{'TOTAL':<20} {total_avg:>7.2f} {(total_avg / 16.67) * 100:>7.1f}%"
|
||||
)
|
||||
lines.append(total_row[: self.viewport_width])
|
||||
lines.append("─" * self.viewport_width)
|
||||
lines.append(
|
||||
f" Frame:{self.frame:04d} Cache:{'HIT' if self._cached_metrics else 'MISS'}"
|
||||
)
|
||||
|
||||
while len(lines) < self.viewport_height:
|
||||
lines.append(" " * self.viewport_width)
|
||||
|
||||
return lines[: self.viewport_height]
|
||||
|
||||
def _render_empty(self) -> list[str]:
|
||||
lines = [" " * self.viewport_width for _ in range(self.viewport_height)]
|
||||
msg = "No metrics available"
|
||||
y = self.viewport_height // 2
|
||||
x = (self.viewport_width - len(msg)) // 2
|
||||
lines[y] = " " * x + msg + " " * (self.viewport_width - x - len(msg))
|
||||
return lines
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
return self.fetch()
|
||||
|
||||
|
||||
class CachedDataSource(DataSource):
|
||||
"""Data source that wraps another source with caching."""
|
||||
|
||||
@@ -146,6 +256,44 @@ class CachedDataSource(DataSource):
|
||||
return self._items
|
||||
|
||||
|
||||
class TransformDataSource(DataSource):
|
||||
"""Data source that transforms items from another source.
|
||||
|
||||
Applies optional filter and map functions to each item.
|
||||
This enables chaining: source → transform → transformed output.
|
||||
|
||||
Args:
|
||||
source: The source to fetch items from
|
||||
filter_fn: Optional function(item: SourceItem) -> bool
|
||||
map_fn: Optional function(item: SourceItem) -> SourceItem
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: DataSource,
|
||||
filter_fn: Callable[[SourceItem], bool] | None = None,
|
||||
map_fn: Callable[[SourceItem], SourceItem] | None = None,
|
||||
):
|
||||
self.source = source
|
||||
self.filter_fn = filter_fn
|
||||
self.map_fn = map_fn
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"transform:{self.source.name}"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
items = self.source.fetch()
|
||||
|
||||
if self.filter_fn:
|
||||
items = [item for item in items if self.filter_fn(item)]
|
||||
|
||||
if self.map_fn:
|
||||
items = [self.map_fn(item) for item in items]
|
||||
|
||||
return items
|
||||
|
||||
|
||||
class CompositeDataSource(DataSource):
|
||||
"""Data source that combines multiple sources."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user