Files
Mainline/engine/pipeline/adapters.py
David Gwilliam e0bbfea26c refactor: consolidate pipeline architecture with unified data source system
MAJOR REFACTORING: Consolidate duplicated pipeline code and standardize on
capability-based dependency resolution. This is a significant but backwards-compatible
restructuring that improves maintainability and extensibility.

## ARCHITECTURE CHANGES

### Data Sources Consolidation
- Move engine/sources_v2.py → engine/data_sources/sources.py
- Move engine/pipeline_sources/ → engine/data_sources/
- Create unified DataSource ABC with common interface:
  * fetch() - idempotent data retrieval
  * get_items() - cached access with automatic refresh
  * refresh() - force cache invalidation
  * is_dynamic - indicate streaming vs static sources
- Support for SourceItem dataclass (content, source, timestamp, metadata)

### Display Backend Improvements
- Update all 7 display backends to use new import paths
- Terminal: Improve dimension detection and handling
- WebSocket: Better error handling and client lifecycle
- Sixel: Refactor graphics rendering
- Pygame: Modernize event handling
- Kitty: Add protocol support for inline images
- Multi: Ensure proper forwarding to all backends
- Null: Maintain testing backend functionality

### Pipeline Adapter Consolidation
- Refactor adapter stages for clarity and flexibility
- RenderStage now handles both item-based and buffer-based rendering
- Add SourceItemsToBufferStage for converting data source items
- Improve DataSourceStage to work with all source types
- Add DisplayStage wrapper for display backends

### Camera & Viewport Refinements
- Update Camera class for new architecture
- Improve viewport dimension detection
- Better handling of resize events across backends

### New Effect Plugins
- border.py: Frame rendering effect with configurable style
- crop.py: Viewport clipping effect for selective display
- tint.py: Color filtering effect for atmosphere

### Tests & Quality
- Add test_border_effect.py with comprehensive border tests
- Add test_crop_effect.py with viewport clipping tests
- Add test_tint_effect.py with color filtering tests
- Update test_pipeline.py for new architecture
- Update test_pipeline_introspection.py for new data source location
- All 463 tests pass with 56% coverage
- Linting: All checks pass with ruff

### Removals (Code Cleanup)
- Delete engine/benchmark.py (deprecated performance testing)
- Delete engine/pipeline_sources/__init__.py (moved to data_sources)
- Delete engine/sources_v2.py (replaced by data_sources/sources.py)
- Update AGENTS.md to reflect new structure

### Import Path Updates
- Update engine/pipeline/controller.py::create_default_pipeline()
  * Old: from engine.sources_v2 import HeadlinesDataSource
  * New: from engine.data_sources.sources import HeadlinesDataSource
- All display backends import from new locations
- All tests import from new locations

## BACKWARDS COMPATIBILITY

This refactoring is intended to be backwards compatible:
- Pipeline execution unchanged (DAG-based with capability matching)
- Effect plugins unchanged (EffectPlugin interface same)
- Display protocol unchanged (Display duck-typing works as before)
- Config system unchanged (presets.toml format same)

## TESTING

- 463 tests pass (0 failures, 19 skipped)
- Full linting check passes
- Manual testing on demo, poetry, websocket modes
- All new effect plugins tested

## FILES CHANGED

- 24 files modified/added/deleted
- 723 insertions, 1,461 deletions (net -738 LOC - cleanup!)
- No breaking changes to public APIs
- All transitive imports updated correctly
2026-03-16 19:47:12 -07:00

747 lines
22 KiB
Python

"""
Stage adapters - Bridge existing components to the Stage interface.
This module provides adapters that wrap existing components
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
"""
import random
from typing import Any
from engine.pipeline.core import PipelineContext, Stage
class RenderStage(Stage):
"""Stage that renders items to a text buffer for display.
This mimics the old demo's render pipeline:
- Selects headlines and renders them to blocks
- Applies camera scroll position
- Adds firehose layer if enabled
"""
def __init__(
self,
items: list,
width: int = 80,
height: int = 24,
camera_speed: float = 1.0,
camera_mode: str = "vertical",
firehose_enabled: bool = False,
name: str = "render",
):
self.name = name
self.category = "render"
self.optional = False
self._items = items
self._width = width
self._height = height
self._camera_speed = camera_speed
self._camera_mode = camera_mode
self._firehose_enabled = firehose_enabled
self._camera_y = 0.0
self._camera_x = 0
self._scroll_accum = 0.0
self._ticker_next_y = 0
self._active: list = []
self._seen: set = set()
self._pool: list = list(items)
self._noise_cache: dict = {}
self._frame_count = 0
@property
def capabilities(self) -> set[str]:
return {"render.output"}
@property
def dependencies(self) -> set[str]:
return {"source"}
def init(self, ctx: PipelineContext) -> bool:
random.shuffle(self._pool)
return True
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Render items to a text buffer."""
from engine.effects import next_headline
from engine.layers import render_firehose, render_ticker_zone
from engine.render import make_block
items = data or self._items
w = ctx.params.viewport_width if ctx.params else self._width
h = ctx.params.viewport_height if ctx.params else self._height
camera_speed = ctx.params.camera_speed if ctx.params else self._camera_speed
firehose = ctx.params.firehose_enabled if ctx.params else self._firehose_enabled
scroll_step = 0.5 / (camera_speed * 10)
self._scroll_accum += scroll_step
GAP = 3
while self._scroll_accum >= scroll_step:
self._scroll_accum -= scroll_step
self._camera_y += 1.0
while (
self._ticker_next_y < int(self._camera_y) + h + 10
and len(self._active) < 50
):
t, src, ts = next_headline(self._pool, items, self._seen)
ticker_content, hc, midx = make_block(t, src, ts, w)
self._active.append((ticker_content, hc, self._ticker_next_y, midx))
self._ticker_next_y += len(ticker_content) + GAP
self._active = [
(c, hc, by, mi)
for c, hc, by, mi in self._active
if by + len(c) > int(self._camera_y)
]
for k in list(self._noise_cache):
if k < int(self._camera_y):
del self._noise_cache[k]
grad_offset = (self._frame_count * 0.01) % 1.0
buf, self._noise_cache = render_ticker_zone(
self._active,
scroll_cam=int(self._camera_y),
camera_x=self._camera_x,
ticker_h=h,
w=w,
noise_cache=self._noise_cache,
grad_offset=grad_offset,
)
if firehose:
firehose_buf = render_firehose(items, w, 0, h)
buf.extend(firehose_buf)
self._frame_count += 1
return buf
class EffectPluginStage(Stage):
"""Adapter wrapping EffectPlugin as a Stage."""
def __init__(self, effect_plugin, name: str = "effect"):
self._effect = effect_plugin
self.name = name
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}"}
@property
def dependencies(self) -> set[str]:
return set()
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Process data through the effect."""
if data is None:
return None
from engine.effects.types import EffectContext, apply_param_bindings
w = ctx.params.viewport_width if ctx.params else 80
h = ctx.params.viewport_height if ctx.params else 24
frame = ctx.params.frame_number if ctx.params else 0
effect_ctx = EffectContext(
terminal_width=w,
terminal_height=h,
scroll_cam=0,
ticker_height=h,
camera_x=0,
mic_excess=0.0,
grad_offset=(frame * 0.01) % 1.0,
frame_number=frame,
has_message=False,
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)
# 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)
self._effect.configure(bound_config)
return self._effect.process(data, effect_ctx)
class DisplayStage(Stage):
"""Adapter wrapping Display as a Stage."""
def __init__(self, display, name: str = "terminal"):
self._display = display
self.name = name
self.category = "display"
self.optional = False
@property
def capabilities(self) -> set[str]:
return {"display.output"}
@property
def dependencies(self) -> set[str]:
return set()
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, reuse=False)
return result is not False
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Output data to display."""
if data is not None:
self._display.show(data)
return data
def cleanup(self) -> None:
self._display.cleanup()
class DataSourceStage(Stage):
"""Adapter wrapping DataSource as a Stage."""
def __init__(self, data_source, name: str = "headlines"):
self._source = data_source
self.name = name
self.category = "source"
self.optional = False
@property
def capabilities(self) -> set[str]:
return {f"source.{self.name}"}
@property
def dependencies(self) -> set[str]:
return set()
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Fetch data from source."""
if hasattr(self._source, "get_items"):
return self._source.get_items()
return data
class PassthroughStage(Stage):
"""Simple stage that passes data through unchanged.
Used for sources that already provide the data in the correct format
(e.g., pipeline introspection that outputs text directly).
"""
def __init__(self, name: str = "passthrough"):
self.name = name
self.category = "render"
self.optional = True
@property
def stage_type(self) -> str:
return "render"
@property
def capabilities(self) -> set[str]:
return {"render.output"}
@property
def dependencies(self) -> set[str]:
return {"source"}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Pass data through unchanged."""
return data
class SourceItemsToBufferStage(Stage):
"""Convert SourceItem objects to text buffer.
Takes a list of SourceItem objects and extracts their content,
splitting on newlines to create a proper text buffer for display.
"""
def __init__(self, name: str = "items-to-buffer"):
self.name = name
self.category = "render"
self.optional = True
@property
def stage_type(self) -> str:
return "render"
@property
def capabilities(self) -> set[str]:
return {"render.output"}
@property
def dependencies(self) -> set[str]:
return {"source"}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Convert SourceItem list to text buffer."""
if data is None:
return []
# If already a list of strings, return as-is
if isinstance(data, list) and data and isinstance(data[0], str):
return data
# If it's a list of SourceItem, extract content
from engine.data_sources import SourceItem
if isinstance(data, list):
result = []
for item in data:
if isinstance(item, SourceItem):
# Split content by newline to get individual lines
lines = item.content.split("\n")
result.extend(lines)
elif hasattr(item, "content"): # Has content attribute
lines = str(item.content).split("\n")
result.extend(lines)
else:
result.append(str(item))
return result
# Single item
if isinstance(data, SourceItem):
return data.content.split("\n")
return [str(data)]
class ItemsStage(Stage):
"""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"):
import warnings
warnings.warn(
"ItemsStage is deprecated. Use DataSourceStage with a DataSource instead.",
DeprecationWarning,
stacklevel=2,
)
self._items = items
self.name = name
self.category = "source"
self.optional = False
@property
def capabilities(self) -> set[str]:
return {f"source.{self.name}"}
@property
def dependencies(self) -> set[str]:
return set()
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Return the pre-fetched items."""
return self._items
class CameraStage(Stage):
"""Adapter wrapping Camera as a Stage."""
def __init__(self, camera, name: str = "vertical"):
self._camera = camera
self.name = name
self.category = "camera"
self.optional = True
@property
def capabilities(self) -> set[str]:
return {"camera"}
@property
def dependencies(self) -> set[str]:
return {"source.items"}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Apply camera transformation to data."""
if data is None:
return None
if hasattr(self._camera, "apply"):
return self._camera.apply(
data, ctx.params.viewport_width if ctx.params else 80
)
return data
def cleanup(self) -> None:
if hasattr(self._camera, "reset"):
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
class ImageToTextStage(Stage):
"""Transform that converts PIL Image to ASCII text buffer.
Takes an ImageItem or PIL Image and converts it to a text buffer
using ASCII character density mapping. The output can be displayed
directly or further processed by effects.
Attributes:
width: Output width in characters
height: Output height in characters
charset: Character set for density mapping (default: simple ASCII)
"""
def __init__(
self,
width: int = 80,
height: int = 24,
charset: str = " .:-=+*#%@",
name: str = "image-to-text",
):
self.name = name
self.category = "transform"
self.optional = False
self.width = width
self.height = height
self.charset = charset
@property
def stage_type(self) -> str:
return "transform"
@property
def capabilities(self) -> set[str]:
from engine.pipeline.core import DataType
return {f"transform.{self.name}", DataType.TEXT_BUFFER}
@property
def dependencies(self) -> set[str]:
return {"source"}
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Convert PIL Image to text buffer."""
if data is None:
return None
from engine.data_sources.sources import ImageItem
# Extract PIL Image from various input types
pil_image = None
if isinstance(data, ImageItem) or hasattr(data, "image"):
pil_image = data.image
else:
# Assume it's already a PIL Image
pil_image = data
# Check if it's a PIL Image
if not hasattr(pil_image, "resize"):
# Not a PIL Image, return as-is
return data if isinstance(data, list) else [str(data)]
# Convert to grayscale and resize
try:
if pil_image.mode != "L":
pil_image = pil_image.convert("L")
except Exception:
return ["[image conversion error]"]
# Calculate cell aspect ratio correction (characters are taller than wide)
aspect_ratio = 0.5
target_w = self.width
target_h = int(self.height * aspect_ratio)
# Resize image to target dimensions
try:
resized = pil_image.resize((target_w, target_h))
except Exception:
return ["[image resize error]"]
# Map pixels to characters
result = []
pixels = list(resized.getdata())
for row in range(target_h):
line = ""
for col in range(target_w):
idx = row * target_w + col
if idx < len(pixels):
brightness = pixels[idx]
char_idx = int((brightness / 255) * (len(self.charset) - 1))
line += self.charset[char_idx]
else:
line += " "
result.append(line)
# Pad or trim to exact height
while len(result) < self.height:
result.append(" " * self.width)
result = result[: self.height]
# Pad lines to width
result = [line.ljust(self.width) for line in result]
return result
def create_stage_from_display(display, name: str = "terminal") -> DisplayStage:
"""Create a Stage from a Display instance."""
return DisplayStage(display, name)
def create_stage_from_effect(effect_plugin, name: str) -> EffectPluginStage:
"""Create a Stage from an EffectPlugin."""
return EffectPluginStage(effect_plugin, name)
def create_stage_from_source(data_source, name: str = "headlines") -> DataSourceStage:
"""Create a Stage from a DataSource."""
return DataSourceStage(data_source, name)
def create_stage_from_camera(camera, name: str = "vertical") -> CameraStage:
"""Create a Stage from a Camera."""
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)
# Get dirty regions from canvas and expose via context
# Effects can access via ctx.get_state("canvas.dirty_rows")
if self._canvas.is_dirty():
dirty_rows = self._canvas.get_dirty_rows()
ctx.set_state("canvas.dirty_rows", dirty_rows)
ctx.set_state("canvas.dirty_regions", self._canvas.get_dirty_regions())
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)