feat(pipeline): add PureData-style inlet/outlet typing

- Add DataType enum (SOURCE_ITEMS, TEXT_BUFFER, etc.)
- Add inlet_types and outlet_types to Stage
- Add _validate_types() for type checking at build time
- Update tests with proper type annotations
This commit is contained in:
2026-03-16 15:39:36 -07:00
parent 4616a21359
commit 76126bdaac
4 changed files with 717 additions and 2 deletions

View File

@@ -130,6 +130,35 @@ class EffectPluginStage(Stage):
self.category = "effect"
self.optional = False
@property
def stage_type(self) -> str:
"""Return stage_type based on effect name.
HUD effects are overlays.
"""
if self.name == "hud":
return "overlay"
return self.category
@property
def render_order(self) -> int:
"""Return render_order based on effect type.
HUD effects have high render_order to appear on top.
"""
if self.name == "hud":
return 100 # High order for overlays
return 0
@property
def is_overlay(self) -> bool:
"""Return True for HUD effects.
HUD is an overlay - it composes on top of the buffer
rather than transforming it for the next stage.
"""
return self.name == "hud"
@property
def capabilities(self) -> set[str]:
return {f"effect.{self.name}"}
@@ -166,6 +195,10 @@ class EffectPluginStage(Stage):
if key.startswith("sensor."):
effect_ctx.set_state(key, value)
# Copy metrics from PipelineContext to EffectContext
if "metrics" in ctx.state:
effect_ctx.set_state("metrics", ctx.state["metrics"])
# 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)
@@ -297,6 +330,106 @@ class CameraStage(Stage):
self._camera.reset()
class FontStage(Stage):
"""Stage that applies font rendering to content.
FontStage is a Transform that takes raw content (text, headlines)
and renders it to an ANSI-formatted buffer using the configured font.
This decouples font rendering from data sources, allowing:
- Different fonts per source
- Runtime font swapping
- Font as a pipeline stage
Attributes:
font_path: Path to font file (None = use config default)
font_size: Font size in points (None = use config default)
font_ref: Reference name for registered font ("default", "cjk", etc.)
"""
def __init__(
self,
font_path: str | None = None,
font_size: int | None = None,
font_ref: str | None = "default",
name: str = "font",
):
self.name = name
self.category = "transform"
self.optional = False
self._font_path = font_path
self._font_size = font_size
self._font_ref = font_ref
self._font = None
@property
def stage_type(self) -> str:
return "transform"
@property
def capabilities(self) -> set[str]:
return {f"transform.{self.name}", "render.output"}
@property
def dependencies(self) -> set[str]:
return {"source"}
def init(self, ctx: PipelineContext) -> bool:
"""Initialize font from config or path."""
from engine import config
if self._font_path:
try:
from PIL import ImageFont
size = self._font_size or config.FONT_SZ
self._font = ImageFont.truetype(self._font_path, size)
except Exception:
return False
return True
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Render content with font to buffer."""
if data is None:
return None
from engine.render import make_block
w = ctx.params.viewport_width if ctx.params else 80
# If data is already a list of strings (buffer), return as-is
if isinstance(data, list) and data and isinstance(data[0], str):
return data
# If data is a list of items, render each with font
if isinstance(data, list):
result = []
for item in data:
# Handle SourceItem or tuple (title, source, timestamp)
if hasattr(item, "content"):
title = item.content
src = getattr(item, "source", "unknown")
ts = getattr(item, "timestamp", "0")
elif isinstance(item, tuple):
title = item[0] if len(item) > 0 else ""
src = item[1] if len(item) > 1 else "unknown"
ts = str(item[2]) if len(item) > 2 else "0"
else:
title = str(item)
src = "unknown"
ts = "0"
try:
block = make_block(title, src, ts, w)
result.extend(block)
except Exception:
result.append(title)
return result
return data
def create_stage_from_display(display, name: str = "terminal") -> DisplayStage:
"""Create a Stage from a Display instance."""
return DisplayStage(display, name)
@@ -317,6 +450,96 @@ def create_stage_from_camera(camera, name: str = "vertical") -> CameraStage:
return CameraStage(camera, name)
def create_stage_from_font(
font_path: str | None = None,
font_size: int | None = None,
font_ref: str | None = "default",
name: str = "font",
) -> FontStage:
"""Create a FontStage for rendering content with fonts."""
return FontStage(
font_path=font_path, font_size=font_size, font_ref=font_ref, name=name
)
class CanvasStage(Stage):
"""Stage that manages a Canvas for rendering.
CanvasStage creates and manages a 2D canvas that can hold rendered content.
Other stages can write to and read from the canvas via the pipeline context.
This enables:
- Pre-rendering content off-screen
- Multiple cameras viewing different regions
- Smooth scrolling (camera moves, content stays)
- Layer compositing
Usage:
- Add CanvasStage to pipeline
- Other stages access canvas via: ctx.get("canvas")
"""
def __init__(
self,
width: int = 80,
height: int = 24,
name: str = "canvas",
):
self.name = name
self.category = "system"
self.optional = True
self._width = width
self._height = height
self._canvas = None
@property
def stage_type(self) -> str:
return "system"
@property
def capabilities(self) -> set[str]:
return {"canvas"}
@property
def dependencies(self) -> set[str]:
return set()
@property
def inlet_types(self) -> set:
from engine.pipeline.core import DataType
return {DataType.ANY}
@property
def outlet_types(self) -> set:
from engine.pipeline.core import DataType
return {DataType.ANY}
def init(self, ctx: PipelineContext) -> bool:
from engine.canvas import Canvas
self._canvas = Canvas(width=self._width, height=self._height)
ctx.set("canvas", self._canvas)
return True
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Pass through data but ensure canvas is in context."""
if self._canvas is None:
from engine.canvas import Canvas
self._canvas = Canvas(width=self._width, height=self._height)
ctx.set("canvas", self._canvas)
return data
def get_canvas(self):
"""Get the canvas instance."""
return self._canvas
def cleanup(self) -> None:
self._canvas = None
def create_items_stage(items, name: str = "headlines") -> ItemsStage:
"""Create a Stage that holds pre-fetched items."""
return ItemsStage(items, name)