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
This commit is contained in:
@@ -264,6 +264,92 @@ class DataSourceStage(Stage):
|
||||
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.
|
||||
|
||||
@@ -430,6 +516,113 @@ class FontStage(Stage):
|
||||
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)
|
||||
|
||||
@@ -519,8 +519,8 @@ def create_pipeline_from_params(params: PipelineParams) -> Pipeline:
|
||||
|
||||
def create_default_pipeline() -> Pipeline:
|
||||
"""Create a default pipeline with all standard components."""
|
||||
from engine.data_sources.sources import HeadlinesDataSource
|
||||
from engine.pipeline.adapters import DataSourceStage
|
||||
from engine.sources_v2 import HeadlinesDataSource
|
||||
|
||||
pipeline = Pipeline()
|
||||
|
||||
|
||||
@@ -29,12 +29,14 @@ class DataType(Enum):
|
||||
ITEM_TUPLES: List[tuple] - (title, source, timestamp) tuples
|
||||
TEXT_BUFFER: List[str] - rendered ANSI buffer for display
|
||||
RAW_TEXT: str - raw text strings
|
||||
PIL_IMAGE: PIL Image object
|
||||
"""
|
||||
|
||||
SOURCE_ITEMS = auto() # List[SourceItem] - from DataSource
|
||||
ITEM_TUPLES = auto() # List[tuple] - (title, source, ts)
|
||||
TEXT_BUFFER = auto() # List[str] - ANSI buffer
|
||||
RAW_TEXT = auto() # str - raw text
|
||||
PIL_IMAGE = auto() # PIL Image object
|
||||
ANY = auto() # Accepts any type
|
||||
NONE = auto() # No data (terminator)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class PipelineParams:
|
||||
|
||||
# Display config
|
||||
display: str = "terminal"
|
||||
border: bool = False
|
||||
|
||||
# Camera config
|
||||
camera_mode: str = "vertical"
|
||||
|
||||
@@ -47,12 +47,14 @@ class PipelinePreset:
|
||||
display: str = "terminal"
|
||||
camera: str = "vertical"
|
||||
effects: list[str] = field(default_factory=list)
|
||||
border: bool = False
|
||||
|
||||
def to_params(self) -> PipelineParams:
|
||||
"""Convert to PipelineParams."""
|
||||
params = PipelineParams()
|
||||
params.source = self.source
|
||||
params.display = self.display
|
||||
params.border = self.border
|
||||
params.camera_mode = self.camera
|
||||
params.effect_order = self.effects.copy()
|
||||
return params
|
||||
@@ -67,6 +69,7 @@ class PipelinePreset:
|
||||
display=data.get("display", "terminal"),
|
||||
camera=data.get("camera", "vertical"),
|
||||
effects=data.get("effects", []),
|
||||
border=data.get("border", False),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ def discover_stages() -> None:
|
||||
|
||||
# Import and register all stage implementations
|
||||
try:
|
||||
from engine.sources_v2 import (
|
||||
from engine.data_sources.sources import (
|
||||
HeadlinesDataSource,
|
||||
PoetryDataSource,
|
||||
)
|
||||
@@ -102,7 +102,7 @@ def discover_stages() -> None:
|
||||
|
||||
# Register pipeline introspection source
|
||||
try:
|
||||
from engine.pipeline_sources.pipeline_introspection import (
|
||||
from engine.data_sources.pipeline_introspection import (
|
||||
PipelineIntrospectionSource,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user