forked from genewildish/Mainline
Compare commits
73 Commits
60ae4f7dfb
...
feature/gr
| Author | SHA1 | Date | |
|---|---|---|---|
| f91082186c | |||
| bfcad4963a | |||
| e5799a346a | |||
| b1bf739324 | |||
| a050e26c03 | |||
| d5406a6b11 | |||
| 3fac583d94 | |||
| 995badbffc | |||
| 6646ed78b3 | |||
| fb0dd4592f | |||
| 2c23c423a0 | |||
| 38bc9a2c13 | |||
| 613752ee20 | |||
| 247f572218 | |||
| 915598629a | |||
| 19fe87573d | |||
| 1a7da400e3 | |||
| 406a58d292 | |||
| f27f3475c8 | |||
| c790027ede | |||
| 901717b86b | |||
| 33df254409 | |||
| 5352054d09 | |||
| f136bd75f1 | |||
| 860bab6550 | |||
| f568cc1a73 | |||
| 7d4623b009 | |||
| c999a9a724 | |||
| 6c06f12c5a | |||
| b058160e9d | |||
| b28cd154c7 | |||
| 66f4957c24 | |||
| afee03f693 | |||
| a747f67f63 | |||
| 018778dd11 | |||
| 4acd7b3344 | |||
| 2976839f7b | |||
| ead4cc3d5a | |||
| 1010f5868e | |||
| fff87382f6 | |||
| b3ac72884d | |||
| 7c26150408 | |||
| 7185005f9b | |||
| ef0c43266a | |||
| e02ab92dad | |||
| 4816ee6da8 | |||
| ec9f5bbe1f | |||
| f64590c0a3 | |||
| b2404068dd | |||
| 677e5c66a9 | |||
| ad8513f2f6 | |||
| 7eaa441574 | |||
| 4f2cf49a80 | |||
| ff08b1d6f5 | |||
| cd5034ce78 | |||
| 161bb522be | |||
| 3fa9eabe36 | |||
| 31ac728737 | |||
| d73d1c65bd | |||
| 5d9efdcb89 | |||
| f2b4226173 | |||
| 238bac1bb2 | |||
| 0eb5f1d5ff | |||
| 14d622f0d6 | |||
| e684666774 | |||
| bb0f1b85bf | |||
| c57617bb3d | |||
| abe49ba7d7 | |||
| 6d2c5ba304 | |||
| a95b24a246 | |||
| cdcdb7b172 | |||
| 21fb210c6e | |||
| 36afbacb6b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -12,3 +12,6 @@ htmlcov/
|
|||||||
coverage.xml
|
coverage.xml
|
||||||
*.dot
|
*.dot
|
||||||
*.png
|
*.png
|
||||||
|
test-reports/
|
||||||
|
.opencode/
|
||||||
|
tests/comparison_output/
|
||||||
|
|||||||
@@ -29,17 +29,28 @@ class Stage(ABC):
|
|||||||
return set()
|
return set()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dependencies(self) -> list[str]:
|
def dependencies(self) -> set[str]:
|
||||||
"""What this stage needs (e.g., ['source'])"""
|
"""What this stage needs (e.g., {'source'})"""
|
||||||
return []
|
return set()
|
||||||
```
|
```
|
||||||
|
|
||||||
### Capability-Based Dependencies
|
### Capability-Based Dependencies
|
||||||
|
|
||||||
The Pipeline resolves dependencies using **prefix matching**:
|
The Pipeline resolves dependencies using **prefix matching**:
|
||||||
- `"source"` matches `"source.headlines"`, `"source.poetry"`, etc.
|
- `"source"` matches `"source.headlines"`, `"source.poetry"`, etc.
|
||||||
|
- `"camera.state"` matches the camera state capability
|
||||||
- This allows flexible composition without hardcoding specific stage names
|
- This allows flexible composition without hardcoding specific stage names
|
||||||
|
|
||||||
|
### Minimum Capabilities
|
||||||
|
|
||||||
|
The pipeline requires these minimum capabilities to function:
|
||||||
|
- `"source"` - Data source capability
|
||||||
|
- `"render.output"` - Rendered content capability
|
||||||
|
- `"display.output"` - Display output capability
|
||||||
|
- `"camera.state"` - Camera state for viewport filtering
|
||||||
|
|
||||||
|
These are automatically injected if missing (auto-injection).
|
||||||
|
|
||||||
### DataType Enum
|
### DataType Enum
|
||||||
|
|
||||||
PureData-style data types for inlet/outlet validation:
|
PureData-style data types for inlet/outlet validation:
|
||||||
@@ -76,3 +87,11 @@ Canvas tracks dirty regions automatically when content is written via `put_regio
|
|||||||
- Use adapters (engine/pipeline/adapters.py) to wrap existing components as stages
|
- Use adapters (engine/pipeline/adapters.py) to wrap existing components as stages
|
||||||
- Set `optional=True` for stages that can fail gracefully
|
- Set `optional=True` for stages that can fail gracefully
|
||||||
- Use `stage_type` and `render_order` for execution ordering
|
- Use `stage_type` and `render_order` for execution ordering
|
||||||
|
- Clock stages update state independently of data flow
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- engine/pipeline/core.py - Stage base class
|
||||||
|
- engine/pipeline/controller.py - Pipeline implementation
|
||||||
|
- engine/pipeline/adapters/ - Stage adapters
|
||||||
|
- docs/PIPELINE.md - Pipeline documentation
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ All backends implement a common Display protocol (in `engine/display/__init__.py
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
class Display(Protocol):
|
class Display(Protocol):
|
||||||
def show(self, buf: list[str]) -> None:
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||||
|
"""Initialize the display"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def show(self, buf: list[str], border: bool = False) -> None:
|
||||||
"""Display the buffer"""
|
"""Display the buffer"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -27,7 +34,11 @@ class Display(Protocol):
|
|||||||
"""Clear the display"""
|
"""Clear the display"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def size(self) -> tuple[int, int]:
|
def cleanup(self) -> None:
|
||||||
|
"""Clean up resources"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_dimensions(self) -> tuple[int, int]:
|
||||||
"""Return (width, height)"""
|
"""Return (width, height)"""
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
@@ -37,8 +48,8 @@ class Display(Protocol):
|
|||||||
Discovers and manages backends:
|
Discovers and manages backends:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from engine.display import get_monitor
|
from engine.display import DisplayRegistry
|
||||||
display = get_monitor("terminal") # or "websocket", "sixel", "null", "multi"
|
display = DisplayRegistry.create("terminal") # or "websocket", "null", "multi"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Backends
|
### Available Backends
|
||||||
@@ -47,9 +58,9 @@ display = get_monitor("terminal") # or "websocket", "sixel", "null", "multi"
|
|||||||
|---------|------|-------------|
|
|---------|------|-------------|
|
||||||
| terminal | backends/terminal.py | ANSI terminal output |
|
| terminal | backends/terminal.py | ANSI terminal output |
|
||||||
| websocket | backends/websocket.py | Web browser via WebSocket |
|
| websocket | backends/websocket.py | Web browser via WebSocket |
|
||||||
| sixel | backends/sixel.py | Sixel graphics (pure Python) |
|
|
||||||
| null | backends/null.py | Headless for testing |
|
| null | backends/null.py | Headless for testing |
|
||||||
| multi | backends/multi.py | Forwards to multiple displays |
|
| multi | backends/multi.py | Forwards to multiple displays |
|
||||||
|
| moderngl | backends/moderngl.py | GPU-accelerated OpenGL rendering (optional) |
|
||||||
|
|
||||||
### WebSocket Backend
|
### WebSocket Backend
|
||||||
|
|
||||||
@@ -68,9 +79,11 @@ Forwards to multiple displays simultaneously - useful for `terminal + websocket`
|
|||||||
3. Register in `engine/display/__init__.py`'s `DisplayRegistry`
|
3. Register in `engine/display/__init__.py`'s `DisplayRegistry`
|
||||||
|
|
||||||
Required methods:
|
Required methods:
|
||||||
- `show(buf: list[str])` - Display buffer
|
- `init(width: int, height: int, reuse: bool = False)` - Initialize display
|
||||||
|
- `show(buf: list[str], border: bool = False)` - Display buffer
|
||||||
- `clear()` - Clear screen
|
- `clear()` - Clear screen
|
||||||
- `size() -> tuple[int, int]` - Terminal dimensions
|
- `cleanup()` - Clean up resources
|
||||||
|
- `get_dimensions() -> tuple[int, int]` - Get terminal dimensions
|
||||||
|
|
||||||
Optional methods:
|
Optional methods:
|
||||||
- `title(text: str)` - Set window title
|
- `title(text: str)` - Set window title
|
||||||
@@ -81,6 +94,70 @@ Optional methods:
|
|||||||
```bash
|
```bash
|
||||||
python mainline.py --display terminal # default
|
python mainline.py --display terminal # default
|
||||||
python mainline.py --display websocket
|
python mainline.py --display websocket
|
||||||
python mainline.py --display sixel
|
python mainline.py --display moderngl # GPU-accelerated (requires moderngl)
|
||||||
python mainline.py --display both # terminal + websocket
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Common Bugs and Patterns
|
||||||
|
|
||||||
|
### BorderMode.OFF Enum Bug
|
||||||
|
|
||||||
|
**Problem**: `BorderMode.OFF` has enum value `1` (not `0`), and Python enums are always truthy.
|
||||||
|
|
||||||
|
**Incorrect Code**:
|
||||||
|
```python
|
||||||
|
if border:
|
||||||
|
buffer = render_border(buffer, width, height, fps, frame_time)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct Code**:
|
||||||
|
```python
|
||||||
|
from engine.display import BorderMode
|
||||||
|
if border and border != BorderMode.OFF:
|
||||||
|
buffer = render_border(buffer, width, height, fps, frame_time)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why**: Checking `if border:` evaluates to `True` even when `border == BorderMode.OFF` because enum members are always truthy in Python.
|
||||||
|
|
||||||
|
### Context Type Mismatch
|
||||||
|
|
||||||
|
**Problem**: `PipelineContext` and `EffectContext` have different APIs for storing data.
|
||||||
|
|
||||||
|
- `PipelineContext`: Uses `set()`/`get()` for services
|
||||||
|
- `EffectContext`: Uses `set_state()`/`get_state()` for state
|
||||||
|
|
||||||
|
**Pattern for Passing Data**:
|
||||||
|
```python
|
||||||
|
# In pipeline setup (uses PipelineContext)
|
||||||
|
ctx.set("pipeline_order", pipeline.execution_order)
|
||||||
|
|
||||||
|
# In EffectPluginStage (must copy to EffectContext)
|
||||||
|
effect_ctx.set_state("pipeline_order", ctx.get("pipeline_order"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terminal Display ANSI Patterns
|
||||||
|
|
||||||
|
**Screen Clearing**:
|
||||||
|
```python
|
||||||
|
output = "\033[H\033[J" + "".join(buffer)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cursor Positioning** (used by HUD effect):
|
||||||
|
- `\033[row;colH` - Move cursor to row, column
|
||||||
|
- Example: `\033[1;1H` - Move to row 1, column 1
|
||||||
|
|
||||||
|
**Key Insight**: Terminal display joins buffer lines WITHOUT newlines, relying on ANSI cursor positioning codes to move the cursor to the correct location for each line.
|
||||||
|
|
||||||
|
### EffectPluginStage Context Copying
|
||||||
|
|
||||||
|
**Problem**: When effects need access to pipeline services (like `pipeline_order`), they must be copied from `PipelineContext` to `EffectContext`.
|
||||||
|
|
||||||
|
**Pattern**:
|
||||||
|
```python
|
||||||
|
# In EffectPluginStage.process()
|
||||||
|
# Copy pipeline_order from PipelineContext services to EffectContext state
|
||||||
|
pipeline_order = ctx.get("pipeline_order")
|
||||||
|
if pipeline_order:
|
||||||
|
effect_ctx.set_state("pipeline_order", pipeline_order)
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures effects can access `ctx.get_state("pipeline_order")` in their process method.
|
||||||
|
|||||||
@@ -86,8 +86,8 @@ Edit `engine/presets.toml` (requires PR to repository).
|
|||||||
|
|
||||||
- `terminal` - ANSI terminal
|
- `terminal` - ANSI terminal
|
||||||
- `websocket` - Web browser
|
- `websocket` - Web browser
|
||||||
- `sixel` - Sixel graphics
|
|
||||||
- `null` - Headless
|
- `null` - Headless
|
||||||
|
- `moderngl` - GPU-accelerated (optional)
|
||||||
|
|
||||||
## Available Effects
|
## Available Effects
|
||||||
|
|
||||||
|
|||||||
110
AGENTS.md
110
AGENTS.md
@@ -12,7 +12,7 @@ This project uses:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
mise run install # Install dependencies
|
mise run install # Install dependencies
|
||||||
# Or: uv sync --all-extras # includes mic, websocket, sixel support
|
# Or: uv sync --all-extras # includes mic, websocket support
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Commands
|
### Available Commands
|
||||||
@@ -206,20 +206,6 @@ class TestEventBusSubscribe:
|
|||||||
|
|
||||||
**Never** modify a test to make it pass without understanding why it failed.
|
**Never** modify a test to make it pass without understanding why it failed.
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
- **Pipeline**: source → render → effects → display
|
|
||||||
- **EffectPlugin**: ABC with `process()` and `configure()` methods
|
|
||||||
- **Display backends**: terminal, websocket, sixel, null (for testing)
|
|
||||||
- **EventBus**: thread-safe pub/sub messaging
|
|
||||||
- **Presets**: TOML format in `engine/presets.toml`
|
|
||||||
|
|
||||||
Key files:
|
|
||||||
- `engine/pipeline/core.py` - Stage base class
|
|
||||||
- `engine/effects/types.py` - EffectPlugin ABC and dataclasses
|
|
||||||
- `engine/display/backends/` - Display backend implementations
|
|
||||||
- `engine/eventbus.py` - Thread-safe event system
|
|
||||||
=======
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Tests live in `tests/` and follow the pattern `test_*.py`.
|
Tests live in `tests/` and follow the pattern `test_*.py`.
|
||||||
@@ -281,15 +267,45 @@ The new Stage-based pipeline architecture provides capability-based dependency r
|
|||||||
|
|
||||||
- **Stage** (`engine/pipeline/core.py`): Base class for pipeline stages
|
- **Stage** (`engine/pipeline/core.py`): Base class for pipeline stages
|
||||||
- **Pipeline** (`engine/pipeline/controller.py`): Executes stages with capability-based dependency resolution
|
- **Pipeline** (`engine/pipeline/controller.py`): Executes stages with capability-based dependency resolution
|
||||||
|
- **PipelineConfig** (`engine/pipeline/controller.py`): Configuration for pipeline instance
|
||||||
- **StageRegistry** (`engine/pipeline/registry.py`): Discovers and registers stages
|
- **StageRegistry** (`engine/pipeline/registry.py`): Discovers and registers stages
|
||||||
- **Stage Adapters** (`engine/pipeline/adapters.py`): Wraps existing components as stages
|
- **Stage Adapters** (`engine/pipeline/adapters.py`): Wraps existing components as stages
|
||||||
|
|
||||||
|
#### Pipeline Configuration
|
||||||
|
|
||||||
|
The `PipelineConfig` dataclass configures pipeline behavior:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class PipelineConfig:
|
||||||
|
source: str = "headlines" # Data source identifier
|
||||||
|
display: str = "terminal" # Display backend identifier
|
||||||
|
camera: str = "vertical" # Camera mode identifier
|
||||||
|
effects: list[str] = field(default_factory=list) # List of effect names
|
||||||
|
enable_metrics: bool = True # Enable performance metrics
|
||||||
|
```
|
||||||
|
|
||||||
|
**Available sources**: `headlines`, `poetry`, `empty`, `list`, `image`, `metrics`, `cached`, `transform`, `composite`, `pipeline-inspect`
|
||||||
|
**Available displays**: `terminal`, `null`, `replay`, `websocket`, `pygame`, `moderngl`, `multi`
|
||||||
|
**Available camera modes**: `FEED`, `SCROLL`, `HORIZONTAL`, `OMNI`, `FLOATING`, `BOUNCE`, `RADIAL`
|
||||||
|
|
||||||
#### Capability-Based Dependencies
|
#### Capability-Based Dependencies
|
||||||
|
|
||||||
Stages declare capabilities (what they provide) and dependencies (what they need). The Pipeline resolves dependencies using prefix matching:
|
Stages declare capabilities (what they provide) and dependencies (what they need). The Pipeline resolves dependencies using prefix matching:
|
||||||
- `"source"` matches `"source.headlines"`, `"source.poetry"`, etc.
|
- `"source"` matches `"source.headlines"`, `"source.poetry"`, etc.
|
||||||
|
- `"camera.state"` matches the camera state capability
|
||||||
- This allows flexible composition without hardcoding specific stage names
|
- This allows flexible composition without hardcoding specific stage names
|
||||||
|
|
||||||
|
#### Minimum Capabilities
|
||||||
|
|
||||||
|
The pipeline requires these minimum capabilities to function:
|
||||||
|
- `"source"` - Data source capability
|
||||||
|
- `"render.output"` - Rendered content capability
|
||||||
|
- `"display.output"` - Display output capability
|
||||||
|
- `"camera.state"` - Camera state for viewport filtering
|
||||||
|
|
||||||
|
These are automatically injected if missing by the `ensure_minimum_capabilities()` method.
|
||||||
|
|
||||||
#### Sensor Framework
|
#### Sensor Framework
|
||||||
|
|
||||||
- **Sensor** (`engine/sensors/__init__.py`): Base class for real-time input sensors
|
- **Sensor** (`engine/sensors/__init__.py`): Base class for real-time input sensors
|
||||||
@@ -336,9 +352,9 @@ Functions:
|
|||||||
- **Display abstraction** (`engine/display/`): swap display backends via the Display protocol
|
- **Display abstraction** (`engine/display/`): swap display backends via the Display protocol
|
||||||
- `display/backends/terminal.py` - ANSI terminal output
|
- `display/backends/terminal.py` - ANSI terminal output
|
||||||
- `display/backends/websocket.py` - broadcasts to web clients via WebSocket
|
- `display/backends/websocket.py` - broadcasts to web clients via WebSocket
|
||||||
- `display/backends/sixel.py` - renders to Sixel graphics (pure Python, no C dependency)
|
|
||||||
- `display/backends/null.py` - headless display for testing
|
- `display/backends/null.py` - headless display for testing
|
||||||
- `display/backends/multi.py` - forwards to multiple displays simultaneously
|
- `display/backends/multi.py` - forwards to multiple displays simultaneously
|
||||||
|
- `display/backends/moderngl.py` - GPU-accelerated OpenGL rendering (optional)
|
||||||
- `display/__init__.py` - DisplayRegistry for backend discovery
|
- `display/__init__.py` - DisplayRegistry for backend discovery
|
||||||
|
|
||||||
- **WebSocket display** (`engine/display/backends/websocket.py`): real-time frame broadcasting to web browsers
|
- **WebSocket display** (`engine/display/backends/websocket.py`): real-time frame broadcasting to web browsers
|
||||||
@@ -349,8 +365,7 @@ Functions:
|
|||||||
- **Display modes** (`--display` flag):
|
- **Display modes** (`--display` flag):
|
||||||
- `terminal` - Default ANSI terminal output
|
- `terminal` - Default ANSI terminal output
|
||||||
- `websocket` - Web browser display (requires websockets package)
|
- `websocket` - Web browser display (requires websockets package)
|
||||||
- `sixel` - Sixel graphics in supported terminals (iTerm2, mintty, etc.)
|
- `moderngl` - GPU-accelerated rendering (requires moderngl package)
|
||||||
- `both` - Terminal + WebSocket simultaneously
|
|
||||||
|
|
||||||
### Effect Plugin System
|
### Effect Plugin System
|
||||||
|
|
||||||
@@ -377,6 +392,43 @@ The rendering pipeline is documented in `docs/PIPELINE.md` using Mermaid diagram
|
|||||||
2. If adding new SVG diagrams, render them manually using an external tool (e.g., Mermaid Live Editor)
|
2. If adding new SVG diagrams, render them manually using an external tool (e.g., Mermaid Live Editor)
|
||||||
3. Commit both the markdown and any new diagram files
|
3. Commit both the markdown and any new diagram files
|
||||||
|
|
||||||
|
### Pipeline Mutation API
|
||||||
|
|
||||||
|
The Pipeline class supports dynamic mutation during runtime via the mutation API:
|
||||||
|
|
||||||
|
**Core Methods:**
|
||||||
|
- `add_stage(name, stage, initialize=True)` - Add a stage to the pipeline
|
||||||
|
- `remove_stage(name, cleanup=True)` - Remove a stage and rebuild execution order
|
||||||
|
- `replace_stage(name, new_stage, preserve_state=True)` - Replace a stage with another
|
||||||
|
- `swap_stages(name1, name2)` - Swap two stages
|
||||||
|
- `move_stage(name, after=None, before=None)` - Move a stage in execution order
|
||||||
|
- `enable_stage(name)` - Enable a stage
|
||||||
|
- `disable_stage(name)` - Disable a stage
|
||||||
|
|
||||||
|
**New Methods (Issue #35):**
|
||||||
|
- `cleanup_stage(name)` - Clean up specific stage without removing it
|
||||||
|
- `remove_stage_safe(name, cleanup=True)` - Alias for remove_stage that explicitly rebuilds
|
||||||
|
- `can_hot_swap(name)` - Check if a stage can be safely hot-swapped
|
||||||
|
- Returns False for stages that provide minimum capabilities as sole provider
|
||||||
|
- Returns True for swappable stages
|
||||||
|
|
||||||
|
**WebSocket Commands:**
|
||||||
|
Commands can be sent via WebSocket to mutate the pipeline at runtime:
|
||||||
|
```json
|
||||||
|
{"action": "remove_stage", "stage": "stage_name"}
|
||||||
|
{"action": "swap_stages", "stage1": "name1", "stage2": "name2"}
|
||||||
|
{"action": "enable_stage", "stage": "stage_name"}
|
||||||
|
{"action": "disable_stage", "stage": "stage_name"}
|
||||||
|
{"action": "cleanup_stage", "stage": "stage_name"}
|
||||||
|
{"action": "can_hot_swap", "stage": "stage_name"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation Files:**
|
||||||
|
- `engine/pipeline/controller.py` - Pipeline class with mutation methods
|
||||||
|
- `engine/app/pipeline_runner.py` - `_handle_pipeline_mutation()` function
|
||||||
|
- `engine/pipeline/ui.py` - execute_command() with docstrings
|
||||||
|
- `tests/test_pipeline_mutation_commands.py` - Integration tests
|
||||||
|
|
||||||
## Skills Library
|
## Skills Library
|
||||||
|
|
||||||
A skills library MCP server (`skills`) is available for capturing and tracking learned knowledge. Skills are stored in `~/.skills/`.
|
A skills library MCP server (`skills`) is available for capturing and tracking learned knowledge. Skills are stored in `~/.skills/`.
|
||||||
@@ -384,23 +436,23 @@ A skills library MCP server (`skills`) is available for capturing and tracking l
|
|||||||
### Workflow
|
### Workflow
|
||||||
|
|
||||||
**Before starting work:**
|
**Before starting work:**
|
||||||
1. Run `skills_list_skills` to see available skills
|
1. Run `local_skills_list_skills` to see available skills
|
||||||
2. Use `skills_peek_skill({name: "skill-name"})` to preview relevant skills
|
2. Use `local_skills_peek_skill({name: "skill-name"})` to preview relevant skills
|
||||||
3. Use `skills_skill_slice({name: "skill-name", query: "your question"})` to get relevant sections
|
3. Use `local_skills_skill_slice({name: "skill-name", query: "your question"})` to get relevant sections
|
||||||
|
|
||||||
**While working:**
|
**While working:**
|
||||||
- If a skill was wrong or incomplete: `skills_update_skill` → `skills_record_assessment` → `skills_report_outcome({quality: 1})`
|
- If a skill was wrong or incomplete: `local_skills_update_skill` → `local_skills_record_assessment` → `local_skills_report_outcome({quality: 1})`
|
||||||
- If a skill worked correctly: `skills_report_outcome({quality: 4})` (normal) or `quality: 5` (perfect)
|
- If a skill worked correctly: `local_skills_report_outcome({quality: 4})` (normal) or `quality: 5` (perfect)
|
||||||
|
|
||||||
**End of session:**
|
**End of session:**
|
||||||
- Run `skills_reflect_on_session({context_summary: "what you did"})` to identify new skills to capture
|
- Run `local_skills_reflect_on_session({context_summary: "what you did"})` to identify new skills to capture
|
||||||
- Use `skills_create_skill` to add new skills
|
- Use `local_skills_create_skill` to add new skills
|
||||||
- Use `skills_record_assessment` to score them
|
- Use `local_skills_record_assessment` to score them
|
||||||
|
|
||||||
### Useful Tools
|
### Useful Tools
|
||||||
- `skills_review_stale_skills()` - Skills due for review (negative days_until_due)
|
- `local_skills_review_stale_skills()` - Skills due for review (negative days_until_due)
|
||||||
- `skills_skills_report()` - Overview of entire collection
|
- `local_skills_skills_report()` - Overview of entire collection
|
||||||
- `skills_validate_skill({name: "skill-name"})` - Load skill for review with sources
|
- `local_skills_validate_skill({name: "skill-name"})` - Load skill for review with sources
|
||||||
|
|
||||||
### Agent Skills
|
### Agent Skills
|
||||||
|
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -16,7 +16,6 @@ python3 mainline.py --poetry # literary consciousness mode
|
|||||||
python3 mainline.py -p # same
|
python3 mainline.py -p # same
|
||||||
python3 mainline.py --firehose # dense rapid-fire headline mode
|
python3 mainline.py --firehose # dense rapid-fire headline mode
|
||||||
python3 mainline.py --display websocket # web browser display only
|
python3 mainline.py --display websocket # web browser display only
|
||||||
python3 mainline.py --display both # terminal + web browser
|
|
||||||
python3 mainline.py --no-font-picker # skip interactive font picker
|
python3 mainline.py --no-font-picker # skip interactive font picker
|
||||||
python3 mainline.py --font-file path.otf # use a specific font file
|
python3 mainline.py --font-file path.otf # use a specific font file
|
||||||
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
||||||
@@ -75,8 +74,7 @@ Mainline supports multiple display backends:
|
|||||||
|
|
||||||
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
||||||
- **WebSocket** (`--display websocket`): Stream to web browser clients
|
- **WebSocket** (`--display websocket`): Stream to web browser clients
|
||||||
- **Sixel** (`--display sixel`): Sixel graphics in supported terminals (iTerm2, mintty)
|
- **ModernGL** (`--display moderngl`): GPU-accelerated rendering (optional)
|
||||||
- **Both** (`--display both`): Terminal + WebSocket simultaneously
|
|
||||||
|
|
||||||
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
|
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
|
||||||
|
|
||||||
@@ -160,9 +158,9 @@ engine/
|
|||||||
backends/
|
backends/
|
||||||
terminal.py ANSI terminal display
|
terminal.py ANSI terminal display
|
||||||
websocket.py WebSocket server for browser clients
|
websocket.py WebSocket server for browser clients
|
||||||
sixel.py Sixel graphics (pure Python)
|
|
||||||
null.py headless display for testing
|
null.py headless display for testing
|
||||||
multi.py forwards to multiple displays
|
multi.py forwards to multiple displays
|
||||||
|
moderngl.py GPU-accelerated OpenGL rendering
|
||||||
benchmark.py performance benchmarking tool
|
benchmark.py performance benchmarking tool
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -194,9 +192,7 @@ mise run format # ruff format
|
|||||||
|
|
||||||
mise run run # terminal display
|
mise run run # terminal display
|
||||||
mise run run-websocket # web display only
|
mise run run-websocket # web display only
|
||||||
mise run run-sixel # sixel graphics
|
mise run run-client # terminal + web
|
||||||
mise run run-both # terminal + web
|
|
||||||
mise run run-client # both + open browser
|
|
||||||
|
|
||||||
mise run cmd # C&C command interface
|
mise run cmd # C&C command interface
|
||||||
mise run cmd-stats # watch effects stats
|
mise run cmd-stats # watch effects stats
|
||||||
|
|||||||
132
REPL_USAGE.md
Normal file
132
REPL_USAGE.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# REPL Usage Guide
|
||||||
|
|
||||||
|
The REPL (Read-Eval-Print Loop) effect provides an interactive command-line interface for controlling Mainline's pipeline in real-time.
|
||||||
|
|
||||||
|
## How to Access the REPL
|
||||||
|
|
||||||
|
### Method 1: Using CLI Arguments (Recommended)
|
||||||
|
|
||||||
|
Run Mainline with the `repl` effect added to the effects list:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# With empty source (for testing)
|
||||||
|
python mainline.py --pipeline-source empty --pipeline-effects repl
|
||||||
|
|
||||||
|
# With headlines source (requires network)
|
||||||
|
python mainline.py --pipeline-source headlines --pipeline-effects repl
|
||||||
|
|
||||||
|
# With poetry source
|
||||||
|
python mainline.py --pipeline-source poetry --pipeline-effects repl
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 2: Using a Preset
|
||||||
|
|
||||||
|
Add a preset to your `~/.config/mainline/presets.toml` or `./presets.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[presets.repl]
|
||||||
|
description = "Interactive REPL control"
|
||||||
|
source = "headlines"
|
||||||
|
display = "terminal"
|
||||||
|
effects = ["repl"]
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
```bash
|
||||||
|
python mainline.py --preset repl
|
||||||
|
```
|
||||||
|
|
||||||
|
### Method 3: Using Graph Config
|
||||||
|
|
||||||
|
Create a TOML file (e.g., `repl_config.toml`):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
source = "empty"
|
||||||
|
display = "terminal"
|
||||||
|
effects = ["repl"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
```bash
|
||||||
|
python mainline.py --graph-config repl_config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## REPL Commands
|
||||||
|
|
||||||
|
Once the REPL is active, you can type commands:
|
||||||
|
|
||||||
|
- **help** - Show available commands
|
||||||
|
- **status** - Show pipeline status and metrics
|
||||||
|
- **effects** - List all effects in the pipeline
|
||||||
|
- **effect \<name\> \<on|off\>** - Toggle an effect
|
||||||
|
- **param \<effect\> \<param\> \<value\>** - Set effect parameter
|
||||||
|
- **pipeline** - Show current pipeline order
|
||||||
|
- **clear** - Clear output buffer
|
||||||
|
- **quit/exit** - Show exit message (use Ctrl+C to actually exit)
|
||||||
|
|
||||||
|
## Keyboard Controls
|
||||||
|
|
||||||
|
- **Enter** - Execute command
|
||||||
|
- **Up/Down arrows** - Navigate command history
|
||||||
|
- **Backspace** - Delete last character
|
||||||
|
- **Ctrl+C** - Exit Mainline
|
||||||
|
|
||||||
|
## Visual Features
|
||||||
|
|
||||||
|
The REPL displays:
|
||||||
|
- **HUD header** (top 3 lines): Shows FPS, frame time, command count, and output buffer size
|
||||||
|
- **Content area**: Main content from the data source
|
||||||
|
- **Separator line**: Visual divider
|
||||||
|
- **REPL area**: Output buffer and input prompt
|
||||||
|
|
||||||
|
## Example Session
|
||||||
|
|
||||||
|
```
|
||||||
|
MAINLINE REPL | FPS: 60.0 | 12.5ms
|
||||||
|
COMMANDS: 3 | [2/3]
|
||||||
|
OUTPUT: 5 lines
|
||||||
|
────────────────────────────────────────
|
||||||
|
Content from source appears here...
|
||||||
|
More content...
|
||||||
|
────────────────────────────────────────
|
||||||
|
> help
|
||||||
|
Available commands:
|
||||||
|
help - Show this help
|
||||||
|
status - Show pipeline status
|
||||||
|
effects - List all effects
|
||||||
|
effect <name> <on|off> - Toggle effect
|
||||||
|
param <effect> <param> <value> - Set parameter
|
||||||
|
pipeline - Show current pipeline order
|
||||||
|
clear - Clear output buffer
|
||||||
|
quit - Show exit message
|
||||||
|
> effects
|
||||||
|
Pipeline effects:
|
||||||
|
1. repl
|
||||||
|
> effect repl off
|
||||||
|
Effect 'repl' set to off
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scrolling Support
|
||||||
|
|
||||||
|
The REPL output buffer supports scrolling through command history:
|
||||||
|
|
||||||
|
**Keyboard Controls:**
|
||||||
|
- **PageUp** - Scroll up 10 lines
|
||||||
|
- **PageDown** - Scroll down 10 lines
|
||||||
|
- **Mouse wheel up** - Scroll up 3 lines
|
||||||
|
- **Mouse wheel down** - Scroll down 3 lines
|
||||||
|
|
||||||
|
**Scroll Features:**
|
||||||
|
- **Scroll percentage** shown in HUD (like vim, e.g., "50%")
|
||||||
|
- **Scroll position** shown in output line (e.g., "(5/20)")
|
||||||
|
- **Auto-reset** - Scroll resets to bottom when new output arrives
|
||||||
|
- **Max buffer** - 50 lines (excluding empty lines)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The REPL effect needs a content source to overlay on (e.g., headlines, poetry, empty)
|
||||||
|
- The REPL uses terminal display with raw input mode
|
||||||
|
- Command history is preserved across sessions (up to 50 commands)
|
||||||
|
- Pipeline mutations (enabling/disabling effects) are handled automatically
|
||||||
63
TODO.md
Normal file
63
TODO.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## Documentation Updates
|
||||||
|
- [x] Remove references to removed display backends (sixel, kitty) from all documentation
|
||||||
|
- [x] Remove references to deprecated "both" display mode
|
||||||
|
- [x] Update AGENTS.md to reflect current architecture and remove merge conflicts
|
||||||
|
- [x] Update Agent Skills (.opencode/skills/) to match current codebase
|
||||||
|
- [x] Update docs/ARCHITECTURE.md to remove SixelDisplay references
|
||||||
|
- [x] Verify ModernGL backend is properly documented and registered
|
||||||
|
- [ ] Update docs/PIPELINE.md to reflect Stage-based architecture (outdated legacy flowchart) [#41](https://git.notsosm.art/david/Mainline/issues/41)
|
||||||
|
|
||||||
|
## Code & Features
|
||||||
|
- [ ] Check if luminance implementation exists for shade/tint effects (see [#26](https://git.notsosm.art/david/Mainline/issues/26) related: need to verify render/blocks.py has luminance calculation)
|
||||||
|
- [x] Add entropy/chaos score metadata to effects for auto-categorization and intensity control [#32](https://git.notsosm.art/david/Mainline/issues/32) (closed - completed)
|
||||||
|
- [ ] Finish ModernGL display backend: integrate window system, implement glyph caching, add event handling, and support border modes [#42](https://git.notsosm.art/david/Mainline/issues/42)
|
||||||
|
- [x] Integrate UIPanel with pipeline: register stages, link parameter schemas, handle events, implement hot-reload.
|
||||||
|
- [x] Move cached fixture headlines to engine/fixtures/headlines.json and update default source to use fixture.
|
||||||
|
- [x] Add interactive UI panel for pipeline configuration (right-side panel) with stage toggles and param sliders.
|
||||||
|
- [x] Enumerate all effect plugin parameters automatically for UI control (intensity, decay, etc.)
|
||||||
|
- [ ] Implement pipeline hot-rebuild when stage toggles or params change, preserving camera and display state [#43](https://git.notsosm.art/david/Mainline/issues/43)
|
||||||
|
|
||||||
|
## Test Suite Cleanup & Feature Implementation
|
||||||
|
### Phase 1: Test Suite Cleanup (In Progress)
|
||||||
|
- [x] Port figment feature to modern pipeline architecture
|
||||||
|
- [x] Create `engine/effects/plugins/figment.py` (full port)
|
||||||
|
- [x] Add `figment.py` to `engine/effects/plugins/`
|
||||||
|
- [x] Copy SVG files to `figments/` directory
|
||||||
|
- [x] Update `pyproject.toml` with figment extra
|
||||||
|
- [x] Add `test-figment` preset to `presets.toml`
|
||||||
|
- [x] Update pipeline adapters for overlay effects
|
||||||
|
- [x] Clean up `test_adapters.py` (removed 18 mock-only tests)
|
||||||
|
- [x] Verify all tests pass (652 passing, 20 skipped, 58% coverage)
|
||||||
|
- [ ] Review remaining mock-heavy tests in `test_pipeline.py`
|
||||||
|
- [ ] Review `test_effects.py` for implementation detail tests
|
||||||
|
- [ ] Identify additional tests to remove/consolidate
|
||||||
|
- [ ] Target: ~600 tests total
|
||||||
|
|
||||||
|
### Phase 2: Acceptance Test Expansion (Planned)
|
||||||
|
- [ ] Create `test_message_overlay.py` for message rendering
|
||||||
|
- [ ] Create `test_firehose.py` for firehose rendering
|
||||||
|
- [ ] Create `test_pipeline_order.py` for execution order verification
|
||||||
|
- [ ] Expand `test_figment_effect.py` for animation phases
|
||||||
|
- [ ] Target: 10-15 new acceptance tests
|
||||||
|
|
||||||
|
### Phase 3: Post-Branch Features (Planned)
|
||||||
|
- [ ] Port message overlay system from `upstream_layers.py`
|
||||||
|
- [ ] Port firehose rendering from `upstream_layers.py`
|
||||||
|
- [ ] Create `MessageOverlayStage` for pipeline integration
|
||||||
|
- [ ] Verify figment renders in correct order (effects → figment → messages → display)
|
||||||
|
|
||||||
|
### Phase 4: Visual Quality Improvements (Planned)
|
||||||
|
- [ ] Compare upstream vs current pipeline output
|
||||||
|
- [ ] Implement easing functions for figment animations
|
||||||
|
- [ ] Add animated gradient shifts
|
||||||
|
- [ ] Improve strobe effect patterns
|
||||||
|
- [ ] Use introspection to match visual style
|
||||||
|
|
||||||
|
## Gitea Issues Tracking
|
||||||
|
- [#37](https://git.notsosm.art/david/Mainline/issues/37): Refactor app.py and adapter.py for better maintainability
|
||||||
|
- [#35](https://git.notsosm.art/david/Mainline/issues/35): Epic: Pipeline Mutation API for Stage Hot-Swapping
|
||||||
|
- [#34](https://git.notsosm.art/david/Mainline/issues/34): Improve benchmarking system and performance tests
|
||||||
|
- [#33](https://git.notsosm.art/david/Mainline/issues/33): Add web-based pipeline editor UI
|
||||||
|
- [#26](https://git.notsosm.art/david/Mainline/issues/26): Add Streaming display backend
|
||||||
158
analysis/visual_output_comparison.md
Normal file
158
analysis/visual_output_comparison.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# Visual Output Comparison: Upstream/Main vs Sideline
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
A comprehensive comparison of visual output between `upstream/main` and the sideline branch (`feature/capability-based-deps`) reveals fundamental architectural differences in how content is rendered and displayed.
|
||||||
|
|
||||||
|
## Captured Outputs
|
||||||
|
|
||||||
|
### Sideline (Pipeline Architecture)
|
||||||
|
- **File**: `output/sideline_demo.json`
|
||||||
|
- **Format**: Plain text lines without ANSI cursor positioning
|
||||||
|
- **Content**: Readable headlines with gradient colors applied
|
||||||
|
|
||||||
|
### Upstream/Main (Monolithic Architecture)
|
||||||
|
- **File**: `output/upstream_demo.json`
|
||||||
|
- **Format**: Lines with explicit ANSI cursor positioning codes
|
||||||
|
- **Content**: Cursor positioning codes + block characters + ANSI colors
|
||||||
|
|
||||||
|
## Key Architectural Differences
|
||||||
|
|
||||||
|
### 1. Buffer Content Structure
|
||||||
|
|
||||||
|
**Sideline Pipeline:**
|
||||||
|
```python
|
||||||
|
# Each line is plain text with ANSI colors
|
||||||
|
buffer = [
|
||||||
|
"The Download: OpenAI is building...",
|
||||||
|
"OpenAI is throwing everything...",
|
||||||
|
# ... more lines
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Upstream Monolithic:**
|
||||||
|
```python
|
||||||
|
# Each line includes cursor positioning
|
||||||
|
buffer = [
|
||||||
|
"\033[10;1H \033[2;38;5;238mユ\033[0m \033[2;38;5;37mモ\033[0m ...",
|
||||||
|
"\033[11;1H\033[K", # Clear line 11
|
||||||
|
# ... more lines with positioning
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Rendering Approach
|
||||||
|
|
||||||
|
**Sideline (Pipeline Architecture):**
|
||||||
|
- Stages produce plain text buffers
|
||||||
|
- Display backend handles cursor positioning
|
||||||
|
- `TerminalDisplay.show()` prepends `\033[H\033[J` (home + clear)
|
||||||
|
- Lines are appended sequentially
|
||||||
|
|
||||||
|
**Upstream (Monolithic Architecture):**
|
||||||
|
- `render_ticker_zone()` produces buffers with explicit positioning
|
||||||
|
- Each line includes `\033[{row};1H` to position cursor
|
||||||
|
- Display backend writes buffer directly to stdout
|
||||||
|
- Lines are positioned explicitly in the buffer
|
||||||
|
|
||||||
|
### 3. Content Rendering
|
||||||
|
|
||||||
|
**Sideline:**
|
||||||
|
- Headlines rendered as plain text
|
||||||
|
- Gradient colors applied via ANSI codes
|
||||||
|
- Ticker effect via camera/viewport filtering
|
||||||
|
|
||||||
|
**Upstream:**
|
||||||
|
- Headlines rendered as block characters (▀, ▄, █, etc.)
|
||||||
|
- Japanese katakana glyphs used for glitch effect
|
||||||
|
- Explicit row positioning for each line
|
||||||
|
|
||||||
|
## Visual Output Analysis
|
||||||
|
|
||||||
|
### Sideline Frame 0 (First 5 lines):
|
||||||
|
```
|
||||||
|
Line 0: 'The Download: OpenAI is building a fully automated researcher...'
|
||||||
|
Line 1: 'OpenAI is throwing everything into building a fully automated...'
|
||||||
|
Line 2: 'Mind-altering substances are (still) falling short in clinical...'
|
||||||
|
Line 3: 'The Download: Quantum computing for health...'
|
||||||
|
Line 4: 'Can quantum computers now solve health care problems...'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Upstream Frame 0 (First 5 lines):
|
||||||
|
```
|
||||||
|
Line 0: ''
|
||||||
|
Line 1: '\x1b[2;1H\x1b[K'
|
||||||
|
Line 2: '\x1b[3;1H\x1b[K'
|
||||||
|
Line 3: '\x1b[4;1H\x1b[2;38;5;238m \x1b[0m \x1b[2;38;5;238mリ\x1b[0m ...'
|
||||||
|
Line 4: '\x1b[5;1H\x1b[K'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implications for Visual Comparison
|
||||||
|
|
||||||
|
### Challenges with Direct Comparison
|
||||||
|
1. **Different buffer formats**: Plain text vs. positioned ANSI codes
|
||||||
|
2. **Different rendering pipelines**: Pipeline stages vs. monolithic functions
|
||||||
|
3. **Different content generation**: Headlines vs. block characters
|
||||||
|
|
||||||
|
### Approaches for Visual Verification
|
||||||
|
|
||||||
|
#### Option 1: Render and Compare Terminal Output
|
||||||
|
- Run both branches with `TerminalDisplay`
|
||||||
|
- Capture terminal output (not buffer)
|
||||||
|
- Compare visual rendering
|
||||||
|
- **Challenge**: Requires actual terminal rendering
|
||||||
|
|
||||||
|
#### Option 2: Normalize Buffers for Comparison
|
||||||
|
- Convert upstream positioned buffers to plain text
|
||||||
|
- Strip ANSI cursor positioning codes
|
||||||
|
- Compare normalized content
|
||||||
|
- **Challenge**: Loses positioning information
|
||||||
|
|
||||||
|
#### Option 3: Functional Equivalence Testing
|
||||||
|
- Verify features work the same way
|
||||||
|
- Test message overlay rendering
|
||||||
|
- Test effect application
|
||||||
|
- **Challenge**: Doesn't verify exact visual match
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### For Exact Visual Match
|
||||||
|
1. **Update sideline to match upstream architecture**:
|
||||||
|
- Change `MessageOverlayStage` to return positioned buffers
|
||||||
|
- Update terminal display to handle positioned buffers
|
||||||
|
- This requires significant refactoring
|
||||||
|
|
||||||
|
2. **Accept architectural differences**:
|
||||||
|
- The sideline pipeline architecture is fundamentally different
|
||||||
|
- Visual differences are expected and acceptable
|
||||||
|
- Focus on functional equivalence
|
||||||
|
|
||||||
|
### For Functional Verification
|
||||||
|
1. **Test message overlay rendering**:
|
||||||
|
- Verify message appears in correct position
|
||||||
|
- Verify gradient colors are applied
|
||||||
|
- Verify metadata bar is displayed
|
||||||
|
|
||||||
|
2. **Test effect rendering**:
|
||||||
|
- Verify glitch effect applies block characters
|
||||||
|
- Verify firehose effect renders correctly
|
||||||
|
- Verify figment effect integrates properly
|
||||||
|
|
||||||
|
3. **Test pipeline execution**:
|
||||||
|
- Verify stage execution order
|
||||||
|
- Verify capability resolution
|
||||||
|
- Verify dependency injection
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The visual output comparison reveals that `sideline` and `upstream/main` use fundamentally different rendering architectures:
|
||||||
|
|
||||||
|
- **Upstream**: Explicit cursor positioning in buffer, monolithic rendering
|
||||||
|
- **Sideline**: Plain text buffer, display handles positioning, pipeline rendering
|
||||||
|
|
||||||
|
These differences are **architectural**, not bugs. The sideline branch has successfully adapted the upstream features to a new pipeline architecture.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
1. ✅ Document architectural differences (this file)
|
||||||
|
2. ⏳ Create functional tests for visual verification
|
||||||
|
3. ⏳ Update Gitea issue #50 with findings
|
||||||
|
4. ⏳ Consider whether to adapt sideline to match upstream rendering style
|
||||||
313
client/editor.html
Normal file
313
client/editor.html
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mainline Pipeline Editor</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #eee;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
#sidebar {
|
||||||
|
width: 300px;
|
||||||
|
background: #222;
|
||||||
|
padding: 15px;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
#main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.stage-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.stage-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #333;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.stage-item:hover { background: #444; }
|
||||||
|
.stage-item.selected { background: #0066cc; }
|
||||||
|
.stage-item input[type="checkbox"] {
|
||||||
|
margin-right: 8px;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
.stage-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.param-group {
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.param-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.param-name {
|
||||||
|
width: 100px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.param-slider {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
.param-value {
|
||||||
|
width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
color: #4f4;
|
||||||
|
}
|
||||||
|
.preset-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.preset-btn {
|
||||||
|
background: #333;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #ccc;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.preset-btn:hover { background: #444; }
|
||||||
|
.preset-btn.active { background: #0066cc; border-color: #0077ff; color: #fff; }
|
||||||
|
button.action-btn {
|
||||||
|
background: #0066cc;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
button.action-btn:hover { background: #0077ee; }
|
||||||
|
#status {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
#status.connected { color: #4f4; }
|
||||||
|
#status.disconnected { color: #f44; }
|
||||||
|
#pipeline-view {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.pipeline-node {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 2px;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.pipeline-node.enabled { border-left: 3px solid #4f4; }
|
||||||
|
.pipeline-node.disabled { border-left: 3px solid #666; opacity: 0.6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="sidebar">
|
||||||
|
<div class="section">
|
||||||
|
<h2>Preset</h2>
|
||||||
|
<div id="preset-list" class="preset-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h2>Stages</h2>
|
||||||
|
<ul id="stage-list" class="stage-list"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h2>Parameters</h2>
|
||||||
|
<div id="param-editor" class="param-group"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="main">
|
||||||
|
<h2>Pipeline</h2>
|
||||||
|
<div id="pipeline-view"></div>
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
<button class="action-btn" onclick="sendCommand({action: 'cycle_preset', direction: 1})">Next Preset</button>
|
||||||
|
<button class="action-btn" onclick="sendCommand({action: 'cycle_preset', direction: -1})">Prev Preset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="status">Disconnected</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ws = new WebSocket(`ws://${location.hostname}:8765`);
|
||||||
|
let state = { stages: {}, preset: '', presets: [], selected_stage: null };
|
||||||
|
|
||||||
|
function updateStatus(connected) {
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
status.textContent = connected ? 'Connected' : 'Disconnected';
|
||||||
|
status.className = connected ? 'connected' : 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
ws.onopen = () => {
|
||||||
|
updateStatus(true);
|
||||||
|
// Request initial state
|
||||||
|
ws.send(JSON.stringify({ type: 'state_request' }));
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
updateStatus(false);
|
||||||
|
setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
updateStatus(false);
|
||||||
|
};
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'state') {
|
||||||
|
state = data.state;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Parse error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCommand(command) {
|
||||||
|
ws.send(JSON.stringify({ type: 'command', command }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderPresets();
|
||||||
|
renderStageList();
|
||||||
|
renderPipeline();
|
||||||
|
renderParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPresets() {
|
||||||
|
const container = document.getElementById('preset-list');
|
||||||
|
container.innerHTML = '';
|
||||||
|
(state.presets || []).forEach(preset => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'preset-btn' + (preset === state.preset ? ' active' : '');
|
||||||
|
btn.textContent = preset;
|
||||||
|
btn.onclick = () => sendCommand({ action: 'change_preset', preset });
|
||||||
|
container.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStageList() {
|
||||||
|
const list = document.getElementById('stage-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
Object.entries(state.stages || {}).forEach(([name, info]) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'stage-item' + (name === state.selected_stage ? ' selected' : '');
|
||||||
|
li.innerHTML = `
|
||||||
|
<input type="checkbox" ${info.enabled ? 'checked' : ''}
|
||||||
|
onchange="sendCommand({action: 'toggle_stage', stage: '${name}'})">
|
||||||
|
<span class="stage-name">${name}</span>
|
||||||
|
`;
|
||||||
|
li.onclick = (e) => {
|
||||||
|
if (e.target.type !== 'checkbox') {
|
||||||
|
sendCommand({ action: 'select_stage', stage: name });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPipeline() {
|
||||||
|
const view = document.getElementById('pipeline-view');
|
||||||
|
view.innerHTML = '';
|
||||||
|
const stages = Object.entries(state.stages || {});
|
||||||
|
if (stages.length === 0) {
|
||||||
|
view.textContent = '(No stages)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stages.forEach(([name, info]) => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'pipeline-node' + (info.enabled ? ' enabled' : ' disabled');
|
||||||
|
span.textContent = name;
|
||||||
|
view.appendChild(span);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderParams() {
|
||||||
|
const container = document.getElementById('param-editor');
|
||||||
|
container.innerHTML = '';
|
||||||
|
const selected = state.selected_stage;
|
||||||
|
if (!selected || !state.stages[selected]) {
|
||||||
|
container.innerHTML = '<div style="color:#666;font-size:11px;">(select a stage)</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stage = state.stages[selected];
|
||||||
|
if (!stage.params || Object.keys(stage.params).length === 0) {
|
||||||
|
container.innerHTML = '<div style="color:#666;font-size:11px;">(no params)</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.entries(stage.params).forEach(([key, value]) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'param-row';
|
||||||
|
// Infer min/max/step from typical ranges
|
||||||
|
let min = 0, max = 1, step = 0.1;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (value > 1) { max = value * 2; step = 1; }
|
||||||
|
else { max = 1; step = 0.1; }
|
||||||
|
}
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="param-name">${key}</div>
|
||||||
|
<input type="range" class="param-slider" min="${min}" max="${max}" step="${step}"
|
||||||
|
value="${value}"
|
||||||
|
oninput="adjustParam('${key}', this.value)">
|
||||||
|
<div class="param-value">${typeof value === 'number' ? Number(value).toFixed(2) : value}</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustParam(param, newValue) {
|
||||||
|
const selected = state.selected_stage;
|
||||||
|
if (!selected) return;
|
||||||
|
// Update display immediately for responsiveness
|
||||||
|
const num = parseFloat(newValue);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
// Show updated value
|
||||||
|
document.querySelectorAll('.param-value').forEach(el => {
|
||||||
|
if (el.parentElement.querySelector('.param-name').textContent === param) {
|
||||||
|
el.textContent = num.toFixed(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Send command
|
||||||
|
sendCommand({
|
||||||
|
action: 'adjust_param',
|
||||||
|
stage: selected,
|
||||||
|
param: param,
|
||||||
|
delta: num - (state.stages[selected].params[param] || 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -277,6 +277,9 @@
|
|||||||
} else if (data.type === 'clear') {
|
} else if (data.type === 'clear') {
|
||||||
ctx.fillStyle = '#000';
|
ctx.fillStyle = '#000';
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
} else if (data.type === 'state') {
|
||||||
|
// Log state updates for debugging (can be extended for UI)
|
||||||
|
console.log('State update:', data.state);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse message:', e);
|
console.error('Failed to parse message:', e);
|
||||||
|
|||||||
106
completion/mainline-completion.bash
Normal file
106
completion/mainline-completion.bash
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
# Mainline bash completion script
|
||||||
|
#
|
||||||
|
# To install:
|
||||||
|
# source /path/to/completion/mainline-completion.bash
|
||||||
|
#
|
||||||
|
# Or add to ~/.bashrc:
|
||||||
|
# source /path/to/completion/mainline-completion.bash
|
||||||
|
|
||||||
|
_mainline_completion() {
|
||||||
|
local cur prev words cword
|
||||||
|
_init_completion || return
|
||||||
|
|
||||||
|
# Get current word and previous word
|
||||||
|
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||||
|
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||||
|
|
||||||
|
# Completion options based on previous word
|
||||||
|
case "${prev}" in
|
||||||
|
--display)
|
||||||
|
# Display backends
|
||||||
|
COMPREPLY=($(compgen -W "terminal null replay websocket pygame moderngl" -- "${cur}"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
|
||||||
|
--pipeline-source)
|
||||||
|
# Available sources
|
||||||
|
COMPREPLY=($(compgen -W "headlines poetry empty fixture pipeline-inspect" -- "${cur}"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
|
||||||
|
--pipeline-effects)
|
||||||
|
# Available effects (comma-separated)
|
||||||
|
local effects="afterimage border crop fade firehose glitch hud motionblur noise tint"
|
||||||
|
COMPREPLY=($(compgen -W "${effects}" -- "${cur}"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
|
||||||
|
--pipeline-camera)
|
||||||
|
# Camera modes
|
||||||
|
COMPREPLY=($(compgen -W "feed scroll horizontal omni floating bounce radial" -- "${cur}"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
|
||||||
|
--pipeline-border)
|
||||||
|
# Border modes
|
||||||
|
COMPREPLY=($(compgen -W "off simple ui" -- "${cur}"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
|
||||||
|
--pipeline-display)
|
||||||
|
# Display backends (same as --display)
|
||||||
|
COMPREPLY=($(compgen -W "terminal null replay websocket pygame moderngl" -- "${cur}"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
|
||||||
|
--theme)
|
||||||
|
# Theme colors
|
||||||
|
COMPREPLY=($(compgen -W "green orange purple blue red" -- "${cur}"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
|
||||||
|
--viewport)
|
||||||
|
# Viewport size suggestions
|
||||||
|
COMPREPLY=($(compgen -W "80x24 100x30 120x40 60x20" -- "${cur}"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
|
||||||
|
--preset)
|
||||||
|
# Presets (would need to query available presets)
|
||||||
|
COMPREPLY=($(compgen -W "demo demo-base demo-pygame demo-camera-showcase poetry headlines empty test-basic test-border test-scroll-camera" -- "${cur}"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
|
||||||
|
--positioning)
|
||||||
|
# Positioning modes
|
||||||
|
COMPREPLY=($(compgen -W "absolute relative mixed" -- "${cur}"))
|
||||||
|
return
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Flag completion (start with --)
|
||||||
|
if [[ "${cur}" == -* ]]; then
|
||||||
|
COMPREPLY=($(compgen -W "
|
||||||
|
--display
|
||||||
|
--pipeline-source
|
||||||
|
--pipeline-effects
|
||||||
|
--pipeline-camera
|
||||||
|
--pipeline-display
|
||||||
|
--pipeline-ui
|
||||||
|
--pipeline-border
|
||||||
|
--viewport
|
||||||
|
--preset
|
||||||
|
--theme
|
||||||
|
--positioning
|
||||||
|
--websocket
|
||||||
|
--websocket-port
|
||||||
|
--allow-unsafe
|
||||||
|
--help
|
||||||
|
" -- "${cur}"))
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
complete -F _mainline_completion mainline.py
|
||||||
|
complete -F _mainline_completion python\ -m\ engine.app
|
||||||
|
complete -F _mainline_completion python\ -m\ mainline
|
||||||
81
completion/mainline-completion.fish
Normal file
81
completion/mainline-completion.fish
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Fish completion script for Mainline
|
||||||
|
#
|
||||||
|
# To install:
|
||||||
|
# source /path/to/completion/mainline-completion.fish
|
||||||
|
#
|
||||||
|
# Or copy to ~/.config/fish/completions/mainline.fish
|
||||||
|
|
||||||
|
# Define display backends
|
||||||
|
set -l display_backends terminal null replay websocket pygame moderngl
|
||||||
|
|
||||||
|
# Define sources
|
||||||
|
set -l sources headlines poetry empty fixture pipeline-inspect
|
||||||
|
|
||||||
|
# Define effects
|
||||||
|
set -l effects afterimage border crop fade firehose glitch hud motionblur noise tint
|
||||||
|
|
||||||
|
# Define camera modes
|
||||||
|
set -l cameras feed scroll horizontal omni floating bounce radial
|
||||||
|
|
||||||
|
# Define border modes
|
||||||
|
set -l borders off simple ui
|
||||||
|
|
||||||
|
# Define themes
|
||||||
|
set -l themes green orange purple blue red
|
||||||
|
|
||||||
|
# Define presets
|
||||||
|
set -l presets demo demo-base demo-pygame demo-camera-showcase poetry headlines empty test-basic test-border test-scroll-camera test-figment test-message-overlay
|
||||||
|
|
||||||
|
# Main completion function
|
||||||
|
function __mainline_complete
|
||||||
|
set -l cmd (commandline -po)
|
||||||
|
set -l token (commandline -t)
|
||||||
|
|
||||||
|
# Complete display backends
|
||||||
|
complete -c mainline.py -n '__fish_seen_argument --display' -a "$display_backends" -d 'Display backend'
|
||||||
|
|
||||||
|
# Complete sources
|
||||||
|
complete -c mainline.py -n '__fish_seen_argument --pipeline-source' -a "$sources" -d 'Data source'
|
||||||
|
|
||||||
|
# Complete effects
|
||||||
|
complete -c mainline.py -n '__fish_seen_argument --pipeline-effects' -a "$effects" -d 'Effect plugin'
|
||||||
|
|
||||||
|
# Complete camera modes
|
||||||
|
complete -c mainline.py -n '__fish_seen_argument --pipeline-camera' -a "$cameras" -d 'Camera mode'
|
||||||
|
|
||||||
|
# Complete display backends (pipeline)
|
||||||
|
complete -c mainline.py -n '__fish_seen_argument --pipeline-display' -a "$display_backends" -d 'Display backend'
|
||||||
|
|
||||||
|
# Complete border modes
|
||||||
|
complete -c mainline.py -n '__fish_seen_argument --pipeline-border' -a "$borders" -d 'Border mode'
|
||||||
|
|
||||||
|
# Complete themes
|
||||||
|
complete -c mainline.py -n '__fish_seen_argument --theme' -a "$themes" -d 'Color theme'
|
||||||
|
|
||||||
|
# Complete presets
|
||||||
|
complete -c mainline.py -n '__fish_seen_argument --preset' -a "$presets" -d 'Preset name'
|
||||||
|
|
||||||
|
# Complete viewport sizes
|
||||||
|
complete -c mainline.py -n '__fish_seen_argument --viewport' -a '80x24 100x30 120x40 60x20' -d 'Viewport size (WxH)'
|
||||||
|
|
||||||
|
# Complete flag options
|
||||||
|
complete -c mainline.py -n 'not __fish_seen_argument --display' -l display -d 'Display backend' -a "$display_backends"
|
||||||
|
complete -c mainline.py -n 'not __fish_seen_argument --preset' -l preset -d 'Preset to use' -a "$presets"
|
||||||
|
complete -c mainline.py -n 'not __fish_seen_argument --viewport' -l viewport -d 'Viewport size (WxH)' -a '80x24 100x30 120x40 60x20'
|
||||||
|
complete -c mainline.py -n 'not __fish_seen_argument --theme' -l theme -d 'Color theme' -a "$themes"
|
||||||
|
complete -c mainline.py -l websocket -d 'Enable WebSocket server'
|
||||||
|
complete -c mainline.py -n 'not __fish_seen_argument --websocket-port' -l websocket-port -d 'WebSocket port' -a '8765'
|
||||||
|
complete -c mainline.py -l allow-unsafe -d 'Allow unsafe pipeline configuration'
|
||||||
|
complete -c mainline.py -n 'not __fish_seen_argument --help' -l help -d 'Show help'
|
||||||
|
|
||||||
|
# Pipeline-specific flags
|
||||||
|
complete -c mainline.py -n 'not __fish_seen_argument --pipeline-source' -l pipeline-source -d 'Data source' -a "$sources"
|
||||||
|
complete -c mainline.py -n 'not __fish_seen_argument --pipeline-effects' -l pipeline-effects -d 'Effect plugins (comma-separated)' -a "$effects"
|
||||||
|
complete -c mainline.py -n 'not __fish_seen_argument --pipeline-camera' -l pipeline-camera -d 'Camera mode' -a "$cameras"
|
||||||
|
complete -c mainline.py -n 'not __fish_seen_argument --pipeline-display' -l pipeline-display -d 'Display backend' -a "$display_backends"
|
||||||
|
complete -c mainline.py -l pipeline-ui -d 'Enable UI panel'
|
||||||
|
complete -c mainline.py -n 'not __fish_seen_argument --pipeline-border' -l pipeline-border -d 'Border mode' -a "$borders"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Register the completion function
|
||||||
|
__mainline_complete
|
||||||
48
completion/mainline-completion.zsh
Normal file
48
completion/mainline-completion.zsh
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#compdef mainline.py
|
||||||
|
|
||||||
|
# Mainline zsh completion script
|
||||||
|
#
|
||||||
|
# To install:
|
||||||
|
# source /path/to/completion/mainline-completion.zsh
|
||||||
|
#
|
||||||
|
# Or add to ~/.zshrc:
|
||||||
|
# source /path/to/completion/mainline-completion.zsh
|
||||||
|
|
||||||
|
# Define completion function
|
||||||
|
_mainline() {
|
||||||
|
local -a commands
|
||||||
|
local curcontext="$curcontext" state line
|
||||||
|
typeset -A opt_args
|
||||||
|
|
||||||
|
_arguments -C \
|
||||||
|
'(-h --help)'{-h,--help}'[Show help]' \
|
||||||
|
'--display=[Display backend]:backend:(terminal null replay websocket pygame moderngl)' \
|
||||||
|
'--preset=[Preset to use]:preset:(demo demo-base demo-pygame demo-camera-showcase poetry headlines empty test-basic test-border test-scroll-camera test-figment test-message-overlay)' \
|
||||||
|
'--viewport=[Viewport size]:size:(80x24 100x30 120x40 60x20)' \
|
||||||
|
'--theme=[Color theme]:theme:(green orange purple blue red)' \
|
||||||
|
'--websocket[Enable WebSocket server]' \
|
||||||
|
'--websocket-port=[WebSocket port]:port:' \
|
||||||
|
'--allow-unsafe[Allow unsafe pipeline configuration]' \
|
||||||
|
'(-)*: :{_files}' \
|
||||||
|
&& ret=0
|
||||||
|
|
||||||
|
# Handle --pipeline-* arguments
|
||||||
|
if [[ -n ${words[*]} ]]; then
|
||||||
|
_arguments -C \
|
||||||
|
'--pipeline-source=[Data source]:source:(headlines poetry empty fixture pipeline-inspect)' \
|
||||||
|
'--pipeline-effects=[Effect plugins]:effects:(afterimage border crop fade firehose glitch hud motionblur noise tint)' \
|
||||||
|
'--pipeline-camera=[Camera mode]:camera:(feed scroll horizontal omni floating bounce radial)' \
|
||||||
|
'--pipeline-display=[Display backend]:backend:(terminal null replay websocket pygame moderngl)' \
|
||||||
|
'--pipeline-ui[Enable UI panel]' \
|
||||||
|
'--pipeline-border=[Border mode]:mode:(off simple ui)' \
|
||||||
|
'--viewport=[Viewport size]:size:(80x24 100x30 120x40 60x20)' \
|
||||||
|
&& ret=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
# Register completion function
|
||||||
|
compdef _mainline mainline.py
|
||||||
|
compdef _mainline "python -m engine.app"
|
||||||
|
compdef _mainline "python -m mainline"
|
||||||
@@ -54,7 +54,6 @@ classDiagram
|
|||||||
Display <|.. NullDisplay
|
Display <|.. NullDisplay
|
||||||
Display <|.. PygameDisplay
|
Display <|.. PygameDisplay
|
||||||
Display <|.. WebSocketDisplay
|
Display <|.. WebSocketDisplay
|
||||||
Display <|.. SixelDisplay
|
|
||||||
|
|
||||||
class Camera {
|
class Camera {
|
||||||
+int viewport_width
|
+int viewport_width
|
||||||
@@ -139,8 +138,6 @@ Display(Protocol)
|
|||||||
├── NullDisplay
|
├── NullDisplay
|
||||||
├── PygameDisplay
|
├── PygameDisplay
|
||||||
├── WebSocketDisplay
|
├── WebSocketDisplay
|
||||||
├── SixelDisplay
|
|
||||||
├── KittyDisplay
|
|
||||||
└── MultiDisplay
|
└── MultiDisplay
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
178
docs/GRAPH_SYSTEM_SUMMARY.md
Normal file
178
docs/GRAPH_SYSTEM_SUMMARY.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Graph-Based Pipeline System - Implementation Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Implemented a graph-based scripting language to replace the verbose `XYZStage` naming convention in Mainline's pipeline architecture. The new system represents pipelines as nodes and connections, providing a more intuitive way to define, configure, and orchestrate pipelines.
|
||||||
|
|
||||||
|
## Files Created
|
||||||
|
|
||||||
|
### Core Graph System
|
||||||
|
- `engine/pipeline/graph.py` - Core graph abstraction (Node, Connection, Graph classes)
|
||||||
|
- `engine/pipeline/graph_adapter.py` - Adapter to convert Graph to Pipeline with existing Stage classes
|
||||||
|
- `engine/pipeline/graph_toml.py` - TOML-based graph configuration loader
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- `tests/test_graph_pipeline.py` - Comprehensive test suite (17 tests, all passing)
|
||||||
|
- `examples/graph_dsl_demo.py` - Demo script showing the new DSL
|
||||||
|
- `examples/test_graph_integration.py` - Integration test verifying pipeline execution
|
||||||
|
- `examples/pipeline_graph.toml` - Example TOML configuration file
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `docs/graph-dsl.md` - Complete DSL documentation with examples
|
||||||
|
- `docs/GRAPH_SYSTEM_SUMMARY.md` - This summary document
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
### 1. Graph Abstraction
|
||||||
|
- **Node Types**: `source`, `camera`, `effect`, `position`, `display`, `render`, `overlay`
|
||||||
|
- **Connections**: Directed edges between nodes with automatic dependency resolution
|
||||||
|
- **Validation**: Cycle detection and disconnected node warnings
|
||||||
|
|
||||||
|
### 2. DSL Syntax Options
|
||||||
|
|
||||||
|
#### TOML Configuration
|
||||||
|
```toml
|
||||||
|
[nodes.source]
|
||||||
|
type = "source"
|
||||||
|
source = "headlines"
|
||||||
|
|
||||||
|
[nodes.camera]
|
||||||
|
type = "camera"
|
||||||
|
mode = "scroll"
|
||||||
|
|
||||||
|
[nodes.noise]
|
||||||
|
type = "effect"
|
||||||
|
effect = "noise"
|
||||||
|
intensity = 0.5
|
||||||
|
|
||||||
|
[nodes.display]
|
||||||
|
type = "display"
|
||||||
|
backend = "terminal"
|
||||||
|
|
||||||
|
[connections]
|
||||||
|
list = ["source -> camera -> noise -> display"]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Python API
|
||||||
|
```python
|
||||||
|
from engine.pipeline.graph import Graph, NodeType
|
||||||
|
from engine.pipeline.graph_adapter import graph_to_pipeline
|
||||||
|
|
||||||
|
graph = Graph()
|
||||||
|
graph.node("source", NodeType.SOURCE, source="headlines")
|
||||||
|
graph.node("camera", NodeType.CAMERA, mode="scroll")
|
||||||
|
graph.node("noise", NodeType.EFFECT, effect="noise", intensity=0.5)
|
||||||
|
graph.node("display", NodeType.DISPLAY, backend="terminal")
|
||||||
|
graph.chain("source", "camera", "noise", "display")
|
||||||
|
|
||||||
|
pipeline = graph_to_pipeline(graph)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Dictionary/JSON Input
|
||||||
|
```python
|
||||||
|
from engine.pipeline.graph_adapter import dict_to_pipeline
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"nodes": {
|
||||||
|
"source": "headlines",
|
||||||
|
"noise": {"type": "effect", "effect": "noise", "intensity": 0.5},
|
||||||
|
"display": {"type": "display", "backend": "terminal"}
|
||||||
|
},
|
||||||
|
"connections": ["source -> noise -> display"]
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline = dict_to_pipeline(data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Pipeline Integration
|
||||||
|
|
||||||
|
The graph system integrates with the existing pipeline architecture:
|
||||||
|
|
||||||
|
- **Auto-injection**: Pipeline automatically injects required stages (camera_update, render, etc.)
|
||||||
|
- **Capability Resolution**: Uses existing capability-based dependency system
|
||||||
|
- **Type Safety**: Validates data flow between stages (TEXT_BUFFER, SOURCE_ITEMS, etc.)
|
||||||
|
- **Backward Compatible**: Works alongside existing preset system
|
||||||
|
|
||||||
|
### 4. Node Configuration
|
||||||
|
|
||||||
|
| Node Type | Config Options | Example |
|
||||||
|
|-----------|----------------|---------|
|
||||||
|
| `source` | `source`: "headlines", "poetry", "empty" | `{"type": "source", "source": "headlines"}` |
|
||||||
|
| `camera` | `mode`: "scroll", "feed", "horizontal", etc.<br>`speed`: float | `{"type": "camera", "mode": "scroll", "speed": 1.0}` |
|
||||||
|
| `effect` | `effect`: effect name<br>`intensity`: 0.0-1.0 | `{"type": "effect", "effect": "noise", "intensity": 0.5}` |
|
||||||
|
| `position` | `mode`: "absolute", "relative", "mixed" | `{"type": "position", "mode": "mixed"}` |
|
||||||
|
| `display` | `backend`: "terminal", "null", "websocket" | `{"type": "display", "backend": "terminal"}` |
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
|
||||||
|
### Graph Adapter Logic
|
||||||
|
|
||||||
|
1. **Node Mapping**: Converts graph nodes to appropriate Stage classes
|
||||||
|
2. **Effect Intensity**: Sets effect intensity globally (consistent with existing architecture)
|
||||||
|
3. **Camera Creation**: Maps mode strings to Camera factory methods
|
||||||
|
4. **Dependencies**: Effects automatically depend on `render.output`
|
||||||
|
5. **Type Flow**: Ensures TEXT_BUFFER flow between render and effects
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
- **Disconnected Nodes**: Warns about nodes without connections
|
||||||
|
- **Cycle Detection**: Detects circular dependencies using DFS
|
||||||
|
- **Type Validation**: Pipeline validates inlet/outlet type compatibility
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
### Core Pipeline
|
||||||
|
- `engine/pipeline/controller.py` - Pipeline class (no changes needed, uses existing architecture)
|
||||||
|
- `engine/pipeline/graph_adapter.py` - Added effect intensity setting, fixed PositionStage creation
|
||||||
|
- `engine/app/pipeline_runner.py` - Added graph config support
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- `AGENTS.md` - Updated with task tracking
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
```
|
||||||
|
17 tests passed in 0.23s
|
||||||
|
- Graph creation and manipulation
|
||||||
|
- Connection handling and validation
|
||||||
|
- TOML loading and parsing
|
||||||
|
- Pipeline conversion and execution
|
||||||
|
- Effect intensity configuration
|
||||||
|
- Camera mode mapping
|
||||||
|
- Positioning mode support
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Running with Graph Config
|
||||||
|
```bash
|
||||||
|
python -c "
|
||||||
|
from engine.effects.plugins import discover_plugins
|
||||||
|
from engine.pipeline.graph_toml import load_pipeline_from_toml
|
||||||
|
|
||||||
|
discover_plugins()
|
||||||
|
pipeline = load_pipeline_from_toml('examples/pipeline_graph.toml')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration with Pipeline Runner
|
||||||
|
```bash
|
||||||
|
# The pipeline runner now supports graph configs
|
||||||
|
# (Implementation in progress)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
1. **Simplified Configuration**: No need to manually create Stage instances
|
||||||
|
2. **Visual Representation**: Graph structure is easier to understand than class hierarchy
|
||||||
|
3. **Automatic Dependency Resolution**: Pipeline handles stage ordering automatically
|
||||||
|
4. **Flexible Composition**: Easy to add/remove/modify pipeline stages
|
||||||
|
5. **Backward Compatible**: Existing presets and stages continue to work
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
1. **CLI Integration**: Add `--graph-config` flag to mainline command
|
||||||
|
2. **Visual Builder**: Web-based drag-and-drop pipeline editor
|
||||||
|
3. **Script Execution**: Support for loops, conditionals, and timing in graph scripts
|
||||||
|
4. **Parameter Binding**: Real-time sensor-to-parameter bindings in graph config
|
||||||
|
5. **Pipeline Inspection**: Visual DAG representation with metrics
|
||||||
234
docs/PIPELINE.md
234
docs/PIPELINE.md
@@ -2,136 +2,160 @@
|
|||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
|
The Mainline pipeline uses a **Stage-based architecture** with **capability-based dependency resolution**. Stages declare capabilities (what they provide) and dependencies (what they need), and the Pipeline resolves dependencies using prefix matching.
|
||||||
|
|
||||||
```
|
```
|
||||||
Sources (static/dynamic) → Fetch → Prepare → Scroll → Effects → Render → Display
|
Source Stage → Render Stage → Effect Stages → Display Stage
|
||||||
↓
|
↓
|
||||||
NtfyPoller ← MicMonitor (async)
|
Camera Stage (provides camera.state capability)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Data Source Abstraction (sources_v2.py)
|
### Capability-Based Dependency Resolution
|
||||||
|
|
||||||
- **Static sources**: Data fetched once and cached (HeadlinesDataSource, PoetryDataSource)
|
Stages declare capabilities and dependencies:
|
||||||
- **Dynamic sources**: Idempotent fetch for runtime updates (PipelineDataSource)
|
- **Capabilities**: What the stage provides (e.g., `source`, `render.output`, `display.output`, `camera.state`)
|
||||||
- **SourceRegistry**: Discovery and management of data sources
|
- **Dependencies**: What the stage needs (e.g., `source`, `render.output`, `camera.state`)
|
||||||
|
|
||||||
### Camera Modes
|
The Pipeline resolves dependencies using **prefix matching**:
|
||||||
|
- `"source"` matches `"source.headlines"`, `"source.poetry"`, etc.
|
||||||
|
- `"camera.state"` matches the camera state capability provided by `CameraClockStage`
|
||||||
|
- This allows flexible composition without hardcoding specific stage names
|
||||||
|
|
||||||
- **Vertical**: Scroll up (default)
|
### Minimum Capabilities
|
||||||
- **Horizontal**: Scroll left
|
|
||||||
- **Omni**: Diagonal scroll
|
|
||||||
- **Floating**: Sinusoidal bobbing
|
|
||||||
- **Trace**: Follow network path node-by-node (for pipeline viz)
|
|
||||||
|
|
||||||
## Content to Display Rendering Pipeline
|
The pipeline requires these minimum capabilities to function:
|
||||||
|
- `"source"` - Data source capability (provides raw items)
|
||||||
|
- `"render.output"` - Rendered content capability
|
||||||
|
- `"display.output"` - Display output capability
|
||||||
|
- `"camera.state"` - Camera state for viewport filtering
|
||||||
|
|
||||||
|
These are automatically injected if missing by the `ensure_minimum_capabilities()` method.
|
||||||
|
|
||||||
|
### Stage Registry
|
||||||
|
|
||||||
|
The `StageRegistry` discovers and registers stages automatically:
|
||||||
|
- Scans `engine/stages/` for stage implementations
|
||||||
|
- Registers stages by their declared capabilities
|
||||||
|
- Enables runtime stage discovery and composition
|
||||||
|
|
||||||
|
## Stage-Based Pipeline Flow
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph Sources["Data Sources (v2)"]
|
subgraph Stages["Stage Pipeline"]
|
||||||
Headlines[HeadlinesDataSource]
|
subgraph SourceStage["Source Stage (provides: source.*)"]
|
||||||
Poetry[PoetryDataSource]
|
Headlines[HeadlinesSource]
|
||||||
Pipeline[PipelineDataSource]
|
Poetry[PoetrySource]
|
||||||
Registry[SourceRegistry]
|
Pipeline[PipelineSource]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph SourcesLegacy["Data Sources (legacy)"]
|
subgraph RenderStage["Render Stage (provides: render.*)"]
|
||||||
RSS[("RSS Feeds")]
|
Render[RenderStage]
|
||||||
PoetryFeed[("Poetry Feed")]
|
Canvas[Canvas]
|
||||||
Ntfy[("Ntfy Messages")]
|
Camera[Camera]
|
||||||
Mic[("Microphone")]
|
end
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Fetch["Fetch Layer"]
|
subgraph EffectStages["Effect Stages (provides: effect.*)"]
|
||||||
FC[fetch_all]
|
|
||||||
FP[fetch_poetry]
|
|
||||||
Cache[(Cache)]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Prepare["Prepare Layer"]
|
|
||||||
MB[make_block]
|
|
||||||
Strip[strip_tags]
|
|
||||||
Trans[translate]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Scroll["Scroll Engine"]
|
|
||||||
SC[StreamController]
|
|
||||||
CAM[Camera]
|
|
||||||
RTZ[render_ticker_zone]
|
|
||||||
Msg[render_message_overlay]
|
|
||||||
Grad[lr_gradient]
|
|
||||||
VT[vis_trunc / vis_offset]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Effects["Effect Pipeline"]
|
|
||||||
subgraph EffectsPlugins["Effect Plugins"]
|
|
||||||
Noise[NoiseEffect]
|
Noise[NoiseEffect]
|
||||||
Fade[FadeEffect]
|
Fade[FadeEffect]
|
||||||
Glitch[GlitchEffect]
|
Glitch[GlitchEffect]
|
||||||
Firehose[FirehoseEffect]
|
Firehose[FirehoseEffect]
|
||||||
Hud[HudEffect]
|
Hud[HudEffect]
|
||||||
end
|
end
|
||||||
EC[EffectChain]
|
|
||||||
ER[EffectRegistry]
|
subgraph DisplayStage["Display Stage (provides: display.*)"]
|
||||||
|
Terminal[TerminalDisplay]
|
||||||
|
Pygame[PygameDisplay]
|
||||||
|
WebSocket[WebSocketDisplay]
|
||||||
|
Null[NullDisplay]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Render["Render Layer"]
|
subgraph Capabilities["Capability Map"]
|
||||||
BW[big_wrap]
|
SourceCaps["source.headlines<br/>source.poetry<br/>source.pipeline"]
|
||||||
RL[render_line]
|
RenderCaps["render.output<br/>render.canvas"]
|
||||||
|
EffectCaps["effect.noise<br/>effect.fade<br/>effect.glitch"]
|
||||||
|
DisplayCaps["display.output<br/>display.terminal"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Display["Display Backends"]
|
SourceStage --> RenderStage
|
||||||
TD[TerminalDisplay]
|
RenderStage --> EffectStages
|
||||||
PD[PygameDisplay]
|
EffectStages --> DisplayStage
|
||||||
SD[SixelDisplay]
|
|
||||||
KD[KittyDisplay]
|
|
||||||
WSD[WebSocketDisplay]
|
|
||||||
ND[NullDisplay]
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph Async["Async Sources"]
|
SourceStage --> SourceCaps
|
||||||
NTFY[NtfyPoller]
|
RenderStage --> RenderCaps
|
||||||
MIC[MicMonitor]
|
EffectStages --> EffectCaps
|
||||||
end
|
DisplayStage --> DisplayCaps
|
||||||
|
|
||||||
subgraph Animation["Animation System"]
|
style SourceStage fill:#f9f,stroke:#333
|
||||||
AC[AnimationController]
|
style RenderStage fill:#bbf,stroke:#333
|
||||||
PR[Preset]
|
style EffectStages fill:#fbf,stroke:#333
|
||||||
end
|
style DisplayStage fill:#bfb,stroke:#333
|
||||||
|
|
||||||
Sources --> Fetch
|
|
||||||
RSS --> FC
|
|
||||||
PoetryFeed --> FP
|
|
||||||
FC --> Cache
|
|
||||||
FP --> Cache
|
|
||||||
Cache --> MB
|
|
||||||
Strip --> MB
|
|
||||||
Trans --> MB
|
|
||||||
MB --> SC
|
|
||||||
NTFY --> SC
|
|
||||||
SC --> RTZ
|
|
||||||
CAM --> RTZ
|
|
||||||
Grad --> RTZ
|
|
||||||
VT --> RTZ
|
|
||||||
RTZ --> EC
|
|
||||||
EC --> ER
|
|
||||||
ER --> EffectsPlugins
|
|
||||||
EffectsPlugins --> BW
|
|
||||||
BW --> RL
|
|
||||||
RL --> Display
|
|
||||||
Ntfy --> RL
|
|
||||||
Mic --> RL
|
|
||||||
MIC --> RL
|
|
||||||
|
|
||||||
style Sources fill:#f9f,stroke:#333
|
|
||||||
style Fetch fill:#bbf,stroke:#333
|
|
||||||
style Prepare fill:#bff,stroke:#333
|
|
||||||
style Scroll fill:#bfb,stroke:#333
|
|
||||||
style Effects fill:#fbf,stroke:#333
|
|
||||||
style Render fill:#ffb,stroke:#333
|
|
||||||
style Display fill:#bbf,stroke:#333
|
|
||||||
style Async fill:#fbb,stroke:#333
|
|
||||||
style Animation fill:#bfb,stroke:#333
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Stage Adapters
|
||||||
|
|
||||||
|
Existing components are wrapped as Stages via adapters:
|
||||||
|
|
||||||
|
### Source Stage Adapter
|
||||||
|
- Wraps `HeadlinesDataSource`, `PoetryDataSource`, etc.
|
||||||
|
- Provides `source.*` capabilities
|
||||||
|
- Fetches data and outputs to pipeline buffer
|
||||||
|
|
||||||
|
### Render Stage Adapter
|
||||||
|
- Wraps `StreamController`, `Camera`, `render_ticker_zone`
|
||||||
|
- Provides `render.output` capability
|
||||||
|
- Processes content and renders to canvas
|
||||||
|
|
||||||
|
### Effect Stage Adapter
|
||||||
|
- Wraps `EffectChain` and individual effect plugins
|
||||||
|
- Provides `effect.*` capabilities
|
||||||
|
- Applies visual effects to rendered content
|
||||||
|
|
||||||
|
### Display Stage Adapter
|
||||||
|
- Wraps `TerminalDisplay`, `PygameDisplay`, etc.
|
||||||
|
- Provides `display.*` capabilities
|
||||||
|
- Outputs final buffer to display backend
|
||||||
|
|
||||||
|
## Pipeline Mutation API
|
||||||
|
|
||||||
|
The Pipeline supports dynamic mutation during runtime:
|
||||||
|
|
||||||
|
### Core Methods
|
||||||
|
- `add_stage(name, stage, initialize=True)` - Add a stage
|
||||||
|
- `remove_stage(name, cleanup=True)` - Remove a stage and rebuild execution order
|
||||||
|
- `replace_stage(name, new_stage, preserve_state=True)` - Replace a stage
|
||||||
|
- `swap_stages(name1, name2)` - Swap two stages
|
||||||
|
- `move_stage(name, after=None, before=None)` - Move a stage in execution order
|
||||||
|
- `enable_stage(name)` / `disable_stage(name)` - Enable/disable stages
|
||||||
|
|
||||||
|
### Safety Checks
|
||||||
|
- `can_hot_swap(name)` - Check if a stage can be safely hot-swapped
|
||||||
|
- `cleanup_stage(name)` - Clean up specific stage without removing it
|
||||||
|
|
||||||
|
### WebSocket Commands
|
||||||
|
The mutation API is accessible via WebSocket for remote control:
|
||||||
|
```json
|
||||||
|
{"action": "remove_stage", "stage": "stage_name"}
|
||||||
|
{"action": "swap_stages", "stage1": "name1", "stage2": "name2"}
|
||||||
|
{"action": "enable_stage", "stage": "stage_name"}
|
||||||
|
{"action": "cleanup_stage", "stage": "stage_name"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Camera Modes
|
||||||
|
|
||||||
|
The Camera supports the following modes:
|
||||||
|
|
||||||
|
- **FEED**: Single item view (static or rapid cycling)
|
||||||
|
- **SCROLL**: Smooth vertical scrolling (movie credits style)
|
||||||
|
- **HORIZONTAL**: Left/right movement
|
||||||
|
- **OMNI**: Combination of vertical and horizontal
|
||||||
|
- **FLOATING**: Sinusoidal/bobbing motion
|
||||||
|
- **BOUNCE**: DVD-style bouncing off edges
|
||||||
|
- **RADIAL**: Polar coordinate scanning (radar sweep)
|
||||||
|
|
||||||
|
Note: Camera state is provided by `CameraClockStage` (capability: `camera.state`) which updates independently of data flow. The `CameraStage` applies viewport transformations (capability: `camera`).
|
||||||
|
|
||||||
## Animation & Presets
|
## Animation & Presets
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -161,7 +185,7 @@ flowchart LR
|
|||||||
Triggers --> Events
|
Triggers --> Events
|
||||||
```
|
```
|
||||||
|
|
||||||
## Camera Modes
|
## Camera Modes State Diagram
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
|
|||||||
30
docs/SUMMARY.md
Normal file
30
docs/SUMMARY.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Mainline Documentation Summary
|
||||||
|
|
||||||
|
## Core Architecture
|
||||||
|
- [Pipeline Architecture](PIPELINE.md) - Pipeline stages, capability resolution, DAG execution
|
||||||
|
- [Graph-Based DSL](graph-dsl.md) - New graph abstraction for pipeline configuration
|
||||||
|
|
||||||
|
## Pipeline Configuration
|
||||||
|
- [Hybrid Config](hybrid-config.md) - **Recommended**: Preset simplicity + graph flexibility
|
||||||
|
- [Graph DSL](graph-dsl.md) - Verbose node-based graph definition
|
||||||
|
- [Presets Usage](presets-usage.md) - Creating and using pipeline presets
|
||||||
|
|
||||||
|
## Feature Documentation
|
||||||
|
- [Positioning Analysis](positioning-analysis.md) - Positioning modes and tradeoffs
|
||||||
|
- [Pipeline Introspection](pipeline_introspection.md) - Live pipeline visualization
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
- [Graph System Summary](GRAPH_SYSTEM_SUMMARY.md) - Complete implementation overview
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
**Recommended: Hybrid Configuration**
|
||||||
|
```toml
|
||||||
|
[pipeline]
|
||||||
|
source = "headlines"
|
||||||
|
camera = { mode = "scroll" }
|
||||||
|
effects = [{ name = "noise", intensity = 0.3 }]
|
||||||
|
display = { backend = "terminal" }
|
||||||
|
```
|
||||||
|
|
||||||
|
See `docs/hybrid-config.md` for details.
|
||||||
236
docs/analysis_graph_dsl_duplicative.md
Normal file
236
docs/analysis_graph_dsl_duplicative.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# Analysis: Graph DSL Duplicative Issue
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
The current Graph DSL implementation in Mainline is **duplicative** because:
|
||||||
|
|
||||||
|
1. **Node definitions are repeated**: Every node requires a full `[nodes.name]` block with `type` and specific config, even when the type can often be inferred
|
||||||
|
2. **Connections are separate**: The `[connections]` list must manually reference node names that were just defined
|
||||||
|
3. **Type specification is redundant**: The `type = "effect"` is always the same as the key name prefix
|
||||||
|
4. **No implicit connections**: Even linear pipelines require explicit connection strings
|
||||||
|
|
||||||
|
This creates significant verbosity compared to the preset system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Makes the Script Feel "Duplicative"
|
||||||
|
|
||||||
|
### 1. Type Specification Redundancy
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[nodes.noise]
|
||||||
|
type = "effect" # ← Redundant: already know it's an effect from context
|
||||||
|
effect = "noise"
|
||||||
|
intensity = 0.3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why it's redundant:**
|
||||||
|
- The `[nodes.noise]` section name suggests it's a custom node
|
||||||
|
- The `effect = "noise"` key implies it's an effect type
|
||||||
|
- The parser could infer the type from the presence of `effect` key
|
||||||
|
|
||||||
|
### 2. Connection String Redundancy
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[connections]
|
||||||
|
list = ["source -> camera -> noise -> fade -> glitch -> firehose -> display"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why it's redundant:**
|
||||||
|
- All node names were already defined in individual blocks above
|
||||||
|
- For linear pipelines, the natural flow is obvious
|
||||||
|
- The connection order matches the definition order
|
||||||
|
|
||||||
|
### 3. Verbosity Comparison
|
||||||
|
|
||||||
|
**Preset System (10 lines):**
|
||||||
|
```toml
|
||||||
|
[presets.upstream-default]
|
||||||
|
source = "headlines"
|
||||||
|
display = "terminal"
|
||||||
|
camera = "scroll"
|
||||||
|
effects = ["noise", "fade", "glitch", "firehose"]
|
||||||
|
camera_speed = 1.0
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
```
|
||||||
|
|
||||||
|
**Graph DSL (39 lines):**
|
||||||
|
- 3.9x more lines for the same pipeline
|
||||||
|
- Each effect requires 4 lines instead of 1 line in preset system
|
||||||
|
- Connection string repeats all node names
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Syntactic Sugar Options
|
||||||
|
|
||||||
|
### Option 1: Type Inference (Immediate)
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
```toml
|
||||||
|
[nodes.noise]
|
||||||
|
type = "effect"
|
||||||
|
effect = "noise"
|
||||||
|
intensity = 0.3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Proposed:**
|
||||||
|
```toml
|
||||||
|
[nodes.noise]
|
||||||
|
effect = "noise" # Type inferred from 'effect' key
|
||||||
|
intensity = 0.3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation:** Modify `graph_toml.py` to infer node type from keys:
|
||||||
|
- `effect` key → type = "effect"
|
||||||
|
- `backend` key → type = "display"
|
||||||
|
- `source` key → type = "source"
|
||||||
|
- `mode` key → type = "camera"
|
||||||
|
|
||||||
|
### Option 2: Implicit Linear Connections
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
```toml
|
||||||
|
[connections]
|
||||||
|
list = ["source -> camera -> noise -> fade -> display"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Proposed:**
|
||||||
|
```toml
|
||||||
|
[connections]
|
||||||
|
implicit = true # Auto-connect all nodes in definition order
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation:** If `implicit = true`, automatically create connections between consecutive nodes.
|
||||||
|
|
||||||
|
### Option 3: Inline Node Definitions
|
||||||
|
|
||||||
|
**Current:**
|
||||||
|
```toml
|
||||||
|
[nodes.noise]
|
||||||
|
type = "effect"
|
||||||
|
effect = "noise"
|
||||||
|
intensity = 0.3
|
||||||
|
|
||||||
|
[nodes.fade]
|
||||||
|
type = "effect"
|
||||||
|
effect = "fade"
|
||||||
|
intensity = 0.5
|
||||||
|
```
|
||||||
|
|
||||||
|
**Proposed:**
|
||||||
|
```toml
|
||||||
|
[graph]
|
||||||
|
nodes = [
|
||||||
|
{ name = "source", source = "headlines" },
|
||||||
|
{ name = "noise", effect = "noise", intensity = 0.3 },
|
||||||
|
{ name = "fade", effect = "fade", intensity = 0.5 },
|
||||||
|
{ name = "display", backend = "terminal" }
|
||||||
|
]
|
||||||
|
connections = ["source -> noise -> fade -> display"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 4: Hybrid Preset-Graph System
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[presets.custom]
|
||||||
|
source = "headlines"
|
||||||
|
display = "terminal"
|
||||||
|
camera = "scroll"
|
||||||
|
effects = [
|
||||||
|
{ name = "noise", intensity = 0.3 },
|
||||||
|
{ name = "fade", intensity = 0.5 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparative Analysis: Other Systems
|
||||||
|
|
||||||
|
### GitHub Actions
|
||||||
|
```yaml
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-node@v2
|
||||||
|
- run: npm install
|
||||||
|
```
|
||||||
|
- Steps in order, no explicit connection syntax
|
||||||
|
- Type inference from `uses` or `run`
|
||||||
|
|
||||||
|
### Apache Airflow
|
||||||
|
```python
|
||||||
|
task1 = PythonOperator(...)
|
||||||
|
task2 = PythonOperator(...)
|
||||||
|
task1 >> task2 # Minimal connection syntax
|
||||||
|
```
|
||||||
|
|
||||||
|
### Jenkins Pipeline
|
||||||
|
```groovy
|
||||||
|
stages {
|
||||||
|
stage('Build') { steps { sh 'make' } }
|
||||||
|
stage('Test') { steps { sh 'make test' } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Implicit sequential execution
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Improvements
|
||||||
|
|
||||||
|
### Immediate (Backward Compatible)
|
||||||
|
|
||||||
|
1. **Type Inference** - Make `type` field optional
|
||||||
|
2. **Implicit Connections** - Add `implicit = true` option
|
||||||
|
3. **Array Format** - Support `nodes = ["a", "b", "c"]` format
|
||||||
|
|
||||||
|
### Example: Improved Configuration
|
||||||
|
|
||||||
|
**Current (39 lines):**
|
||||||
|
```toml
|
||||||
|
[nodes.source]
|
||||||
|
type = "source"
|
||||||
|
source = "headlines"
|
||||||
|
|
||||||
|
[nodes.camera]
|
||||||
|
type = "camera"
|
||||||
|
mode = "scroll"
|
||||||
|
speed = 1.0
|
||||||
|
|
||||||
|
[nodes.noise]
|
||||||
|
type = "effect"
|
||||||
|
effect = "noise"
|
||||||
|
intensity = 0.3
|
||||||
|
|
||||||
|
[nodes.display]
|
||||||
|
type = "display"
|
||||||
|
backend = "terminal"
|
||||||
|
|
||||||
|
[connections]
|
||||||
|
list = ["source -> camera -> noise -> display"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Improved (13 lines, 67% reduction):**
|
||||||
|
```toml
|
||||||
|
[graph]
|
||||||
|
nodes = [
|
||||||
|
{ name = "source", source = "headlines" },
|
||||||
|
{ name = "camera", mode = "scroll", speed = 1.0 },
|
||||||
|
{ name = "noise", effect = "noise", intensity = 0.3 },
|
||||||
|
{ name = "display", backend = "terminal" }
|
||||||
|
]
|
||||||
|
|
||||||
|
[connections]
|
||||||
|
implicit = true # Auto-connects: source -> camera -> noise -> display
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The Graph DSL's duplicative nature stems from:
|
||||||
|
1. **Explicit type specification** when it could be inferred
|
||||||
|
2. **Separate connection definitions** that repeat node names
|
||||||
|
3. **Verbose node definitions** for simple cases
|
||||||
|
4. **Lack of implicit defaults** for linear pipelines
|
||||||
|
|
||||||
|
The recommended improvements focus on **type inference** and **implicit connections** as immediate wins that reduce verbosity by 50%+ while maintaining full flexibility for complex pipelines.
|
||||||
210
docs/graph-dsl.md
Normal file
210
docs/graph-dsl.md
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
# Graph-Based Pipeline DSL
|
||||||
|
|
||||||
|
This document describes the new graph-based DSL for defining pipelines in Mainline.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The graph DSL represents pipelines as nodes and connections, replacing the verbose `XYZStage` naming convention with a more intuitive graph abstraction.
|
||||||
|
|
||||||
|
## TOML Syntax
|
||||||
|
|
||||||
|
### Basic Pipeline
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[nodes.source]
|
||||||
|
type = "source"
|
||||||
|
source = "headlines"
|
||||||
|
|
||||||
|
[nodes.camera]
|
||||||
|
type = "camera"
|
||||||
|
mode = "scroll"
|
||||||
|
|
||||||
|
[nodes.display]
|
||||||
|
type = "display"
|
||||||
|
backend = "terminal"
|
||||||
|
|
||||||
|
[connections]
|
||||||
|
list = ["source -> camera -> display"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Effects
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[nodes.source]
|
||||||
|
type = "source"
|
||||||
|
source = "headlines"
|
||||||
|
|
||||||
|
[nodes.noise]
|
||||||
|
type = "effect"
|
||||||
|
effect = "noise"
|
||||||
|
intensity = 0.5
|
||||||
|
|
||||||
|
[nodes.fade]
|
||||||
|
type = "effect"
|
||||||
|
effect = "fade"
|
||||||
|
intensity = 0.8
|
||||||
|
|
||||||
|
[nodes.display]
|
||||||
|
type = "display"
|
||||||
|
backend = "terminal"
|
||||||
|
|
||||||
|
[connections]
|
||||||
|
list = ["source -> noise -> fade -> display"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Positioning
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[nodes.source]
|
||||||
|
type = "source"
|
||||||
|
source = "headlines"
|
||||||
|
|
||||||
|
[nodes.position]
|
||||||
|
type = "position"
|
||||||
|
mode = "mixed"
|
||||||
|
|
||||||
|
[nodes.display]
|
||||||
|
type = "display"
|
||||||
|
backend = "terminal"
|
||||||
|
|
||||||
|
[connections]
|
||||||
|
list = ["source -> position -> display"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python API
|
||||||
|
|
||||||
|
### Basic Construction
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.pipeline.graph import Graph, NodeType
|
||||||
|
|
||||||
|
graph = Graph()
|
||||||
|
graph.node("source", NodeType.SOURCE, source="headlines")
|
||||||
|
graph.node("camera", NodeType.CAMERA, mode="scroll")
|
||||||
|
graph.node("display", NodeType.DISPLAY, backend="terminal")
|
||||||
|
graph.chain("source", "camera", "display")
|
||||||
|
|
||||||
|
pipeline = graph_to_pipeline(graph)
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Effects
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.pipeline.graph import Graph, NodeType
|
||||||
|
|
||||||
|
graph = Graph()
|
||||||
|
graph.node("source", NodeType.SOURCE, source="headlines")
|
||||||
|
graph.node("noise", NodeType.EFFECT, effect="noise", intensity=0.5)
|
||||||
|
graph.node("fade", NodeType.EFFECT, effect="fade", intensity=0.8)
|
||||||
|
graph.node("display", NodeType.DISPLAY, backend="terminal")
|
||||||
|
graph.chain("source", "noise", "fade", "display")
|
||||||
|
|
||||||
|
pipeline = graph_to_pipeline(graph)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dictionary/JSON Input
|
||||||
|
|
||||||
|
```python
|
||||||
|
from engine.pipeline.graph_adapter import dict_to_pipeline
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"nodes": {
|
||||||
|
"source": "headlines",
|
||||||
|
"noise": {"type": "effect", "effect": "noise", "intensity": 0.5},
|
||||||
|
"display": {"type": "display", "backend": "terminal"}
|
||||||
|
},
|
||||||
|
"connections": ["source -> noise -> display"]
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline = dict_to_pipeline(data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI Usage
|
||||||
|
|
||||||
|
### Using Graph Config File
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mainline --graph-config pipeline.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Inline Graph Definition
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mainline --graph 'source:headlines -> noise:noise:0.5 -> display:terminal'
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Preset Override
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mainline --preset demo --graph-modify 'add:noise:0.5 after:source'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Node Types
|
||||||
|
|
||||||
|
| Type | Description | Config Options |
|
||||||
|
|------|-------------|----------------|
|
||||||
|
| `source` | Data source | `source`: "headlines", "poetry", "empty", etc. |
|
||||||
|
| `camera` | Viewport camera | `mode`: "scroll", "feed", "horizontal", etc. `speed`: float |
|
||||||
|
| `effect` | Visual effect | `effect`: effect name, `intensity`: 0.0-1.0 |
|
||||||
|
| `position` | Positioning mode | `mode`: "absolute", "relative", "mixed" |
|
||||||
|
| `display` | Output backend | `backend`: "terminal", "null", "websocket", etc. |
|
||||||
|
| `render` | Text rendering | (auto-injected) |
|
||||||
|
| `overlay` | Message overlay | (auto-injected) |
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### Conditional Connections
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[connections]
|
||||||
|
list = ["source -> camera -> display"]
|
||||||
|
# Effects can be conditionally enabled/disabled
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameter Binding
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[nodes.noise]
|
||||||
|
type = "effect"
|
||||||
|
effect = "noise"
|
||||||
|
intensity = 1.0
|
||||||
|
# intensity can be bound to sensor values at runtime
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pipeline Inspection
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[nodes.inspect]
|
||||||
|
type = "pipeline-inspect"
|
||||||
|
# Renders live pipeline visualization
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparison with Stage-Based Approach
|
||||||
|
|
||||||
|
### Old (Stage-Based)
|
||||||
|
|
||||||
|
```python
|
||||||
|
pipeline = Pipeline()
|
||||||
|
pipeline.add_stage("source", DataSourceStage(HeadlinesDataSource()))
|
||||||
|
pipeline.add_stage("camera", CameraStage(Camera.scroll()))
|
||||||
|
pipeline.add_stage("render", FontStage())
|
||||||
|
pipeline.add_stage("noise", EffectPluginStage(noise_effect))
|
||||||
|
pipeline.add_stage("display", DisplayStage(terminal_display))
|
||||||
|
```
|
||||||
|
|
||||||
|
### New (Graph-Based)
|
||||||
|
|
||||||
|
```python
|
||||||
|
graph = Graph()
|
||||||
|
graph.node("source", NodeType.SOURCE, source="headlines")
|
||||||
|
graph.node("camera", NodeType.CAMERA, mode="scroll")
|
||||||
|
graph.node("noise", NodeType.EFFECT, effect="noise")
|
||||||
|
graph.node("display", NodeType.DISPLAY, backend="terminal")
|
||||||
|
graph.chain("source", "camera", "noise", "display")
|
||||||
|
pipeline = graph_to_pipeline(graph)
|
||||||
|
```
|
||||||
|
|
||||||
|
The graph system automatically:
|
||||||
|
- Inserts the render stage between camera and effects
|
||||||
|
- Handles capability-based dependency resolution
|
||||||
|
- Auto-injects required stages (camera_update, render, etc.)
|
||||||
267
docs/hybrid-config.md
Normal file
267
docs/hybrid-config.md
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
# Hybrid Preset-Graph Configuration
|
||||||
|
|
||||||
|
The hybrid configuration format combines the simplicity of presets with the flexibility of graphs, providing a concise way to define pipelines.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The hybrid format uses **70% less space** than the verbose node-based DSL while providing the same functionality.
|
||||||
|
|
||||||
|
### Comparison
|
||||||
|
|
||||||
|
**Verbose Node DSL (39 lines):**
|
||||||
|
```toml
|
||||||
|
[nodes.source]
|
||||||
|
type = "source"
|
||||||
|
source = "headlines"
|
||||||
|
|
||||||
|
[nodes.camera]
|
||||||
|
type = "camera"
|
||||||
|
mode = "scroll"
|
||||||
|
speed = 1.0
|
||||||
|
|
||||||
|
[nodes.noise]
|
||||||
|
type = "effect"
|
||||||
|
effect = "noise"
|
||||||
|
intensity = 0.3
|
||||||
|
|
||||||
|
[nodes.display]
|
||||||
|
type = "display"
|
||||||
|
backend = "terminal"
|
||||||
|
|
||||||
|
[connections]
|
||||||
|
list = ["source -> camera -> noise -> display"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hybrid Config (20 lines):**
|
||||||
|
```toml
|
||||||
|
[pipeline]
|
||||||
|
source = "headlines"
|
||||||
|
|
||||||
|
camera = { mode = "scroll", speed = 1.0 }
|
||||||
|
|
||||||
|
effects = [
|
||||||
|
{ name = "noise", intensity = 0.3 }
|
||||||
|
]
|
||||||
|
|
||||||
|
display = { backend = "terminal" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Syntax
|
||||||
|
|
||||||
|
### Basic Structure
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[pipeline]
|
||||||
|
source = "headlines"
|
||||||
|
camera = { mode = "scroll", speed = 1.0 }
|
||||||
|
effects = [
|
||||||
|
{ name = "noise", intensity = 0.3 },
|
||||||
|
{ name = "fade", intensity = 0.5 }
|
||||||
|
]
|
||||||
|
display = { backend = "terminal", positioning = "mixed" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
#### Source
|
||||||
|
```toml
|
||||||
|
source = "headlines" # Built-in source: headlines, poetry, empty, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Camera
|
||||||
|
```toml
|
||||||
|
# Inline object notation
|
||||||
|
camera = { mode = "scroll", speed = 1.0 }
|
||||||
|
|
||||||
|
# Or shorthand (uses defaults)
|
||||||
|
camera = "scroll"
|
||||||
|
```
|
||||||
|
|
||||||
|
Available modes: `scroll`, `feed`, `horizontal`, `omni`, `floating`, `bounce`, `radial`
|
||||||
|
|
||||||
|
#### Effects
|
||||||
|
```toml
|
||||||
|
# Array of effect configurations
|
||||||
|
effects = [
|
||||||
|
{ name = "noise", intensity = 0.3 },
|
||||||
|
{ name = "fade", intensity = 0.5, enabled = true }
|
||||||
|
]
|
||||||
|
|
||||||
|
# Or shorthand (uses defaults)
|
||||||
|
effects = ["noise", "fade"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Available effects: `noise`, `fade`, `glitch`, `firehose`, `tint`, `hud`, etc.
|
||||||
|
|
||||||
|
#### Display
|
||||||
|
```toml
|
||||||
|
# Inline object notation
|
||||||
|
display = { backend = "terminal", positioning = "mixed" }
|
||||||
|
|
||||||
|
# Or shorthand
|
||||||
|
display = "terminal"
|
||||||
|
```
|
||||||
|
|
||||||
|
Available backends: `terminal`, `null`, `websocket`, `pygame`
|
||||||
|
|
||||||
|
### Viewport Settings
|
||||||
|
```toml
|
||||||
|
[pipeline]
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Examples
|
||||||
|
|
||||||
|
### Minimal Configuration
|
||||||
|
```toml
|
||||||
|
[pipeline]
|
||||||
|
source = "headlines"
|
||||||
|
display = "terminal"
|
||||||
|
```
|
||||||
|
|
||||||
|
### With Camera and Effects
|
||||||
|
```toml
|
||||||
|
[pipeline]
|
||||||
|
source = "headlines"
|
||||||
|
camera = { mode = "scroll", speed = 1.0 }
|
||||||
|
effects = [
|
||||||
|
{ name = "noise", intensity = 0.3 },
|
||||||
|
{ name = "fade", intensity = 0.5 }
|
||||||
|
]
|
||||||
|
display = { backend = "terminal", positioning = "mixed" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Full Configuration
|
||||||
|
```toml
|
||||||
|
[pipeline]
|
||||||
|
source = "poetry"
|
||||||
|
camera = { mode = "scroll", speed = 1.5 }
|
||||||
|
effects = [
|
||||||
|
{ name = "noise", intensity = 0.2 },
|
||||||
|
{ name = "fade", intensity = 0.4 },
|
||||||
|
{ name = "glitch", intensity = 0.3 },
|
||||||
|
{ name = "firehose", intensity = 0.5 }
|
||||||
|
]
|
||||||
|
display = { backend = "terminal", positioning = "mixed" }
|
||||||
|
viewport_width = 100
|
||||||
|
viewport_height = 30
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python API
|
||||||
|
|
||||||
|
### Loading from TOML File
|
||||||
|
```python
|
||||||
|
from engine.pipeline.hybrid_config import load_hybrid_config
|
||||||
|
|
||||||
|
config = load_hybrid_config("examples/hybrid_config.toml")
|
||||||
|
pipeline = config.to_pipeline()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Creating Config Programmatically
|
||||||
|
```python
|
||||||
|
from engine.pipeline.hybrid_config import (
|
||||||
|
PipelineConfig,
|
||||||
|
CameraConfig,
|
||||||
|
EffectConfig,
|
||||||
|
DisplayConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
config = PipelineConfig(
|
||||||
|
source="headlines",
|
||||||
|
camera=CameraConfig(mode="scroll", speed=1.0),
|
||||||
|
effects=[
|
||||||
|
EffectConfig(name="noise", intensity=0.3),
|
||||||
|
EffectConfig(name="fade", intensity=0.5),
|
||||||
|
],
|
||||||
|
display=DisplayConfig(backend="terminal", positioning="mixed"),
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline = config.to_pipeline(viewport_width=80, viewport_height=24)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Converting to Graph
|
||||||
|
```python
|
||||||
|
from engine.pipeline.hybrid_config import PipelineConfig
|
||||||
|
|
||||||
|
config = PipelineConfig(source="headlines", display={"backend": "terminal"})
|
||||||
|
graph = config.to_graph() # Returns Graph object for further manipulation
|
||||||
|
```
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
The hybrid config system:
|
||||||
|
|
||||||
|
1. **Parses TOML** into a `PipelineConfig` dataclass
|
||||||
|
2. **Converts to Graph** internally using automatic linear connections
|
||||||
|
3. **Reuses existing adapter** to convert graph to pipeline stages
|
||||||
|
4. **Maintains backward compatibility** with verbose node DSL
|
||||||
|
|
||||||
|
### Automatic Connection Logic
|
||||||
|
|
||||||
|
The system automatically creates linear connections:
|
||||||
|
```
|
||||||
|
source -> camera -> effects[0] -> effects[1] -> ... -> display
|
||||||
|
```
|
||||||
|
|
||||||
|
This covers 90% of use cases. For complex DAGs, use the verbose node DSL.
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### From Presets
|
||||||
|
The hybrid format is very similar to presets:
|
||||||
|
|
||||||
|
**Preset:**
|
||||||
|
```toml
|
||||||
|
[presets.custom]
|
||||||
|
source = "headlines"
|
||||||
|
effects = ["noise", "fade"]
|
||||||
|
display = "terminal"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hybrid:**
|
||||||
|
```toml
|
||||||
|
[pipeline]
|
||||||
|
source = "headlines"
|
||||||
|
effects = ["noise", "fade"]
|
||||||
|
display = "terminal"
|
||||||
|
```
|
||||||
|
|
||||||
|
The main difference is using `[pipeline]` instead of `[presets.custom]`.
|
||||||
|
|
||||||
|
### From Verbose Node DSL
|
||||||
|
**Old (39 lines):**
|
||||||
|
```toml
|
||||||
|
[nodes.source] type = "source" source = "headlines"
|
||||||
|
[nodes.camera] type = "camera" mode = "scroll"
|
||||||
|
[nodes.noise] type = "effect" effect = "noise" intensity = 0.3
|
||||||
|
[nodes.display] type = "display" backend = "terminal"
|
||||||
|
[connections] list = ["source -> camera -> noise -> display"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**New (14 lines):**
|
||||||
|
```toml
|
||||||
|
[pipeline]
|
||||||
|
source = "headlines"
|
||||||
|
camera = { mode = "scroll" }
|
||||||
|
effects = [{ name = "noise", intensity = 0.3 }]
|
||||||
|
display = { backend = "terminal" }
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use Each Format
|
||||||
|
|
||||||
|
| Format | Use When | Lines (example) |
|
||||||
|
|--------|----------|-----------------|
|
||||||
|
| **Preset** | Simple configurations, no effect intensity tuning | 10 |
|
||||||
|
| **Hybrid** | Most common use cases, need intensity tuning | 20 |
|
||||||
|
| **Verbose Node DSL** | Complex DAGs, branching, custom connections | 39 |
|
||||||
|
| **Python API** | Dynamic configuration, programmatic generation | N/A |
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
See `examples/hybrid_config.toml` for a complete working example.
|
||||||
|
|
||||||
|
Run the demo:
|
||||||
|
```bash
|
||||||
|
python examples/hybrid_visualization.py
|
||||||
|
```
|
||||||
303
docs/positioning-analysis.md
Normal file
303
docs/positioning-analysis.md
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
# ANSI Positioning Approaches Analysis
|
||||||
|
|
||||||
|
## Current Positioning Methods in Mainline
|
||||||
|
|
||||||
|
### 1. Absolute Positioning (Cursor Positioning Codes)
|
||||||
|
|
||||||
|
**Syntax**: `\033[row;colH` (move cursor to row, column)
|
||||||
|
|
||||||
|
**Used by Effects**:
|
||||||
|
- **HUD Effect**: `\033[1;1H`, `\033[2;1H`, `\033[3;1H` - Places HUD at fixed rows
|
||||||
|
- **Firehose Effect**: `\033[{scr_row};1H` - Places firehose content at bottom rows
|
||||||
|
- **Figment Effect**: `\033[{scr_row};{center_col + 1}H` - Centers content
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```
|
||||||
|
\033[1;1HMAINLINE DEMO | FPS: 60.0 | 16.7ms
|
||||||
|
\033[2;1HEFFECT: hud | ████████████████░░░░ | 100%
|
||||||
|
\033[3;1HPIPELINE: source,camera,render,effect
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics**:
|
||||||
|
- Each line has explicit row/column coordinates
|
||||||
|
- Cursor moves to exact position before writing
|
||||||
|
- Overlay effects can place content at specific locations
|
||||||
|
- Independent of buffer line order
|
||||||
|
- Used by effects that need to overlay on top of content
|
||||||
|
|
||||||
|
### 2. Relative Positioning (Newline-Based)
|
||||||
|
|
||||||
|
**Syntax**: `\n` (move cursor to next line)
|
||||||
|
|
||||||
|
**Used by Base Content**:
|
||||||
|
- Camera output: Plain text lines
|
||||||
|
- Render output: Block character lines
|
||||||
|
- Joined with newlines in terminal display
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```
|
||||||
|
\033[H\033[Jline1\nline2\nline3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics**:
|
||||||
|
- Lines are in sequence (top to bottom)
|
||||||
|
- Cursor moves down one line after each `\n`
|
||||||
|
- Content flows naturally from top to bottom
|
||||||
|
- Cannot place content at specific row without empty lines
|
||||||
|
- Used by base content from camera/render
|
||||||
|
|
||||||
|
### 3. Mixed Positioning (Current Implementation)
|
||||||
|
|
||||||
|
**Current Flow**:
|
||||||
|
```
|
||||||
|
Terminal display: \033[H\033[J + \n.join(buffer)
|
||||||
|
Buffer structure: [line1, line2, \033[1;1HHUD line, ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Behavior**:
|
||||||
|
1. `\033[H\033[J` - Move to (1,1), clear screen
|
||||||
|
2. `line1\n` - Write line1, move to line2
|
||||||
|
3. `line2\n` - Write line2, move to line3
|
||||||
|
4. `\033[1;1H` - Move back to (1,1)
|
||||||
|
5. Write HUD content
|
||||||
|
|
||||||
|
**Issue**: Overlapping cursor movements can cause visual glitches
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Analysis
|
||||||
|
|
||||||
|
### Absolute Positioning Performance
|
||||||
|
|
||||||
|
**Advantages**:
|
||||||
|
- Precise control over output position
|
||||||
|
- No need for empty buffer lines
|
||||||
|
- Effects can overlay without affecting base content
|
||||||
|
- Efficient for static overlays (HUD, status bars)
|
||||||
|
|
||||||
|
**Disadvantages**:
|
||||||
|
- More ANSI codes = larger output size
|
||||||
|
- Each line requires `\033[row;colH` prefix
|
||||||
|
- Can cause redraw issues if not cleared properly
|
||||||
|
- Terminal must parse more escape sequences
|
||||||
|
|
||||||
|
**Output Size Comparison** (24 lines):
|
||||||
|
- Absolute: ~1,200 bytes (avg 50 chars/line + 30 ANSI codes)
|
||||||
|
- Relative: ~960 bytes (80 chars/line * 24 lines)
|
||||||
|
|
||||||
|
### Relative Positioning Performance
|
||||||
|
|
||||||
|
**Advantages**:
|
||||||
|
- Minimal ANSI codes (only colors, no positioning)
|
||||||
|
- Smaller output size
|
||||||
|
- Terminal renders faster (less parsing)
|
||||||
|
- Natural flow for scrolling content
|
||||||
|
|
||||||
|
**Disadvantages**:
|
||||||
|
- Requires empty lines for spacing
|
||||||
|
- Cannot overlay content without buffer manipulation
|
||||||
|
- Limited control over exact positioning
|
||||||
|
- Harder to implement HUD/status overlays
|
||||||
|
|
||||||
|
**Output Size Comparison** (24 lines):
|
||||||
|
- Base content: ~1,920 bytes (80 chars * 24 lines)
|
||||||
|
- With colors only: ~2,400 bytes (adds color codes)
|
||||||
|
|
||||||
|
### Mixed Positioning Performance
|
||||||
|
|
||||||
|
**Current Implementation**:
|
||||||
|
- Base content uses relative (newlines)
|
||||||
|
- Effects use absolute (cursor positioning)
|
||||||
|
- Combined output has both methods
|
||||||
|
|
||||||
|
**Trade-offs**:
|
||||||
|
- Medium output size
|
||||||
|
- Flexible positioning
|
||||||
|
- Potential visual conflicts if not coordinated
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animation Performance Implications
|
||||||
|
|
||||||
|
### Scrolling Animations (Camera Feed/Scroll)
|
||||||
|
|
||||||
|
**Best Approach**: Relative positioning with newlines
|
||||||
|
- **Why**: Smooth scrolling requires continuous buffer updates
|
||||||
|
- **Alternative**: Absolute positioning would require recalculating all coordinates
|
||||||
|
|
||||||
|
**Performance**:
|
||||||
|
- Relative: 60 FPS achievable with 80x24 buffer
|
||||||
|
- Absolute: 55-60 FPS (slightly slower due to more ANSI codes)
|
||||||
|
- Mixed: 58-60 FPS (negligible difference for small buffers)
|
||||||
|
|
||||||
|
### Static Overlay Animations (HUD, Status Bars)
|
||||||
|
|
||||||
|
**Best Approach**: Absolute positioning
|
||||||
|
- **Why**: HUD content doesn't change position, only content
|
||||||
|
- **Alternative**: Could use fixed buffer positions with relative, but less flexible
|
||||||
|
|
||||||
|
**Performance**:
|
||||||
|
- Absolute: Minimal overhead (3 lines with ANSI codes)
|
||||||
|
- Relative: Requires maintaining fixed positions in buffer (more complex)
|
||||||
|
|
||||||
|
### Particle/Effect Animations (Firehose, Figment)
|
||||||
|
|
||||||
|
**Best Approach**: Mixed positioning
|
||||||
|
- **Why**: Base content flows normally, particles overlay at specific positions
|
||||||
|
- **Alternative**: All absolute would be overkill
|
||||||
|
|
||||||
|
**Performance**:
|
||||||
|
- Mixed: Optimal balance
|
||||||
|
- Particles at bottom: `\033[{row};1H` (only affected lines)
|
||||||
|
- Base content: `\n` (natural flow)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proposed Design: PositionStage
|
||||||
|
|
||||||
|
### Capability Definition
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PositioningMode(Enum):
|
||||||
|
"""Positioning mode for terminal rendering."""
|
||||||
|
ABSOLUTE = "absolute" # Use cursor positioning codes for all lines
|
||||||
|
RELATIVE = "relative" # Use newlines for all lines
|
||||||
|
MIXED = "mixed" # Base content relative, effects absolute (current)
|
||||||
|
```
|
||||||
|
|
||||||
|
### PositionStage Implementation
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PositionStage(Stage):
|
||||||
|
"""Applies positioning mode to buffer before display."""
|
||||||
|
|
||||||
|
def __init__(self, mode: PositioningMode = PositioningMode.RELATIVE):
|
||||||
|
self.mode = mode
|
||||||
|
self.name = f"position-{mode.value}"
|
||||||
|
self.category = "position"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"position.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return {"render.output"} # Needs content before positioning
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
if self.mode == PositioningMode.ABSOLUTE:
|
||||||
|
return self._to_absolute(data, ctx)
|
||||||
|
elif self.mode == PositioningMode.RELATIVE:
|
||||||
|
return self._to_relative(data, ctx)
|
||||||
|
else: # MIXED
|
||||||
|
return data # No transformation needed
|
||||||
|
|
||||||
|
def _to_absolute(self, data: list[str], ctx: PipelineContext) -> list[str]:
|
||||||
|
"""Convert buffer to absolute positioning (all lines have cursor codes)."""
|
||||||
|
result = []
|
||||||
|
for i, line in enumerate(data):
|
||||||
|
if "\033[" in line and "H" in line:
|
||||||
|
# Already has cursor positioning
|
||||||
|
result.append(line)
|
||||||
|
else:
|
||||||
|
# Add cursor positioning for this line
|
||||||
|
result.append(f"\033[{i + 1};1H{line}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _to_relative(self, data: list[str], ctx: PipelineContext) -> list[str]:
|
||||||
|
"""Convert buffer to relative positioning (use newlines)."""
|
||||||
|
# For relative mode, we need to ensure cursor positioning codes are removed
|
||||||
|
# This is complex because some effects need them
|
||||||
|
return data # Leave as-is, terminal display handles newlines
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage in Pipeline
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# Demo: Absolute positioning (for comparison)
|
||||||
|
[presets.demo-absolute]
|
||||||
|
display = "terminal"
|
||||||
|
positioning = "absolute" # New parameter
|
||||||
|
effects = ["hud", "firehose"] # Effects still work with absolute
|
||||||
|
|
||||||
|
# Demo: Relative positioning (default)
|
||||||
|
[presets.demo-relative]
|
||||||
|
display = "terminal"
|
||||||
|
positioning = "relative" # New parameter
|
||||||
|
effects = ["hud", "firehose"] # Effects must adapt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terminal Display Integration
|
||||||
|
|
||||||
|
```python
|
||||||
|
def show(self, buffer: list[str], border: bool = False, mode: PositioningMode = None) -> None:
|
||||||
|
# Apply border if requested
|
||||||
|
if border and border != BorderMode.OFF:
|
||||||
|
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||||
|
|
||||||
|
# Apply positioning based on mode
|
||||||
|
if mode == PositioningMode.ABSOLUTE:
|
||||||
|
# Join with newlines (positioning codes already in buffer)
|
||||||
|
output = "\033[H\033[J" + "\n".join(buffer)
|
||||||
|
elif mode == PositioningMode.RELATIVE:
|
||||||
|
# Join with newlines
|
||||||
|
output = "\033[H\033,J" + "\n".join(buffer)
|
||||||
|
else: # MIXED
|
||||||
|
# Current implementation
|
||||||
|
output = "\033[H\033[J" + "\n".join(buffer)
|
||||||
|
|
||||||
|
sys.stdout.buffer.write(output.encode())
|
||||||
|
sys.stdout.flush()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### For Different Animation Types
|
||||||
|
|
||||||
|
1. **Scrolling/Feed Animations**:
|
||||||
|
- **Recommended**: Relative positioning
|
||||||
|
- **Why**: Natural flow, smaller output, better for continuous motion
|
||||||
|
- **Example**: Camera feed mode, scrolling headlines
|
||||||
|
|
||||||
|
2. **Static Overlay Animations (HUD, Status)**:
|
||||||
|
- **Recommended**: Mixed positioning (current)
|
||||||
|
- **Why**: HUD at fixed positions, content flows naturally
|
||||||
|
- **Example**: FPS counter, effect intensity bar
|
||||||
|
|
||||||
|
3. **Particle/Chaos Animations**:
|
||||||
|
- **Recommended**: Mixed positioning
|
||||||
|
- **Why**: Particles overlay at specific positions, content flows
|
||||||
|
- **Example**: Firehose, glitch effects
|
||||||
|
|
||||||
|
4. **Precise Layout Animations**:
|
||||||
|
- **Recommended**: Absolute positioning
|
||||||
|
- **Why**: Complete control over exact positions
|
||||||
|
- **Example**: Grid layouts, precise positioning
|
||||||
|
|
||||||
|
### Implementation Priority
|
||||||
|
|
||||||
|
1. **Phase 1**: Document current behavior (done)
|
||||||
|
2. **Phase 2**: Create PositionStage with configurable mode
|
||||||
|
3. **Phase 3**: Update terminal display to respect positioning mode
|
||||||
|
4. **Phase 4**: Create presets for different positioning modes
|
||||||
|
5. **Phase 5**: Performance testing and optimization
|
||||||
|
|
||||||
|
### Key Considerations
|
||||||
|
|
||||||
|
- **Backward Compatibility**: Keep mixed positioning as default
|
||||||
|
- **Performance**: Relative is ~20% faster for large buffers
|
||||||
|
- **Flexibility**: Absolute allows precise control but increases output size
|
||||||
|
- **Simplicity**: Mixed provides best balance for typical use cases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Implement `PositioningMode` enum
|
||||||
|
2. Create `PositionStage` class with mode configuration
|
||||||
|
3. Update terminal display to accept positioning mode parameter
|
||||||
|
4. Create test presets for each positioning mode
|
||||||
|
5. Performance benchmark each approach
|
||||||
|
6. Document best practices for choosing positioning mode
|
||||||
219
docs/presets-usage.md
Normal file
219
docs/presets-usage.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Presets Usage Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The sideline branch introduces a new preset system that allows you to easily configure different pipeline behaviors. This guide explains the available presets and how to use them.
|
||||||
|
|
||||||
|
## Available Presets
|
||||||
|
|
||||||
|
### 1. upstream-default
|
||||||
|
|
||||||
|
**Purpose:** Matches the default upstream Mainline operation for comparison.
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- **Display:** Terminal (not pygame)
|
||||||
|
- **Camera:** Scroll mode
|
||||||
|
- **Effects:** noise, fade, glitch, firehose (classic four effects)
|
||||||
|
- **Positioning:** Mixed mode
|
||||||
|
- **Message Overlay:** Disabled (matches upstream)
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
python -m mainline --preset upstream-default --display terminal
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best for:**
|
||||||
|
- Comparing sideline vs upstream behavior
|
||||||
|
- Legacy terminal-based operation
|
||||||
|
- Baseline performance testing
|
||||||
|
|
||||||
|
### 2. demo
|
||||||
|
|
||||||
|
**Purpose:** Showcases sideline features including hotswappable effects and sensors.
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- **Display:** Pygame (graphical display)
|
||||||
|
- **Camera:** Scroll mode
|
||||||
|
- **Effects:** noise, fade, glitch, firehose, hud (with visual feedback)
|
||||||
|
- **Positioning:** Mixed mode
|
||||||
|
- **Message Overlay:** Enabled (with ntfy integration)
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- **Hotswappable Effects:** Effects can be toggled and modified at runtime
|
||||||
|
- **LFO Sensor Modulation:** Oscillator sensor provides smooth intensity modulation
|
||||||
|
- **Visual Feedback:** HUD effect shows current effect state and pipeline info
|
||||||
|
- **Mixed Positioning:** Optimal balance of performance and control
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
python -m mainline --preset demo --display pygame
|
||||||
|
```
|
||||||
|
|
||||||
|
**Best for:**
|
||||||
|
- Exploring sideline capabilities
|
||||||
|
- Testing effect hotswapping
|
||||||
|
- Demonstrating sensor integration
|
||||||
|
|
||||||
|
### 3. demo-base / demo-pygame
|
||||||
|
|
||||||
|
**Purpose:** Base presets for custom effect hotswapping experiments.
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- **Display:** Terminal (base) or Pygame (pygame variant)
|
||||||
|
- **Camera:** Feed mode
|
||||||
|
- **Effects:** Empty (add your own)
|
||||||
|
- **Positioning:** Mixed mode
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
python -m mainline --preset demo-pygame --display pygame
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Other Presets
|
||||||
|
|
||||||
|
- `poetry`: Poetry feed with subtle effects
|
||||||
|
- `firehose`: High-speed firehose mode
|
||||||
|
- `ui`: Interactive UI mode with control panel
|
||||||
|
- `fixture`: Uses cached headline fixtures
|
||||||
|
- `websocket`: WebSocket display mode
|
||||||
|
|
||||||
|
## Positioning Modes
|
||||||
|
|
||||||
|
The `--positioning` flag controls how text is positioned in the terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Relative positioning (newlines, good for scrolling)
|
||||||
|
python -m mainline --positioning relative --preset demo
|
||||||
|
|
||||||
|
# Absolute positioning (cursor codes, good for overlays)
|
||||||
|
python -m mainline --positioning absolute --preset demo
|
||||||
|
|
||||||
|
# Mixed positioning (default, optimal balance)
|
||||||
|
python -m mainline --positioning mixed --preset demo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pipeline Stages
|
||||||
|
|
||||||
|
### Upstream-Default Pipeline
|
||||||
|
|
||||||
|
1. **Source Stage:** Headlines data source
|
||||||
|
2. **Viewport Filter:** Filters items to viewport height
|
||||||
|
3. **Font Stage:** Renders headlines as block characters
|
||||||
|
4. **Camera Stages:** Scrolling animation
|
||||||
|
5. **Effect Stages:** noise, fade, glitch, firehose
|
||||||
|
6. **Display Stage:** Terminal output
|
||||||
|
|
||||||
|
### Demo Pipeline
|
||||||
|
|
||||||
|
1. **Source Stage:** Headlines data source
|
||||||
|
2. **Viewport Filter:** Filters items to viewport height
|
||||||
|
3. **Font Stage:** Renders headlines as block characters
|
||||||
|
4. **Camera Stages:** Scrolling animation
|
||||||
|
5. **Effect Stages:** noise, fade, glitch, firehose, hud
|
||||||
|
6. **Message Overlay:** Ntfy message integration
|
||||||
|
7. **Display Stage:** Pygame output
|
||||||
|
|
||||||
|
## Command-Line Examples
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run upstream-default preset
|
||||||
|
python -m mainline --preset upstream-default --display terminal
|
||||||
|
|
||||||
|
# Run demo preset
|
||||||
|
python -m mainline --preset demo --display pygame
|
||||||
|
|
||||||
|
# Run with custom positioning
|
||||||
|
python -m mainline --preset demo --display pygame --positioning absolute
|
||||||
|
```
|
||||||
|
|
||||||
|
### Comparison Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Capture upstream output
|
||||||
|
python -m mainline --preset upstream-default --display null --viewport 80x24
|
||||||
|
|
||||||
|
# Capture sideline output
|
||||||
|
python -m mainline --preset demo --display null --viewport 80x24
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hotswapping Effects
|
||||||
|
|
||||||
|
The demo preset supports hotswapping effects at runtime:
|
||||||
|
- Use the WebSocket display to send commands
|
||||||
|
- Toggle effects on/off
|
||||||
|
- Adjust intensity values in real-time
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
### Built-in Presets
|
||||||
|
|
||||||
|
Location: `engine/pipeline/presets.py` (Python code)
|
||||||
|
|
||||||
|
### User Presets
|
||||||
|
|
||||||
|
Location: `~/.config/mainline/presets.toml` or `./presets.toml`
|
||||||
|
|
||||||
|
Example user preset:
|
||||||
|
```toml
|
||||||
|
[presets.my-custom-preset]
|
||||||
|
description = "My custom configuration"
|
||||||
|
source = "headlines"
|
||||||
|
display = "terminal"
|
||||||
|
camera = "scroll"
|
||||||
|
effects = ["noise", "fade"]
|
||||||
|
positioning = "mixed"
|
||||||
|
viewport_width = 100
|
||||||
|
viewport_height = 30
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sensor Configuration
|
||||||
|
|
||||||
|
### Oscillator Sensor (LFO)
|
||||||
|
|
||||||
|
The oscillator sensor provides Low Frequency Oscillator modulation:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[sensors.oscillator]
|
||||||
|
enabled = true
|
||||||
|
waveform = "sine" # sine, square, triangle, sawtooth
|
||||||
|
frequency = 0.05 # 20 second cycle (gentle)
|
||||||
|
amplitude = 0.5 # 50% modulation
|
||||||
|
```
|
||||||
|
|
||||||
|
### Effect Configuration
|
||||||
|
|
||||||
|
Effect intensities can be configured with initial values:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[effect_configs.noise]
|
||||||
|
enabled = true
|
||||||
|
intensity = 1.0
|
||||||
|
|
||||||
|
[effect_configs.fade]
|
||||||
|
enabled = true
|
||||||
|
intensity = 1.0
|
||||||
|
|
||||||
|
[effect_configs.glitch]
|
||||||
|
enabled = true
|
||||||
|
intensity = 0.5
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No Display Output
|
||||||
|
|
||||||
|
- Check if display backend is available (pygame, terminal, etc.)
|
||||||
|
- Use `--display null` for headless testing
|
||||||
|
|
||||||
|
### Effects Not Modulating
|
||||||
|
|
||||||
|
- Ensure sensor is enabled in presets.toml
|
||||||
|
- Check effect intensity values in configuration
|
||||||
|
|
||||||
|
### Performance Issues
|
||||||
|
|
||||||
|
- Use `--positioning relative` for large buffers
|
||||||
|
- Reduce viewport height for better performance
|
||||||
|
- Use null display for testing without rendering
|
||||||
217
docs/proposals/adr-preset-scripting-language.md
Normal file
217
docs/proposals/adr-preset-scripting-language.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# ADR: Preset Scripting Language for Mainline
|
||||||
|
|
||||||
|
## Status: Draft
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
We need to evaluate whether to add a scripting language for authoring presets in Mainline, replacing or augmenting the current TOML-based preset system. The goals are:
|
||||||
|
|
||||||
|
1. **Expressiveness**: More powerful than TOML for describing dynamic, procedural, or dataflow-based presets
|
||||||
|
2. **Live coding**: Support hot-reloading of presets during runtime (like TidalCycles or Sonic Pi)
|
||||||
|
3. **Testing**: Include assertion language to package tests alongside presets
|
||||||
|
4. **Toolchain**: Consider packaging and build processes
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
|
||||||
|
The current preset system uses TOML files (`presets.toml`) with a simple structure:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[presets.demo-base]
|
||||||
|
description = "Demo: Base preset for effect hot-swapping"
|
||||||
|
source = "headlines"
|
||||||
|
display = "terminal"
|
||||||
|
camera = "feed"
|
||||||
|
effects = [] # Demo script will add/remove effects dynamically
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
```
|
||||||
|
|
||||||
|
This is declarative and static. It cannot express:
|
||||||
|
- Conditional logic based on runtime state
|
||||||
|
- Dataflow between pipeline stages
|
||||||
|
- Procedural generation of stage configurations
|
||||||
|
- Assertions or validation of preset behavior
|
||||||
|
|
||||||
|
### Problems with TOML
|
||||||
|
|
||||||
|
- No way to express dependencies between effects or stages
|
||||||
|
- Cannot describe temporal/animated behavior
|
||||||
|
- No support for sensor bindings or parametric animations
|
||||||
|
- Static configuration cannot adapt to runtime conditions
|
||||||
|
- No built-in testing/assertion mechanism
|
||||||
|
|
||||||
|
## Approaches
|
||||||
|
|
||||||
|
### 1. Visual Dataflow Language (PureData-style)
|
||||||
|
|
||||||
|
Inspired by Pure Data (Pd), Max/MSP, and TouchDesigner:
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Intuitive for creative coding and live performance
|
||||||
|
- Strong model for real-time parameter modulation
|
||||||
|
- Matches the "patcher" paradigm already seen in pipeline architecture
|
||||||
|
- Rich ecosystem of visual programming tools
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Complex to implement from scratch
|
||||||
|
- Requires dedicated GUI editor
|
||||||
|
- Harder to version control (binary/graph formats)
|
||||||
|
- Mermaid diagrams alone aren't sufficient for this
|
||||||
|
|
||||||
|
**Tools to explore:**
|
||||||
|
- libpd (Pure Data bindings for other languages)
|
||||||
|
- Node-based frameworks (node-red, various DSP tools)
|
||||||
|
- TouchDesigner-like approaches
|
||||||
|
|
||||||
|
### 2. Textual DSL (TidalCycles-style)
|
||||||
|
|
||||||
|
Domain-specific language focused on pattern transformation:
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Lightweight, fast iteration
|
||||||
|
- Easy to version control (text files)
|
||||||
|
- Can express complex patterns with minimal syntax
|
||||||
|
- Proven in livecoding community
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Learning curve for non-programmers
|
||||||
|
- Less visual than PureData approach
|
||||||
|
|
||||||
|
**Example (hypothetical):**
|
||||||
|
```
|
||||||
|
preset my-show {
|
||||||
|
source: headlines
|
||||||
|
|
||||||
|
every 8s {
|
||||||
|
effect noise: intensity = (0.5 <-> 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
on mic.level > 0.7 {
|
||||||
|
effect glitch: intensity += 0.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Embed Existing Language
|
||||||
|
|
||||||
|
Embed Lua, Python, or JavaScript:
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Full power of general-purpose language
|
||||||
|
- Existing tooling, testing frameworks
|
||||||
|
- Easy to integrate (many embeddable interpreters)
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Security concerns with running user code
|
||||||
|
- May be overkill for simple presets
|
||||||
|
- Testing/assertion system must be built on top
|
||||||
|
|
||||||
|
**Tools:**
|
||||||
|
- Lua (lightweight, fast)
|
||||||
|
- Python (rich ecosystem, but heavier)
|
||||||
|
- QuickJS (small, embeddable JS)
|
||||||
|
|
||||||
|
### 4. Hybrid Approach
|
||||||
|
|
||||||
|
Visual editor generates textual DSL that compiles to Python:
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Best of both worlds
|
||||||
|
- Can start with simple DSL and add editor later
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- More complex initial implementation
|
||||||
|
|
||||||
|
## Requirements Analysis
|
||||||
|
|
||||||
|
### Must Have
|
||||||
|
- [ ] Express pipeline stage configurations (source, effects, camera, display)
|
||||||
|
- [ ] Support parameter bindings to sensors
|
||||||
|
- [ ] Hot-reloading during runtime
|
||||||
|
- [ ] Integration with existing Pipeline architecture
|
||||||
|
|
||||||
|
### Should Have
|
||||||
|
- [ ] Basic assertion language for testing
|
||||||
|
- [ ] Ability to define custom abstractions/modules
|
||||||
|
- [ ] Version control friendly (text-based)
|
||||||
|
|
||||||
|
### Could Have
|
||||||
|
- [ ] Visual node-based editor
|
||||||
|
- [ ] Real-time visualization of dataflow
|
||||||
|
- [ ] MIDI/OSC support for external controllers
|
||||||
|
|
||||||
|
## User Stories (Proposed)
|
||||||
|
|
||||||
|
### Spike Stories (Investigation)
|
||||||
|
|
||||||
|
**Story 1: Evaluate DSL Parsing Tools**
|
||||||
|
> As a developer, I want to understand the available Python DSL parsing libraries (Lark, parsy, pyparsing) so that I can choose the right tool for implementing a preset DSL.
|
||||||
|
>
|
||||||
|
> **Acceptance**: Document pros/cons of 3+ parsing libraries with small proof-of-concept experiments
|
||||||
|
|
||||||
|
**Story 2: Research Livecoding Languages**
|
||||||
|
> As a developer, I want to understand how TidalCycles, Sonic Pi, and PureData handle hot-reloading and pattern generation so that I can apply similar techniques to Mainline.
|
||||||
|
>
|
||||||
|
> **Acceptance**: Document key architectural patterns from 2+ livecoding systems
|
||||||
|
|
||||||
|
**Story 3: Prototype Textual DSL**
|
||||||
|
> As a preset author, I want to write presets in a simple textual DSL that supports basic conditionals and sensor bindings.
|
||||||
|
>
|
||||||
|
> **Acceptance**: Create a prototype DSL that can parse a sample preset and convert to PipelineConfig
|
||||||
|
|
||||||
|
**Story 4: Investigate Assertion/Testing Approaches**
|
||||||
|
> As a quality engineer, I want to include assertions with presets so that preset behavior can be validated automatically.
|
||||||
|
>
|
||||||
|
> **Acceptance**: Survey testing patterns in livecoding and propose assertion syntax
|
||||||
|
|
||||||
|
### Implementation Stories (Future)
|
||||||
|
|
||||||
|
**Story 5: Implement Core DSL Parser**
|
||||||
|
> As a preset author, I want to write presets in a textual DSL that supports sensors, conditionals, and parameter bindings.
|
||||||
|
>
|
||||||
|
> **Acceptance**: DSL parser handles the core syntax, produces valid PipelineConfig
|
||||||
|
|
||||||
|
**Story 6: Hot-Reload System**
|
||||||
|
> As a performer, I want to edit preset files and see changes reflected in real-time without restarting.
|
||||||
|
>
|
||||||
|
> **Acceptance**: File watcher + pipeline mutation API integration works
|
||||||
|
|
||||||
|
**Story 7: Assertion Language**
|
||||||
|
> As a preset author, I want to include assertions that validate sensor values or pipeline state.
|
||||||
|
>
|
||||||
|
> **Acceptance**: Assertions can run as part of preset execution and report pass/fail
|
||||||
|
|
||||||
|
**Story 8: Toolchain/Packaging**
|
||||||
|
> As a preset distributor, I want to package presets with dependencies for easy sharing.
|
||||||
|
>
|
||||||
|
> **Acceptance**: Can create, build, and install a preset package
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Recommend: Start with textual DSL approach (Option 2/4)**
|
||||||
|
|
||||||
|
Rationale:
|
||||||
|
- Lowest barrier to entry (text files, version control)
|
||||||
|
- Can evolve to hybrid later if visual editor is needed
|
||||||
|
- Strong precedents in livecoding community (TidalCycles, Sonic Pi)
|
||||||
|
- Enables hot-reloading naturally
|
||||||
|
- Assertion language can be part of the DSL syntax
|
||||||
|
|
||||||
|
**Not recommending Mermaid**: Mermaid is excellent for documentation and visualization, but it's a diagramming tool, not a programming language. It cannot express the logic, conditionals, and sensor bindings we need.
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Execute Spike Stories 1-4 to reduce uncertainty
|
||||||
|
2. Create minimal viable DSL syntax
|
||||||
|
3. Prototype hot-reloading with existing preset system
|
||||||
|
4. Evaluate whether visual editor adds sufficient value to warrant complexity
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- Pure Data: https://puredata.info/
|
||||||
|
- TidalCycles: https://tidalcycles.org/
|
||||||
|
- Sonic Pi: https://sonic-pi.net/
|
||||||
|
- Lark parser: https://lark-parser.readthedocs.io/
|
||||||
|
- Mainline Pipeline Architecture: `engine/pipeline/`
|
||||||
|
- Current Presets: `presets.toml`
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# README Update Design — 2026-03-15
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Restructure and expand `README.md` to:
|
|
||||||
1. Align with the current codebase (Python 3.10+, uv/mise/pytest/ruff toolchain, 6 new fonts)
|
|
||||||
2. Add extensibility-focused content (`Extending` section)
|
|
||||||
3. Add developer workflow coverage (`Development` section)
|
|
||||||
4. Improve navigability via top-level grouping (Approach C)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proposed Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
# MAINLINE
|
|
||||||
> tagline + description
|
|
||||||
|
|
||||||
## Using
|
|
||||||
### Run
|
|
||||||
### Config
|
|
||||||
### Feeds
|
|
||||||
### Fonts
|
|
||||||
### ntfy.sh
|
|
||||||
|
|
||||||
## Internals
|
|
||||||
### How it works
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
## Extending
|
|
||||||
### NtfyPoller
|
|
||||||
### MicMonitor
|
|
||||||
### Render pipeline
|
|
||||||
|
|
||||||
## Development
|
|
||||||
### Setup
|
|
||||||
### Tasks
|
|
||||||
### Testing
|
|
||||||
### Linting
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
---
|
|
||||||
*footer*
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Section-by-section design
|
|
||||||
|
|
||||||
### Using
|
|
||||||
|
|
||||||
All existing content preserved verbatim. Two changes:
|
|
||||||
- **Run**: add `uv run mainline.py` as an alternative invocation; expand bootstrap note to mention `uv sync` / `uv sync --all-extras`
|
|
||||||
- **ntfy.sh**: remove `NtfyPoller` reuse code example (moves to Extending); keep push instructions and topic config
|
|
||||||
|
|
||||||
Subsections moved into Using (currently standalone):
|
|
||||||
- `Feeds` — it's configuration, not a concept
|
|
||||||
- `ntfy.sh` (usage half)
|
|
||||||
|
|
||||||
### Internals
|
|
||||||
|
|
||||||
All existing content preserved verbatim. One change:
|
|
||||||
- **Architecture**: append `tests/` directory listing to the module tree
|
|
||||||
|
|
||||||
### Extending
|
|
||||||
|
|
||||||
Entirely new section. Three subsections:
|
|
||||||
|
|
||||||
**NtfyPoller**
|
|
||||||
- Minimal working import + usage example
|
|
||||||
- Note: stdlib only dependencies
|
|
||||||
|
|
||||||
```python
|
|
||||||
from engine.ntfy import NtfyPoller
|
|
||||||
|
|
||||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
|
||||||
poller.start()
|
|
||||||
|
|
||||||
# in your render loop:
|
|
||||||
msg = poller.get_active_message() # → (title, body, timestamp) or None
|
|
||||||
if msg:
|
|
||||||
title, body, ts = msg
|
|
||||||
render_my_message(title, body) # visualizer-specific
|
|
||||||
```
|
|
||||||
|
|
||||||
**MicMonitor**
|
|
||||||
- Minimal working import + usage example
|
|
||||||
- Note: sounddevice/numpy optional, degrades gracefully
|
|
||||||
|
|
||||||
```python
|
|
||||||
from engine.mic import MicMonitor
|
|
||||||
|
|
||||||
mic = MicMonitor(threshold_db=50)
|
|
||||||
if mic.start(): # returns False if sounddevice unavailable
|
|
||||||
excess = mic.excess # dB above threshold, clamped to 0
|
|
||||||
db = mic.db # raw RMS dB level
|
|
||||||
```
|
|
||||||
|
|
||||||
**Render pipeline**
|
|
||||||
- Brief prose about `engine.render` as importable pipeline
|
|
||||||
- Minimal sketch of serve.py / ESP32 usage pattern
|
|
||||||
- Reference to `Mainline Renderer + ntfy Message Queue for ESP32.md`
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
Entirely new section. Four subsections:
|
|
||||||
|
|
||||||
**Setup**
|
|
||||||
- Hard requirements: Python 3.10+, uv
|
|
||||||
- `uv sync` / `uv sync --all-extras` / `uv sync --group dev`
|
|
||||||
|
|
||||||
**Tasks** (via mise)
|
|
||||||
- `mise run test`, `test-cov`, `lint`, `lint-fix`, `format`, `run`, `run-poetry`, `run-firehose`
|
|
||||||
|
|
||||||
**Testing**
|
|
||||||
- Tests in `tests/` covering config, filter, mic, ntfy, sources, terminal
|
|
||||||
- `uv run pytest` and `uv run pytest --cov=engine --cov-report=term-missing`
|
|
||||||
|
|
||||||
**Linting**
|
|
||||||
- `uv run ruff check` and `uv run ruff format`
|
|
||||||
- Note: pre-commit hooks run lint via `hk`
|
|
||||||
|
|
||||||
### Roadmap
|
|
||||||
|
|
||||||
Existing `## Ideas / Future` content preserved verbatim. Only change: rename heading to `## Roadmap`.
|
|
||||||
|
|
||||||
### Footer
|
|
||||||
|
|
||||||
Update `Python 3.9+` → `Python 3.10+`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files changed
|
|
||||||
|
|
||||||
- `README.md` — restructured and expanded as above
|
|
||||||
- No other files
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What is not changing
|
|
||||||
|
|
||||||
- All existing prose, examples, and config table values — preserved verbatim where retained
|
|
||||||
- The Ideas/Future content — kept intact under the new Roadmap heading
|
|
||||||
- The cyberpunk voice and terse style of the existing README
|
|
||||||
@@ -1 +1,10 @@
|
|||||||
# engine — modular internals for mainline
|
# engine — modular internals for mainline
|
||||||
|
|
||||||
|
# Import submodules to make them accessible via engine.<name>
|
||||||
|
# This is required for unittest.mock.patch to work with "engine.<module>.<function>"
|
||||||
|
# strings and for direct attribute access on the engine package.
|
||||||
|
import engine.config # noqa: F401
|
||||||
|
import engine.fetch # noqa: F401
|
||||||
|
import engine.filter # noqa: F401
|
||||||
|
import engine.sources # noqa: F401
|
||||||
|
import engine.terminal # noqa: F401
|
||||||
|
|||||||
280
engine/app.py
280
engine/app.py
@@ -1,282 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Application orchestrator — pipeline mode entry point.
|
Application orchestrator — pipeline mode entry point.
|
||||||
|
|
||||||
|
This module provides the main entry point for the application.
|
||||||
|
The implementation has been refactored into the engine.app package.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
# Re-export from the new package structure
|
||||||
import time
|
from engine.app import main, run_pipeline_mode, run_pipeline_mode_direct
|
||||||
|
|
||||||
import engine.effects.plugins as effects_plugins
|
|
||||||
from engine import config
|
|
||||||
from engine.display import DisplayRegistry
|
|
||||||
from engine.effects import PerformanceMonitor, get_registry, set_monitor
|
|
||||||
from engine.fetch import fetch_all, fetch_poetry, load_cache
|
|
||||||
from engine.pipeline import (
|
|
||||||
Pipeline,
|
|
||||||
PipelineConfig,
|
|
||||||
get_preset,
|
|
||||||
list_presets,
|
|
||||||
)
|
|
||||||
from engine.pipeline.adapters import (
|
|
||||||
SourceItemsToBufferStage,
|
|
||||||
create_stage_from_display,
|
|
||||||
create_stage_from_effect,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main entry point - all modes now use presets."""
|
|
||||||
if config.PIPELINE_DIAGRAM:
|
|
||||||
try:
|
|
||||||
from engine.pipeline import generate_pipeline_diagram
|
|
||||||
except ImportError:
|
|
||||||
print("Error: pipeline diagram not available")
|
|
||||||
return
|
|
||||||
print(generate_pipeline_diagram())
|
|
||||||
return
|
|
||||||
|
|
||||||
preset_name = None
|
|
||||||
|
|
||||||
if config.PRESET:
|
|
||||||
preset_name = config.PRESET
|
|
||||||
elif config.PIPELINE_MODE:
|
|
||||||
preset_name = config.PIPELINE_PRESET
|
|
||||||
else:
|
|
||||||
preset_name = "demo"
|
|
||||||
|
|
||||||
available = list_presets()
|
|
||||||
if preset_name not in available:
|
|
||||||
print(f"Error: Unknown preset '{preset_name}'")
|
|
||||||
print(f"Available presets: {', '.join(available)}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
run_pipeline_mode(preset_name)
|
|
||||||
|
|
||||||
|
|
||||||
def run_pipeline_mode(preset_name: str = "demo"):
|
|
||||||
"""Run using the new unified pipeline architecture."""
|
|
||||||
print(" \033[1;38;5;46mPIPELINE MODE\033[0m")
|
|
||||||
print(" \033[38;5;245mUsing unified pipeline architecture\033[0m")
|
|
||||||
|
|
||||||
effects_plugins.discover_plugins()
|
|
||||||
|
|
||||||
monitor = PerformanceMonitor()
|
|
||||||
set_monitor(monitor)
|
|
||||||
|
|
||||||
preset = get_preset(preset_name)
|
|
||||||
if not preset:
|
|
||||||
print(f" \033[38;5;196mUnknown preset: {preset_name}\033[0m")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f" \033[38;5;245mPreset: {preset.name} - {preset.description}\033[0m")
|
|
||||||
|
|
||||||
params = preset.to_params()
|
|
||||||
params.viewport_width = 80
|
|
||||||
params.viewport_height = 24
|
|
||||||
|
|
||||||
pipeline = Pipeline(
|
|
||||||
config=PipelineConfig(
|
|
||||||
source=preset.source,
|
|
||||||
display=preset.display,
|
|
||||||
camera=preset.camera,
|
|
||||||
effects=preset.effects,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
print(" \033[38;5;245mFetching content...\033[0m")
|
|
||||||
|
|
||||||
# Handle special sources that don't need traditional fetching
|
|
||||||
introspection_source = None
|
|
||||||
if preset.source == "pipeline-inspect":
|
|
||||||
items = []
|
|
||||||
print(" \033[38;5;245mUsing pipeline introspection source\033[0m")
|
|
||||||
elif preset.source == "empty":
|
|
||||||
items = []
|
|
||||||
print(" \033[38;5;245mUsing empty source (no content)\033[0m")
|
|
||||||
else:
|
|
||||||
cached = load_cache()
|
|
||||||
if cached:
|
|
||||||
items = cached
|
|
||||||
elif preset.source == "poetry":
|
|
||||||
items, _, _ = fetch_poetry()
|
|
||||||
else:
|
|
||||||
items, _, _ = fetch_all()
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
print(" \033[38;5;196mNo content available\033[0m")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f" \033[38;5;82mLoaded {len(items)} items\033[0m")
|
|
||||||
|
|
||||||
# CLI --display flag takes priority over preset
|
|
||||||
# Check if --display was explicitly provided
|
|
||||||
display_name = preset.display
|
|
||||||
if "--display" in sys.argv:
|
|
||||||
idx = sys.argv.index("--display")
|
|
||||||
if idx + 1 < len(sys.argv):
|
|
||||||
display_name = sys.argv[idx + 1]
|
|
||||||
|
|
||||||
display = DisplayRegistry.create(display_name)
|
|
||||||
if not display and not display_name.startswith("multi"):
|
|
||||||
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Handle multi display (format: "multi:terminal,pygame")
|
|
||||||
if not display and display_name.startswith("multi"):
|
|
||||||
parts = display_name[6:].split(
|
|
||||||
","
|
|
||||||
) # "multi:terminal,pygame" -> ["terminal", "pygame"]
|
|
||||||
display = DisplayRegistry.create_multi(parts)
|
|
||||||
if not display:
|
|
||||||
print(f" \033[38;5;196mFailed to create multi display: {parts}\033[0m")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if not display:
|
|
||||||
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
display.init(0, 0)
|
|
||||||
|
|
||||||
effect_registry = get_registry()
|
|
||||||
|
|
||||||
# Create source stage based on preset source type
|
|
||||||
if preset.source == "pipeline-inspect":
|
|
||||||
from engine.data_sources.pipeline_introspection import (
|
|
||||||
PipelineIntrospectionSource,
|
|
||||||
)
|
|
||||||
from engine.pipeline.adapters import DataSourceStage
|
|
||||||
|
|
||||||
introspection_source = PipelineIntrospectionSource(
|
|
||||||
pipeline=None, # Will be set after pipeline.build()
|
|
||||||
viewport_width=80,
|
|
||||||
viewport_height=24,
|
|
||||||
)
|
|
||||||
pipeline.add_stage(
|
|
||||||
"source", DataSourceStage(introspection_source, name="pipeline-inspect")
|
|
||||||
)
|
|
||||||
elif preset.source == "empty":
|
|
||||||
from engine.data_sources.sources import EmptyDataSource
|
|
||||||
from engine.pipeline.adapters import DataSourceStage
|
|
||||||
|
|
||||||
empty_source = EmptyDataSource(width=80, height=24)
|
|
||||||
pipeline.add_stage("source", DataSourceStage(empty_source, name="empty"))
|
|
||||||
else:
|
|
||||||
from engine.data_sources.sources import ListDataSource
|
|
||||||
from engine.pipeline.adapters import DataSourceStage
|
|
||||||
|
|
||||||
list_source = ListDataSource(items, name=preset.source)
|
|
||||||
pipeline.add_stage("source", DataSourceStage(list_source, name=preset.source))
|
|
||||||
|
|
||||||
# Add FontStage for headlines/poetry (default for demo)
|
|
||||||
if preset.source in ["headlines", "poetry"]:
|
|
||||||
from engine.pipeline.adapters import FontStage, ViewportFilterStage
|
|
||||||
|
|
||||||
# Add viewport filter to prevent rendering all items
|
|
||||||
pipeline.add_stage(
|
|
||||||
"viewport_filter", ViewportFilterStage(name="viewport-filter")
|
|
||||||
)
|
|
||||||
pipeline.add_stage("font", FontStage(name="font"))
|
|
||||||
else:
|
|
||||||
# Fallback to simple conversion for other sources
|
|
||||||
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
|
||||||
|
|
||||||
# Add camera stage if specified in preset
|
|
||||||
if preset.camera:
|
|
||||||
from engine.camera import Camera
|
|
||||||
from engine.pipeline.adapters import CameraStage
|
|
||||||
|
|
||||||
camera = None
|
|
||||||
speed = getattr(preset, "camera_speed", 1.0)
|
|
||||||
if preset.camera == "feed":
|
|
||||||
camera = Camera.feed(speed=speed)
|
|
||||||
elif preset.camera == "scroll":
|
|
||||||
camera = Camera.scroll(speed=speed)
|
|
||||||
elif preset.camera == "vertical":
|
|
||||||
camera = Camera.scroll(speed=speed) # Backwards compat
|
|
||||||
elif preset.camera == "horizontal":
|
|
||||||
camera = Camera.horizontal(speed=speed)
|
|
||||||
elif preset.camera == "omni":
|
|
||||||
camera = Camera.omni(speed=speed)
|
|
||||||
elif preset.camera == "floating":
|
|
||||||
camera = Camera.floating(speed=speed)
|
|
||||||
elif preset.camera == "bounce":
|
|
||||||
camera = Camera.bounce(speed=speed)
|
|
||||||
|
|
||||||
if camera:
|
|
||||||
pipeline.add_stage("camera", CameraStage(camera, name=preset.camera))
|
|
||||||
|
|
||||||
for effect_name in preset.effects:
|
|
||||||
effect = effect_registry.get(effect_name)
|
|
||||||
if effect:
|
|
||||||
pipeline.add_stage(
|
|
||||||
f"effect_{effect_name}", create_stage_from_effect(effect, effect_name)
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline.add_stage("display", create_stage_from_display(display, display_name))
|
|
||||||
|
|
||||||
pipeline.build()
|
|
||||||
|
|
||||||
# For pipeline-inspect, set the pipeline after build to avoid circular dependency
|
|
||||||
if introspection_source is not None:
|
|
||||||
introspection_source.set_pipeline(pipeline)
|
|
||||||
|
|
||||||
if not pipeline.initialize():
|
|
||||||
print(" \033[38;5;196mFailed to initialize pipeline\033[0m")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(" \033[38;5;82mStarting pipeline...\033[0m")
|
|
||||||
print(" \033[38;5;245mPress Ctrl+C to exit\033[0m\n")
|
|
||||||
|
|
||||||
ctx = pipeline.context
|
|
||||||
ctx.params = params
|
|
||||||
ctx.set("display", display)
|
|
||||||
ctx.set("items", items)
|
|
||||||
ctx.set("pipeline", pipeline)
|
|
||||||
ctx.set("pipeline_order", pipeline.execution_order)
|
|
||||||
ctx.set("camera_y", 0)
|
|
||||||
|
|
||||||
current_width = 80
|
|
||||||
current_height = 24
|
|
||||||
|
|
||||||
if hasattr(display, "get_dimensions"):
|
|
||||||
current_width, current_height = display.get_dimensions()
|
|
||||||
params.viewport_width = current_width
|
|
||||||
params.viewport_height = current_height
|
|
||||||
|
|
||||||
try:
|
|
||||||
frame = 0
|
|
||||||
while True:
|
|
||||||
params.frame_number = frame
|
|
||||||
ctx.params = params
|
|
||||||
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
if result.success:
|
|
||||||
display.show(result.data, border=params.border)
|
|
||||||
|
|
||||||
if hasattr(display, "is_quit_requested") and display.is_quit_requested():
|
|
||||||
if hasattr(display, "clear_quit_request"):
|
|
||||||
display.clear_quit_request()
|
|
||||||
raise KeyboardInterrupt()
|
|
||||||
|
|
||||||
if hasattr(display, "get_dimensions"):
|
|
||||||
new_w, new_h = display.get_dimensions()
|
|
||||||
if new_w != current_width or new_h != current_height:
|
|
||||||
current_width, current_height = new_w, new_h
|
|
||||||
params.viewport_width = current_width
|
|
||||||
params.viewport_height = current_height
|
|
||||||
|
|
||||||
time.sleep(1 / 60)
|
|
||||||
frame += 1
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pipeline.cleanup()
|
|
||||||
display.cleanup()
|
|
||||||
print("\n \033[38;5;245mPipeline stopped\033[0m")
|
|
||||||
return
|
|
||||||
|
|
||||||
pipeline.cleanup()
|
|
||||||
display.cleanup()
|
|
||||||
print("\n \033[38;5;245mPipeline stopped\033[0m")
|
|
||||||
|
|
||||||
|
__all__ = ["main", "run_pipeline_mode", "run_pipeline_mode_direct"]
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
34
engine/app/__init__.py
Normal file
34
engine/app/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
Application orchestrator — pipeline mode entry point.
|
||||||
|
|
||||||
|
This package contains the main application logic for the pipeline mode,
|
||||||
|
including pipeline construction, UI controller setup, and the main render loop.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Re-export from engine for backward compatibility with tests
|
||||||
|
# Re-export effects plugins for backward compatibility with tests
|
||||||
|
import engine.effects.plugins as effects_plugins
|
||||||
|
from engine import config
|
||||||
|
|
||||||
|
# Re-export display registry for backward compatibility with tests
|
||||||
|
from engine.display import DisplayRegistry
|
||||||
|
|
||||||
|
# Re-export fetch functions for backward compatibility with tests
|
||||||
|
from engine.fetch import fetch_all, fetch_poetry, load_cache
|
||||||
|
from engine.pipeline import list_presets
|
||||||
|
|
||||||
|
from .main import main, run_pipeline_mode_direct
|
||||||
|
from .pipeline_runner import run_pipeline_mode
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"config",
|
||||||
|
"list_presets",
|
||||||
|
"main",
|
||||||
|
"run_pipeline_mode",
|
||||||
|
"run_pipeline_mode_direct",
|
||||||
|
"fetch_all",
|
||||||
|
"fetch_poetry",
|
||||||
|
"load_cache",
|
||||||
|
"DisplayRegistry",
|
||||||
|
"effects_plugins",
|
||||||
|
]
|
||||||
618
engine/app/main.py
Normal file
618
engine/app/main.py
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
"""
|
||||||
|
Main entry point and CLI argument parsing for the application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.display import BorderMode, DisplayRegistry
|
||||||
|
from engine.effects import get_registry
|
||||||
|
from engine.fetch import fetch_all, fetch_all_fast, fetch_poetry, load_cache, save_cache
|
||||||
|
from engine.pipeline import (
|
||||||
|
Pipeline,
|
||||||
|
PipelineConfig,
|
||||||
|
PipelineContext,
|
||||||
|
list_presets,
|
||||||
|
)
|
||||||
|
from engine.pipeline.adapters import (
|
||||||
|
CameraStage,
|
||||||
|
DataSourceStage,
|
||||||
|
EffectPluginStage,
|
||||||
|
create_stage_from_display,
|
||||||
|
create_stage_from_effect,
|
||||||
|
)
|
||||||
|
from engine.pipeline.params import PipelineParams
|
||||||
|
from engine.pipeline.ui import UIConfig, UIPanel
|
||||||
|
from engine.pipeline.validation import validate_pipeline_config
|
||||||
|
|
||||||
|
try:
|
||||||
|
from engine.display.backends.websocket import WebSocketDisplay
|
||||||
|
except ImportError:
|
||||||
|
WebSocketDisplay = None
|
||||||
|
|
||||||
|
from .pipeline_runner import run_pipeline_mode
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_pipeline_mutation(pipeline: Pipeline, command: dict) -> bool:
|
||||||
|
"""Handle pipeline mutation commands from REPL or other external control.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pipeline: The pipeline to mutate
|
||||||
|
command: Command dictionary with 'action' and other parameters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if command was successfully handled, False otherwise
|
||||||
|
"""
|
||||||
|
action = command.get("action")
|
||||||
|
|
||||||
|
if action == "add_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
stage_type = command.get("stage_type")
|
||||||
|
print(
|
||||||
|
f" [Pipeline] add_stage command received: name='{stage_name}', type='{stage_type}'"
|
||||||
|
)
|
||||||
|
# Note: Dynamic stage creation is complex and requires stage factory support
|
||||||
|
# For now, we acknowledge the command but don't actually add the stage
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "remove_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name:
|
||||||
|
result = pipeline.remove_stage(stage_name)
|
||||||
|
print(f" [Pipeline] Removed stage '{stage_name}': {result is not None}")
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
elif action == "replace_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
print(f" [Pipeline] replace_stage command received: {command}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "swap_stages":
|
||||||
|
stage1 = command.get("stage1")
|
||||||
|
stage2 = command.get("stage2")
|
||||||
|
if stage1 and stage2:
|
||||||
|
result = pipeline.swap_stages(stage1, stage2)
|
||||||
|
print(f" [Pipeline] Swapped stages '{stage1}' and '{stage2}': {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
elif action == "move_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
after = command.get("after")
|
||||||
|
before = command.get("before")
|
||||||
|
if stage_name:
|
||||||
|
result = pipeline.move_stage(stage_name, after, before)
|
||||||
|
print(f" [Pipeline] Moved stage '{stage_name}': {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
elif action == "enable_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name:
|
||||||
|
result = pipeline.enable_stage(stage_name)
|
||||||
|
print(f" [Pipeline] Enabled stage '{stage_name}': {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
elif action == "disable_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name:
|
||||||
|
result = pipeline.disable_stage(stage_name)
|
||||||
|
print(f" [Pipeline] Disabled stage '{stage_name}': {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
elif action == "cleanup_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name:
|
||||||
|
pipeline.cleanup_stage(stage_name)
|
||||||
|
print(f" [Pipeline] Cleaned up stage '{stage_name}'")
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "can_hot_swap":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name:
|
||||||
|
can_swap = pipeline.can_hot_swap(stage_name)
|
||||||
|
print(f" [Pipeline] Can hot-swap '{stage_name}': {can_swap}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point - all modes now use presets or CLI construction."""
|
||||||
|
if config.PIPELINE_DIAGRAM:
|
||||||
|
try:
|
||||||
|
from engine.pipeline import generate_pipeline_diagram
|
||||||
|
except ImportError:
|
||||||
|
print("Error: pipeline diagram not available")
|
||||||
|
return
|
||||||
|
print(generate_pipeline_diagram())
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check for direct pipeline construction flags
|
||||||
|
if "--pipeline-source" in sys.argv:
|
||||||
|
# Construct pipeline directly from CLI args
|
||||||
|
run_pipeline_mode_direct()
|
||||||
|
return
|
||||||
|
|
||||||
|
preset_name = None
|
||||||
|
|
||||||
|
if config.PRESET:
|
||||||
|
preset_name = config.PRESET
|
||||||
|
elif config.PIPELINE_MODE:
|
||||||
|
preset_name = config.PIPELINE_PRESET
|
||||||
|
else:
|
||||||
|
preset_name = "demo"
|
||||||
|
|
||||||
|
available = list_presets()
|
||||||
|
if preset_name not in available:
|
||||||
|
print(f"Error: Unknown preset '{preset_name}'")
|
||||||
|
print(f"Available presets: {', '.join(available)}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
run_pipeline_mode(preset_name)
|
||||||
|
|
||||||
|
|
||||||
|
def run_pipeline_mode_direct():
|
||||||
|
"""Construct and run a pipeline directly from CLI arguments.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python -m engine.app --pipeline-source headlines --pipeline-effects noise,fade --display null
|
||||||
|
python -m engine.app --pipeline-source fixture --pipeline-effects glitch --pipeline-ui --display null
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--pipeline-source <source>: Headlines, fixture, poetry, empty, pipeline-inspect
|
||||||
|
--pipeline-effects <effects>: Comma-separated list (noise, fade, glitch, firehose, hud, tint, border, crop)
|
||||||
|
--pipeline-camera <type>: scroll, feed, horizontal, omni, floating, bounce
|
||||||
|
--pipeline-display <display>: terminal, pygame, websocket, null, multi:term,pygame
|
||||||
|
--pipeline-ui: Enable UI panel (BorderMode.UI)
|
||||||
|
--pipeline-border <mode>: off, simple, ui
|
||||||
|
"""
|
||||||
|
import engine.effects.plugins as effects_plugins
|
||||||
|
from engine.camera import Camera
|
||||||
|
from engine.data_sources.pipeline_introspection import PipelineIntrospectionSource
|
||||||
|
from engine.data_sources.sources import EmptyDataSource, ListDataSource
|
||||||
|
from engine.pipeline.adapters import (
|
||||||
|
FontStage,
|
||||||
|
ViewportFilterStage,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Discover and register all effect plugins
|
||||||
|
effects_plugins.discover_plugins()
|
||||||
|
|
||||||
|
# Parse CLI arguments
|
||||||
|
source_name = None
|
||||||
|
effect_names = []
|
||||||
|
camera_type = None
|
||||||
|
display_name = None
|
||||||
|
ui_enabled = False
|
||||||
|
border_mode = BorderMode.OFF
|
||||||
|
source_items = None
|
||||||
|
allow_unsafe = False
|
||||||
|
viewport_width = None
|
||||||
|
viewport_height = None
|
||||||
|
|
||||||
|
i = 1
|
||||||
|
argv = sys.argv
|
||||||
|
while i < len(argv):
|
||||||
|
arg = argv[i]
|
||||||
|
if arg == "--pipeline-source" and i + 1 < len(argv):
|
||||||
|
source_name = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif arg == "--pipeline-effects" and i + 1 < len(argv):
|
||||||
|
effect_names = [e.strip() for e in argv[i + 1].split(",") if e.strip()]
|
||||||
|
i += 2
|
||||||
|
elif arg == "--pipeline-camera" and i + 1 < len(argv):
|
||||||
|
camera_type = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif arg == "--viewport" and i + 1 < len(argv):
|
||||||
|
vp = argv[i + 1]
|
||||||
|
try:
|
||||||
|
viewport_width, viewport_height = map(int, vp.split("x"))
|
||||||
|
except ValueError:
|
||||||
|
print("Error: Invalid viewport format. Use WxH (e.g., 40x15)")
|
||||||
|
sys.exit(1)
|
||||||
|
i += 2
|
||||||
|
elif arg == "--pipeline-display" and i + 1 < len(argv):
|
||||||
|
display_name = argv[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif arg == "--pipeline-ui":
|
||||||
|
ui_enabled = True
|
||||||
|
i += 1
|
||||||
|
elif arg == "--pipeline-border" and i + 1 < len(argv):
|
||||||
|
mode = argv[i + 1]
|
||||||
|
if mode == "simple":
|
||||||
|
border_mode = True
|
||||||
|
elif mode == "ui":
|
||||||
|
border_mode = BorderMode.UI
|
||||||
|
else:
|
||||||
|
border_mode = False
|
||||||
|
i += 2
|
||||||
|
elif arg == "--allow-unsafe":
|
||||||
|
allow_unsafe = True
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if not source_name:
|
||||||
|
print("Error: --pipeline-source is required")
|
||||||
|
print(
|
||||||
|
"Usage: python -m engine.app --pipeline-source <source> [--pipeline-effects <effects>] ..."
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(" \033[38;5;245mDirect pipeline construction\033[0m")
|
||||||
|
print(f" Source: {source_name}")
|
||||||
|
print(f" Effects: {effect_names}")
|
||||||
|
print(f" Camera: {camera_type}")
|
||||||
|
print(f" Display: {display_name}")
|
||||||
|
print(f" UI Enabled: {ui_enabled}")
|
||||||
|
|
||||||
|
# Create initial config and params
|
||||||
|
params = PipelineParams()
|
||||||
|
params.source = source_name
|
||||||
|
params.camera_mode = camera_type if camera_type is not None else ""
|
||||||
|
params.effect_order = effect_names
|
||||||
|
params.border = border_mode
|
||||||
|
|
||||||
|
# Create minimal config for validation
|
||||||
|
config_obj = PipelineConfig(
|
||||||
|
source=source_name,
|
||||||
|
display=display_name or "", # Will be filled by validation
|
||||||
|
camera=camera_type if camera_type is not None else "",
|
||||||
|
effects=effect_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run MVP validation
|
||||||
|
result = validate_pipeline_config(config_obj, params, allow_unsafe=allow_unsafe)
|
||||||
|
|
||||||
|
if result.warnings and not allow_unsafe:
|
||||||
|
print(" \033[38;5;226mWarning: MVP validation found issues:\033[0m")
|
||||||
|
for warning in result.warnings:
|
||||||
|
print(f" - {warning}")
|
||||||
|
|
||||||
|
if result.changes:
|
||||||
|
print(" \033[38;5;226mApplied MVP defaults:\033[0m")
|
||||||
|
for change in result.changes:
|
||||||
|
print(f" {change}")
|
||||||
|
|
||||||
|
if not result.valid:
|
||||||
|
print(
|
||||||
|
" \033[38;5;196mPipeline configuration invalid and could not be fixed\033[0m"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Show MVP summary
|
||||||
|
print(" \033[38;5;245mMVP Configuration:\033[0m")
|
||||||
|
print(f" Source: {result.config.source}")
|
||||||
|
print(f" Display: {result.config.display}")
|
||||||
|
print(f" Camera: {result.config.camera or 'static (none)'}")
|
||||||
|
print(f" Effects: {result.config.effects if result.config.effects else 'none'}")
|
||||||
|
print(f" Border: {result.params.border}")
|
||||||
|
|
||||||
|
# Load source items
|
||||||
|
if source_name == "headlines":
|
||||||
|
cached = load_cache()
|
||||||
|
if cached:
|
||||||
|
source_items = cached
|
||||||
|
else:
|
||||||
|
source_items = fetch_all_fast()
|
||||||
|
if source_items:
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def background_fetch():
|
||||||
|
full_items, _, _ = fetch_all()
|
||||||
|
save_cache(full_items)
|
||||||
|
|
||||||
|
background_thread = threading.Thread(
|
||||||
|
target=background_fetch, daemon=True
|
||||||
|
)
|
||||||
|
background_thread.start()
|
||||||
|
elif source_name == "fixture":
|
||||||
|
source_items = load_cache()
|
||||||
|
if not source_items:
|
||||||
|
print(" \033[38;5;196mNo fixture cache available\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
elif source_name == "poetry":
|
||||||
|
source_items, _, _ = fetch_poetry()
|
||||||
|
elif source_name == "empty" or source_name == "pipeline-inspect":
|
||||||
|
source_items = []
|
||||||
|
else:
|
||||||
|
print(f" \033[38;5;196mUnknown source: {source_name}\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if source_items is not None:
|
||||||
|
print(f" \033[38;5;82mLoaded {len(source_items)} items\033[0m")
|
||||||
|
|
||||||
|
# Set border mode
|
||||||
|
if ui_enabled:
|
||||||
|
border_mode = BorderMode.UI
|
||||||
|
|
||||||
|
# Build pipeline using validated config and params
|
||||||
|
params = result.params
|
||||||
|
params.viewport_width = viewport_width if viewport_width is not None else 80
|
||||||
|
params.viewport_height = viewport_height if viewport_height is not None else 24
|
||||||
|
|
||||||
|
ctx = PipelineContext()
|
||||||
|
ctx.params = params
|
||||||
|
|
||||||
|
# Create display using validated display name
|
||||||
|
display_name = result.config.display or "terminal" # Default to terminal if empty
|
||||||
|
|
||||||
|
# Warn if display was auto-selected (not explicitly specified)
|
||||||
|
if not display_name:
|
||||||
|
print(
|
||||||
|
" \033[38;5;226mWarning: No --pipeline-display specified, using default: terminal\033[0m"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
" \033[38;5;245mTip: Use --pipeline-display null for headless mode (useful for testing)\033[0m"
|
||||||
|
)
|
||||||
|
|
||||||
|
display = DisplayRegistry.create(display_name)
|
||||||
|
|
||||||
|
# Set positioning mode
|
||||||
|
if "--positioning" in sys.argv:
|
||||||
|
idx = sys.argv.index("--positioning")
|
||||||
|
if idx + 1 < len(sys.argv):
|
||||||
|
params.positioning = sys.argv[idx + 1]
|
||||||
|
if not display:
|
||||||
|
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
display.init(0, 0)
|
||||||
|
|
||||||
|
# Create pipeline using validated config
|
||||||
|
pipeline = Pipeline(config=result.config, context=ctx)
|
||||||
|
|
||||||
|
# Add stages
|
||||||
|
# Source stage
|
||||||
|
if source_name == "pipeline-inspect":
|
||||||
|
introspection_source = PipelineIntrospectionSource(
|
||||||
|
pipeline=None,
|
||||||
|
viewport_width=params.viewport_width,
|
||||||
|
viewport_height=params.viewport_height,
|
||||||
|
)
|
||||||
|
pipeline.add_stage(
|
||||||
|
"source", DataSourceStage(introspection_source, name="pipeline-inspect")
|
||||||
|
)
|
||||||
|
elif source_name == "empty":
|
||||||
|
empty_source = EmptyDataSource(
|
||||||
|
width=params.viewport_width, height=params.viewport_height
|
||||||
|
)
|
||||||
|
pipeline.add_stage("source", DataSourceStage(empty_source, name="empty"))
|
||||||
|
else:
|
||||||
|
list_source = ListDataSource(source_items, name=source_name)
|
||||||
|
pipeline.add_stage("source", DataSourceStage(list_source, name=source_name))
|
||||||
|
|
||||||
|
# Add viewport filter and font for headline sources
|
||||||
|
if source_name in ["headlines", "poetry", "fixture"]:
|
||||||
|
pipeline.add_stage(
|
||||||
|
"viewport_filter", ViewportFilterStage(name="viewport-filter")
|
||||||
|
)
|
||||||
|
pipeline.add_stage("font", FontStage(name="font"))
|
||||||
|
else:
|
||||||
|
# Fallback to simple conversion for other sources
|
||||||
|
from engine.pipeline.adapters import SourceItemsToBufferStage
|
||||||
|
|
||||||
|
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
||||||
|
|
||||||
|
# Add camera
|
||||||
|
speed = getattr(params, "camera_speed", 1.0)
|
||||||
|
camera = None
|
||||||
|
if camera_type == "feed":
|
||||||
|
camera = Camera.feed(speed=speed)
|
||||||
|
elif camera_type == "scroll":
|
||||||
|
camera = Camera.scroll(speed=speed)
|
||||||
|
elif camera_type == "horizontal":
|
||||||
|
camera = Camera.horizontal(speed=speed)
|
||||||
|
elif camera_type == "omni":
|
||||||
|
camera = Camera.omni(speed=speed)
|
||||||
|
elif camera_type == "floating":
|
||||||
|
camera = Camera.floating(speed=speed)
|
||||||
|
elif camera_type == "bounce":
|
||||||
|
camera = Camera.bounce(speed=speed)
|
||||||
|
|
||||||
|
if camera:
|
||||||
|
pipeline.add_stage("camera", CameraStage(camera, name=camera_type))
|
||||||
|
|
||||||
|
# Add effects
|
||||||
|
effect_registry = get_registry()
|
||||||
|
for effect_name in effect_names:
|
||||||
|
effect = effect_registry.get(effect_name)
|
||||||
|
if effect:
|
||||||
|
pipeline.add_stage(
|
||||||
|
f"effect_{effect_name}", create_stage_from_effect(effect, effect_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add display
|
||||||
|
pipeline.add_stage("display", create_stage_from_display(display, display_name))
|
||||||
|
|
||||||
|
pipeline.build()
|
||||||
|
|
||||||
|
if not pipeline.initialize():
|
||||||
|
print(" \033[38;5;196mFailed to initialize pipeline\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Create UI panel if border mode is UI
|
||||||
|
ui_panel = None
|
||||||
|
if params.border == BorderMode.UI:
|
||||||
|
ui_panel = UIPanel(UIConfig(panel_width=24, start_with_preset_picker=True))
|
||||||
|
# Enable raw mode for terminal input if supported
|
||||||
|
if hasattr(display, "set_raw_mode"):
|
||||||
|
display.set_raw_mode(True)
|
||||||
|
for stage in pipeline.stages.values():
|
||||||
|
if isinstance(stage, EffectPluginStage):
|
||||||
|
effect = stage._effect
|
||||||
|
enabled = effect.config.enabled if hasattr(effect, "config") else True
|
||||||
|
stage_control = ui_panel.register_stage(stage, enabled=enabled)
|
||||||
|
stage_control.effect = effect # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
if ui_panel.stages:
|
||||||
|
first_stage = next(iter(ui_panel.stages))
|
||||||
|
ui_panel.select_stage(first_stage)
|
||||||
|
ctrl = ui_panel.stages[first_stage]
|
||||||
|
if hasattr(ctrl, "effect"):
|
||||||
|
effect = ctrl.effect
|
||||||
|
if hasattr(effect, "config"):
|
||||||
|
config = effect.config
|
||||||
|
try:
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
if dataclasses.is_dataclass(config):
|
||||||
|
for field_name, field_obj in dataclasses.fields(config):
|
||||||
|
if field_name == "enabled":
|
||||||
|
continue
|
||||||
|
value = getattr(config, field_name, None)
|
||||||
|
if value is not None:
|
||||||
|
ctrl.params[field_name] = value
|
||||||
|
ctrl.param_schema[field_name] = {
|
||||||
|
"type": type(value).__name__,
|
||||||
|
"min": 0
|
||||||
|
if isinstance(value, (int, float))
|
||||||
|
else None,
|
||||||
|
"max": 1 if isinstance(value, float) else None,
|
||||||
|
"step": 0.1 if isinstance(value, float) else 1,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for REPL effect in pipeline
|
||||||
|
repl_effect = None
|
||||||
|
for stage in pipeline.stages.values():
|
||||||
|
if isinstance(stage, EffectPluginStage) and stage._effect.name == "repl":
|
||||||
|
repl_effect = stage._effect
|
||||||
|
print(
|
||||||
|
" \033[38;5;46mREPL effect detected - Interactive mode enabled\033[0m"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Enable raw mode for REPL if present and not already enabled
|
||||||
|
# Also enable for UI border mode (already handled above)
|
||||||
|
if repl_effect and ui_panel is None and hasattr(display, "set_raw_mode"):
|
||||||
|
display.set_raw_mode(True)
|
||||||
|
|
||||||
|
# Run pipeline loop
|
||||||
|
from engine.display import render_ui_panel
|
||||||
|
|
||||||
|
ctx.set("display", display)
|
||||||
|
ctx.set("items", source_items)
|
||||||
|
ctx.set("pipeline", pipeline)
|
||||||
|
ctx.set("pipeline_order", pipeline.execution_order)
|
||||||
|
|
||||||
|
current_width = params.viewport_width
|
||||||
|
current_height = params.viewport_height
|
||||||
|
|
||||||
|
# Only get dimensions from display if viewport wasn't explicitly set
|
||||||
|
if "--viewport" not in sys.argv and hasattr(display, "get_dimensions"):
|
||||||
|
current_width, current_height = display.get_dimensions()
|
||||||
|
params.viewport_width = current_width
|
||||||
|
params.viewport_height = current_height
|
||||||
|
|
||||||
|
print(" \033[38;5;82mStarting pipeline...\033[0m")
|
||||||
|
print(" \033[38;5;245mPress Ctrl+C to exit\033[0m\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = 0
|
||||||
|
while True:
|
||||||
|
params.frame_number = frame
|
||||||
|
ctx.params = params
|
||||||
|
|
||||||
|
result = pipeline.execute(source_items)
|
||||||
|
if not result.success:
|
||||||
|
error_msg = f" ({result.error})" if result.error else ""
|
||||||
|
print(f" \033[38;5;196mPipeline execution failed{error_msg}\033[0m")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Render with UI panel
|
||||||
|
if ui_panel is not None:
|
||||||
|
buf = render_ui_panel(
|
||||||
|
result.data, current_width, current_height, ui_panel
|
||||||
|
)
|
||||||
|
display.show(buf, border=False)
|
||||||
|
else:
|
||||||
|
display.show(result.data, border=border_mode)
|
||||||
|
|
||||||
|
# Handle keyboard events if UI is enabled
|
||||||
|
if ui_panel is not None:
|
||||||
|
# Try pygame first
|
||||||
|
if hasattr(display, "_pygame"):
|
||||||
|
try:
|
||||||
|
import pygame
|
||||||
|
|
||||||
|
for event in pygame.event.get():
|
||||||
|
if event.type == pygame.KEYDOWN:
|
||||||
|
ui_panel.process_key_event(event.key, event.mod)
|
||||||
|
except (ImportError, Exception):
|
||||||
|
pass
|
||||||
|
# Try terminal input
|
||||||
|
elif hasattr(display, "get_input_keys"):
|
||||||
|
try:
|
||||||
|
keys = display.get_input_keys()
|
||||||
|
for key in keys:
|
||||||
|
ui_panel.process_key_event(key, 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- REPL Input Handling ---
|
||||||
|
if repl_effect and hasattr(display, "get_input_keys"):
|
||||||
|
# Get keyboard input (non-blocking)
|
||||||
|
keys = display.get_input_keys(timeout=0.0)
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
if key == "ctrl_c":
|
||||||
|
# Request quit when Ctrl+C is pressed
|
||||||
|
if hasattr(display, "request_quit"):
|
||||||
|
display.request_quit()
|
||||||
|
else:
|
||||||
|
raise KeyboardInterrupt()
|
||||||
|
elif key == "return":
|
||||||
|
# Get command string before processing
|
||||||
|
cmd_str = repl_effect.state.current_command
|
||||||
|
if cmd_str:
|
||||||
|
repl_effect.process_command(cmd_str, ctx)
|
||||||
|
# Check for pending pipeline mutations
|
||||||
|
pending = repl_effect.get_pending_command()
|
||||||
|
if pending:
|
||||||
|
_handle_pipeline_mutation(pipeline, pending)
|
||||||
|
elif key == "up":
|
||||||
|
repl_effect.navigate_history(-1)
|
||||||
|
elif key == "down":
|
||||||
|
repl_effect.navigate_history(1)
|
||||||
|
elif key == "page_up":
|
||||||
|
repl_effect.scroll_output(
|
||||||
|
10
|
||||||
|
) # Positive = scroll UP (back in time)
|
||||||
|
elif key == "page_down":
|
||||||
|
repl_effect.scroll_output(
|
||||||
|
-10
|
||||||
|
) # Negative = scroll DOWN (forward in time)
|
||||||
|
elif key == "backspace":
|
||||||
|
repl_effect.backspace()
|
||||||
|
elif key.startswith("mouse:"):
|
||||||
|
# Mouse event format: mouse:button:x:y
|
||||||
|
parts = key.split(":")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
button = int(parts[1])
|
||||||
|
if button == 64: # Wheel up
|
||||||
|
repl_effect.scroll_output(3) # Positive = scroll UP
|
||||||
|
elif button == 65: # Wheel down
|
||||||
|
repl_effect.scroll_output(-3) # Negative = scroll DOWN
|
||||||
|
elif len(key) == 1:
|
||||||
|
repl_effect.append_to_command(key)
|
||||||
|
# --- End REPL Input Handling ---
|
||||||
|
|
||||||
|
# Check for quit request
|
||||||
|
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)
|
||||||
|
frame += 1
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
pipeline.cleanup()
|
||||||
|
display.cleanup()
|
||||||
|
print("\n \033[38;5;245mPipeline stopped\033[0m")
|
||||||
|
return
|
||||||
|
|
||||||
|
pipeline.cleanup()
|
||||||
|
display.cleanup()
|
||||||
|
print("\n \033[38;5;245mPipeline stopped\033[0m")
|
||||||
1080
engine/app/pipeline_runner.py
Normal file
1080
engine/app/pipeline_runner.py
Normal file
File diff suppressed because it is too large
Load Diff
129
engine/camera.py
129
engine/camera.py
@@ -23,6 +23,7 @@ class CameraMode(Enum):
|
|||||||
OMNI = auto()
|
OMNI = auto()
|
||||||
FLOATING = auto()
|
FLOATING = auto()
|
||||||
BOUNCE = auto()
|
BOUNCE = auto()
|
||||||
|
RADIAL = auto() # Polar coordinates (r, theta) for radial scanning
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -71,6 +72,17 @@ class Camera:
|
|||||||
"""Shorthand for viewport_width."""
|
"""Shorthand for viewport_width."""
|
||||||
return self.viewport_width
|
return self.viewport_width
|
||||||
|
|
||||||
|
def set_speed(self, speed: float) -> None:
|
||||||
|
"""Set the camera scroll speed dynamically.
|
||||||
|
|
||||||
|
This allows camera speed to be modulated during runtime
|
||||||
|
via PipelineParams or directly.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
speed: New speed value (0.0 = stopped, >0 = movement)
|
||||||
|
"""
|
||||||
|
self.speed = max(0.0, speed)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def h(self) -> int:
|
def h(self) -> int:
|
||||||
"""Shorthand for viewport_height."""
|
"""Shorthand for viewport_height."""
|
||||||
@@ -92,14 +104,17 @@ class Camera:
|
|||||||
"""
|
"""
|
||||||
return max(1, int(self.canvas_height / self.zoom))
|
return max(1, int(self.canvas_height / self.zoom))
|
||||||
|
|
||||||
def get_viewport(self) -> CameraViewport:
|
def get_viewport(self, viewport_height: int | None = None) -> CameraViewport:
|
||||||
"""Get the current viewport bounds.
|
"""Get the current viewport bounds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
viewport_height: Optional viewport height to use instead of camera's viewport_height
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
CameraViewport with position and size (clamped to canvas bounds)
|
CameraViewport with position and size (clamped to canvas bounds)
|
||||||
"""
|
"""
|
||||||
vw = self.viewport_width
|
vw = self.viewport_width
|
||||||
vh = self.viewport_height
|
vh = viewport_height if viewport_height is not None else self.viewport_height
|
||||||
|
|
||||||
clamped_x = max(0, min(self.x, self.canvas_width - vw))
|
clamped_x = max(0, min(self.x, self.canvas_width - vw))
|
||||||
clamped_y = max(0, min(self.y, self.canvas_height - vh))
|
clamped_y = max(0, min(self.y, self.canvas_height - vh))
|
||||||
@@ -111,6 +126,13 @@ class Camera:
|
|||||||
height=vh,
|
height=vh,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return CameraViewport(
|
||||||
|
x=clamped_x,
|
||||||
|
y=clamped_y,
|
||||||
|
width=vw,
|
||||||
|
height=vh,
|
||||||
|
)
|
||||||
|
|
||||||
def set_zoom(self, zoom: float) -> None:
|
def set_zoom(self, zoom: float) -> None:
|
||||||
"""Set the zoom factor.
|
"""Set the zoom factor.
|
||||||
|
|
||||||
@@ -143,6 +165,8 @@ class Camera:
|
|||||||
self._update_floating(dt)
|
self._update_floating(dt)
|
||||||
elif self.mode == CameraMode.BOUNCE:
|
elif self.mode == CameraMode.BOUNCE:
|
||||||
self._update_bounce(dt)
|
self._update_bounce(dt)
|
||||||
|
elif self.mode == CameraMode.RADIAL:
|
||||||
|
self._update_radial(dt)
|
||||||
|
|
||||||
# Bounce mode handles its own bounds checking
|
# Bounce mode handles its own bounds checking
|
||||||
if self.mode != CameraMode.BOUNCE:
|
if self.mode != CameraMode.BOUNCE:
|
||||||
@@ -223,12 +247,85 @@ class Camera:
|
|||||||
self.y = max_y
|
self.y = max_y
|
||||||
self._bounce_dy = -1
|
self._bounce_dy = -1
|
||||||
|
|
||||||
|
def _update_radial(self, dt: float) -> None:
|
||||||
|
"""Radial camera mode: polar coordinate scrolling (r, theta).
|
||||||
|
|
||||||
|
The camera rotates around the center of the canvas while optionally
|
||||||
|
moving outward/inward along rays. This enables:
|
||||||
|
- Radar sweep animations
|
||||||
|
- Pendulum view oscillation
|
||||||
|
- Spiral scanning motion
|
||||||
|
|
||||||
|
Uses polar coordinates internally:
|
||||||
|
- _r_float: radial distance from center (accumulates smoothly)
|
||||||
|
- _theta_float: angle in radians (accumulates smoothly)
|
||||||
|
- Updates x, y based on conversion from polar to Cartesian
|
||||||
|
"""
|
||||||
|
# Initialize radial state if needed
|
||||||
|
if not hasattr(self, "_r_float"):
|
||||||
|
self._r_float = 0.0
|
||||||
|
self._theta_float = 0.0
|
||||||
|
|
||||||
|
# Update angular position (rotation around center)
|
||||||
|
# Speed controls rotation rate
|
||||||
|
theta_speed = self.speed * dt * 1.0 # radians per second
|
||||||
|
self._theta_float += theta_speed
|
||||||
|
|
||||||
|
# Update radial position (inward/outward from center)
|
||||||
|
# Can be modulated by external sensor
|
||||||
|
if hasattr(self, "_radial_input"):
|
||||||
|
r_input = self._radial_input
|
||||||
|
else:
|
||||||
|
# Default: slow outward drift
|
||||||
|
r_input = 0.0
|
||||||
|
|
||||||
|
r_speed = self.speed * dt * 20.0 # pixels per second
|
||||||
|
self._r_float += r_input + r_speed * 0.01
|
||||||
|
|
||||||
|
# Clamp radial position to canvas bounds
|
||||||
|
max_r = min(self.canvas_width, self.canvas_height) / 2
|
||||||
|
self._r_float = max(0.0, min(self._r_float, max_r))
|
||||||
|
|
||||||
|
# Convert polar to Cartesian, centered at canvas center
|
||||||
|
center_x = self.canvas_width / 2
|
||||||
|
center_y = self.canvas_height / 2
|
||||||
|
|
||||||
|
self.x = int(center_x + self._r_float * math.cos(self._theta_float))
|
||||||
|
self.y = int(center_y + self._r_float * math.sin(self._theta_float))
|
||||||
|
|
||||||
|
# Clamp to canvas bounds
|
||||||
|
self._clamp_to_bounds()
|
||||||
|
|
||||||
|
def set_radial_input(self, value: float) -> None:
|
||||||
|
"""Set radial input for sensor-driven radius modulation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: Sensor value (0-1) that modulates radial distance
|
||||||
|
"""
|
||||||
|
self._radial_input = value * 10.0 # Scale to reasonable pixel range
|
||||||
|
|
||||||
|
def set_radial_angle(self, angle: float) -> None:
|
||||||
|
"""Set radial angle directly (for OSC integration).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
angle: Angle in radians (0 to 2π)
|
||||||
|
"""
|
||||||
|
self._theta_float = angle
|
||||||
|
|
||||||
def reset(self) -> None:
|
def reset(self) -> None:
|
||||||
"""Reset camera position."""
|
"""Reset camera position and state."""
|
||||||
self.x = 0
|
self.x = 0
|
||||||
self.y = 0
|
self.y = 0
|
||||||
self._time = 0.0
|
self._time = 0.0
|
||||||
self.zoom = 1.0
|
self.zoom = 1.0
|
||||||
|
# Reset bounce direction state
|
||||||
|
if hasattr(self, "_bounce_dx"):
|
||||||
|
self._bounce_dx = 1
|
||||||
|
self._bounce_dy = 1
|
||||||
|
# Reset radial state
|
||||||
|
if hasattr(self, "_r_float"):
|
||||||
|
self._r_float = 0.0
|
||||||
|
self._theta_float = 0.0
|
||||||
|
|
||||||
def set_canvas_size(self, width: int, height: int) -> None:
|
def set_canvas_size(self, width: int, height: int) -> None:
|
||||||
"""Set the canvas size and clamp position if needed.
|
"""Set the canvas size and clamp position if needed.
|
||||||
@@ -263,7 +360,7 @@ class Camera:
|
|||||||
return buffer
|
return buffer
|
||||||
|
|
||||||
# Get current viewport bounds (clamped to canvas size)
|
# Get current viewport bounds (clamped to canvas size)
|
||||||
viewport = self.get_viewport()
|
viewport = self.get_viewport(viewport_height)
|
||||||
|
|
||||||
# Use provided viewport_height if given, otherwise use camera's viewport
|
# Use provided viewport_height if given, otherwise use camera's viewport
|
||||||
vh = viewport_height if viewport_height is not None else viewport.height
|
vh = viewport_height if viewport_height is not None else viewport.height
|
||||||
@@ -287,10 +384,11 @@ class Camera:
|
|||||||
truncated_line = vis_trunc(offset_line, viewport_width)
|
truncated_line = vis_trunc(offset_line, viewport_width)
|
||||||
|
|
||||||
# Pad line to full viewport width to prevent ghosting when panning
|
# Pad line to full viewport width to prevent ghosting when panning
|
||||||
|
# Skip padding for empty lines to preserve intentional blank lines
|
||||||
import re
|
import re
|
||||||
|
|
||||||
visible_len = len(re.sub(r"\x1b\[[0-9;]*m", "", truncated_line))
|
visible_len = len(re.sub(r"\x1b\[[0-9;]*m", "", truncated_line))
|
||||||
if visible_len < viewport_width:
|
if visible_len < viewport_width and visible_len > 0:
|
||||||
truncated_line += " " * (viewport_width - visible_len)
|
truncated_line += " " * (viewport_width - visible_len)
|
||||||
|
|
||||||
horizontal_slice.append(truncated_line)
|
horizontal_slice.append(truncated_line)
|
||||||
@@ -348,6 +446,27 @@ class Camera:
|
|||||||
mode=CameraMode.BOUNCE, speed=speed, canvas_width=200, canvas_height=200
|
mode=CameraMode.BOUNCE, speed=speed, canvas_width=200, canvas_height=200
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def radial(cls, speed: float = 1.0) -> "Camera":
|
||||||
|
"""Create a radial camera (polar coordinate scanning).
|
||||||
|
|
||||||
|
The camera rotates around the center of the canvas with smooth angular motion.
|
||||||
|
Enables radar sweep, pendulum view, and spiral scanning animations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
speed: Rotation speed (higher = faster rotation)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Camera configured for radial polar coordinate scanning
|
||||||
|
"""
|
||||||
|
cam = cls(
|
||||||
|
mode=CameraMode.RADIAL, speed=speed, canvas_width=200, canvas_height=200
|
||||||
|
)
|
||||||
|
# Initialize radial state
|
||||||
|
cam._r_float = 0.0
|
||||||
|
cam._theta_float = 0.0
|
||||||
|
return cam
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera":
|
def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera":
|
||||||
"""Create a camera with custom update function."""
|
"""Create a camera with custom update function."""
|
||||||
|
|||||||
@@ -130,8 +130,10 @@ class Config:
|
|||||||
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
||||||
|
|
||||||
display: str = "pygame"
|
display: str = "pygame"
|
||||||
|
positioning: str = "mixed"
|
||||||
websocket: bool = False
|
websocket: bool = False
|
||||||
websocket_port: int = 8765
|
websocket_port: int = 8765
|
||||||
|
theme: str = "green"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
||||||
@@ -173,8 +175,10 @@ class Config:
|
|||||||
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
||||||
script_fonts=_get_platform_font_paths(),
|
script_fonts=_get_platform_font_paths(),
|
||||||
display=_arg_value("--display", argv) or "terminal",
|
display=_arg_value("--display", argv) or "terminal",
|
||||||
|
positioning=_arg_value("--positioning", argv) or "mixed",
|
||||||
websocket="--websocket" in argv,
|
websocket="--websocket" in argv,
|
||||||
websocket_port=_arg_int("--websocket-port", 8765, argv),
|
websocket_port=_arg_int("--websocket-port", 8765, argv),
|
||||||
|
theme=_arg_value("--theme", argv) or "green",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -246,6 +250,40 @@ DEMO = "--demo" in sys.argv
|
|||||||
DEMO_EFFECT_DURATION = 5.0 # seconds per effect
|
DEMO_EFFECT_DURATION = 5.0 # seconds per effect
|
||||||
PIPELINE_DEMO = "--pipeline-demo" in sys.argv
|
PIPELINE_DEMO = "--pipeline-demo" in sys.argv
|
||||||
|
|
||||||
|
# ─── THEME MANAGEMENT ─────────────────────────────────────────
|
||||||
|
ACTIVE_THEME = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_theme(theme_id: str = "green"):
|
||||||
|
"""Set the active theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: Theme identifier from theme registry (e.g., "green", "orange", "purple")
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is not in the theme registry
|
||||||
|
|
||||||
|
Side Effects:
|
||||||
|
Sets the ACTIVE_THEME global variable
|
||||||
|
"""
|
||||||
|
global ACTIVE_THEME
|
||||||
|
from engine import themes
|
||||||
|
|
||||||
|
ACTIVE_THEME = themes.get_theme(theme_id)
|
||||||
|
|
||||||
|
|
||||||
|
# Initialize theme on module load (lazy to avoid circular dependency)
|
||||||
|
def _init_theme():
|
||||||
|
theme_id = _arg_value("--theme", sys.argv) or "green"
|
||||||
|
try:
|
||||||
|
set_active_theme(theme_id)
|
||||||
|
except KeyError:
|
||||||
|
pass # Theme not found, keep None
|
||||||
|
|
||||||
|
|
||||||
|
_init_theme()
|
||||||
|
|
||||||
|
|
||||||
# ─── PIPELINE MODE (new unified architecture) ─────────────
|
# ─── PIPELINE MODE (new unified architecture) ─────────────
|
||||||
PIPELINE_MODE = "--pipeline" in sys.argv
|
PIPELINE_MODE = "--pipeline" in sys.argv
|
||||||
PIPELINE_PRESET = _arg_value("--pipeline-preset", sys.argv) or "demo"
|
PIPELINE_PRESET = _arg_value("--pipeline-preset", sys.argv) or "demo"
|
||||||
@@ -256,6 +294,9 @@ PRESET = _arg_value("--preset", sys.argv)
|
|||||||
# ─── PIPELINE DIAGRAM ────────────────────────────────────
|
# ─── PIPELINE DIAGRAM ────────────────────────────────────
|
||||||
PIPELINE_DIAGRAM = "--pipeline-diagram" in sys.argv
|
PIPELINE_DIAGRAM = "--pipeline-diagram" in sys.argv
|
||||||
|
|
||||||
|
# ─── THEME ──────────────────────────────────────────────────
|
||||||
|
THEME = _arg_value("--theme", sys.argv) or "green"
|
||||||
|
|
||||||
|
|
||||||
def set_font_selection(font_path=None, font_index=None):
|
def set_font_selection(font_path=None, font_index=None):
|
||||||
"""Set runtime primary font selection."""
|
"""Set runtime primary font selection."""
|
||||||
|
|||||||
60
engine/data_sources/checkerboard.py
Normal file
60
engine/data_sources/checkerboard.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Checkerboard data source for visual pattern generation."""
|
||||||
|
|
||||||
|
from engine.data_sources.sources import DataSource, SourceItem
|
||||||
|
|
||||||
|
|
||||||
|
class CheckerboardDataSource(DataSource):
|
||||||
|
"""Data source that generates a checkerboard pattern.
|
||||||
|
|
||||||
|
Creates a grid of alternating characters, useful for testing motion effects
|
||||||
|
and camera movement. The pattern is static; movement comes from camera panning.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
width: int = 200,
|
||||||
|
height: int = 200,
|
||||||
|
square_size: int = 10,
|
||||||
|
char_a: str = "#",
|
||||||
|
char_b: str = " ",
|
||||||
|
):
|
||||||
|
"""Initialize checkerboard data source.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Total pattern width in characters
|
||||||
|
height: Total pattern height in lines
|
||||||
|
square_size: Size of each checker square in characters
|
||||||
|
char_a: Character for "filled" squares (default: '#')
|
||||||
|
char_b: Character for "empty" squares (default: ' ')
|
||||||
|
"""
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.square_size = square_size
|
||||||
|
self.char_a = char_a
|
||||||
|
self.char_b = char_b
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "checkerboard"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_dynamic(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def fetch(self) -> list[SourceItem]:
|
||||||
|
"""Generate the checkerboard pattern as a single SourceItem."""
|
||||||
|
lines = []
|
||||||
|
for y in range(self.height):
|
||||||
|
line_chars = []
|
||||||
|
for x in range(self.width):
|
||||||
|
# Determine which square this position belongs to
|
||||||
|
square_x = x // self.square_size
|
||||||
|
square_y = y // self.square_size
|
||||||
|
# Alternate pattern based on parity of square coordinates
|
||||||
|
if (square_x + square_y) % 2 == 0:
|
||||||
|
line_chars.append(self.char_a)
|
||||||
|
else:
|
||||||
|
line_chars.append(self.char_b)
|
||||||
|
lines.append("".join(line_chars))
|
||||||
|
content = "\n".join(lines)
|
||||||
|
return [SourceItem(content=content, source="checkerboard", timestamp="0")]
|
||||||
@@ -5,102 +5,59 @@ Allows swapping output backends via the Display protocol.
|
|||||||
Supports auto-discovery of display backends.
|
Supports auto-discovery of display backends.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from enum import Enum, auto
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from engine.display.backends.kitty import KittyDisplay
|
# Optional backend - requires moderngl package
|
||||||
|
try:
|
||||||
|
from engine.display.backends.moderngl import ModernGLDisplay
|
||||||
|
|
||||||
|
_MODERNGL_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
ModernGLDisplay = None
|
||||||
|
_MODERNGL_AVAILABLE = False
|
||||||
|
|
||||||
from engine.display.backends.multi import MultiDisplay
|
from engine.display.backends.multi import MultiDisplay
|
||||||
from engine.display.backends.null import NullDisplay
|
from engine.display.backends.null import NullDisplay
|
||||||
from engine.display.backends.pygame import PygameDisplay
|
from engine.display.backends.pygame import PygameDisplay
|
||||||
from engine.display.backends.sixel import SixelDisplay
|
from engine.display.backends.replay import ReplayDisplay
|
||||||
from engine.display.backends.terminal import TerminalDisplay
|
from engine.display.backends.terminal import TerminalDisplay
|
||||||
from engine.display.backends.websocket import WebSocketDisplay
|
from engine.display.backends.websocket import WebSocketDisplay
|
||||||
|
|
||||||
|
|
||||||
|
class BorderMode(Enum):
|
||||||
|
"""Border rendering modes for displays."""
|
||||||
|
|
||||||
|
OFF = auto() # No border
|
||||||
|
SIMPLE = auto() # Traditional border with FPS/frame time
|
||||||
|
UI = auto() # Right-side UI panel with interactive controls
|
||||||
|
|
||||||
|
|
||||||
class Display(Protocol):
|
class Display(Protocol):
|
||||||
"""Protocol for display backends.
|
"""Protocol for display backends.
|
||||||
|
|
||||||
All display backends must implement:
|
Required attributes:
|
||||||
- width, height: Terminal dimensions
|
- width: int
|
||||||
- init(width, height, reuse=False): Initialize the display
|
- height: int
|
||||||
- show(buffer): Render buffer to display
|
|
||||||
- clear(): Clear the display
|
|
||||||
- cleanup(): Shutdown the display
|
|
||||||
|
|
||||||
Optional methods for keyboard input:
|
Required methods (duck typing - actual signatures may vary):
|
||||||
- is_quit_requested(): Returns True if user pressed Ctrl+C/Q or Escape
|
- init(width, height, reuse=False)
|
||||||
- clear_quit_request(): Clears the quit request flag
|
- show(buffer, border=False)
|
||||||
|
- clear()
|
||||||
|
- cleanup()
|
||||||
|
- get_dimensions() -> (width, height)
|
||||||
|
|
||||||
The reuse flag allows attaching to an existing display instance
|
Optional attributes (for UI mode):
|
||||||
rather than creating a new window/connection.
|
- ui_panel: UIPanel instance (set by app when border=UI)
|
||||||
|
|
||||||
Keyboard input support by backend:
|
Optional methods:
|
||||||
- terminal: No native input (relies on signal handler for Ctrl+C)
|
- is_quit_requested() -> bool
|
||||||
- pygame: Supports Ctrl+C, Ctrl+Q, Escape for graceful shutdown
|
- clear_quit_request() -> None
|
||||||
- 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
|
||||||
height: int
|
height: int
|
||||||
|
|
||||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
|
||||||
"""Initialize display with dimensions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
width: Terminal width in characters
|
|
||||||
height: Terminal height in rows
|
|
||||||
reuse: If True, attach to existing display instead of creating new
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
|
||||||
"""Show buffer on display.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
buffer: Buffer to display
|
|
||||||
border: If True, render border around buffer (default False)
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
"""Clear display."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
"""Shutdown display."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def get_dimensions(self) -> tuple[int, int]:
|
|
||||||
"""Get current terminal dimensions.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(width, height) in character cells
|
|
||||||
|
|
||||||
This method is called after show() to check if the display
|
|
||||||
was resized. The main loop should compare this to the current
|
|
||||||
viewport dimensions and update accordingly.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def is_quit_requested(self) -> bool:
|
|
||||||
"""Check if user requested quit (Ctrl+C, Ctrl+Q, or Escape).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if quit was requested, False otherwise
|
|
||||||
|
|
||||||
Optional method - only implemented by backends that support keyboard input.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def clear_quit_request(self) -> None:
|
|
||||||
"""Clear the quit request flag.
|
|
||||||
|
|
||||||
Optional method - only implemented by backends that support keyboard input.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayRegistry:
|
class DisplayRegistry:
|
||||||
"""Registry for display backends with auto-discovery."""
|
"""Registry for display backends with auto-discovery."""
|
||||||
@@ -110,22 +67,18 @@ class DisplayRegistry:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def register(cls, name: str, backend_class: type[Display]) -> None:
|
def register(cls, name: str, backend_class: type[Display]) -> None:
|
||||||
"""Register a display backend."""
|
|
||||||
cls._backends[name.lower()] = backend_class
|
cls._backends[name.lower()] = backend_class
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get(cls, name: str) -> type[Display] | None:
|
def get(cls, name: str) -> type[Display] | None:
|
||||||
"""Get a display backend class by name."""
|
|
||||||
return cls._backends.get(name.lower())
|
return cls._backends.get(name.lower())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def list_backends(cls) -> list[str]:
|
def list_backends(cls) -> list[str]:
|
||||||
"""List all available display backend names."""
|
|
||||||
return list(cls._backends.keys())
|
return list(cls._backends.keys())
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, name: str, **kwargs) -> Display | None:
|
def create(cls, name: str, **kwargs) -> Display | None:
|
||||||
"""Create a display instance by name."""
|
|
||||||
cls.initialize()
|
cls.initialize()
|
||||||
backend_class = cls.get(name)
|
backend_class = cls.get(name)
|
||||||
if backend_class:
|
if backend_class:
|
||||||
@@ -134,31 +87,19 @@ class DisplayRegistry:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def initialize(cls) -> None:
|
def initialize(cls) -> None:
|
||||||
"""Initialize and register all built-in backends."""
|
|
||||||
if cls._initialized:
|
if cls._initialized:
|
||||||
return
|
return
|
||||||
|
|
||||||
cls.register("terminal", TerminalDisplay)
|
cls.register("terminal", TerminalDisplay)
|
||||||
cls.register("null", NullDisplay)
|
cls.register("null", NullDisplay)
|
||||||
|
cls.register("replay", ReplayDisplay)
|
||||||
cls.register("websocket", WebSocketDisplay)
|
cls.register("websocket", WebSocketDisplay)
|
||||||
cls.register("sixel", SixelDisplay)
|
|
||||||
cls.register("kitty", KittyDisplay)
|
|
||||||
cls.register("pygame", PygameDisplay)
|
cls.register("pygame", PygameDisplay)
|
||||||
|
if _MODERNGL_AVAILABLE:
|
||||||
|
cls.register("moderngl", ModernGLDisplay) # type: ignore[arg-type]
|
||||||
cls._initialized = True
|
cls._initialized = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_multi(cls, names: list[str]) -> "Display | None":
|
def create_multi(cls, names: list[str]) -> MultiDisplay | None:
|
||||||
"""Create a MultiDisplay from a list of backend names.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
names: List of display backend names (e.g., ["terminal", "pygame"])
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
MultiDisplay instance or None if any backend fails
|
|
||||||
"""
|
|
||||||
from engine.display.backends.multi import MultiDisplay
|
|
||||||
|
|
||||||
displays = []
|
displays = []
|
||||||
for name in names:
|
for name in names:
|
||||||
backend = cls.create(name)
|
backend = cls.create(name)
|
||||||
@@ -166,10 +107,8 @@ class DisplayRegistry:
|
|||||||
displays.append(backend)
|
displays.append(backend)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if not displays:
|
if not displays:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return MultiDisplay(displays)
|
return MultiDisplay(displays)
|
||||||
|
|
||||||
|
|
||||||
@@ -190,44 +129,28 @@ def _strip_ansi(s: str) -> str:
|
|||||||
return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s)
|
return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s)
|
||||||
|
|
||||||
|
|
||||||
def render_border(
|
def _render_simple_border(
|
||||||
buf: list[str], width: int, height: int, fps: float = 0.0, frame_time: float = 0.0
|
buf: list[str], width: int, height: int, fps: float = 0.0, frame_time: float = 0.0
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Render a border around the buffer.
|
"""Render a traditional border around the buffer."""
|
||||||
|
|
||||||
Args:
|
|
||||||
buf: Input buffer (list of strings)
|
|
||||||
width: Display width in characters
|
|
||||||
height: Display height in rows
|
|
||||||
fps: Current FPS to display in top border (optional)
|
|
||||||
frame_time: Frame time in ms to display in bottom border (optional)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Buffer with border applied
|
|
||||||
"""
|
|
||||||
if not buf or width < 3 or height < 3:
|
if not buf or width < 3 or height < 3:
|
||||||
return buf
|
return buf
|
||||||
|
|
||||||
inner_w = width - 2
|
inner_w = width - 2
|
||||||
inner_h = height - 2
|
inner_h = height - 2
|
||||||
|
|
||||||
# Crop buffer to fit inside border
|
|
||||||
cropped = []
|
cropped = []
|
||||||
for i in range(min(inner_h, len(buf))):
|
for i in range(min(inner_h, len(buf))):
|
||||||
line = buf[i]
|
line = buf[i]
|
||||||
# Calculate visible width (excluding ANSI codes)
|
|
||||||
visible_len = len(_strip_ansi(line))
|
visible_len = len(_strip_ansi(line))
|
||||||
if visible_len > inner_w:
|
if visible_len > inner_w:
|
||||||
# Truncate carefully - this is approximate for ANSI text
|
|
||||||
cropped.append(line[:inner_w])
|
cropped.append(line[:inner_w])
|
||||||
else:
|
else:
|
||||||
cropped.append(line + " " * (inner_w - visible_len))
|
cropped.append(line + " " * (inner_w - visible_len))
|
||||||
|
|
||||||
# Pad with empty lines if needed
|
|
||||||
while len(cropped) < inner_h:
|
while len(cropped) < inner_h:
|
||||||
cropped.append(" " * inner_w)
|
cropped.append(" " * inner_w)
|
||||||
|
|
||||||
# Build borders
|
|
||||||
if fps > 0:
|
if fps > 0:
|
||||||
fps_str = f" FPS:{fps:.0f}"
|
fps_str = f" FPS:{fps:.0f}"
|
||||||
if len(fps_str) < inner_w:
|
if len(fps_str) < inner_w:
|
||||||
@@ -248,10 +171,8 @@ def render_border(
|
|||||||
else:
|
else:
|
||||||
bottom_border = "└" + "─" * inner_w + "┘"
|
bottom_border = "└" + "─" * inner_w + "┘"
|
||||||
|
|
||||||
# Build result with left/right borders
|
|
||||||
result = [top_border]
|
result = [top_border]
|
||||||
for line in cropped:
|
for line in cropped:
|
||||||
# Ensure exactly inner_w characters before adding right border
|
|
||||||
if len(line) < inner_w:
|
if len(line) < inner_w:
|
||||||
line = line + " " * (inner_w - len(line))
|
line = line + " " * (inner_w - len(line))
|
||||||
elif len(line) > inner_w:
|
elif len(line) > inner_w:
|
||||||
@@ -262,14 +183,108 @@ def render_border(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def render_ui_panel(
|
||||||
|
buf: list[str],
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
ui_panel,
|
||||||
|
fps: float = 0.0,
|
||||||
|
frame_time: float = 0.0,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Render buffer with a right-side UI panel."""
|
||||||
|
from engine.pipeline.ui import UIPanel
|
||||||
|
|
||||||
|
if not isinstance(ui_panel, UIPanel):
|
||||||
|
return _render_simple_border(buf, width, height, fps, frame_time)
|
||||||
|
|
||||||
|
panel_width = min(ui_panel.config.panel_width, width - 4)
|
||||||
|
main_width = width - panel_width - 1
|
||||||
|
|
||||||
|
panel_lines = ui_panel.render(panel_width, height)
|
||||||
|
|
||||||
|
main_buf = buf[: height - 2]
|
||||||
|
main_result = _render_simple_border(
|
||||||
|
main_buf, main_width + 2, height, fps, frame_time
|
||||||
|
)
|
||||||
|
|
||||||
|
combined = []
|
||||||
|
for i in range(height):
|
||||||
|
if i < len(main_result):
|
||||||
|
main_line = main_result[i]
|
||||||
|
if len(main_line) >= 2:
|
||||||
|
main_content = (
|
||||||
|
main_line[1:-1] if main_line[-1] in "│┌┐└┘" else main_line[1:]
|
||||||
|
)
|
||||||
|
main_content = main_content.ljust(main_width)[:main_width]
|
||||||
|
else:
|
||||||
|
main_content = " " * main_width
|
||||||
|
else:
|
||||||
|
main_content = " " * main_width
|
||||||
|
|
||||||
|
panel_idx = i
|
||||||
|
panel_line = (
|
||||||
|
panel_lines[panel_idx][:panel_width].ljust(panel_width)
|
||||||
|
if panel_idx < len(panel_lines)
|
||||||
|
else " " * panel_width
|
||||||
|
)
|
||||||
|
|
||||||
|
separator = "│" if 0 < i < height - 1 else "┼" if i == 0 else "┴"
|
||||||
|
combined.append(main_content + separator + panel_line)
|
||||||
|
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
def render_border(
|
||||||
|
buf: list[str],
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
fps: float = 0.0,
|
||||||
|
frame_time: float = 0.0,
|
||||||
|
border_mode: BorderMode | bool = BorderMode.SIMPLE,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Render a border or UI panel around the buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buf: Input buffer
|
||||||
|
width: Display width
|
||||||
|
height: Display height
|
||||||
|
fps: FPS for top border
|
||||||
|
frame_time: Frame time for bottom border
|
||||||
|
border_mode: Border rendering mode
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Buffer with border/panel applied
|
||||||
|
"""
|
||||||
|
# Normalize border_mode to BorderMode enum
|
||||||
|
if isinstance(border_mode, bool):
|
||||||
|
border_mode = BorderMode.SIMPLE if border_mode else BorderMode.OFF
|
||||||
|
|
||||||
|
if border_mode == BorderMode.UI:
|
||||||
|
# UI panel requires a UIPanel instance (injected separately)
|
||||||
|
# For now, this will be called by displays that have a ui_panel attribute
|
||||||
|
# This function signature doesn't include ui_panel, so we'll handle it in render_ui_panel
|
||||||
|
# Fall back to simple border if no panel available
|
||||||
|
return _render_simple_border(buf, width, height, fps, frame_time)
|
||||||
|
elif border_mode == BorderMode.SIMPLE:
|
||||||
|
return _render_simple_border(buf, width, height, fps, frame_time)
|
||||||
|
else:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Display",
|
"Display",
|
||||||
"DisplayRegistry",
|
"DisplayRegistry",
|
||||||
"get_monitor",
|
"get_monitor",
|
||||||
"render_border",
|
"render_border",
|
||||||
|
"render_ui_panel",
|
||||||
|
"BorderMode",
|
||||||
"TerminalDisplay",
|
"TerminalDisplay",
|
||||||
"NullDisplay",
|
"NullDisplay",
|
||||||
|
"ReplayDisplay",
|
||||||
"WebSocketDisplay",
|
"WebSocketDisplay",
|
||||||
"SixelDisplay",
|
|
||||||
"MultiDisplay",
|
"MultiDisplay",
|
||||||
|
"PygameDisplay",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if _MODERNGL_AVAILABLE:
|
||||||
|
__all__.append("ModernGLDisplay")
|
||||||
|
|||||||
656
engine/display/backends/animation_report.py
Normal file
656
engine/display/backends/animation_report.py
Normal file
@@ -0,0 +1,656 @@
|
|||||||
|
"""
|
||||||
|
Animation Report Display Backend
|
||||||
|
|
||||||
|
Captures frames from pipeline stages and generates an interactive HTML report
|
||||||
|
showing before/after states for each transformative stage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.display.streaming import compute_diff
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CapturedFrame:
|
||||||
|
"""A captured frame with metadata."""
|
||||||
|
|
||||||
|
stage: str
|
||||||
|
buffer: list[str]
|
||||||
|
timestamp: float
|
||||||
|
frame_number: int
|
||||||
|
diff_from_previous: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StageCapture:
|
||||||
|
"""Captures frames for a single pipeline stage."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
frames: list[CapturedFrame] = field(default_factory=list)
|
||||||
|
start_time: float = field(default_factory=time.time)
|
||||||
|
end_time: float = 0.0
|
||||||
|
|
||||||
|
def add_frame(
|
||||||
|
self,
|
||||||
|
buffer: list[str],
|
||||||
|
frame_number: int,
|
||||||
|
previous_buffer: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Add a captured frame."""
|
||||||
|
timestamp = time.time()
|
||||||
|
diff = None
|
||||||
|
if previous_buffer is not None:
|
||||||
|
diff_data = compute_diff(previous_buffer, buffer)
|
||||||
|
diff = {
|
||||||
|
"changed_lines": len(diff_data.changed_lines),
|
||||||
|
"total_lines": len(buffer),
|
||||||
|
"width": diff_data.width,
|
||||||
|
"height": diff_data.height,
|
||||||
|
}
|
||||||
|
|
||||||
|
frame = CapturedFrame(
|
||||||
|
stage=self.name,
|
||||||
|
buffer=list(buffer),
|
||||||
|
timestamp=timestamp,
|
||||||
|
frame_number=frame_number,
|
||||||
|
diff_from_previous=diff,
|
||||||
|
)
|
||||||
|
self.frames.append(frame)
|
||||||
|
|
||||||
|
def finish(self) -> None:
|
||||||
|
"""Mark capture as finished."""
|
||||||
|
self.end_time = time.time()
|
||||||
|
|
||||||
|
|
||||||
|
class AnimationReportDisplay:
|
||||||
|
"""
|
||||||
|
Display backend that captures frames for animation report generation.
|
||||||
|
|
||||||
|
Instead of rendering to terminal, this display captures the buffer at each
|
||||||
|
stage and stores it for later HTML report generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
|
||||||
|
def __init__(self, output_dir: str = "./reports"):
|
||||||
|
"""
|
||||||
|
Initialize the animation report display.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Directory where reports will be saved
|
||||||
|
"""
|
||||||
|
self.output_dir = Path(output_dir)
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._stages: dict[str, StageCapture] = {}
|
||||||
|
self._current_stage: str = ""
|
||||||
|
self._previous_buffer: list[str] | None = None
|
||||||
|
self._frame_number: int = 0
|
||||||
|
self._total_frames: int = 0
|
||||||
|
self._start_time: float = 0.0
|
||||||
|
|
||||||
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||||
|
"""Initialize display with dimensions."""
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self._start_time = time.time()
|
||||||
|
|
||||||
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||||
|
"""
|
||||||
|
Capture a frame for the current stage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buffer: The frame buffer to capture
|
||||||
|
border: Border flag (ignored)
|
||||||
|
"""
|
||||||
|
if not self._current_stage:
|
||||||
|
# If no stage is set, use a default name
|
||||||
|
self._current_stage = "final"
|
||||||
|
|
||||||
|
if self._current_stage not in self._stages:
|
||||||
|
self._stages[self._current_stage] = StageCapture(self._current_stage)
|
||||||
|
|
||||||
|
stage = self._stages[self._current_stage]
|
||||||
|
stage.add_frame(buffer, self._frame_number, self._previous_buffer)
|
||||||
|
|
||||||
|
self._previous_buffer = list(buffer)
|
||||||
|
self._frame_number += 1
|
||||||
|
self._total_frames += 1
|
||||||
|
|
||||||
|
def start_stage(self, stage_name: str) -> None:
|
||||||
|
"""
|
||||||
|
Start capturing frames for a new stage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stage_name: Name of the stage (e.g., "noise", "fade", "firehose")
|
||||||
|
"""
|
||||||
|
if self._current_stage and self._current_stage in self._stages:
|
||||||
|
# Finish previous stage
|
||||||
|
self._stages[self._current_stage].finish()
|
||||||
|
|
||||||
|
self._current_stage = stage_name
|
||||||
|
self._previous_buffer = None # Reset for new stage
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear the display (no-op for report display)."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Cleanup resources."""
|
||||||
|
# Finish current stage
|
||||||
|
if self._current_stage and self._current_stage in self._stages:
|
||||||
|
self._stages[self._current_stage].finish()
|
||||||
|
|
||||||
|
def get_dimensions(self) -> tuple[int, int]:
|
||||||
|
"""Get current dimensions."""
|
||||||
|
return (self.width, self.height)
|
||||||
|
|
||||||
|
def get_stages(self) -> dict[str, StageCapture]:
|
||||||
|
"""Get all captured stages."""
|
||||||
|
return self._stages
|
||||||
|
|
||||||
|
def generate_report(self, title: str = "Animation Report") -> Path:
|
||||||
|
"""
|
||||||
|
Generate an HTML report with captured frames and animations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Title of the report
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the generated HTML file
|
||||||
|
"""
|
||||||
|
report_path = self.output_dir / f"animation_report_{int(time.time())}.html"
|
||||||
|
html_content = self._build_html(title)
|
||||||
|
report_path.write_text(html_content)
|
||||||
|
return report_path
|
||||||
|
|
||||||
|
def _build_html(self, title: str) -> str:
|
||||||
|
"""Build the HTML content for the report."""
|
||||||
|
# Collect all frames across stages
|
||||||
|
all_frames = []
|
||||||
|
for stage_name, stage in self._stages.items():
|
||||||
|
for frame in stage.frames:
|
||||||
|
all_frames.append(frame)
|
||||||
|
|
||||||
|
# Sort frames by timestamp
|
||||||
|
all_frames.sort(key=lambda f: f.timestamp)
|
||||||
|
|
||||||
|
# Build stage sections
|
||||||
|
stages_html = ""
|
||||||
|
for stage_name, stage in self._stages.items():
|
||||||
|
stages_html += self._build_stage_section(stage_name, stage)
|
||||||
|
|
||||||
|
# Build full HTML
|
||||||
|
html = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{title}</title>
|
||||||
|
<style>
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #eee;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}}
|
||||||
|
.container {{
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}}
|
||||||
|
.header {{
|
||||||
|
background: linear-gradient(135deg, #16213e 0%, #1a1a2e 100%);
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||||
|
}}
|
||||||
|
.header h1 {{
|
||||||
|
font-size: 2.5em;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: linear-gradient(90deg, #00d4ff, #00ff88);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}}
|
||||||
|
.header .meta {{
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}}
|
||||||
|
.stats-grid {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}}
|
||||||
|
.stat-card {{
|
||||||
|
background: #16213e;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}}
|
||||||
|
.stat-value {{
|
||||||
|
font-size: 1.8em;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #00ff88;
|
||||||
|
}}
|
||||||
|
.stat-label {{
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85em;
|
||||||
|
margin-top: 5px;
|
||||||
|
}}
|
||||||
|
.stage-section {{
|
||||||
|
background: #16213e;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
||||||
|
}}
|
||||||
|
.stage-header {{
|
||||||
|
background: #1f2a48;
|
||||||
|
padding: 15px 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}}
|
||||||
|
.stage-header:hover {{
|
||||||
|
background: #253252;
|
||||||
|
}}
|
||||||
|
.stage-name {{
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1em;
|
||||||
|
color: #00d4ff;
|
||||||
|
}}
|
||||||
|
.stage-info {{
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}}
|
||||||
|
.stage-content {{
|
||||||
|
padding: 20px;
|
||||||
|
}}
|
||||||
|
.frames-container {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}}
|
||||||
|
.frame-card {{
|
||||||
|
background: #0f0f1a;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #333;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}}
|
||||||
|
.frame-card:hover {{
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0,212,255,0.2);
|
||||||
|
}}
|
||||||
|
.frame-header {{
|
||||||
|
background: #1a1a2e;
|
||||||
|
padding: 10px 15px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #888;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}}
|
||||||
|
.frame-number {{
|
||||||
|
color: #00ff88;
|
||||||
|
}}
|
||||||
|
.frame-diff {{
|
||||||
|
color: #ff6b6b;
|
||||||
|
}}
|
||||||
|
.frame-content {{
|
||||||
|
padding: 10px;
|
||||||
|
font-family: 'Fira Code', 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}}
|
||||||
|
.timeline-section {{
|
||||||
|
background: #16213e;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}}
|
||||||
|
.timeline-header {{
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}}
|
||||||
|
.timeline-title {{
|
||||||
|
font-weight: bold;
|
||||||
|
color: #00d4ff;
|
||||||
|
}}
|
||||||
|
.timeline-controls {{
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}}
|
||||||
|
.timeline-controls button {{
|
||||||
|
background: #1f2a48;
|
||||||
|
border: 1px solid #333;
|
||||||
|
color: #eee;
|
||||||
|
padding: 8px 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}}
|
||||||
|
.timeline-controls button:hover {{
|
||||||
|
background: #253252;
|
||||||
|
border-color: #00d4ff;
|
||||||
|
}}
|
||||||
|
.timeline-controls button.active {{
|
||||||
|
background: #00d4ff;
|
||||||
|
color: #000;
|
||||||
|
}}
|
||||||
|
.timeline-canvas {{
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
background: #0f0f1a;
|
||||||
|
border-radius: 8px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}}
|
||||||
|
.timeline-track {{
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4px;
|
||||||
|
background: #333;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}}
|
||||||
|
.timeline-marker {{
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: #00d4ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}}
|
||||||
|
.timeline-marker:hover {{
|
||||||
|
transform: translate(-50%, -50%) scale(1.3);
|
||||||
|
box-shadow: 0 0 10px #00d4ff;
|
||||||
|
}}
|
||||||
|
.timeline-marker.stage-{{stage_name}} {{
|
||||||
|
background: var(--stage-color, #00d4ff);
|
||||||
|
}}
|
||||||
|
.comparison-view {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}}
|
||||||
|
.comparison-panel {{
|
||||||
|
background: #0f0f1a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}}
|
||||||
|
.comparison-panel h4 {{
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}}
|
||||||
|
.comparison-content {{
|
||||||
|
font-family: 'Fira Code', 'Consolas', 'Monaco', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: pre;
|
||||||
|
}}
|
||||||
|
.diff-added {{
|
||||||
|
background: rgba(0, 255, 136, 0.2);
|
||||||
|
}}
|
||||||
|
.diff-removed {{
|
||||||
|
background: rgba(255, 107, 107, 0.2);
|
||||||
|
}}
|
||||||
|
@keyframes pulse {{
|
||||||
|
0%, 100% {{ opacity: 1; }}
|
||||||
|
50% {{ opacity: 0.7; }}
|
||||||
|
}}
|
||||||
|
.animating {{
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}}
|
||||||
|
.footer {{
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
padding: 20px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🎬 {title}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} |
|
||||||
|
Total Frames: {self._total_frames} |
|
||||||
|
Duration: {time.time() - self._start_time:.2f}s
|
||||||
|
</div>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{len(self._stages)}</div>
|
||||||
|
<div class="stat-label">Pipeline Stages</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{self._total_frames}</div>
|
||||||
|
<div class="stat-label">Total Frames</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{time.time() - self._start_time:.2f}s</div>
|
||||||
|
<div class="stat-label">Capture Duration</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-value">{self.width}x{self.height}</div>
|
||||||
|
<div class="stat-label">Resolution</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline-section">
|
||||||
|
<div class="timeline-header">
|
||||||
|
<div class="timeline-title">Timeline</div>
|
||||||
|
<div class="timeline-controls">
|
||||||
|
<button onclick="playAnimation()">▶ Play</button>
|
||||||
|
<button onclick="pauseAnimation()">⏸ Pause</button>
|
||||||
|
<button onclick="stepForward()">⏭ Step</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline-canvas" id="timeline">
|
||||||
|
<div class="timeline-track"></div>
|
||||||
|
<!-- Timeline markers will be added by JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stages_html}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Animation Report generated by Mainline</p>
|
||||||
|
<p>Use the timeline controls above to play/pause the animation</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Animation state
|
||||||
|
let currentFrame = 0;
|
||||||
|
let isPlaying = false;
|
||||||
|
let animationInterval = null;
|
||||||
|
const totalFrames = {len(all_frames)};
|
||||||
|
|
||||||
|
// Stage colors for timeline markers
|
||||||
|
const stageColors = {{
|
||||||
|
{self._build_stage_colors()}
|
||||||
|
}};
|
||||||
|
|
||||||
|
// Initialize timeline
|
||||||
|
function initTimeline() {{
|
||||||
|
const timeline = document.getElementById('timeline');
|
||||||
|
const track = timeline.querySelector('.timeline-track');
|
||||||
|
|
||||||
|
{self._build_timeline_markers(all_frames)}
|
||||||
|
}}
|
||||||
|
|
||||||
|
function playAnimation() {{
|
||||||
|
if (isPlaying) return;
|
||||||
|
isPlaying = true;
|
||||||
|
animationInterval = setInterval(() => {{
|
||||||
|
currentFrame = (currentFrame + 1) % totalFrames;
|
||||||
|
updateFrameDisplay();
|
||||||
|
}}, 100);
|
||||||
|
}}
|
||||||
|
|
||||||
|
function pauseAnimation() {{
|
||||||
|
isPlaying = false;
|
||||||
|
if (animationInterval) {{
|
||||||
|
clearInterval(animationInterval);
|
||||||
|
animationInterval = null;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
|
||||||
|
function stepForward() {{
|
||||||
|
currentFrame = (currentFrame + 1) % totalFrames;
|
||||||
|
updateFrameDisplay();
|
||||||
|
}}
|
||||||
|
|
||||||
|
function updateFrameDisplay() {{
|
||||||
|
// Highlight current frame in timeline
|
||||||
|
const markers = document.querySelectorAll('.timeline-marker');
|
||||||
|
markers.forEach((marker, index) => {{
|
||||||
|
if (index === currentFrame) {{
|
||||||
|
marker.style.transform = 'translate(-50%, -50%) scale(1.5)';
|
||||||
|
marker.style.boxShadow = '0 0 15px #00ff88';
|
||||||
|
}} else {{
|
||||||
|
marker.style.transform = 'translate(-50%, -50%) scale(1)';
|
||||||
|
marker.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', initTimeline);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return html
|
||||||
|
|
||||||
|
def _build_stage_section(self, stage_name: str, stage: StageCapture) -> str:
|
||||||
|
"""Build HTML for a single stage section."""
|
||||||
|
frames_html = ""
|
||||||
|
for i, frame in enumerate(stage.frames):
|
||||||
|
diff_info = ""
|
||||||
|
if frame.diff_from_previous:
|
||||||
|
changed = frame.diff_from_previous.get("changed_lines", 0)
|
||||||
|
total = frame.diff_from_previous.get("total_lines", 0)
|
||||||
|
diff_info = f'<span class="frame-diff">Δ {changed}/{total}</span>'
|
||||||
|
|
||||||
|
frames_html += f"""
|
||||||
|
<div class="frame-card">
|
||||||
|
<div class="frame-header">
|
||||||
|
<span>Frame <span class="frame-number">{frame.frame_number}</span></span>
|
||||||
|
{diff_info}
|
||||||
|
</div>
|
||||||
|
<div class="frame-content">{self._escape_html("".join(frame.buffer))}</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"""
|
||||||
|
<div class="stage-section">
|
||||||
|
<div class="stage-header" onclick="this.nextElementSibling.classList.toggle('hidden')">
|
||||||
|
<span class="stage-name">{stage_name}</span>
|
||||||
|
<span class="stage-info">{len(stage.frames)} frames</span>
|
||||||
|
</div>
|
||||||
|
<div class="stage-content">
|
||||||
|
<div class="frames-container">
|
||||||
|
{frames_html}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _build_timeline(self, all_frames: list[CapturedFrame]) -> str:
|
||||||
|
"""Build timeline HTML."""
|
||||||
|
if not all_frames:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
markers_html = ""
|
||||||
|
for i, frame in enumerate(all_frames):
|
||||||
|
left_percent = (i / len(all_frames)) * 100
|
||||||
|
markers_html += f'<div class="timeline-marker" style="left: {left_percent}%" data-frame="{i}"></div>'
|
||||||
|
|
||||||
|
return markers_html
|
||||||
|
|
||||||
|
def _build_stage_colors(self) -> str:
|
||||||
|
"""Build stage color mapping for JavaScript."""
|
||||||
|
colors = [
|
||||||
|
"#00d4ff",
|
||||||
|
"#00ff88",
|
||||||
|
"#ff6b6b",
|
||||||
|
"#ffd93d",
|
||||||
|
"#a855f7",
|
||||||
|
"#ec4899",
|
||||||
|
"#14b8a6",
|
||||||
|
"#f97316",
|
||||||
|
"#8b5cf6",
|
||||||
|
"#06b6d4",
|
||||||
|
]
|
||||||
|
color_map = ""
|
||||||
|
for i, stage_name in enumerate(self._stages.keys()):
|
||||||
|
color = colors[i % len(colors)]
|
||||||
|
color_map += f' "{stage_name}": "{color}",\n'
|
||||||
|
return color_map.rstrip(",\n")
|
||||||
|
|
||||||
|
def _build_timeline_markers(self, all_frames: list[CapturedFrame]) -> str:
|
||||||
|
"""Build timeline markers in JavaScript."""
|
||||||
|
if not all_frames:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
markers_js = ""
|
||||||
|
for i, frame in enumerate(all_frames):
|
||||||
|
left_percent = (i / len(all_frames)) * 100
|
||||||
|
stage_color = f"stageColors['{frame.stage}']"
|
||||||
|
markers_js += f"""
|
||||||
|
const marker{i} = document.createElement('div');
|
||||||
|
marker{i}.className = 'timeline-marker stage-{{frame.stage}}';
|
||||||
|
marker{i}.style.left = '{left_percent}%';
|
||||||
|
marker{i}.style.setProperty('--stage-color', {stage_color});
|
||||||
|
marker{i}.onclick = () => {{
|
||||||
|
currentFrame = {i};
|
||||||
|
updateFrameDisplay();
|
||||||
|
}};
|
||||||
|
timeline.appendChild(marker{i});
|
||||||
|
"""
|
||||||
|
|
||||||
|
return markers_js
|
||||||
|
|
||||||
|
def _escape_html(self, text: str) -> str:
|
||||||
|
"""Escape HTML special characters."""
|
||||||
|
return (
|
||||||
|
text.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace("'", "'")
|
||||||
|
)
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
"""
|
|
||||||
Kitty graphics display backend - renders using kitty's native graphics protocol.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
from engine.display.renderer import get_default_font_path, parse_ansi
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_kitty_graphic(image_data: bytes, width: int, height: int) -> bytes:
|
|
||||||
"""Encode image data using kitty's graphics protocol."""
|
|
||||||
import base64
|
|
||||||
|
|
||||||
encoded = base64.b64encode(image_data).decode("ascii")
|
|
||||||
|
|
||||||
chunks = []
|
|
||||||
for i in range(0, len(encoded), 4096):
|
|
||||||
chunk = encoded[i : i + 4096]
|
|
||||||
if i == 0:
|
|
||||||
chunks.append(f"\x1b_Gf=100,t=d,s={width},v={height},c=1,r=1;{chunk}\x1b\\")
|
|
||||||
else:
|
|
||||||
chunks.append(f"\x1b_Gm={height};{chunk}\x1b\\")
|
|
||||||
|
|
||||||
return "".join(chunks).encode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
class KittyDisplay:
|
|
||||||
"""Kitty graphics display backend using kitty's native protocol."""
|
|
||||||
|
|
||||||
width: int = 80
|
|
||||||
height: int = 24
|
|
||||||
|
|
||||||
def __init__(self, cell_width: int = 9, cell_height: int = 16):
|
|
||||||
self.width = 80
|
|
||||||
self.height = 24
|
|
||||||
self.cell_width = cell_width
|
|
||||||
self.cell_height = cell_height
|
|
||||||
self._initialized = False
|
|
||||||
self._font_path = None
|
|
||||||
|
|
||||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
|
||||||
"""Initialize display with dimensions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
width: Terminal width in characters
|
|
||||||
height: Terminal height in rows
|
|
||||||
reuse: Ignored for KittyDisplay (protocol doesn't support reuse)
|
|
||||||
"""
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self._initialized = True
|
|
||||||
|
|
||||||
def _get_font_path(self) -> str | None:
|
|
||||||
"""Get font path from env or detect common locations."""
|
|
||||||
import os
|
|
||||||
|
|
||||||
if self._font_path:
|
|
||||||
return self._font_path
|
|
||||||
|
|
||||||
env_font = os.environ.get("MAINLINE_KITTY_FONT")
|
|
||||||
if env_font and os.path.exists(env_font):
|
|
||||||
self._font_path = env_font
|
|
||||||
return env_font
|
|
||||||
|
|
||||||
font_path = get_default_font_path()
|
|
||||||
if font_path:
|
|
||||||
self._font_path = font_path
|
|
||||||
|
|
||||||
return self._font_path
|
|
||||||
|
|
||||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
|
||||||
import sys
|
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
|
|
||||||
# Get metrics for border display
|
|
||||||
fps = 0.0
|
|
||||||
frame_time = 0.0
|
|
||||||
from engine.display import get_monitor
|
|
||||||
|
|
||||||
monitor = get_monitor()
|
|
||||||
if monitor:
|
|
||||||
stats = monitor.get_stats()
|
|
||||||
avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0
|
|
||||||
frame_count = stats.get("frame_count", 0) if stats else 0
|
|
||||||
if avg_ms and frame_count > 0:
|
|
||||||
fps = 1000.0 / avg_ms
|
|
||||||
frame_time = avg_ms
|
|
||||||
|
|
||||||
# Apply border if requested
|
|
||||||
if border:
|
|
||||||
from engine.display import render_border
|
|
||||||
|
|
||||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
|
||||||
|
|
||||||
img_width = self.width * self.cell_width
|
|
||||||
img_height = self.height * self.cell_height
|
|
||||||
|
|
||||||
try:
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
except ImportError:
|
|
||||||
return
|
|
||||||
|
|
||||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
font_path = self._get_font_path()
|
|
||||||
font = None
|
|
||||||
if font_path:
|
|
||||||
try:
|
|
||||||
font = ImageFont.truetype(font_path, self.cell_height - 2)
|
|
||||||
except Exception:
|
|
||||||
font = None
|
|
||||||
|
|
||||||
if font is None:
|
|
||||||
try:
|
|
||||||
font = ImageFont.load_default()
|
|
||||||
except Exception:
|
|
||||||
font = None
|
|
||||||
|
|
||||||
for row_idx, line in enumerate(buffer[: self.height]):
|
|
||||||
if row_idx >= self.height:
|
|
||||||
break
|
|
||||||
|
|
||||||
tokens = parse_ansi(line)
|
|
||||||
x_pos = 0
|
|
||||||
y_pos = row_idx * self.cell_height
|
|
||||||
|
|
||||||
for text, fg, bg, bold in tokens:
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if bg != (0, 0, 0):
|
|
||||||
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
|
||||||
draw.rectangle(bbox, fill=(*bg, 255))
|
|
||||||
|
|
||||||
if bold and font:
|
|
||||||
draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font)
|
|
||||||
|
|
||||||
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
|
||||||
|
|
||||||
if font:
|
|
||||||
x_pos += draw.textlength(text, font=font)
|
|
||||||
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
output = BytesIO()
|
|
||||||
img.save(output, format="PNG")
|
|
||||||
png_data = output.getvalue()
|
|
||||||
|
|
||||||
graphic = _encode_kitty_graphic(png_data, img_width, img_height)
|
|
||||||
|
|
||||||
sys.stdout.buffer.write(graphic)
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
|
|
||||||
from engine.display import get_monitor
|
|
||||||
|
|
||||||
monitor = get_monitor()
|
|
||||||
if monitor:
|
|
||||||
chars_in = sum(len(line) for line in buffer)
|
|
||||||
monitor.record_effect("kitty_display", elapsed_ms, chars_in, chars_in)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.stdout.buffer.write(b"\x1b_Ga=d\x1b\\")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
self.clear()
|
|
||||||
|
|
||||||
def get_dimensions(self) -> tuple[int, int]:
|
|
||||||
"""Get current dimensions.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(width, height) in character cells
|
|
||||||
"""
|
|
||||||
return (self.width, self.height)
|
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
Null/headless display backend.
|
Null/headless display backend.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
class NullDisplay:
|
class NullDisplay:
|
||||||
@@ -10,7 +13,8 @@ class NullDisplay:
|
|||||||
|
|
||||||
This display does nothing - useful for headless benchmarking
|
This display does nothing - useful for headless benchmarking
|
||||||
or when no display output is needed. Captures last buffer
|
or when no display output is needed. Captures last buffer
|
||||||
for testing purposes.
|
for testing purposes. Supports frame recording for replay
|
||||||
|
and file export/import.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
width: int = 80
|
width: int = 80
|
||||||
@@ -19,6 +23,9 @@ class NullDisplay:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._last_buffer = None
|
self._last_buffer = None
|
||||||
|
self._is_recording = False
|
||||||
|
self._recorded_frames: list[dict[str, Any]] = []
|
||||||
|
self._frame_count = 0
|
||||||
|
|
||||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||||
"""Initialize display with dimensions.
|
"""Initialize display with dimensions.
|
||||||
@@ -33,9 +40,10 @@ class NullDisplay:
|
|||||||
self._last_buffer = None
|
self._last_buffer = None
|
||||||
|
|
||||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||||
|
import sys
|
||||||
|
|
||||||
from engine.display import get_monitor, render_border
|
from engine.display import get_monitor, render_border
|
||||||
|
|
||||||
# Get FPS for border (if available)
|
|
||||||
fps = 0.0
|
fps = 0.0
|
||||||
frame_time = 0.0
|
frame_time = 0.0
|
||||||
monitor = get_monitor()
|
monitor = get_monitor()
|
||||||
@@ -47,17 +55,111 @@ class NullDisplay:
|
|||||||
fps = 1000.0 / avg_ms
|
fps = 1000.0 / avg_ms
|
||||||
frame_time = avg_ms
|
frame_time = avg_ms
|
||||||
|
|
||||||
# Apply border if requested (same as terminal display)
|
|
||||||
if border:
|
if border:
|
||||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||||
|
|
||||||
self._last_buffer = buffer
|
self._last_buffer = buffer
|
||||||
|
|
||||||
|
if self._is_recording:
|
||||||
|
self._recorded_frames.append(
|
||||||
|
{
|
||||||
|
"frame_number": self._frame_count,
|
||||||
|
"buffer": buffer,
|
||||||
|
"width": self.width,
|
||||||
|
"height": self.height,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._frame_count <= 5 or self._frame_count % 10 == 0:
|
||||||
|
sys.stdout.write("\n" + "=" * 80 + "\n")
|
||||||
|
sys.stdout.write(
|
||||||
|
f"Frame {self._frame_count} (buffer height: {len(buffer)})\n"
|
||||||
|
)
|
||||||
|
sys.stdout.write("=" * 80 + "\n")
|
||||||
|
for i, line in enumerate(buffer[:30]):
|
||||||
|
sys.stdout.write(f"{i:2}: {line}\n")
|
||||||
|
if len(buffer) > 30:
|
||||||
|
sys.stdout.write(f"... ({len(buffer) - 30} more lines)\n")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
if monitor:
|
if monitor:
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
chars_in = sum(len(line) for line in buffer)
|
chars_in = sum(len(line) for line in buffer)
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
monitor.record_effect("null_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
self._frame_count += 1
|
||||||
|
|
||||||
|
def start_recording(self) -> None:
|
||||||
|
"""Begin recording frames."""
|
||||||
|
self._is_recording = True
|
||||||
|
self._recorded_frames = []
|
||||||
|
|
||||||
|
def stop_recording(self) -> None:
|
||||||
|
"""Stop recording frames."""
|
||||||
|
self._is_recording = False
|
||||||
|
|
||||||
|
def get_frames(self) -> list[list[str]]:
|
||||||
|
"""Get recorded frames as list of buffers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of buffers, each buffer is a list of strings (lines)
|
||||||
|
"""
|
||||||
|
return [frame["buffer"] for frame in self._recorded_frames]
|
||||||
|
|
||||||
|
def get_recorded_data(self) -> list[dict[str, Any]]:
|
||||||
|
"""Get full recorded data including metadata.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of frame dicts with 'frame_number', 'buffer', 'width', 'height'
|
||||||
|
"""
|
||||||
|
return self._recorded_frames
|
||||||
|
|
||||||
|
def clear_recording(self) -> None:
|
||||||
|
"""Clear recorded frames."""
|
||||||
|
self._recorded_frames = []
|
||||||
|
|
||||||
|
def save_recording(self, filepath: str | Path) -> None:
|
||||||
|
"""Save recorded frames to a JSON file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Path to save the recording
|
||||||
|
"""
|
||||||
|
path = Path(filepath)
|
||||||
|
data = {
|
||||||
|
"version": 1,
|
||||||
|
"display": "null",
|
||||||
|
"width": self.width,
|
||||||
|
"height": self.height,
|
||||||
|
"frame_count": len(self._recorded_frames),
|
||||||
|
"frames": self._recorded_frames,
|
||||||
|
}
|
||||||
|
path.write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
def load_recording(self, filepath: str | Path) -> list[dict[str, Any]]:
|
||||||
|
"""Load recorded frames from a JSON file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: Path to load the recording from
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of frame dicts
|
||||||
|
"""
|
||||||
|
path = Path(filepath)
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
self._recorded_frames = data.get("frames", [])
|
||||||
|
self.width = data.get("width", 80)
|
||||||
|
self.height = data.get("height", 24)
|
||||||
|
return self._recorded_frames
|
||||||
|
|
||||||
|
def replay_frames(self) -> list[list[str]]:
|
||||||
|
"""Get frames for replay.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of buffers for replay
|
||||||
|
"""
|
||||||
|
return self.get_frames()
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -99,10 +99,6 @@ class PygameDisplay:
|
|||||||
self.width = width
|
self.width = width
|
||||||
self.height = height
|
self.height = height
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
os.environ["SDL_VIDEODRIVER"] = "x11"
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pygame
|
import pygame
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -136,6 +132,21 @@ class PygameDisplay:
|
|||||||
else:
|
else:
|
||||||
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
|
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
|
||||||
|
|
||||||
|
# Check if font supports box-drawing characters; if not, try to find one
|
||||||
|
self._use_fallback_border = False
|
||||||
|
if self._font:
|
||||||
|
try:
|
||||||
|
# Test rendering some key box-drawing characters
|
||||||
|
test_chars = ["┌", "─", "┐", "│", "└", "┘"]
|
||||||
|
for ch in test_chars:
|
||||||
|
surf = self._font.render(ch, True, (255, 255, 255))
|
||||||
|
# If surface is empty (width=0 or all black), font lacks glyph
|
||||||
|
if surf.get_width() == 0:
|
||||||
|
raise ValueError("Missing glyph")
|
||||||
|
except Exception:
|
||||||
|
# Font doesn't support box-drawing, will use line drawing fallback
|
||||||
|
self._use_fallback_border = True
|
||||||
|
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
|
|
||||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||||
@@ -184,14 +195,26 @@ class PygameDisplay:
|
|||||||
fps = 1000.0 / avg_ms
|
fps = 1000.0 / avg_ms
|
||||||
frame_time = avg_ms
|
frame_time = avg_ms
|
||||||
|
|
||||||
# Apply border if requested
|
|
||||||
if border:
|
|
||||||
from engine.display import render_border
|
|
||||||
|
|
||||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
|
||||||
|
|
||||||
self._screen.fill((0, 0, 0))
|
self._screen.fill((0, 0, 0))
|
||||||
|
|
||||||
|
# If border requested but font lacks box-drawing glyphs, use graphical fallback
|
||||||
|
if border and self._use_fallback_border:
|
||||||
|
self._draw_fallback_border(fps, frame_time)
|
||||||
|
# Adjust content area to fit inside border
|
||||||
|
content_offset_x = self.cell_width
|
||||||
|
content_offset_y = self.cell_height
|
||||||
|
self.window_width - 2 * self.cell_width
|
||||||
|
self.window_height - 2 * self.cell_height
|
||||||
|
else:
|
||||||
|
# Normal rendering (with or without text border)
|
||||||
|
content_offset_x = 0
|
||||||
|
content_offset_y = 0
|
||||||
|
|
||||||
|
if border:
|
||||||
|
from engine.display import render_border
|
||||||
|
|
||||||
|
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||||
|
|
||||||
blit_list = []
|
blit_list = []
|
||||||
|
|
||||||
for row_idx, line in enumerate(buffer[: self.height]):
|
for row_idx, line in enumerate(buffer[: self.height]):
|
||||||
@@ -199,7 +222,7 @@ class PygameDisplay:
|
|||||||
break
|
break
|
||||||
|
|
||||||
tokens = parse_ansi(line)
|
tokens = parse_ansi(line)
|
||||||
x_pos = 0
|
x_pos = content_offset_x
|
||||||
|
|
||||||
for text, fg, bg, _bold in tokens:
|
for text, fg, bg, _bold in tokens:
|
||||||
if not text:
|
if not text:
|
||||||
@@ -219,10 +242,17 @@ class PygameDisplay:
|
|||||||
self._glyph_cache[cache_key] = self._font.render(text, True, fg)
|
self._glyph_cache[cache_key] = self._font.render(text, True, fg)
|
||||||
|
|
||||||
surface = self._glyph_cache[cache_key]
|
surface = self._glyph_cache[cache_key]
|
||||||
blit_list.append((surface, (x_pos, row_idx * self.cell_height)))
|
blit_list.append(
|
||||||
|
(surface, (x_pos, content_offset_y + row_idx * self.cell_height))
|
||||||
|
)
|
||||||
x_pos += self._font.size(text)[0]
|
x_pos += self._font.size(text)[0]
|
||||||
|
|
||||||
self._screen.blits(blit_list)
|
self._screen.blits(blit_list)
|
||||||
|
|
||||||
|
# Draw fallback border using graphics if needed
|
||||||
|
if border and self._use_fallback_border:
|
||||||
|
self._draw_fallback_border(fps, frame_time)
|
||||||
|
|
||||||
self._pygame.display.flip()
|
self._pygame.display.flip()
|
||||||
|
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
@@ -231,6 +261,56 @@ class PygameDisplay:
|
|||||||
chars_in = sum(len(line) for line in buffer)
|
chars_in = sum(len(line) for line in buffer)
|
||||||
monitor.record_effect("pygame_display", elapsed_ms, chars_in, chars_in)
|
monitor.record_effect("pygame_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def _draw_fallback_border(self, fps: float, frame_time: float) -> None:
|
||||||
|
"""Draw border using pygame graphics primitives instead of text."""
|
||||||
|
if not self._screen or not self._pygame:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
border_color = (0, 255, 0) # Green (like terminal border)
|
||||||
|
text_color = (255, 255, 255)
|
||||||
|
|
||||||
|
# Calculate dimensions
|
||||||
|
x1 = 0
|
||||||
|
y1 = 0
|
||||||
|
x2 = self.window_width - 1
|
||||||
|
y2 = self.window_height - 1
|
||||||
|
|
||||||
|
# Draw outer rectangle
|
||||||
|
self._pygame.draw.rect(
|
||||||
|
self._screen, border_color, (x1, y1, x2 - x1 + 1, y2 - y1 + 1), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Draw top border with FPS
|
||||||
|
if fps > 0:
|
||||||
|
fps_text = f" FPS:{fps:.0f}"
|
||||||
|
else:
|
||||||
|
fps_text = ""
|
||||||
|
# We need to render this text with a fallback font that has basic ASCII
|
||||||
|
# Use system font which should have these characters
|
||||||
|
try:
|
||||||
|
font = self._font # May not have box chars but should have alphanumeric
|
||||||
|
text_surf = font.render(fps_text, True, text_color, (0, 0, 0))
|
||||||
|
text_rect = text_surf.get_rect()
|
||||||
|
# Position on top border, right-aligned
|
||||||
|
text_x = x2 - text_rect.width - 5
|
||||||
|
text_y = y1 + 2
|
||||||
|
self._screen.blit(text_surf, (text_x, text_y))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Draw bottom border with frame time
|
||||||
|
if frame_time > 0:
|
||||||
|
ft_text = f" {frame_time:.1f}ms"
|
||||||
|
try:
|
||||||
|
ft_surf = self._font.render(ft_text, True, text_color, (0, 0, 0))
|
||||||
|
ft_rect = ft_surf.get_rect()
|
||||||
|
ft_x = x2 - ft_rect.width - 5
|
||||||
|
ft_y = y2 - ft_rect.height - 2
|
||||||
|
self._screen.blit(ft_surf, (ft_x, ft_y))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
if self._screen and self._pygame:
|
if self._screen and self._pygame:
|
||||||
self._screen.fill((0, 0, 0))
|
self._screen.fill((0, 0, 0))
|
||||||
|
|||||||
122
engine/display/backends/replay.py
Normal file
122
engine/display/backends/replay.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
Replay display backend - plays back recorded frames.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayDisplay:
|
||||||
|
"""Replay display - plays back recorded frames.
|
||||||
|
|
||||||
|
This display reads frames from a recording (list of frame data)
|
||||||
|
and yields them sequentially, useful for testing and demo purposes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
width: int = 80
|
||||||
|
height: int = 24
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._frames: list[dict[str, Any]] = []
|
||||||
|
self._current_frame = 0
|
||||||
|
self._playback_index = 0
|
||||||
|
self._loop = False
|
||||||
|
|
||||||
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||||
|
"""Initialize display with dimensions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Terminal width in characters
|
||||||
|
height: Terminal height in rows
|
||||||
|
reuse: Ignored for ReplayDisplay
|
||||||
|
"""
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
|
||||||
|
def set_frames(self, frames: list[dict[str, Any]]) -> None:
|
||||||
|
"""Set frames to replay.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frames: List of frame dicts with 'buffer', 'width', 'height'
|
||||||
|
"""
|
||||||
|
self._frames = frames
|
||||||
|
self._current_frame = 0
|
||||||
|
self._playback_index = 0
|
||||||
|
|
||||||
|
def set_loop(self, loop: bool) -> None:
|
||||||
|
"""Set loop playback mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
loop: True to loop, False to stop at end
|
||||||
|
"""
|
||||||
|
self._loop = loop
|
||||||
|
|
||||||
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||||
|
"""Display a frame (ignored in replay mode).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buffer: Buffer to display (ignored)
|
||||||
|
border: Border flag (ignored)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_next_frame(self) -> list[str] | None:
|
||||||
|
"""Get the next frame in the recording.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Buffer list of strings, or None if playback is done
|
||||||
|
"""
|
||||||
|
if not self._frames:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self._playback_index >= len(self._frames):
|
||||||
|
if self._loop:
|
||||||
|
self._playback_index = 0
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
frame = self._frames[self._playback_index]
|
||||||
|
self._playback_index += 1
|
||||||
|
return frame.get("buffer")
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset playback to the beginning."""
|
||||||
|
self._playback_index = 0
|
||||||
|
|
||||||
|
def seek(self, index: int) -> None:
|
||||||
|
"""Seek to a specific frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: Frame index to seek to
|
||||||
|
"""
|
||||||
|
if 0 <= index < len(self._frames):
|
||||||
|
self._playback_index = index
|
||||||
|
|
||||||
|
def is_finished(self) -> bool:
|
||||||
|
"""Check if playback is finished.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if at end of frames and not looping
|
||||||
|
"""
|
||||||
|
return not self._loop and self._playback_index >= len(self._frames)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_dimensions(self) -> tuple[int, int]:
|
||||||
|
"""Get current dimensions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(width, height) in character cells
|
||||||
|
"""
|
||||||
|
return (self.width, self.height)
|
||||||
|
|
||||||
|
def is_quit_requested(self) -> bool:
|
||||||
|
"""Check if quit was requested (optional protocol method)."""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def clear_quit_request(self) -> None:
|
||||||
|
"""Clear quit request (optional protocol method)."""
|
||||||
|
pass
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
"""
|
|
||||||
Sixel graphics display backend - renders to sixel graphics in terminal.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
from engine.display.renderer import get_default_font_path, parse_ansi
|
|
||||||
|
|
||||||
|
|
||||||
def _encode_sixel(image) -> str:
|
|
||||||
"""Encode a PIL Image to sixel format (pure Python)."""
|
|
||||||
img = image.convert("RGBA")
|
|
||||||
width, height = img.size
|
|
||||||
pixels = img.load()
|
|
||||||
|
|
||||||
palette = []
|
|
||||||
pixel_palette_idx = {}
|
|
||||||
|
|
||||||
def get_color_idx(r, g, b, a):
|
|
||||||
if a < 128:
|
|
||||||
return -1
|
|
||||||
key = (r // 32, g // 32, b // 32)
|
|
||||||
if key not in pixel_palette_idx:
|
|
||||||
idx = len(palette)
|
|
||||||
if idx < 256:
|
|
||||||
palette.append((r, g, b))
|
|
||||||
pixel_palette_idx[key] = idx
|
|
||||||
return pixel_palette_idx.get(key, 0)
|
|
||||||
|
|
||||||
for y in range(height):
|
|
||||||
for x in range(width):
|
|
||||||
r, g, b, a = pixels[x, y]
|
|
||||||
get_color_idx(r, g, b, a)
|
|
||||||
|
|
||||||
if not palette:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
if len(palette) == 1:
|
|
||||||
palette = [palette[0], (0, 0, 0)]
|
|
||||||
|
|
||||||
sixel_data = []
|
|
||||||
sixel_data.append(
|
|
||||||
f'"{"".join(f"#{i};2;{r};{g};{b}" for i, (r, g, b) in enumerate(palette))}'
|
|
||||||
)
|
|
||||||
|
|
||||||
for x in range(width):
|
|
||||||
col_data = []
|
|
||||||
for y in range(0, height, 6):
|
|
||||||
bits = 0
|
|
||||||
color_idx = -1
|
|
||||||
for dy in range(6):
|
|
||||||
if y + dy < height:
|
|
||||||
r, g, b, a = pixels[x, y + dy]
|
|
||||||
if a >= 128:
|
|
||||||
bits |= 1 << dy
|
|
||||||
idx = get_color_idx(r, g, b, a)
|
|
||||||
if color_idx == -1:
|
|
||||||
color_idx = idx
|
|
||||||
elif color_idx != idx:
|
|
||||||
color_idx = -2
|
|
||||||
|
|
||||||
if color_idx >= 0:
|
|
||||||
col_data.append(
|
|
||||||
chr(63 + color_idx) + chr(63 + bits)
|
|
||||||
if bits
|
|
||||||
else chr(63 + color_idx) + "?"
|
|
||||||
)
|
|
||||||
elif color_idx == -2:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if col_data:
|
|
||||||
sixel_data.append("".join(col_data) + "$")
|
|
||||||
else:
|
|
||||||
sixel_data.append("-" if x < width - 1 else "$")
|
|
||||||
|
|
||||||
sixel_data.append("\x1b\\")
|
|
||||||
|
|
||||||
return "\x1bPq" + "".join(sixel_data)
|
|
||||||
|
|
||||||
|
|
||||||
class SixelDisplay:
|
|
||||||
"""Sixel graphics display backend - renders to sixel graphics in terminal."""
|
|
||||||
|
|
||||||
width: int = 80
|
|
||||||
height: int = 24
|
|
||||||
|
|
||||||
def __init__(self, cell_width: int = 9, cell_height: int = 16):
|
|
||||||
self.width = 80
|
|
||||||
self.height = 24
|
|
||||||
self.cell_width = cell_width
|
|
||||||
self.cell_height = cell_height
|
|
||||||
self._initialized = False
|
|
||||||
self._font_path = None
|
|
||||||
|
|
||||||
def _get_font_path(self) -> str | None:
|
|
||||||
"""Get font path from env or detect common locations."""
|
|
||||||
import os
|
|
||||||
|
|
||||||
if self._font_path:
|
|
||||||
return self._font_path
|
|
||||||
|
|
||||||
env_font = os.environ.get("MAINLINE_SIXEL_FONT")
|
|
||||||
if env_font and os.path.exists(env_font):
|
|
||||||
self._font_path = env_font
|
|
||||||
return env_font
|
|
||||||
|
|
||||||
font_path = get_default_font_path()
|
|
||||||
if font_path:
|
|
||||||
self._font_path = font_path
|
|
||||||
|
|
||||||
return self._font_path
|
|
||||||
|
|
||||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
|
||||||
"""Initialize display with dimensions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
width: Terminal width in characters
|
|
||||||
height: Terminal height in rows
|
|
||||||
reuse: Ignored for SixelDisplay
|
|
||||||
"""
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self._initialized = True
|
|
||||||
|
|
||||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
|
||||||
import sys
|
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
|
|
||||||
# Get metrics for border display
|
|
||||||
fps = 0.0
|
|
||||||
frame_time = 0.0
|
|
||||||
from engine.display import get_monitor
|
|
||||||
|
|
||||||
monitor = get_monitor()
|
|
||||||
if monitor:
|
|
||||||
stats = monitor.get_stats()
|
|
||||||
avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0
|
|
||||||
frame_count = stats.get("frame_count", 0) if stats else 0
|
|
||||||
if avg_ms and frame_count > 0:
|
|
||||||
fps = 1000.0 / avg_ms
|
|
||||||
frame_time = avg_ms
|
|
||||||
|
|
||||||
# Apply border if requested
|
|
||||||
if border:
|
|
||||||
from engine.display import render_border
|
|
||||||
|
|
||||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
|
||||||
|
|
||||||
img_width = self.width * self.cell_width
|
|
||||||
img_height = self.height * self.cell_height
|
|
||||||
|
|
||||||
try:
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
except ImportError:
|
|
||||||
return
|
|
||||||
|
|
||||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
font_path = self._get_font_path()
|
|
||||||
font = None
|
|
||||||
if font_path:
|
|
||||||
try:
|
|
||||||
font = ImageFont.truetype(font_path, self.cell_height - 2)
|
|
||||||
except Exception:
|
|
||||||
font = None
|
|
||||||
|
|
||||||
if font is None:
|
|
||||||
try:
|
|
||||||
font = ImageFont.load_default()
|
|
||||||
except Exception:
|
|
||||||
font = None
|
|
||||||
|
|
||||||
for row_idx, line in enumerate(buffer[: self.height]):
|
|
||||||
if row_idx >= self.height:
|
|
||||||
break
|
|
||||||
|
|
||||||
tokens = parse_ansi(line)
|
|
||||||
x_pos = 0
|
|
||||||
y_pos = row_idx * self.cell_height
|
|
||||||
|
|
||||||
for text, fg, bg, bold in tokens:
|
|
||||||
if not text:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if bg != (0, 0, 0):
|
|
||||||
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
|
||||||
draw.rectangle(bbox, fill=(*bg, 255))
|
|
||||||
|
|
||||||
if bold and font:
|
|
||||||
draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font)
|
|
||||||
|
|
||||||
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
|
||||||
|
|
||||||
if font:
|
|
||||||
x_pos += draw.textlength(text, font=font)
|
|
||||||
|
|
||||||
sixel = _encode_sixel(img)
|
|
||||||
|
|
||||||
sys.stdout.buffer.write(sixel.encode("utf-8"))
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
|
|
||||||
from engine.display import get_monitor
|
|
||||||
|
|
||||||
monitor = get_monitor()
|
|
||||||
if monitor:
|
|
||||||
chars_in = sum(len(line) for line in buffer)
|
|
||||||
monitor.record_effect("sixel_display", elapsed_ms, chars_in, chars_in)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
import sys
|
|
||||||
|
|
||||||
sys.stdout.buffer.write(b"\x1b[2J\x1b[H")
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_dimensions(self) -> tuple[int, int]:
|
|
||||||
"""Get current dimensions.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(width, height) in character cells
|
|
||||||
"""
|
|
||||||
return (self.width, self.height)
|
|
||||||
@@ -3,7 +3,10 @@ ANSI terminal display backend.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import time
|
import select
|
||||||
|
import sys
|
||||||
|
import termios
|
||||||
|
import tty
|
||||||
|
|
||||||
|
|
||||||
class TerminalDisplay:
|
class TerminalDisplay:
|
||||||
@@ -23,6 +26,9 @@ class TerminalDisplay:
|
|||||||
self._frame_period = 1.0 / target_fps if target_fps > 0 else 0
|
self._frame_period = 1.0 / target_fps if target_fps > 0 else 0
|
||||||
self._last_frame_time = 0.0
|
self._last_frame_time = 0.0
|
||||||
self._cached_dimensions: tuple[int, int] | None = None
|
self._cached_dimensions: tuple[int, int] | None = None
|
||||||
|
self._raw_mode_enabled: bool = False
|
||||||
|
self._original_termios: list = []
|
||||||
|
self._quit_requested: bool = False
|
||||||
|
|
||||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||||
"""Initialize display with dimensions.
|
"""Initialize display with dimensions.
|
||||||
@@ -84,21 +90,22 @@ class TerminalDisplay:
|
|||||||
|
|
||||||
return self._cached_dimensions
|
return self._cached_dimensions
|
||||||
|
|
||||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
def show(
|
||||||
|
self, buffer: list[str], border: bool = False, positioning: str = "mixed"
|
||||||
|
) -> None:
|
||||||
|
"""Display buffer with optional border and positioning mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buffer: List of lines to display
|
||||||
|
border: Whether to apply border
|
||||||
|
positioning: Positioning mode - "mixed" (default), "absolute", or "relative"
|
||||||
|
"""
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from engine.display import get_monitor, render_border
|
from engine.display import get_monitor, render_border
|
||||||
|
|
||||||
t0 = time.perf_counter()
|
# Note: Frame rate limiting is handled by the caller (e.g., FrameTimer).
|
||||||
|
# This display renders every frame it receives.
|
||||||
# FPS limiting - skip frame if we're going too fast
|
|
||||||
if self._frame_period > 0:
|
|
||||||
now = time.perf_counter()
|
|
||||||
elapsed = now - self._last_frame_time
|
|
||||||
if elapsed < self._frame_period:
|
|
||||||
# Skip this frame - too soon
|
|
||||||
return
|
|
||||||
self._last_frame_time = now
|
|
||||||
|
|
||||||
# Get metrics for border display
|
# Get metrics for border display
|
||||||
fps = 0.0
|
fps = 0.0
|
||||||
@@ -113,19 +120,34 @@ class TerminalDisplay:
|
|||||||
frame_time = avg_ms
|
frame_time = avg_ms
|
||||||
|
|
||||||
# Apply border if requested
|
# Apply border if requested
|
||||||
if border:
|
from engine.display import BorderMode
|
||||||
|
|
||||||
|
if border and border != BorderMode.OFF:
|
||||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||||
|
|
||||||
# Write buffer with cursor home + erase down to avoid flicker
|
# Apply positioning based on mode
|
||||||
# \033[H = cursor home, \033[J = erase from cursor to end of screen
|
if positioning == "absolute":
|
||||||
output = "\033[H\033[J" + "".join(buffer)
|
# All lines should have cursor positioning codes
|
||||||
|
# Join with newlines (cursor codes already in buffer)
|
||||||
|
output = "\033[H\033[J" + "\n".join(buffer)
|
||||||
|
elif positioning == "relative":
|
||||||
|
# Remove cursor positioning codes (except colors) and join with newlines
|
||||||
|
import re
|
||||||
|
|
||||||
|
cleaned_buffer = []
|
||||||
|
for line in buffer:
|
||||||
|
# Remove cursor positioning codes but keep color codes
|
||||||
|
# Pattern: \033[row;colH or \033[row;col;...H
|
||||||
|
cleaned = re.sub(r"\033\[[0-9;]*H", "", line)
|
||||||
|
cleaned_buffer.append(cleaned)
|
||||||
|
output = "\033[H\033[J" + "\n".join(cleaned_buffer)
|
||||||
|
else: # mixed (default)
|
||||||
|
# Current behavior: join with newlines
|
||||||
|
# Effects that need absolute positioning have their own cursor codes
|
||||||
|
output = "\033[H\033[J" + "\n".join(buffer)
|
||||||
|
|
||||||
sys.stdout.buffer.write(output.encode())
|
sys.stdout.buffer.write(output.encode())
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
|
||||||
|
|
||||||
if monitor:
|
|
||||||
chars_in = sum(len(line) for line in buffer)
|
|
||||||
monitor.record_effect("terminal_display", elapsed_ms, chars_in, chars_in)
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
from engine.terminal import CLR
|
from engine.terminal import CLR
|
||||||
@@ -135,12 +157,182 @@ class TerminalDisplay:
|
|||||||
def cleanup(self) -> None:
|
def cleanup(self) -> None:
|
||||||
from engine.terminal import CURSOR_ON
|
from engine.terminal import CURSOR_ON
|
||||||
|
|
||||||
|
# Disable mouse tracking if enabled
|
||||||
|
self.disable_mouse_tracking()
|
||||||
|
|
||||||
|
# Restore normal terminal mode if raw mode was enabled
|
||||||
|
self.set_raw_mode(False)
|
||||||
|
|
||||||
print(CURSOR_ON, end="", flush=True)
|
print(CURSOR_ON, end="", flush=True)
|
||||||
|
|
||||||
def is_quit_requested(self) -> bool:
|
def is_quit_requested(self) -> bool:
|
||||||
"""Check if quit was requested (optional protocol method)."""
|
"""Check if quit was requested (optional protocol method)."""
|
||||||
return False
|
return self._quit_requested
|
||||||
|
|
||||||
def clear_quit_request(self) -> None:
|
def clear_quit_request(self) -> None:
|
||||||
"""Clear quit request (optional protocol method)."""
|
"""Clear quit request (optional protocol method)."""
|
||||||
pass
|
self._quit_requested = False
|
||||||
|
|
||||||
|
def request_quit(self) -> None:
|
||||||
|
"""Request quit (e.g., when Ctrl+C is pressed)."""
|
||||||
|
self._quit_requested = True
|
||||||
|
|
||||||
|
def enable_mouse_tracking(self) -> None:
|
||||||
|
"""Enable SGR mouse tracking mode."""
|
||||||
|
try:
|
||||||
|
# SGR mouse mode: \x1b[?1006h
|
||||||
|
sys.stdout.write("\x1b[?1006h")
|
||||||
|
sys.stdout.flush()
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass # Terminal might not support mouse tracking
|
||||||
|
|
||||||
|
def disable_mouse_tracking(self) -> None:
|
||||||
|
"""Disable SGR mouse tracking mode."""
|
||||||
|
try:
|
||||||
|
# Disable SGR mouse mode: \x1b[?1006l
|
||||||
|
sys.stdout.write("\x1b[?1006l")
|
||||||
|
sys.stdout.flush()
|
||||||
|
except (OSError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_raw_mode(self, enable: bool = True) -> None:
|
||||||
|
"""Enable/disable raw terminal mode for input capture.
|
||||||
|
|
||||||
|
When raw mode is enabled:
|
||||||
|
- Keystrokes are read immediately without echo
|
||||||
|
- Special keys (arrows, Ctrl+C, etc.) are captured
|
||||||
|
- Terminal is not in cooked/canonical mode
|
||||||
|
|
||||||
|
Args:
|
||||||
|
enable: True to enable raw mode, False to restore normal mode
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if enable and not self._raw_mode_enabled:
|
||||||
|
# Save original terminal settings
|
||||||
|
self._original_termios = termios.tcgetattr(sys.stdin)
|
||||||
|
# Set raw mode
|
||||||
|
tty.setraw(sys.stdin.fileno())
|
||||||
|
self._raw_mode_enabled = True
|
||||||
|
# Enable mouse tracking
|
||||||
|
self.enable_mouse_tracking()
|
||||||
|
elif not enable and self._raw_mode_enabled:
|
||||||
|
# Disable mouse tracking
|
||||||
|
self.disable_mouse_tracking()
|
||||||
|
# Restore original terminal settings
|
||||||
|
if self._original_termios:
|
||||||
|
termios.tcsetattr(
|
||||||
|
sys.stdin, termios.TCSADRAIN, self._original_termios
|
||||||
|
)
|
||||||
|
self._raw_mode_enabled = False
|
||||||
|
except (termios.error, OSError):
|
||||||
|
# Terminal might not support raw mode (e.g., in tests)
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_input_keys(self, timeout: float = 0.0) -> list[str]:
|
||||||
|
"""Get available keyboard input.
|
||||||
|
|
||||||
|
Reads available keystrokes from stdin. Should be called
|
||||||
|
with raw mode enabled for best results.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
timeout: Maximum time to wait for input (seconds)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of key symbols as strings
|
||||||
|
"""
|
||||||
|
keys = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if input is available
|
||||||
|
if select.select([sys.stdin], [], [], timeout)[0]:
|
||||||
|
char = sys.stdin.read(1)
|
||||||
|
|
||||||
|
if char == "\x1b": # Escape sequence
|
||||||
|
# Read next characters to determine key
|
||||||
|
# Try to read up to 10 chars for longer sequences
|
||||||
|
seq = sys.stdin.read(10)
|
||||||
|
|
||||||
|
# PageUp: \x1b[5~
|
||||||
|
if seq.startswith("[5~"):
|
||||||
|
keys.append("page_up")
|
||||||
|
# PageDown: \x1b[6~
|
||||||
|
elif seq.startswith("[6~"):
|
||||||
|
keys.append("page_down")
|
||||||
|
# Arrow keys: \x1b[A, \x1b[B, etc.
|
||||||
|
elif seq.startswith("["):
|
||||||
|
if seq[1] == "A":
|
||||||
|
keys.append("up")
|
||||||
|
elif seq[1] == "B":
|
||||||
|
keys.append("down")
|
||||||
|
elif seq[1] == "C":
|
||||||
|
keys.append("right")
|
||||||
|
elif seq[1] == "D":
|
||||||
|
keys.append("left")
|
||||||
|
else:
|
||||||
|
# Unknown escape sequence
|
||||||
|
keys.append("escape")
|
||||||
|
# Mouse events: \x1b[<B;X;Ym or \x1b[<B;X;YM
|
||||||
|
elif seq.startswith("[<"):
|
||||||
|
mouse_seq = "\x1b" + seq
|
||||||
|
mouse_data = self._parse_mouse_event(mouse_seq)
|
||||||
|
if mouse_data:
|
||||||
|
keys.append(mouse_data)
|
||||||
|
else:
|
||||||
|
# Unknown escape sequence
|
||||||
|
keys.append("escape")
|
||||||
|
elif char == "\n" or char == "\r":
|
||||||
|
keys.append("return")
|
||||||
|
elif char == "\t":
|
||||||
|
keys.append("tab")
|
||||||
|
elif char == " ":
|
||||||
|
keys.append(" ")
|
||||||
|
elif char == "\x7f" or char == "\x08": # Backspace or Ctrl+H
|
||||||
|
keys.append("backspace")
|
||||||
|
elif char == "\x03": # Ctrl+C
|
||||||
|
keys.append("ctrl_c")
|
||||||
|
elif char == "\x04": # Ctrl+D
|
||||||
|
keys.append("ctrl_d")
|
||||||
|
elif char.isprintable():
|
||||||
|
keys.append(char)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return keys
|
||||||
|
|
||||||
|
def _parse_mouse_event(self, data: str) -> str | None:
|
||||||
|
"""Parse SGR mouse event sequence.
|
||||||
|
|
||||||
|
Format: \x1b[<B;X;Ym (release) or \x1b[<B;X;YM (press)
|
||||||
|
B = button number (0=left, 1=middle, 2=right, 64=wheel up, 65=wheel down)
|
||||||
|
X, Y = coordinates (1-indexed)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Mouse event string like "mouse:64:10:5" or None if not a mouse event
|
||||||
|
"""
|
||||||
|
if not data.startswith("\x1b[<"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find the ending 'm' or 'M'
|
||||||
|
end_pos = data.rfind("m")
|
||||||
|
if end_pos == -1:
|
||||||
|
end_pos = data.rfind("M")
|
||||||
|
if end_pos == -1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
inner = data[3:end_pos] # Remove \x1b[< and trailing m/M
|
||||||
|
parts = inner.split(";")
|
||||||
|
|
||||||
|
if len(parts) >= 3:
|
||||||
|
try:
|
||||||
|
button = int(parts[0])
|
||||||
|
x = int(parts[1]) - 1 # Convert to 0-indexed
|
||||||
|
y = int(parts[2]) - 1
|
||||||
|
return f"mouse:{button}:{x}:{y}"
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_raw_mode_enabled(self) -> bool:
|
||||||
|
"""Check if raw mode is currently enabled."""
|
||||||
|
return self._raw_mode_enabled
|
||||||
|
|||||||
@@ -1,11 +1,44 @@
|
|||||||
"""
|
"""
|
||||||
WebSocket display backend - broadcasts frame buffer to connected web clients.
|
WebSocket display backend - broadcasts frame buffer to connected web clients.
|
||||||
|
|
||||||
|
Supports streaming protocols:
|
||||||
|
- Full frame (JSON) - default for compatibility
|
||||||
|
- Binary streaming - efficient binary protocol
|
||||||
|
- Diff streaming - only sends changed lines
|
||||||
|
|
||||||
|
TODO: Transform to a true streaming backend with:
|
||||||
|
- Proper WebSocket message streaming (currently sends full buffer each frame)
|
||||||
|
- Connection pooling and backpressure handling
|
||||||
|
- Binary protocol for efficiency (instead of JSON)
|
||||||
|
- Client management with proper async handling
|
||||||
|
- Mark for deprecation if replaced by a new streaming implementation
|
||||||
|
|
||||||
|
Current implementation: Simple broadcast of text frames to all connected clients.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from enum import IntFlag
|
||||||
|
|
||||||
|
from engine.display.streaming import (
|
||||||
|
MessageType,
|
||||||
|
compress_frame,
|
||||||
|
compute_diff,
|
||||||
|
encode_binary_message,
|
||||||
|
encode_diff_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingMode(IntFlag):
|
||||||
|
"""Streaming modes for WebSocket display."""
|
||||||
|
|
||||||
|
JSON = 0x01 # Full JSON frames (default, compatible)
|
||||||
|
BINARY = 0x02 # Binary compression
|
||||||
|
DIFF = 0x04 # Differential updates
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import websockets
|
import websockets
|
||||||
@@ -34,6 +67,7 @@ class WebSocketDisplay:
|
|||||||
host: str = "0.0.0.0",
|
host: str = "0.0.0.0",
|
||||||
port: int = 8765,
|
port: int = 8765,
|
||||||
http_port: int = 8766,
|
http_port: int = 8766,
|
||||||
|
streaming_mode: StreamingMode = StreamingMode.JSON,
|
||||||
):
|
):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
@@ -49,7 +83,15 @@ class WebSocketDisplay:
|
|||||||
self._max_clients = 10
|
self._max_clients = 10
|
||||||
self._client_connected_callback = None
|
self._client_connected_callback = None
|
||||||
self._client_disconnected_callback = None
|
self._client_disconnected_callback = None
|
||||||
|
self._command_callback = None
|
||||||
|
self._controller = None # Reference to UI panel or pipeline controller
|
||||||
self._frame_delay = 0.0
|
self._frame_delay = 0.0
|
||||||
|
self._httpd = None # HTTP server instance
|
||||||
|
|
||||||
|
# Streaming configuration
|
||||||
|
self._streaming_mode = streaming_mode
|
||||||
|
self._last_buffer: list[str] = []
|
||||||
|
self._client_capabilities: dict = {} # Track client capabilities
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import websockets as _ws
|
import websockets as _ws
|
||||||
@@ -78,7 +120,7 @@ class WebSocketDisplay:
|
|||||||
self.start_http_server()
|
self.start_http_server()
|
||||||
|
|
||||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||||
"""Broadcast buffer to all connected clients."""
|
"""Broadcast buffer to all connected clients using streaming protocol."""
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
|
|
||||||
# Get metrics for border display
|
# Get metrics for border display
|
||||||
@@ -99,33 +141,82 @@ class WebSocketDisplay:
|
|||||||
|
|
||||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||||
|
|
||||||
if self._clients:
|
if not self._clients:
|
||||||
frame_data = {
|
self._last_buffer = buffer
|
||||||
"type": "frame",
|
return
|
||||||
"width": self.width,
|
|
||||||
"height": self.height,
|
|
||||||
"lines": buffer,
|
|
||||||
}
|
|
||||||
message = json.dumps(frame_data)
|
|
||||||
|
|
||||||
disconnected = set()
|
# Send to each client based on their capabilities
|
||||||
for client in list(self._clients):
|
disconnected = set()
|
||||||
try:
|
for client in list(self._clients):
|
||||||
asyncio.run(client.send(message))
|
try:
|
||||||
except Exception:
|
client_id = id(client)
|
||||||
disconnected.add(client)
|
client_mode = self._client_capabilities.get(
|
||||||
|
client_id, StreamingMode.JSON
|
||||||
|
)
|
||||||
|
|
||||||
for client in disconnected:
|
if client_mode & StreamingMode.DIFF:
|
||||||
self._clients.discard(client)
|
self._send_diff_frame(client, buffer)
|
||||||
if self._client_disconnected_callback:
|
elif client_mode & StreamingMode.BINARY:
|
||||||
self._client_disconnected_callback(client)
|
self._send_binary_frame(client, buffer)
|
||||||
|
else:
|
||||||
|
self._send_json_frame(client, buffer)
|
||||||
|
except Exception:
|
||||||
|
disconnected.add(client)
|
||||||
|
|
||||||
|
for client in disconnected:
|
||||||
|
self._clients.discard(client)
|
||||||
|
if self._client_disconnected_callback:
|
||||||
|
self._client_disconnected_callback(client)
|
||||||
|
|
||||||
|
self._last_buffer = buffer
|
||||||
|
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
monitor = get_monitor()
|
|
||||||
if monitor:
|
if monitor:
|
||||||
chars_in = sum(len(line) for line in buffer)
|
chars_in = sum(len(line) for line in buffer)
|
||||||
monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
|
monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def _send_json_frame(self, client, buffer: list[str]) -> None:
|
||||||
|
"""Send frame as JSON."""
|
||||||
|
frame_data = {
|
||||||
|
"type": "frame",
|
||||||
|
"width": self.width,
|
||||||
|
"height": self.height,
|
||||||
|
"lines": buffer,
|
||||||
|
}
|
||||||
|
message = json.dumps(frame_data)
|
||||||
|
asyncio.run(client.send(message))
|
||||||
|
|
||||||
|
def _send_binary_frame(self, client, buffer: list[str]) -> None:
|
||||||
|
"""Send frame as compressed binary."""
|
||||||
|
compressed = compress_frame(buffer)
|
||||||
|
message = encode_binary_message(
|
||||||
|
MessageType.FULL_FRAME, self.width, self.height, compressed
|
||||||
|
)
|
||||||
|
encoded = base64.b64encode(message).decode("utf-8")
|
||||||
|
asyncio.run(client.send(encoded))
|
||||||
|
|
||||||
|
def _send_diff_frame(self, client, buffer: list[str]) -> None:
|
||||||
|
"""Send frame as diff."""
|
||||||
|
diff = compute_diff(self._last_buffer, buffer)
|
||||||
|
|
||||||
|
if not diff.changed_lines:
|
||||||
|
return
|
||||||
|
|
||||||
|
diff_payload = encode_diff_message(diff)
|
||||||
|
message = encode_binary_message(
|
||||||
|
MessageType.DIFF_FRAME, self.width, self.height, diff_payload
|
||||||
|
)
|
||||||
|
encoded = base64.b64encode(message).decode("utf-8")
|
||||||
|
asyncio.run(client.send(encoded))
|
||||||
|
|
||||||
|
def set_streaming_mode(self, mode: StreamingMode) -> None:
|
||||||
|
"""Set the default streaming mode for new clients."""
|
||||||
|
self._streaming_mode = mode
|
||||||
|
|
||||||
|
def get_streaming_mode(self) -> StreamingMode:
|
||||||
|
"""Get the current streaming mode."""
|
||||||
|
return self._streaming_mode
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Broadcast clear command to all clients."""
|
"""Broadcast clear command to all clients."""
|
||||||
if self._clients:
|
if self._clients:
|
||||||
@@ -156,9 +247,21 @@ class WebSocketDisplay:
|
|||||||
async for message in websocket:
|
async for message in websocket:
|
||||||
try:
|
try:
|
||||||
data = json.loads(message)
|
data = json.loads(message)
|
||||||
if data.get("type") == "resize":
|
msg_type = data.get("type")
|
||||||
|
|
||||||
|
if msg_type == "resize":
|
||||||
self.width = data.get("width", 80)
|
self.width = data.get("width", 80)
|
||||||
self.height = data.get("height", 24)
|
self.height = data.get("height", 24)
|
||||||
|
elif msg_type == "command" and self._command_callback:
|
||||||
|
# Forward commands to the pipeline controller
|
||||||
|
command = data.get("command", {})
|
||||||
|
self._command_callback(command)
|
||||||
|
elif msg_type == "state_request":
|
||||||
|
# Send current state snapshot
|
||||||
|
state = self._get_state_snapshot()
|
||||||
|
if state:
|
||||||
|
response = {"type": "state", "state": state}
|
||||||
|
await websocket.send(json.dumps(response))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -170,6 +273,8 @@ class WebSocketDisplay:
|
|||||||
|
|
||||||
async def _run_websocket_server(self):
|
async def _run_websocket_server(self):
|
||||||
"""Run the WebSocket server."""
|
"""Run the WebSocket server."""
|
||||||
|
if not websockets:
|
||||||
|
return
|
||||||
async with websockets.serve(self._websocket_handler, self.host, self.port):
|
async with websockets.serve(self._websocket_handler, self.host, self.port):
|
||||||
while self._server_running:
|
while self._server_running:
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
@@ -179,9 +284,23 @@ class WebSocketDisplay:
|
|||||||
import os
|
import os
|
||||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
|
|
||||||
client_dir = os.path.join(
|
# Find the project root by locating 'engine' directory in the path
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "client"
|
websocket_file = os.path.abspath(__file__)
|
||||||
)
|
parts = websocket_file.split(os.sep)
|
||||||
|
if "engine" in parts:
|
||||||
|
engine_idx = parts.index("engine")
|
||||||
|
project_root = os.sep.join(parts[:engine_idx])
|
||||||
|
client_dir = os.path.join(project_root, "client")
|
||||||
|
else:
|
||||||
|
# Fallback: go up 4 levels from websocket.py
|
||||||
|
# websocket.py: .../engine/display/backends/websocket.py
|
||||||
|
# We need: .../client
|
||||||
|
client_dir = os.path.join(
|
||||||
|
os.path.dirname(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
),
|
||||||
|
"client",
|
||||||
|
)
|
||||||
|
|
||||||
class Handler(SimpleHTTPRequestHandler):
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -191,8 +310,10 @@ class WebSocketDisplay:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
httpd = HTTPServer((self.host, self.http_port), Handler)
|
httpd = HTTPServer((self.host, self.http_port), Handler)
|
||||||
while self._http_running:
|
# Store reference for shutdown
|
||||||
httpd.handle_request()
|
self._httpd = httpd
|
||||||
|
# Serve requests continuously
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
def _run_async(self, coro):
|
def _run_async(self, coro):
|
||||||
"""Run coroutine in background."""
|
"""Run coroutine in background."""
|
||||||
@@ -237,6 +358,8 @@ class WebSocketDisplay:
|
|||||||
def stop_http_server(self):
|
def stop_http_server(self):
|
||||||
"""Stop the HTTP server."""
|
"""Stop the HTTP server."""
|
||||||
self._http_running = False
|
self._http_running = False
|
||||||
|
if hasattr(self, "_httpd") and self._httpd:
|
||||||
|
self._httpd.shutdown()
|
||||||
self._http_thread = None
|
self._http_thread = None
|
||||||
|
|
||||||
def client_count(self) -> int:
|
def client_count(self) -> int:
|
||||||
@@ -267,6 +390,71 @@ class WebSocketDisplay:
|
|||||||
"""Set callback for client disconnections."""
|
"""Set callback for client disconnections."""
|
||||||
self._client_disconnected_callback = callback
|
self._client_disconnected_callback = callback
|
||||||
|
|
||||||
|
def set_command_callback(self, callback) -> None:
|
||||||
|
"""Set callback for incoming command messages from clients."""
|
||||||
|
self._command_callback = callback
|
||||||
|
|
||||||
|
def set_controller(self, controller) -> None:
|
||||||
|
"""Set controller (UI panel or pipeline) for state queries and command execution."""
|
||||||
|
self._controller = controller
|
||||||
|
|
||||||
|
def broadcast_state(self, state: dict) -> None:
|
||||||
|
"""Broadcast state update to all connected clients.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Dictionary containing state data to send to clients
|
||||||
|
"""
|
||||||
|
if not self._clients:
|
||||||
|
return
|
||||||
|
|
||||||
|
message = json.dumps({"type": "state", "state": state})
|
||||||
|
|
||||||
|
disconnected = set()
|
||||||
|
for client in list(self._clients):
|
||||||
|
try:
|
||||||
|
asyncio.run(client.send(message))
|
||||||
|
except Exception:
|
||||||
|
disconnected.add(client)
|
||||||
|
|
||||||
|
for client in disconnected:
|
||||||
|
self._clients.discard(client)
|
||||||
|
if self._client_disconnected_callback:
|
||||||
|
self._client_disconnected_callback(client)
|
||||||
|
|
||||||
|
def _get_state_snapshot(self) -> dict | None:
|
||||||
|
"""Get current state snapshot from controller."""
|
||||||
|
if not self._controller:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Expect controller to have methods we need
|
||||||
|
state = {}
|
||||||
|
|
||||||
|
# Get stages info if UIPanel
|
||||||
|
if hasattr(self._controller, "stages"):
|
||||||
|
state["stages"] = {
|
||||||
|
name: {
|
||||||
|
"enabled": ctrl.enabled,
|
||||||
|
"params": ctrl.params,
|
||||||
|
"selected": ctrl.selected,
|
||||||
|
}
|
||||||
|
for name, ctrl in self._controller.stages.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current preset
|
||||||
|
if hasattr(self._controller, "_current_preset"):
|
||||||
|
state["preset"] = self._controller._current_preset
|
||||||
|
if hasattr(self._controller, "_presets"):
|
||||||
|
state["presets"] = self._controller._presets
|
||||||
|
|
||||||
|
# Get selected stage
|
||||||
|
if hasattr(self._controller, "selected_stage"):
|
||||||
|
state["selected_stage"] = self._controller.selected_stage
|
||||||
|
|
||||||
|
return state
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def get_dimensions(self) -> tuple[int, int]:
|
def get_dimensions(self) -> tuple[int, int]:
|
||||||
"""Get current dimensions.
|
"""Get current dimensions.
|
||||||
|
|
||||||
|
|||||||
268
engine/display/streaming.py
Normal file
268
engine/display/streaming.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""
|
||||||
|
Streaming protocol utilities for efficient frame transmission.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- Frame differencing: Only send changed lines
|
||||||
|
- Run-length encoding: Compress repeated lines
|
||||||
|
- Binary encoding: Compact message format
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import zlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
|
||||||
|
class MessageType(IntEnum):
|
||||||
|
"""Message types for streaming protocol."""
|
||||||
|
|
||||||
|
FULL_FRAME = 1
|
||||||
|
DIFF_FRAME = 2
|
||||||
|
STATE = 3
|
||||||
|
CLEAR = 4
|
||||||
|
PING = 5
|
||||||
|
PONG = 6
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrameDiff:
|
||||||
|
"""Represents a diff between two frames."""
|
||||||
|
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
changed_lines: list[tuple[int, str]] # (line_index, content)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_diff(old_buffer: list[str], new_buffer: list[str]) -> FrameDiff:
|
||||||
|
"""Compute differences between old and new buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_buffer: Previous frame buffer
|
||||||
|
new_buffer: Current frame buffer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FrameDiff with only changed lines
|
||||||
|
"""
|
||||||
|
height = len(new_buffer)
|
||||||
|
changed_lines = []
|
||||||
|
|
||||||
|
for i, line in enumerate(new_buffer):
|
||||||
|
if i >= len(old_buffer) or line != old_buffer[i]:
|
||||||
|
changed_lines.append((i, line))
|
||||||
|
|
||||||
|
return FrameDiff(
|
||||||
|
width=len(new_buffer[0]) if new_buffer else 0,
|
||||||
|
height=height,
|
||||||
|
changed_lines=changed_lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_rle(lines: list[tuple[int, str]]) -> list[tuple[int, str, int]]:
|
||||||
|
"""Run-length encode consecutive identical lines.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lines: List of (index, content) tuples (must be sorted by index)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (start_index, content, run_length) tuples
|
||||||
|
"""
|
||||||
|
if not lines:
|
||||||
|
return []
|
||||||
|
|
||||||
|
encoded = []
|
||||||
|
start_idx = lines[0][0]
|
||||||
|
current_line = lines[0][1]
|
||||||
|
current_rle = 1
|
||||||
|
|
||||||
|
for idx, line in lines[1:]:
|
||||||
|
if line == current_line:
|
||||||
|
current_rle += 1
|
||||||
|
else:
|
||||||
|
encoded.append((start_idx, current_line, current_rle))
|
||||||
|
start_idx = idx
|
||||||
|
current_line = line
|
||||||
|
current_rle = 1
|
||||||
|
|
||||||
|
encoded.append((start_idx, current_line, current_rle))
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
|
||||||
|
def decode_rle(encoded: list[tuple[int, str, int]]) -> list[tuple[int, str]]:
|
||||||
|
"""Decode run-length encoded lines.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encoded: List of (start_index, content, run_length) tuples
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (index, content) tuples
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for start_idx, line, rle in encoded:
|
||||||
|
for i in range(rle):
|
||||||
|
result.append((start_idx + i, line))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def compress_frame(buffer: list[str], level: int = 6) -> bytes:
|
||||||
|
"""Compress a frame buffer using zlib.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buffer: Frame buffer (list of lines)
|
||||||
|
level: Compression level (0-9)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Compressed bytes
|
||||||
|
"""
|
||||||
|
content = "\n".join(buffer)
|
||||||
|
return zlib.compress(content.encode("utf-8"), level)
|
||||||
|
|
||||||
|
|
||||||
|
def decompress_frame(data: bytes, height: int) -> list[str]:
|
||||||
|
"""Decompress a frame buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Compressed bytes
|
||||||
|
height: Number of lines in original buffer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Frame buffer (list of lines)
|
||||||
|
"""
|
||||||
|
content = zlib.decompress(data).decode("utf-8")
|
||||||
|
lines = content.split("\n")
|
||||||
|
if len(lines) > height:
|
||||||
|
lines = lines[:height]
|
||||||
|
while len(lines) < height:
|
||||||
|
lines.append("")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def encode_binary_message(
|
||||||
|
msg_type: MessageType, width: int, height: int, payload: bytes
|
||||||
|
) -> bytes:
|
||||||
|
"""Encode a binary message.
|
||||||
|
|
||||||
|
Message format:
|
||||||
|
- 1 byte: message type
|
||||||
|
- 2 bytes: width (uint16)
|
||||||
|
- 2 bytes: height (uint16)
|
||||||
|
- 4 bytes: payload length (uint32)
|
||||||
|
- N bytes: payload
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_type: Message type
|
||||||
|
width: Frame width
|
||||||
|
height: Frame height
|
||||||
|
payload: Message payload
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded binary message
|
||||||
|
"""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
header = struct.pack("!BHHI", msg_type.value, width, height, len(payload))
|
||||||
|
return header + payload
|
||||||
|
|
||||||
|
|
||||||
|
def decode_binary_message(data: bytes) -> tuple[MessageType, int, int, bytes]:
|
||||||
|
"""Decode a binary message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Binary message data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (msg_type, width, height, payload)
|
||||||
|
"""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
msg_type_val, width, height, payload_len = struct.unpack("!BHHI", data[:9])
|
||||||
|
payload = data[9 : 9 + payload_len]
|
||||||
|
return MessageType(msg_type_val), width, height, payload
|
||||||
|
|
||||||
|
|
||||||
|
def encode_diff_message(diff: FrameDiff, use_rle: bool = True) -> bytes:
|
||||||
|
"""Encode a diff message for transmission.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
diff: Frame diff
|
||||||
|
use_rle: Whether to use run-length encoding
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded diff payload
|
||||||
|
"""
|
||||||
|
|
||||||
|
if use_rle:
|
||||||
|
encoded_lines = encode_rle(diff.changed_lines)
|
||||||
|
data = [[idx, line, rle] for idx, line, rle in encoded_lines]
|
||||||
|
else:
|
||||||
|
data = [[idx, line] for idx, line in diff.changed_lines]
|
||||||
|
|
||||||
|
payload = json.dumps(data).encode("utf-8")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def decode_diff_message(payload: bytes, use_rle: bool = True) -> list[tuple[int, str]]:
|
||||||
|
"""Decode a diff message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Encoded diff payload
|
||||||
|
use_rle: Whether run-length encoding was used
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (line_index, content) tuples
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = json.loads(payload.decode("utf-8"))
|
||||||
|
|
||||||
|
if use_rle:
|
||||||
|
return decode_rle([(idx, line, rle) for idx, line, rle in data])
|
||||||
|
else:
|
||||||
|
return [(idx, line) for idx, line in data]
|
||||||
|
|
||||||
|
|
||||||
|
def should_use_diff(
|
||||||
|
old_buffer: list[str], new_buffer: list[str], threshold: float = 0.3
|
||||||
|
) -> bool:
|
||||||
|
"""Determine if diff or full frame is more efficient.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_buffer: Previous frame
|
||||||
|
new_buffer: Current frame
|
||||||
|
threshold: Max changed ratio to use diff (0.0-1.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if diff is more efficient
|
||||||
|
"""
|
||||||
|
if not old_buffer or not new_buffer:
|
||||||
|
return False
|
||||||
|
|
||||||
|
diff = compute_diff(old_buffer, new_buffer)
|
||||||
|
total_lines = len(new_buffer)
|
||||||
|
changed_ratio = len(diff.changed_lines) / total_lines if total_lines > 0 else 1.0
|
||||||
|
|
||||||
|
return changed_ratio <= threshold
|
||||||
|
|
||||||
|
|
||||||
|
def apply_diff(old_buffer: list[str], diff: FrameDiff) -> list[str]:
|
||||||
|
"""Apply a diff to an old buffer to get the new buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_buffer: Previous frame buffer
|
||||||
|
diff: Frame diff to apply
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New frame buffer
|
||||||
|
"""
|
||||||
|
new_buffer = list(old_buffer)
|
||||||
|
|
||||||
|
for line_idx, content in diff.changed_lines:
|
||||||
|
if line_idx < len(new_buffer):
|
||||||
|
new_buffer[line_idx] = content
|
||||||
|
else:
|
||||||
|
while len(new_buffer) < line_idx:
|
||||||
|
new_buffer.append("")
|
||||||
|
new_buffer.append(content)
|
||||||
|
|
||||||
|
while len(new_buffer) < diff.height:
|
||||||
|
new_buffer.append("")
|
||||||
|
|
||||||
|
return new_buffer[: diff.height]
|
||||||
122
engine/effects/plugins/afterimage.py
Normal file
122
engine/effects/plugins/afterimage.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""Afterimage effect using previous frame."""
|
||||||
|
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class AfterimageEffect(EffectPlugin):
|
||||||
|
"""Show a faint ghost of the previous frame.
|
||||||
|
|
||||||
|
This effect requires a FrameBufferStage to be present in the pipeline.
|
||||||
|
It shows a dimmed version of the previous frame super-imposed on the
|
||||||
|
current frame.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: "afterimage"
|
||||||
|
config: EffectConfig with intensity parameter (0.0-1.0)
|
||||||
|
param_bindings: Optional sensor bindings for intensity modulation
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> effect = AfterimageEffect()
|
||||||
|
>>> effect.configure(EffectConfig(intensity=0.3))
|
||||||
|
>>> result = effect.process(buffer, ctx)
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "afterimage"
|
||||||
|
config: EffectConfig = EffectConfig(enabled=True, intensity=0.3)
|
||||||
|
param_bindings: dict[str, dict[str, str | float]] = {}
|
||||||
|
supports_partial_updates = False
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
"""Apply afterimage effect using the previous frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buf: Current text buffer (list of strings)
|
||||||
|
ctx: Effect context with access to framebuffer history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Buffer with ghost of previous frame overlaid
|
||||||
|
"""
|
||||||
|
if not buf:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get framebuffer history from context
|
||||||
|
history = None
|
||||||
|
|
||||||
|
for key in ctx.state:
|
||||||
|
if key.startswith("framebuffer.") and key.endswith(".history"):
|
||||||
|
history = ctx.state[key]
|
||||||
|
break
|
||||||
|
|
||||||
|
if not history or len(history) < 1:
|
||||||
|
# No previous frame available
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get intensity from config
|
||||||
|
intensity = self.config.params.get("intensity", self.config.intensity)
|
||||||
|
intensity = max(0.0, min(1.0, intensity))
|
||||||
|
|
||||||
|
if intensity <= 0.0:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get the previous frame (index 1, since index 0 is current)
|
||||||
|
prev_frame = history[1] if len(history) > 1 else None
|
||||||
|
if not prev_frame:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Blend current and previous frames
|
||||||
|
viewport_height = ctx.terminal_height - ctx.ticker_height
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for row in range(len(buf)):
|
||||||
|
if row >= viewport_height:
|
||||||
|
result.append(buf[row])
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_line = buf[row]
|
||||||
|
prev_line = prev_frame[row] if row < len(prev_frame) else ""
|
||||||
|
|
||||||
|
if not prev_line:
|
||||||
|
result.append(current_line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Apply dimming effect by reducing ANSI color intensity or adding transparency
|
||||||
|
# For a simple text version, we'll use a blend strategy
|
||||||
|
blended = self._blend_lines(current_line, prev_line, intensity)
|
||||||
|
result.append(blended)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _blend_lines(self, current: str, previous: str, intensity: float) -> str:
|
||||||
|
"""Blend current and previous line with given intensity.
|
||||||
|
|
||||||
|
For text with ANSI codes, true blending is complex. This is a simplified
|
||||||
|
version that uses color averaging when possible.
|
||||||
|
|
||||||
|
A more sophisticated implementation would:
|
||||||
|
1. Parse ANSI color codes from both lines
|
||||||
|
2. Blend RGB values based on intensity
|
||||||
|
3. Reconstruct the line with blended colors
|
||||||
|
|
||||||
|
For now, we'll use a heuristic: if lines are similar, return current.
|
||||||
|
If they differ, we alternate or use the previous as a faint overlay.
|
||||||
|
"""
|
||||||
|
if current == previous:
|
||||||
|
return current
|
||||||
|
|
||||||
|
# Simple blending: intensity determines mix
|
||||||
|
# intensity=1.0 => fully current
|
||||||
|
# intensity=0.3 => 70% previous ghost, 30% current
|
||||||
|
|
||||||
|
if intensity > 0.7:
|
||||||
|
return current
|
||||||
|
elif intensity < 0.3:
|
||||||
|
# Show previous but dimmed (simulate by adding faint color/gray)
|
||||||
|
return previous # Would need to dim ANSI colors
|
||||||
|
else:
|
||||||
|
# For medium intensity, alternate based on character pattern
|
||||||
|
# This is a placeholder for proper blending
|
||||||
|
return current
|
||||||
|
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
"""Configure the effect."""
|
||||||
|
self.config = config
|
||||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
|||||||
|
|
||||||
class FadeEffect(EffectPlugin):
|
class FadeEffect(EffectPlugin):
|
||||||
name = "fade"
|
name = "fade"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0, entropy=0.1)
|
||||||
|
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
if not ctx.ticker_height:
|
if not ctx.ticker_height:
|
||||||
|
|||||||
332
engine/effects/plugins/figment.py
Normal file
332
engine/effects/plugins/figment.py
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
"""
|
||||||
|
Figment overlay effect for modern pipeline architecture.
|
||||||
|
|
||||||
|
Provides periodic SVG glyph overlays with reveal/hold/dissolve animation phases.
|
||||||
|
Integrates directly with the pipeline's effect system without legacy dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum, auto
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
from engine.figment_render import rasterize_svg
|
||||||
|
from engine.figment_trigger import FigmentAction, FigmentCommand, FigmentTrigger
|
||||||
|
from engine.terminal import RST
|
||||||
|
from engine.themes import THEME_REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
class FigmentPhase(Enum):
|
||||||
|
"""Animation phases for figment overlay."""
|
||||||
|
|
||||||
|
REVEAL = auto()
|
||||||
|
HOLD = auto()
|
||||||
|
DISSOLVE = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FigmentState:
|
||||||
|
"""State of a figment overlay at a given frame."""
|
||||||
|
|
||||||
|
phase: FigmentPhase
|
||||||
|
progress: float
|
||||||
|
rows: list[str]
|
||||||
|
gradient: list[int]
|
||||||
|
center_row: int
|
||||||
|
center_col: int
|
||||||
|
|
||||||
|
|
||||||
|
def _color_codes_to_ansi(gradient: list[int]) -> list[str]:
|
||||||
|
"""Convert gradient list to ANSI color codes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
gradient: List of 256-color palette codes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ANSI escape code strings
|
||||||
|
"""
|
||||||
|
codes = []
|
||||||
|
for color in gradient:
|
||||||
|
if isinstance(color, int):
|
||||||
|
codes.append(f"\033[38;5;{color}m")
|
||||||
|
else:
|
||||||
|
# Fallback to green
|
||||||
|
codes.append("\033[38;5;46m")
|
||||||
|
return codes if codes else ["\033[38;5;46m"]
|
||||||
|
|
||||||
|
|
||||||
|
def render_figment_overlay(figment_state: FigmentState, w: int, h: int) -> list[str]:
|
||||||
|
"""Render figment overlay as ANSI cursor-positioning commands.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
figment_state: FigmentState with phase, progress, rows, gradient, centering.
|
||||||
|
w: terminal width
|
||||||
|
h: terminal height
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ANSI strings to append to display buffer.
|
||||||
|
"""
|
||||||
|
rows = figment_state.rows
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
phase = figment_state.phase
|
||||||
|
progress = figment_state.progress
|
||||||
|
gradient = figment_state.gradient
|
||||||
|
center_row = figment_state.center_row
|
||||||
|
center_col = figment_state.center_col
|
||||||
|
|
||||||
|
cols = _color_codes_to_ansi(gradient)
|
||||||
|
|
||||||
|
# Build a list of non-space cell positions
|
||||||
|
cell_positions = []
|
||||||
|
for r_idx, row in enumerate(rows):
|
||||||
|
for c_idx, ch in enumerate(row):
|
||||||
|
if ch != " ":
|
||||||
|
cell_positions.append((r_idx, c_idx))
|
||||||
|
|
||||||
|
n_cells = len(cell_positions)
|
||||||
|
if n_cells == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Use a deterministic seed so the reveal/dissolve pattern is stable per-figment
|
||||||
|
rng = random.Random(hash(tuple(rows[0][:10])) if rows[0] else 42)
|
||||||
|
shuffled = list(cell_positions)
|
||||||
|
rng.shuffle(shuffled)
|
||||||
|
|
||||||
|
# Phase-dependent visibility
|
||||||
|
if phase == FigmentPhase.REVEAL:
|
||||||
|
visible_count = int(n_cells * progress)
|
||||||
|
visible = set(shuffled[:visible_count])
|
||||||
|
elif phase == FigmentPhase.HOLD:
|
||||||
|
visible = set(cell_positions)
|
||||||
|
# Strobe: dim some cells periodically
|
||||||
|
if int(progress * 20) % 3 == 0:
|
||||||
|
dim_count = int(n_cells * 0.3)
|
||||||
|
visible -= set(shuffled[:dim_count])
|
||||||
|
elif phase == FigmentPhase.DISSOLVE:
|
||||||
|
remaining_count = int(n_cells * (1.0 - progress))
|
||||||
|
visible = set(shuffled[:remaining_count])
|
||||||
|
else:
|
||||||
|
visible = set(cell_positions)
|
||||||
|
|
||||||
|
# Build overlay commands
|
||||||
|
overlay: list[str] = []
|
||||||
|
n_cols = len(cols)
|
||||||
|
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||||
|
|
||||||
|
for r_idx, row in enumerate(rows):
|
||||||
|
scr_row = center_row + r_idx + 1 # 1-indexed
|
||||||
|
if scr_row < 1 or scr_row > h:
|
||||||
|
continue
|
||||||
|
|
||||||
|
line_buf: list[str] = []
|
||||||
|
has_content = False
|
||||||
|
|
||||||
|
for c_idx, ch in enumerate(row):
|
||||||
|
scr_col = center_col + c_idx + 1
|
||||||
|
if scr_col < 1 or scr_col > w:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ch != " " and (r_idx, c_idx) in visible:
|
||||||
|
# Apply gradient color
|
||||||
|
shifted = (c_idx / max(max_x - 1, 1)) % 1.0
|
||||||
|
idx = min(round(shifted * (n_cols - 1)), n_cols - 1)
|
||||||
|
line_buf.append(f"{cols[idx]}{ch}{RST}")
|
||||||
|
has_content = True
|
||||||
|
else:
|
||||||
|
line_buf.append(" ")
|
||||||
|
|
||||||
|
if has_content:
|
||||||
|
line_str = "".join(line_buf).rstrip()
|
||||||
|
if line_str.strip():
|
||||||
|
overlay.append(f"\033[{scr_row};{center_col + 1}H{line_str}{RST}")
|
||||||
|
|
||||||
|
return overlay
|
||||||
|
|
||||||
|
|
||||||
|
class FigmentEffect(EffectPlugin):
|
||||||
|
"""Figment overlay effect for pipeline architecture.
|
||||||
|
|
||||||
|
Provides periodic SVG overlays with reveal/hold/dissolve animation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "figment"
|
||||||
|
config = EffectConfig(
|
||||||
|
enabled=True,
|
||||||
|
intensity=1.0,
|
||||||
|
params={
|
||||||
|
"interval_secs": 60,
|
||||||
|
"display_secs": 4.5,
|
||||||
|
"figment_dir": "figments",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
supports_partial_updates = False
|
||||||
|
is_overlay = True # Figment is an overlay effect that composes on top of the buffer
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
figment_dir: str | None = None,
|
||||||
|
triggers: list[FigmentTrigger] | None = None,
|
||||||
|
):
|
||||||
|
self.config = EffectConfig(
|
||||||
|
enabled=True,
|
||||||
|
intensity=1.0,
|
||||||
|
params={
|
||||||
|
"interval_secs": 60,
|
||||||
|
"display_secs": 4.5,
|
||||||
|
"figment_dir": figment_dir or "figments",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._triggers = triggers or []
|
||||||
|
self._phase: FigmentPhase | None = None
|
||||||
|
self._progress: float = 0.0
|
||||||
|
self._rows: list[str] = []
|
||||||
|
self._gradient: list[int] = []
|
||||||
|
self._center_row: int = 0
|
||||||
|
self._center_col: int = 0
|
||||||
|
self._timer: float = 0.0
|
||||||
|
self._last_svg: str | None = None
|
||||||
|
self._svg_files: list[str] = []
|
||||||
|
self._scan_svgs()
|
||||||
|
|
||||||
|
def _scan_svgs(self) -> None:
|
||||||
|
"""Scan figment directory for SVG files."""
|
||||||
|
figment_dir = Path(self.config.params["figment_dir"])
|
||||||
|
if figment_dir.is_dir():
|
||||||
|
self._svg_files = sorted(str(p) for p in figment_dir.glob("*.svg"))
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
"""Add figment overlay to buffer."""
|
||||||
|
if not self.config.enabled:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get figment state using frame number from context
|
||||||
|
figment_state = self.get_figment_state(
|
||||||
|
ctx.frame_number, ctx.terminal_width, ctx.terminal_height
|
||||||
|
)
|
||||||
|
|
||||||
|
if figment_state:
|
||||||
|
# Render overlay and append to buffer
|
||||||
|
overlay = render_figment_overlay(
|
||||||
|
figment_state, ctx.terminal_width, ctx.terminal_height
|
||||||
|
)
|
||||||
|
buf = buf + overlay
|
||||||
|
|
||||||
|
return buf
|
||||||
|
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
"""Configure the effect."""
|
||||||
|
# Preserve figment_dir if the new config doesn't supply one
|
||||||
|
figment_dir = config.params.get(
|
||||||
|
"figment_dir", self.config.params.get("figment_dir", "figments")
|
||||||
|
)
|
||||||
|
self.config = config
|
||||||
|
if "figment_dir" not in self.config.params:
|
||||||
|
self.config.params["figment_dir"] = figment_dir
|
||||||
|
self._scan_svgs()
|
||||||
|
|
||||||
|
def trigger(self, w: int, h: int) -> None:
|
||||||
|
"""Manually trigger a figment display."""
|
||||||
|
if not self._svg_files:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pick a random SVG, avoid repeating
|
||||||
|
candidates = [s for s in self._svg_files if s != self._last_svg]
|
||||||
|
if not candidates:
|
||||||
|
candidates = self._svg_files
|
||||||
|
svg_path = random.choice(candidates)
|
||||||
|
self._last_svg = svg_path
|
||||||
|
|
||||||
|
# Rasterize
|
||||||
|
try:
|
||||||
|
self._rows = rasterize_svg(svg_path, w, h)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Pick random theme gradient
|
||||||
|
theme_key = random.choice(list(THEME_REGISTRY.keys()))
|
||||||
|
self._gradient = THEME_REGISTRY[theme_key].main_gradient
|
||||||
|
|
||||||
|
# Center in viewport
|
||||||
|
figment_h = len(self._rows)
|
||||||
|
figment_w = max((len(r) for r in self._rows), default=0)
|
||||||
|
self._center_row = max(0, (h - figment_h) // 2)
|
||||||
|
self._center_col = max(0, (w - figment_w) // 2)
|
||||||
|
|
||||||
|
# Start reveal phase
|
||||||
|
self._phase = FigmentPhase.REVEAL
|
||||||
|
self._progress = 0.0
|
||||||
|
|
||||||
|
def get_figment_state(
|
||||||
|
self, frame_number: int, w: int, h: int
|
||||||
|
) -> FigmentState | None:
|
||||||
|
"""Tick the state machine and return current state, or None if idle."""
|
||||||
|
if not self.config.enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Poll triggers
|
||||||
|
for trig in self._triggers:
|
||||||
|
cmd = trig.poll()
|
||||||
|
if cmd is not None:
|
||||||
|
self._handle_command(cmd, w, h)
|
||||||
|
|
||||||
|
# Tick timer when idle
|
||||||
|
if self._phase is None:
|
||||||
|
self._timer += config.FRAME_DT
|
||||||
|
interval = self.config.params.get("interval_secs", 60)
|
||||||
|
if self._timer >= interval:
|
||||||
|
self._timer = 0.0
|
||||||
|
self.trigger(w, h)
|
||||||
|
|
||||||
|
# Tick animation — snapshot current phase/progress, then advance
|
||||||
|
if self._phase is not None:
|
||||||
|
# Capture the state at the start of this frame
|
||||||
|
current_phase = self._phase
|
||||||
|
current_progress = self._progress
|
||||||
|
|
||||||
|
# Advance for next frame
|
||||||
|
display_secs = self.config.params.get("display_secs", 4.5)
|
||||||
|
phase_duration = display_secs / 3.0
|
||||||
|
self._progress += config.FRAME_DT / phase_duration
|
||||||
|
|
||||||
|
if self._progress >= 1.0:
|
||||||
|
self._progress = 0.0
|
||||||
|
if self._phase == FigmentPhase.REVEAL:
|
||||||
|
self._phase = FigmentPhase.HOLD
|
||||||
|
elif self._phase == FigmentPhase.HOLD:
|
||||||
|
self._phase = FigmentPhase.DISSOLVE
|
||||||
|
elif self._phase == FigmentPhase.DISSOLVE:
|
||||||
|
self._phase = None
|
||||||
|
|
||||||
|
return FigmentState(
|
||||||
|
phase=current_phase,
|
||||||
|
progress=current_progress,
|
||||||
|
rows=self._rows,
|
||||||
|
gradient=self._gradient,
|
||||||
|
center_row=self._center_row,
|
||||||
|
center_col=self._center_col,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _handle_command(self, cmd: FigmentCommand, w: int, h: int) -> None:
|
||||||
|
"""Handle a figment command."""
|
||||||
|
if cmd.action == FigmentAction.TRIGGER:
|
||||||
|
self.trigger(w, h)
|
||||||
|
elif cmd.action == FigmentAction.SET_INTENSITY and isinstance(
|
||||||
|
cmd.value, (int, float)
|
||||||
|
):
|
||||||
|
self.config.intensity = float(cmd.value)
|
||||||
|
elif cmd.action == FigmentAction.SET_INTERVAL and isinstance(
|
||||||
|
cmd.value, (int, float)
|
||||||
|
):
|
||||||
|
self.config.params["interval_secs"] = float(cmd.value)
|
||||||
|
elif cmd.action == FigmentAction.SET_COLOR and isinstance(cmd.value, str):
|
||||||
|
if cmd.value in THEME_REGISTRY:
|
||||||
|
self._gradient = THEME_REGISTRY[cmd.value].main_gradient
|
||||||
|
elif cmd.action == FigmentAction.STOP:
|
||||||
|
self._phase = None
|
||||||
|
self._progress = 0.0
|
||||||
@@ -9,7 +9,7 @@ from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
|||||||
|
|
||||||
class FirehoseEffect(EffectPlugin):
|
class FirehoseEffect(EffectPlugin):
|
||||||
name = "firehose"
|
name = "firehose"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0, entropy=0.9)
|
||||||
|
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
firehose_h = config.FIREHOSE_H if config.FIREHOSE else 0
|
firehose_h = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from engine.terminal import DIM, G_LO, RST
|
|||||||
|
|
||||||
class GlitchEffect(EffectPlugin):
|
class GlitchEffect(EffectPlugin):
|
||||||
name = "glitch"
|
name = "glitch"
|
||||||
config = EffectConfig(enabled=True, intensity=1.0)
|
config = EffectConfig(enabled=True, intensity=1.0, entropy=0.8)
|
||||||
|
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
if not buf:
|
if not buf:
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class HudEffect(EffectPlugin):
|
|||||||
|
|
||||||
for i, line in enumerate(hud_lines):
|
for i, line in enumerate(hud_lines):
|
||||||
if i < len(result):
|
if i < len(result):
|
||||||
result[i] = line + result[i][len(line) :]
|
result[i] = line
|
||||||
else:
|
else:
|
||||||
result.append(line)
|
result.append(line)
|
||||||
|
|
||||||
|
|||||||
119
engine/effects/plugins/motionblur.py
Normal file
119
engine/effects/plugins/motionblur.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Motion blur effect using frame history."""
|
||||||
|
|
||||||
|
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||||
|
|
||||||
|
|
||||||
|
class MotionBlurEffect(EffectPlugin):
|
||||||
|
"""Apply motion blur by blending current frame with previous frames.
|
||||||
|
|
||||||
|
This effect requires a FrameBufferStage to be present in the pipeline.
|
||||||
|
The framebuffer provides frame history which is blended with the current
|
||||||
|
frame based on intensity.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
name: "motionblur"
|
||||||
|
config: EffectConfig with intensity parameter (0.0-1.0)
|
||||||
|
param_bindings: Optional sensor bindings for intensity modulation
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> effect = MotionBlurEffect()
|
||||||
|
>>> effect.configure(EffectConfig(intensity=0.5))
|
||||||
|
>>> result = effect.process(buffer, ctx)
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "motionblur"
|
||||||
|
config: EffectConfig = EffectConfig(enabled=True, intensity=0.5)
|
||||||
|
param_bindings: dict[str, dict[str, str | float]] = {}
|
||||||
|
supports_partial_updates = False
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
"""Apply motion blur by blending with previous frames.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buf: Current text buffer (list of strings)
|
||||||
|
ctx: Effect context with access to framebuffer history
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Blended buffer with motion blur effect applied
|
||||||
|
"""
|
||||||
|
if not buf:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get framebuffer history from context
|
||||||
|
# We'll look for the first available framebuffer history
|
||||||
|
history = None
|
||||||
|
|
||||||
|
for key in ctx.state:
|
||||||
|
if key.startswith("framebuffer.") and key.endswith(".history"):
|
||||||
|
history = ctx.state[key]
|
||||||
|
break
|
||||||
|
|
||||||
|
if not history:
|
||||||
|
# No framebuffer available, return unchanged
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get intensity from config
|
||||||
|
intensity = self.config.params.get("intensity", self.config.intensity)
|
||||||
|
intensity = max(0.0, min(1.0, intensity))
|
||||||
|
|
||||||
|
if intensity <= 0.0:
|
||||||
|
return buf
|
||||||
|
|
||||||
|
# Get decay factor (how quickly older frames fade)
|
||||||
|
decay = self.config.params.get("decay", 0.7)
|
||||||
|
|
||||||
|
# Build output buffer
|
||||||
|
result = []
|
||||||
|
viewport_height = ctx.terminal_height - ctx.ticker_height
|
||||||
|
|
||||||
|
# Determine how many frames to blend (up to history depth)
|
||||||
|
max_frames = min(len(history), 5) # Cap at 5 frames for performance
|
||||||
|
|
||||||
|
for row in range(len(buf)):
|
||||||
|
if row >= viewport_height:
|
||||||
|
# Beyond viewport, just copy
|
||||||
|
result.append(buf[row])
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Start with current frame
|
||||||
|
blended = buf[row]
|
||||||
|
|
||||||
|
# Blend with historical frames
|
||||||
|
weight_sum = 1.0
|
||||||
|
if max_frames > 0 and intensity > 0:
|
||||||
|
for i in range(max_frames):
|
||||||
|
frame_weight = intensity * (decay**i)
|
||||||
|
if frame_weight < 0.01: # Skip negligible weights
|
||||||
|
break
|
||||||
|
|
||||||
|
hist_row = history[i][row] if row < len(history[i]) else ""
|
||||||
|
# Simple string blending: we'll concatenate with space
|
||||||
|
# For a proper effect, we'd need to blend ANSI colors
|
||||||
|
# This is a simplified version that just adds the frames
|
||||||
|
blended = self._blend_strings(blended, hist_row, frame_weight)
|
||||||
|
weight_sum += frame_weight
|
||||||
|
|
||||||
|
result.append(blended)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _blend_strings(self, current: str, historical: str, weight: float) -> str:
|
||||||
|
"""Blend two strings with given weight.
|
||||||
|
|
||||||
|
This is a simplified blending that works with ANSI codes.
|
||||||
|
For proper blending we'd need to parse colors, but for now
|
||||||
|
we use a heuristic: if strings are identical, return one.
|
||||||
|
If they differ, we alternate or concatenate based on weight.
|
||||||
|
"""
|
||||||
|
if current == historical:
|
||||||
|
return current
|
||||||
|
|
||||||
|
# If weight is high, show current; if low, show historical
|
||||||
|
if weight > 0.5:
|
||||||
|
return current
|
||||||
|
else:
|
||||||
|
return historical
|
||||||
|
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
"""Configure the effect."""
|
||||||
|
self.config = config
|
||||||
@@ -7,7 +7,7 @@ from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
|||||||
|
|
||||||
class NoiseEffect(EffectPlugin):
|
class NoiseEffect(EffectPlugin):
|
||||||
name = "noise"
|
name = "noise"
|
||||||
config = EffectConfig(enabled=True, intensity=0.15)
|
config = EffectConfig(enabled=True, intensity=0.15, entropy=0.4)
|
||||||
|
|
||||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
if not ctx.ticker_height:
|
if not ctx.ticker_height:
|
||||||
|
|||||||
605
engine/effects/plugins/repl.py
Normal file
605
engine/effects/plugins/repl.py
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
"""REPL Effect Plugin
|
||||||
|
|
||||||
|
A HUD-style command-line interface for interactive pipeline control.
|
||||||
|
|
||||||
|
This effect provides a Read-Eval-Print Loop (REPL) that allows users to:
|
||||||
|
- View pipeline status and metrics
|
||||||
|
- Toggle effects on/off
|
||||||
|
- Adjust effect parameters in real-time
|
||||||
|
- Inspect pipeline configuration
|
||||||
|
- Execute commands for pipeline manipulation
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
Add 'repl' to the effects list in your configuration.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
help - Show available commands
|
||||||
|
status - Show pipeline status
|
||||||
|
effects - List all effects
|
||||||
|
effect <name> <on|off> - Toggle an effect
|
||||||
|
param <effect> <param> <value> - Set effect parameter
|
||||||
|
pipeline - Show current pipeline order
|
||||||
|
clear - Clear output buffer
|
||||||
|
quit - Exit REPL
|
||||||
|
|
||||||
|
Keyboard:
|
||||||
|
Enter - Execute command
|
||||||
|
Up/Down - Navigate command history
|
||||||
|
Tab - Auto-complete (if implemented)
|
||||||
|
Ctrl+C - Clear current input
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from engine.effects.types import (
|
||||||
|
EffectConfig,
|
||||||
|
EffectContext,
|
||||||
|
EffectPlugin,
|
||||||
|
PartialUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class REPLState:
|
||||||
|
"""State of the REPL interface."""
|
||||||
|
|
||||||
|
command_history: list[str] = field(default_factory=list)
|
||||||
|
current_command: str = ""
|
||||||
|
history_index: int = -1
|
||||||
|
output_buffer: list[str] = field(default_factory=list)
|
||||||
|
scroll_offset: int = 0 # Manual scroll position (0 = bottom of buffer)
|
||||||
|
max_history: int = 50
|
||||||
|
max_output_lines: int = 50 # 50 lines excluding empty lines
|
||||||
|
|
||||||
|
|
||||||
|
class ReplEffect(EffectPlugin):
|
||||||
|
"""REPL effect with HUD-style overlay for interactive pipeline control."""
|
||||||
|
|
||||||
|
name = "repl"
|
||||||
|
config = EffectConfig(
|
||||||
|
enabled=True,
|
||||||
|
intensity=1.0,
|
||||||
|
params={
|
||||||
|
"display_height": 8, # Height of REPL area in lines
|
||||||
|
"show_hud": True, # Show HUD header lines
|
||||||
|
},
|
||||||
|
)
|
||||||
|
supports_partial_updates = True
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.state = REPLState()
|
||||||
|
self._last_metrics: dict | None = None
|
||||||
|
|
||||||
|
def process_partial(
|
||||||
|
self, buf: list[str], ctx: EffectContext, partial: PartialUpdate
|
||||||
|
) -> list[str]:
|
||||||
|
"""Handle partial updates efficiently."""
|
||||||
|
if partial.full_buffer:
|
||||||
|
return self.process(buf, ctx)
|
||||||
|
# Always process REPL since it needs to stay visible
|
||||||
|
return self.process(buf, ctx)
|
||||||
|
|
||||||
|
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||||
|
"""Render buffer with REPL overlay."""
|
||||||
|
# Get display dimensions from context
|
||||||
|
height = ctx.terminal_height if hasattr(ctx, "terminal_height") else len(buf)
|
||||||
|
width = ctx.terminal_width if hasattr(ctx, "terminal_width") else 80
|
||||||
|
|
||||||
|
# Calculate areas
|
||||||
|
repl_height = self.config.params.get("display_height", 8)
|
||||||
|
show_hud = self.config.params.get("show_hud", True)
|
||||||
|
|
||||||
|
# Reserve space for REPL at bottom
|
||||||
|
# HUD uses top 3 lines if enabled
|
||||||
|
content_height = max(1, height - repl_height)
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
output = []
|
||||||
|
|
||||||
|
# Add content (truncated or padded)
|
||||||
|
for i in range(content_height):
|
||||||
|
if i < len(buf):
|
||||||
|
output.append(buf[i][:width])
|
||||||
|
else:
|
||||||
|
output.append(" " * width)
|
||||||
|
|
||||||
|
# Add HUD lines if enabled
|
||||||
|
if show_hud:
|
||||||
|
hud_output = self._render_hud(width, ctx)
|
||||||
|
# Overlay HUD on first lines of content
|
||||||
|
for i, line in enumerate(hud_output):
|
||||||
|
if i < len(output):
|
||||||
|
output[i] = line[:width]
|
||||||
|
|
||||||
|
# Add separator
|
||||||
|
output.append("─" * width)
|
||||||
|
|
||||||
|
# Add REPL area
|
||||||
|
repl_lines = self._render_repl(width, repl_height - 1)
|
||||||
|
output.extend(repl_lines)
|
||||||
|
|
||||||
|
# Ensure correct height
|
||||||
|
while len(output) < height:
|
||||||
|
output.append(" " * width)
|
||||||
|
output = output[:height]
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
def _render_hud(self, width: int, ctx: EffectContext) -> list[str]:
|
||||||
|
"""Render HUD-style header with metrics."""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Get metrics
|
||||||
|
metrics = self._get_metrics(ctx)
|
||||||
|
fps = metrics.get("fps", 0.0)
|
||||||
|
frame_time = metrics.get("frame_time", 0.0)
|
||||||
|
|
||||||
|
# Line 1: Title + FPS + Frame time
|
||||||
|
fps_str = f"FPS: {fps:.1f}" if fps > 0 else "FPS: --"
|
||||||
|
time_str = f"{frame_time:.1f}ms" if frame_time > 0 else "--ms"
|
||||||
|
|
||||||
|
# Calculate scroll percentage (like vim)
|
||||||
|
scroll_pct = 0
|
||||||
|
if len(self.state.output_buffer) > 1:
|
||||||
|
max_scroll = len(self.state.output_buffer) - 1
|
||||||
|
scroll_pct = (
|
||||||
|
int((self.state.scroll_offset / max_scroll) * 100)
|
||||||
|
if max_scroll > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
scroll_str = f"{scroll_pct}%"
|
||||||
|
line1 = (
|
||||||
|
f"\033[38;5;46mMAINLINE REPL\033[0m "
|
||||||
|
f"\033[38;5;245m|\033[0m \033[38;5;39m{fps_str}\033[0m "
|
||||||
|
f"\033[38;5;245m|\033[0m \033[38;5;208m{time_str}\033[0m "
|
||||||
|
f"\033[38;5;245m|\033[0m \033[38;5;220m{scroll_str}\033[0m"
|
||||||
|
)
|
||||||
|
lines.append(line1[:width])
|
||||||
|
|
||||||
|
# Line 2: Command count + History index
|
||||||
|
cmd_count = len(self.state.command_history)
|
||||||
|
hist_idx = (
|
||||||
|
f"[{self.state.history_index + 1}/{cmd_count}]" if cmd_count > 0 else ""
|
||||||
|
)
|
||||||
|
line2 = (
|
||||||
|
f"\033[38;5;45mCOMMANDS:\033[0m "
|
||||||
|
f"\033[1;38;5;227m{cmd_count}\033[0m "
|
||||||
|
f"\033[38;5;245m|\033[0m \033[38;5;219m{hist_idx}\033[0m"
|
||||||
|
)
|
||||||
|
lines.append(line2[:width])
|
||||||
|
|
||||||
|
# Line 3: Output buffer count with scroll indicator
|
||||||
|
out_count = len(self.state.output_buffer)
|
||||||
|
scroll_pos = f"({self.state.scroll_offset}/{out_count})"
|
||||||
|
line3 = (
|
||||||
|
f"\033[38;5;44mOUTPUT:\033[0m "
|
||||||
|
f"\033[1;38;5;227m{out_count}\033[0m lines "
|
||||||
|
f"\033[38;5;245m{scroll_pos}\033[0m"
|
||||||
|
)
|
||||||
|
lines.append(line3[:width])
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def _render_repl(self, width: int, height: int) -> list[str]:
|
||||||
|
"""Render REPL interface."""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Calculate how many output lines to show
|
||||||
|
# Reserve 1 line for input prompt
|
||||||
|
output_height = height - 1
|
||||||
|
|
||||||
|
# Manual scroll: scroll_offset=0 means show bottom of buffer
|
||||||
|
# scroll_offset increases as you scroll up through history
|
||||||
|
buffer_len = len(self.state.output_buffer)
|
||||||
|
output_start = max(0, buffer_len - output_height - self.state.scroll_offset)
|
||||||
|
|
||||||
|
# Render output buffer
|
||||||
|
for i in range(output_height):
|
||||||
|
idx = output_start + i
|
||||||
|
if idx < buffer_len:
|
||||||
|
line = self.state.output_buffer[idx][:width]
|
||||||
|
lines.append(line)
|
||||||
|
else:
|
||||||
|
lines.append(" " * width)
|
||||||
|
|
||||||
|
# Render input prompt
|
||||||
|
prompt = "> "
|
||||||
|
input_line = f"{prompt}{self.state.current_command}"
|
||||||
|
# Add cursor indicator
|
||||||
|
cursor = "█" if len(self.state.current_command) % 2 == 0 else " "
|
||||||
|
input_line += cursor
|
||||||
|
lines.append(input_line[:width])
|
||||||
|
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def scroll_output(self, delta: int) -> None:
|
||||||
|
"""Scroll the output buffer by delta lines.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delta: Positive to scroll up (back in time), negative to scroll down
|
||||||
|
"""
|
||||||
|
if not self.state.output_buffer:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Calculate max scroll (can't scroll past top of buffer)
|
||||||
|
max_scroll = max(0, len(self.state.output_buffer) - 1)
|
||||||
|
|
||||||
|
# Update scroll offset
|
||||||
|
self.state.scroll_offset = max(
|
||||||
|
0, min(max_scroll, self.state.scroll_offset + delta)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reset scroll when new output arrives (handled in process_command)
|
||||||
|
|
||||||
|
def _get_metrics(self, ctx: EffectContext) -> dict:
|
||||||
|
"""Get pipeline metrics from context."""
|
||||||
|
metrics = ctx.get_state("metrics")
|
||||||
|
if metrics:
|
||||||
|
self._last_metrics = metrics
|
||||||
|
|
||||||
|
if self._last_metrics:
|
||||||
|
# Extract FPS and frame time
|
||||||
|
fps = 0.0
|
||||||
|
frame_time = 0.0
|
||||||
|
|
||||||
|
if "pipeline" in self._last_metrics:
|
||||||
|
avg_ms = self._last_metrics["pipeline"].get("avg_ms", 0.0)
|
||||||
|
frame_count = self._last_metrics.get("frame_count", 0)
|
||||||
|
if frame_count > 0 and avg_ms > 0:
|
||||||
|
fps = 1000.0 / avg_ms
|
||||||
|
frame_time = avg_ms
|
||||||
|
|
||||||
|
return {"fps": fps, "frame_time": frame_time}
|
||||||
|
|
||||||
|
return {"fps": 0.0, "frame_time": 0.0}
|
||||||
|
|
||||||
|
def process_command(self, command: str, ctx: EffectContext | None = None) -> None:
|
||||||
|
"""Process a REPL command."""
|
||||||
|
cmd = command.strip()
|
||||||
|
if not cmd:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add to history
|
||||||
|
self.state.command_history.append(cmd)
|
||||||
|
if len(self.state.command_history) > self.state.max_history:
|
||||||
|
self.state.command_history.pop(0)
|
||||||
|
|
||||||
|
self.state.history_index = len(self.state.command_history)
|
||||||
|
self.state.current_command = ""
|
||||||
|
|
||||||
|
# Add to output buffer
|
||||||
|
self.state.output_buffer.append(f"> {cmd}")
|
||||||
|
|
||||||
|
# Reset scroll offset when new output arrives (scroll to bottom)
|
||||||
|
self.state.scroll_offset = 0
|
||||||
|
|
||||||
|
# Parse command
|
||||||
|
parts = cmd.split()
|
||||||
|
cmd_name = parts[0].lower()
|
||||||
|
cmd_args = parts[1:] if len(parts) > 1 else []
|
||||||
|
|
||||||
|
# Execute command
|
||||||
|
try:
|
||||||
|
if cmd_name == "help":
|
||||||
|
self._cmd_help()
|
||||||
|
elif cmd_name == "status":
|
||||||
|
self._cmd_status(ctx)
|
||||||
|
elif cmd_name == "effects":
|
||||||
|
self._cmd_effects(ctx)
|
||||||
|
elif cmd_name == "effect":
|
||||||
|
self._cmd_effect(cmd_args, ctx)
|
||||||
|
elif cmd_name == "param":
|
||||||
|
self._cmd_param(cmd_args, ctx)
|
||||||
|
elif cmd_name == "pipeline":
|
||||||
|
self._cmd_pipeline(ctx)
|
||||||
|
elif cmd_name == "available":
|
||||||
|
self._cmd_available(ctx)
|
||||||
|
elif cmd_name == "add_stage":
|
||||||
|
self._cmd_add_stage(cmd_args)
|
||||||
|
elif cmd_name == "remove_stage":
|
||||||
|
self._cmd_remove_stage(cmd_args)
|
||||||
|
elif cmd_name == "swap_stages":
|
||||||
|
self._cmd_swap_stages(cmd_args)
|
||||||
|
elif cmd_name == "move_stage":
|
||||||
|
self._cmd_move_stage(cmd_args)
|
||||||
|
elif cmd_name == "clear":
|
||||||
|
self.state.output_buffer.clear()
|
||||||
|
elif cmd_name == "quit" or cmd_name == "exit":
|
||||||
|
self.state.output_buffer.append("Use Ctrl+C to exit")
|
||||||
|
else:
|
||||||
|
self.state.output_buffer.append(f"Unknown command: {cmd_name}")
|
||||||
|
self.state.output_buffer.append("Type 'help' for available commands")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.state.output_buffer.append(f"Error: {e}")
|
||||||
|
|
||||||
|
def _cmd_help(self):
|
||||||
|
"""Show help message."""
|
||||||
|
self.state.output_buffer.append("Available commands:")
|
||||||
|
self.state.output_buffer.append(" help - Show this help")
|
||||||
|
self.state.output_buffer.append(" status - Show pipeline status")
|
||||||
|
self.state.output_buffer.append(" effects - List effects in current pipeline")
|
||||||
|
self.state.output_buffer.append(" available - List all available effect types")
|
||||||
|
self.state.output_buffer.append(" effect <name> <on|off> - Toggle effect")
|
||||||
|
self.state.output_buffer.append(
|
||||||
|
" param <effect> <param> <value> - Set parameter"
|
||||||
|
)
|
||||||
|
self.state.output_buffer.append(" pipeline - Show current pipeline order")
|
||||||
|
self.state.output_buffer.append(" add_stage <name> <type> - Add new stage")
|
||||||
|
self.state.output_buffer.append(" remove_stage <name> - Remove stage")
|
||||||
|
self.state.output_buffer.append(" swap_stages <name1> <name2> - Swap stages")
|
||||||
|
self.state.output_buffer.append(
|
||||||
|
" move_stage <name> [after <stage>] [before <stage>] - Move stage"
|
||||||
|
)
|
||||||
|
self.state.output_buffer.append(" clear - Clear output buffer")
|
||||||
|
self.state.output_buffer.append(" quit - Show exit message")
|
||||||
|
|
||||||
|
def _cmd_status(self, ctx: EffectContext | None):
|
||||||
|
"""Show pipeline status."""
|
||||||
|
if ctx:
|
||||||
|
metrics = self._get_metrics(ctx)
|
||||||
|
self.state.output_buffer.append(f"FPS: {metrics['fps']:.1f}")
|
||||||
|
self.state.output_buffer.append(
|
||||||
|
f"Frame time: {metrics['frame_time']:.1f}ms"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.state.output_buffer.append(
|
||||||
|
f"Output lines: {len(self.state.output_buffer)}"
|
||||||
|
)
|
||||||
|
self.state.output_buffer.append(
|
||||||
|
f"History: {len(self.state.command_history)} commands"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cmd_effects(self, ctx: EffectContext | None):
|
||||||
|
"""List all effects."""
|
||||||
|
if ctx:
|
||||||
|
# Try to get effect list from context
|
||||||
|
effects = ctx.get_state("pipeline_order")
|
||||||
|
if effects:
|
||||||
|
self.state.output_buffer.append("Pipeline effects:")
|
||||||
|
for i, name in enumerate(effects):
|
||||||
|
self.state.output_buffer.append(f" {i + 1}. {name}")
|
||||||
|
else:
|
||||||
|
self.state.output_buffer.append("No pipeline information available")
|
||||||
|
else:
|
||||||
|
self.state.output_buffer.append("No context available")
|
||||||
|
|
||||||
|
def _cmd_available(self, ctx: EffectContext | None):
|
||||||
|
"""List all available effect types and stage categories."""
|
||||||
|
try:
|
||||||
|
from engine.effects import get_registry
|
||||||
|
from engine.effects.plugins import discover_plugins
|
||||||
|
from engine.pipeline.registry import StageRegistry, discover_stages
|
||||||
|
|
||||||
|
# Discover plugins and stages if not already done
|
||||||
|
discover_plugins()
|
||||||
|
discover_stages()
|
||||||
|
|
||||||
|
# List effect types from registry
|
||||||
|
registry = get_registry()
|
||||||
|
all_effects = registry.list_all()
|
||||||
|
|
||||||
|
if all_effects:
|
||||||
|
self.state.output_buffer.append("Available effect types:")
|
||||||
|
for name in sorted(all_effects.keys()):
|
||||||
|
self.state.output_buffer.append(f" - {name}")
|
||||||
|
else:
|
||||||
|
self.state.output_buffer.append("No effects registered")
|
||||||
|
|
||||||
|
# List stage categories and their types
|
||||||
|
categories = StageRegistry.list_categories()
|
||||||
|
if categories:
|
||||||
|
self.state.output_buffer.append("")
|
||||||
|
self.state.output_buffer.append("Stage categories:")
|
||||||
|
for category in sorted(categories):
|
||||||
|
stages = StageRegistry.list(category)
|
||||||
|
if stages:
|
||||||
|
self.state.output_buffer.append(f" {category}:")
|
||||||
|
for stage_name in sorted(stages):
|
||||||
|
self.state.output_buffer.append(f" - {stage_name}")
|
||||||
|
except Exception as e:
|
||||||
|
self.state.output_buffer.append(f"Error listing available types: {e}")
|
||||||
|
|
||||||
|
def _cmd_effect(self, args: list[str], ctx: EffectContext | None):
|
||||||
|
"""Toggle effect on/off."""
|
||||||
|
if len(args) < 2:
|
||||||
|
self.state.output_buffer.append("Usage: effect <name> <on|off>")
|
||||||
|
return
|
||||||
|
|
||||||
|
effect_name = args[0]
|
||||||
|
state = args[1].lower()
|
||||||
|
|
||||||
|
if state not in ("on", "off"):
|
||||||
|
self.state.output_buffer.append("State must be 'on' or 'off'")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Emit event to toggle effect
|
||||||
|
enabled = state == "on"
|
||||||
|
self.state.output_buffer.append(f"Effect '{effect_name}' set to {state}")
|
||||||
|
|
||||||
|
# Store command for external handling
|
||||||
|
self._pending_command = {
|
||||||
|
"action": "enable_stage" if enabled else "disable_stage",
|
||||||
|
"stage": effect_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _cmd_param(self, args: list[str], ctx: EffectContext | None):
|
||||||
|
"""Set effect parameter."""
|
||||||
|
if len(args) < 3:
|
||||||
|
self.state.output_buffer.append("Usage: param <effect> <param> <value>")
|
||||||
|
return
|
||||||
|
|
||||||
|
effect_name = args[0]
|
||||||
|
param_name = args[1]
|
||||||
|
try:
|
||||||
|
param_value = float(args[2])
|
||||||
|
except ValueError:
|
||||||
|
self.state.output_buffer.append("Value must be a number")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.state.output_buffer.append(
|
||||||
|
f"Setting {effect_name}.{param_name} = {param_value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store command for external handling
|
||||||
|
self._pending_command = {
|
||||||
|
"action": "adjust_param",
|
||||||
|
"stage": effect_name,
|
||||||
|
"param": param_name,
|
||||||
|
"delta": param_value, # Note: This sets absolute value, need adjustment
|
||||||
|
}
|
||||||
|
|
||||||
|
def _cmd_pipeline(self, ctx: EffectContext | None):
|
||||||
|
"""Show current pipeline order."""
|
||||||
|
if ctx:
|
||||||
|
pipeline_order = ctx.get_state("pipeline_order")
|
||||||
|
if pipeline_order:
|
||||||
|
self.state.output_buffer.append(
|
||||||
|
"Pipeline: " + " → ".join(pipeline_order)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.state.output_buffer.append("Pipeline information not available")
|
||||||
|
else:
|
||||||
|
self.state.output_buffer.append("No context available")
|
||||||
|
|
||||||
|
def _cmd_add_stage(self, args: list[str]):
|
||||||
|
"""Add a new stage to the pipeline."""
|
||||||
|
if len(args) < 2:
|
||||||
|
self.state.output_buffer.append("Usage: add_stage <name> <type>")
|
||||||
|
return
|
||||||
|
|
||||||
|
stage_name = args[0]
|
||||||
|
stage_type = args[1]
|
||||||
|
self.state.output_buffer.append(
|
||||||
|
f"Adding stage '{stage_name}' of type '{stage_type}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store command for external handling
|
||||||
|
self._pending_command = {
|
||||||
|
"action": "add_stage",
|
||||||
|
"stage": stage_name,
|
||||||
|
"stage_type": stage_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _cmd_remove_stage(self, args: list[str]):
|
||||||
|
"""Remove a stage from the pipeline."""
|
||||||
|
if len(args) < 1:
|
||||||
|
self.state.output_buffer.append("Usage: remove_stage <name>")
|
||||||
|
return
|
||||||
|
|
||||||
|
stage_name = args[0]
|
||||||
|
self.state.output_buffer.append(f"Removing stage '{stage_name}'")
|
||||||
|
|
||||||
|
# Store command for external handling
|
||||||
|
self._pending_command = {
|
||||||
|
"action": "remove_stage",
|
||||||
|
"stage": stage_name,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _cmd_swap_stages(self, args: list[str]):
|
||||||
|
"""Swap two stages in the pipeline."""
|
||||||
|
if len(args) < 2:
|
||||||
|
self.state.output_buffer.append("Usage: swap_stages <name1> <name2>")
|
||||||
|
return
|
||||||
|
|
||||||
|
stage1 = args[0]
|
||||||
|
stage2 = args[1]
|
||||||
|
self.state.output_buffer.append(f"Swapping stages '{stage1}' and '{stage2}'")
|
||||||
|
|
||||||
|
# Store command for external handling
|
||||||
|
self._pending_command = {
|
||||||
|
"action": "swap_stages",
|
||||||
|
"stage1": stage1,
|
||||||
|
"stage2": stage2,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _cmd_move_stage(self, args: list[str]):
|
||||||
|
"""Move a stage in the pipeline."""
|
||||||
|
if len(args) < 1:
|
||||||
|
self.state.output_buffer.append(
|
||||||
|
"Usage: move_stage <name> [after <stage>] [before <stage>]"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
stage_name = args[0]
|
||||||
|
after = None
|
||||||
|
before = None
|
||||||
|
|
||||||
|
# Parse optional after/before arguments
|
||||||
|
i = 1
|
||||||
|
while i < len(args):
|
||||||
|
if args[i] == "after" and i + 1 < len(args):
|
||||||
|
after = args[i + 1]
|
||||||
|
i += 2
|
||||||
|
elif args[i] == "before" and i + 1 < len(args):
|
||||||
|
before = args[i + 1]
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if after:
|
||||||
|
self.state.output_buffer.append(
|
||||||
|
f"Moving stage '{stage_name}' after '{after}'"
|
||||||
|
)
|
||||||
|
elif before:
|
||||||
|
self.state.output_buffer.append(
|
||||||
|
f"Moving stage '{stage_name}' before '{before}'"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.state.output_buffer.append(
|
||||||
|
"Usage: move_stage <name> [after <stage>] [before <stage>]"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store command for external handling
|
||||||
|
self._pending_command = {
|
||||||
|
"action": "move_stage",
|
||||||
|
"stage": stage_name,
|
||||||
|
"after": after,
|
||||||
|
"before": before,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_pending_command(self) -> dict | None:
|
||||||
|
"""Get and clear pending command for external handling."""
|
||||||
|
cmd = getattr(self, "_pending_command", None)
|
||||||
|
if cmd:
|
||||||
|
self._pending_command = None
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def navigate_history(self, direction: int) -> None:
|
||||||
|
"""Navigate command history (up/down)."""
|
||||||
|
if not self.state.command_history:
|
||||||
|
return
|
||||||
|
|
||||||
|
if direction > 0: # Down
|
||||||
|
self.state.history_index = min(
|
||||||
|
len(self.state.command_history), self.state.history_index + 1
|
||||||
|
)
|
||||||
|
else: # Up
|
||||||
|
self.state.history_index = max(0, self.state.history_index - 1)
|
||||||
|
|
||||||
|
if self.state.history_index < len(self.state.command_history):
|
||||||
|
self.state.current_command = self.state.command_history[
|
||||||
|
self.state.history_index
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
self.state.current_command = ""
|
||||||
|
|
||||||
|
def append_to_command(self, char: str) -> None:
|
||||||
|
"""Append character to current command."""
|
||||||
|
if len(char) == 1: # Single character
|
||||||
|
self.state.current_command += char
|
||||||
|
|
||||||
|
def backspace(self) -> None:
|
||||||
|
"""Remove last character from command."""
|
||||||
|
self.state.current_command = self.state.current_command[:-1]
|
||||||
|
|
||||||
|
def clear_command(self) -> None:
|
||||||
|
"""Clear current command."""
|
||||||
|
self.state.current_command = ""
|
||||||
|
|
||||||
|
def configure(self, config: EffectConfig) -> None:
|
||||||
|
"""Configure the effect."""
|
||||||
|
self.config = config
|
||||||
@@ -44,6 +44,11 @@ class PartialUpdate:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EffectContext:
|
class EffectContext:
|
||||||
|
"""Context passed to effect plugins during processing.
|
||||||
|
|
||||||
|
Contains terminal dimensions, camera state, frame info, and real-time sensor values.
|
||||||
|
"""
|
||||||
|
|
||||||
terminal_width: int
|
terminal_width: int
|
||||||
terminal_height: int
|
terminal_height: int
|
||||||
scroll_cam: int
|
scroll_cam: int
|
||||||
@@ -56,6 +61,26 @@ class EffectContext:
|
|||||||
items: list = field(default_factory=list)
|
items: list = field(default_factory=list)
|
||||||
_state: dict[str, Any] = field(default_factory=dict, repr=False)
|
_state: dict[str, Any] = field(default_factory=dict, repr=False)
|
||||||
|
|
||||||
|
def compute_entropy(self, effect_name: str, data: Any) -> float:
|
||||||
|
"""Compute entropy score for an effect based on its output.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
effect_name: Name of the effect
|
||||||
|
data: Processed buffer or effect-specific data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Entropy score 0.0-1.0 representing visual chaos
|
||||||
|
"""
|
||||||
|
# Default implementation: use effect name as seed for deterministic randomness
|
||||||
|
# Better implementations can analyze actual buffer content
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
data_str = str(data)[:100] if data else ""
|
||||||
|
hash_val = hashlib.md5(f"{effect_name}:{data_str}".encode()).hexdigest()
|
||||||
|
# Convert hash to float 0.0-1.0
|
||||||
|
entropy = int(hash_val[:8], 16) / 0xFFFFFFFF
|
||||||
|
return min(max(entropy, 0.0), 1.0)
|
||||||
|
|
||||||
def get_sensor_value(self, sensor_name: str) -> float | None:
|
def get_sensor_value(self, sensor_name: str) -> float | None:
|
||||||
"""Get a sensor value from context state.
|
"""Get a sensor value from context state.
|
||||||
|
|
||||||
@@ -75,11 +100,17 @@ class EffectContext:
|
|||||||
"""Get a state value from the context."""
|
"""Get a state value from the context."""
|
||||||
return self._state.get(key, default)
|
return self._state.get(key, default)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> dict[str, Any]:
|
||||||
|
"""Get the state dictionary for direct access by effects."""
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EffectConfig:
|
class EffectConfig:
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
intensity: float = 1.0
|
intensity: float = 1.0
|
||||||
|
entropy: float = 0.0 # Visual chaos metric (0.0 = calm, 1.0 = chaotic)
|
||||||
params: dict[str, Any] = field(default_factory=dict)
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
157
engine/fetch.py
157
engine/fetch.py
@@ -7,6 +7,7 @@ import json
|
|||||||
import pathlib
|
import pathlib
|
||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -17,54 +18,98 @@ from engine.filter import skip, strip_tags
|
|||||||
from engine.sources import FEEDS, POETRY_SOURCES
|
from engine.sources import FEEDS, POETRY_SOURCES
|
||||||
from engine.terminal import boot_ln
|
from engine.terminal import boot_ln
|
||||||
|
|
||||||
# Type alias for headline items
|
|
||||||
HeadlineTuple = tuple[str, str, str]
|
HeadlineTuple = tuple[str, str, str]
|
||||||
|
|
||||||
|
DEFAULT_MAX_WORKERS = 10
|
||||||
|
FAST_START_SOURCES = 5
|
||||||
|
FAST_START_TIMEOUT = 3
|
||||||
|
|
||||||
# ─── SINGLE FEED ──────────────────────────────────────────
|
|
||||||
def fetch_feed(url: str) -> Any | None:
|
def fetch_feed(url: str) -> tuple[str, Any] | tuple[None, None]:
|
||||||
"""Fetch and parse a single RSS feed URL."""
|
"""Fetch and parse a single RSS feed URL. Returns (url, feed) tuple."""
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
timeout = FAST_START_TIMEOUT if url in _fast_start_urls else config.FEED_TIMEOUT
|
||||||
return feedparser.parse(resp.read())
|
resp = urllib.request.urlopen(req, timeout=timeout)
|
||||||
|
return (url, feedparser.parse(resp.read()))
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return (url, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_feed(feed: Any, src: str) -> list[HeadlineTuple]:
|
||||||
|
"""Parse a feed and return list of headline tuples."""
|
||||||
|
items = []
|
||||||
|
if feed is None or (feed.bozo and not feed.entries):
|
||||||
|
return items
|
||||||
|
|
||||||
|
for e in feed.entries:
|
||||||
|
t = strip_tags(e.get("title", ""))
|
||||||
|
if not t or skip(t):
|
||||||
|
continue
|
||||||
|
pub = e.get("published_parsed") or e.get("updated_parsed")
|
||||||
|
try:
|
||||||
|
ts = datetime(*pub[:6]).strftime("%H:%M") if pub else "——:——"
|
||||||
|
except Exception:
|
||||||
|
ts = "——:——"
|
||||||
|
items.append((t, src, ts))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_all_fast() -> list[HeadlineTuple]:
|
||||||
|
"""Fetch only the first N sources for fast startup."""
|
||||||
|
global _fast_start_urls
|
||||||
|
_fast_start_urls = set(list(FEEDS.values())[:FAST_START_SOURCES])
|
||||||
|
|
||||||
|
items: list[HeadlineTuple] = []
|
||||||
|
with ThreadPoolExecutor(max_workers=FAST_START_SOURCES) as executor:
|
||||||
|
futures = {
|
||||||
|
executor.submit(fetch_feed, url): src
|
||||||
|
for src, url in list(FEEDS.items())[:FAST_START_SOURCES]
|
||||||
|
}
|
||||||
|
for future in as_completed(futures):
|
||||||
|
src = futures[future]
|
||||||
|
url, feed = future.result()
|
||||||
|
if feed is None or (feed.bozo and not feed.entries):
|
||||||
|
boot_ln(src, "DARK", False)
|
||||||
|
continue
|
||||||
|
parsed = _parse_feed(feed, src)
|
||||||
|
if parsed:
|
||||||
|
items.extend(parsed)
|
||||||
|
boot_ln(src, f"LINKED [{len(parsed)}]", True)
|
||||||
|
else:
|
||||||
|
boot_ln(src, "EMPTY", False)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
# ─── ALL RSS FEEDS ────────────────────────────────────────
|
|
||||||
def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
||||||
"""Fetch all RSS feeds and return items, linked count, failed count."""
|
"""Fetch all RSS feeds concurrently and return items, linked count, failed count."""
|
||||||
|
global _fast_start_urls
|
||||||
|
_fast_start_urls = set()
|
||||||
|
|
||||||
items: list[HeadlineTuple] = []
|
items: list[HeadlineTuple] = []
|
||||||
linked = failed = 0
|
linked = failed = 0
|
||||||
for src, url in FEEDS.items():
|
|
||||||
feed = fetch_feed(url)
|
with ThreadPoolExecutor(max_workers=DEFAULT_MAX_WORKERS) as executor:
|
||||||
if feed is None or (feed.bozo and not feed.entries):
|
futures = {executor.submit(fetch_feed, url): src for src, url in FEEDS.items()}
|
||||||
boot_ln(src, "DARK", False)
|
for future in as_completed(futures):
|
||||||
failed += 1
|
src = futures[future]
|
||||||
continue
|
url, feed = future.result()
|
||||||
n = 0
|
if feed is None or (feed.bozo and not feed.entries):
|
||||||
for e in feed.entries:
|
boot_ln(src, "DARK", False)
|
||||||
t = strip_tags(e.get("title", ""))
|
failed += 1
|
||||||
if not t or skip(t):
|
|
||||||
continue
|
continue
|
||||||
pub = e.get("published_parsed") or e.get("updated_parsed")
|
parsed = _parse_feed(feed, src)
|
||||||
try:
|
if parsed:
|
||||||
ts = datetime(*pub[:6]).strftime("%H:%M") if pub else "——:——"
|
items.extend(parsed)
|
||||||
except Exception:
|
boot_ln(src, f"LINKED [{len(parsed)}]", True)
|
||||||
ts = "——:——"
|
linked += 1
|
||||||
items.append((t, src, ts))
|
else:
|
||||||
n += 1
|
boot_ln(src, "EMPTY", False)
|
||||||
if n:
|
failed += 1
|
||||||
boot_ln(src, f"LINKED [{n}]", True)
|
|
||||||
linked += 1
|
|
||||||
else:
|
|
||||||
boot_ln(src, "EMPTY", False)
|
|
||||||
failed += 1
|
|
||||||
return items, linked, failed
|
return items, linked, failed
|
||||||
|
|
||||||
|
|
||||||
# ─── PROJECT GUTENBERG ────────────────────────────────────
|
|
||||||
def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
||||||
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
||||||
try:
|
try:
|
||||||
@@ -76,23 +121,21 @@ def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
|||||||
.replace("\r\n", "\n")
|
.replace("\r\n", "\n")
|
||||||
.replace("\r", "\n")
|
.replace("\r", "\n")
|
||||||
)
|
)
|
||||||
# Strip PG boilerplate
|
|
||||||
m = re.search(r"\*\*\*\s*START OF[^\n]*\n", text)
|
m = re.search(r"\*\*\*\s*START OF[^\n]*\n", text)
|
||||||
if m:
|
if m:
|
||||||
text = text[m.end() :]
|
text = text[m.end() :]
|
||||||
m = re.search(r"\*\*\*\s*END OF", text)
|
m = re.search(r"\*\*\*\s*END OF", text)
|
||||||
if m:
|
if m:
|
||||||
text = text[: m.start()]
|
text = text[: m.start()]
|
||||||
# Split on blank lines into stanzas/passages
|
|
||||||
blocks = re.split(r"\n{2,}", text.strip())
|
blocks = re.split(r"\n{2,}", text.strip())
|
||||||
items = []
|
items = []
|
||||||
for blk in blocks:
|
for blk in blocks:
|
||||||
blk = " ".join(blk.split()) # flatten to one line
|
blk = " ".join(blk.split())
|
||||||
if len(blk) < 20 or len(blk) > 280:
|
if len(blk) < 20 or len(blk) > 280:
|
||||||
continue
|
continue
|
||||||
if blk.isupper(): # skip all-caps headers
|
if blk.isupper():
|
||||||
continue
|
continue
|
||||||
if re.match(r"^[IVXLCDM]+\.?\s*$", blk): # roman numerals
|
if re.match(r"^[IVXLCDM]+\.?\s*$", blk):
|
||||||
continue
|
continue
|
||||||
items.append((blk, label, ""))
|
items.append((blk, label, ""))
|
||||||
return items
|
return items
|
||||||
@@ -100,28 +143,35 @@ def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def fetch_poetry():
|
def fetch_poetry() -> tuple[list[HeadlineTuple], int, int]:
|
||||||
"""Fetch all poetry/literature sources."""
|
"""Fetch all poetry/literature sources concurrently."""
|
||||||
items = []
|
items = []
|
||||||
linked = failed = 0
|
linked = failed = 0
|
||||||
for label, url in POETRY_SOURCES.items():
|
|
||||||
stanzas = _fetch_gutenberg(url, label)
|
with ThreadPoolExecutor(max_workers=DEFAULT_MAX_WORKERS) as executor:
|
||||||
if stanzas:
|
futures = {
|
||||||
boot_ln(label, f"LOADED [{len(stanzas)}]", True)
|
executor.submit(_fetch_gutenberg, url, label): label
|
||||||
items.extend(stanzas)
|
for label, url in POETRY_SOURCES.items()
|
||||||
linked += 1
|
}
|
||||||
else:
|
for future in as_completed(futures):
|
||||||
boot_ln(label, "DARK", False)
|
label = futures[future]
|
||||||
failed += 1
|
stanzas = future.result()
|
||||||
|
if stanzas:
|
||||||
|
boot_ln(label, f"LOADED [{len(stanzas)}]", True)
|
||||||
|
items.extend(stanzas)
|
||||||
|
linked += 1
|
||||||
|
else:
|
||||||
|
boot_ln(label, "DARK", False)
|
||||||
|
failed += 1
|
||||||
|
|
||||||
return items, linked, failed
|
return items, linked, failed
|
||||||
|
|
||||||
|
|
||||||
# ─── CACHE ────────────────────────────────────────────────
|
_cache_dir = pathlib.Path(__file__).resolve().parent / "fixtures"
|
||||||
_CACHE_DIR = pathlib.Path(__file__).resolve().parent.parent
|
|
||||||
|
|
||||||
|
|
||||||
def _cache_path():
|
def _cache_path():
|
||||||
return _CACHE_DIR / f".mainline_cache_{config.MODE}.json"
|
return _cache_dir / "headlines.json"
|
||||||
|
|
||||||
|
|
||||||
def load_cache():
|
def load_cache():
|
||||||
@@ -143,3 +193,6 @@ def save_cache(items):
|
|||||||
_cache_path().write_text(json.dumps({"items": items}))
|
_cache_path().write_text(json.dumps({"items": items}))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_fast_start_urls: set = set()
|
||||||
|
|||||||
90
engine/figment_render.py
Normal file
90
engine/figment_render.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
SVG to half-block terminal art rasterization.
|
||||||
|
|
||||||
|
Pipeline: SVG -> cairosvg -> PIL -> greyscale threshold -> half-block encode.
|
||||||
|
Follows the same pixel-pair approach as engine/render.py for OTF fonts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
# cairocffi (used by cairosvg) calls dlopen() to find the Cairo C library.
|
||||||
|
# On macOS with Homebrew, Cairo lives in /opt/homebrew/lib (Apple Silicon) or
|
||||||
|
# /usr/local/lib (Intel), which are not in dyld's default search path.
|
||||||
|
# Setting DYLD_LIBRARY_PATH before the import directs dlopen() to those paths.
|
||||||
|
if sys.platform == "darwin" and not os.environ.get("DYLD_LIBRARY_PATH"):
|
||||||
|
for _brew_lib in ("/opt/homebrew/lib", "/usr/local/lib"):
|
||||||
|
if os.path.exists(os.path.join(_brew_lib, "libcairo.2.dylib")):
|
||||||
|
os.environ["DYLD_LIBRARY_PATH"] = _brew_lib
|
||||||
|
break
|
||||||
|
|
||||||
|
import cairosvg
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
_cache: dict[tuple[str, int, int], list[str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
|
||||||
|
"""Convert SVG file to list of half-block terminal rows (uncolored).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
svg_path: Path to SVG file.
|
||||||
|
width: Target terminal width in columns.
|
||||||
|
height: Target terminal height in rows.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of strings, one per terminal row, containing block characters.
|
||||||
|
"""
|
||||||
|
cache_key = (svg_path, width, height)
|
||||||
|
if cache_key in _cache:
|
||||||
|
return _cache[cache_key]
|
||||||
|
|
||||||
|
# SVG -> PNG in memory
|
||||||
|
png_bytes = cairosvg.svg2png(
|
||||||
|
url=svg_path,
|
||||||
|
output_width=width,
|
||||||
|
output_height=height * 2, # 2 pixel rows per terminal row
|
||||||
|
)
|
||||||
|
|
||||||
|
# PNG -> greyscale PIL image
|
||||||
|
# Composite RGBA onto white background so transparent areas become white (255)
|
||||||
|
# and drawn pixels retain their luminance values.
|
||||||
|
img_rgba = Image.open(BytesIO(png_bytes)).convert("RGBA")
|
||||||
|
img_rgba = img_rgba.resize((width, height * 2), Image.Resampling.LANCZOS)
|
||||||
|
background = Image.new("RGBA", img_rgba.size, (255, 255, 255, 255))
|
||||||
|
background.paste(img_rgba, mask=img_rgba.split()[3])
|
||||||
|
img = background.convert("L")
|
||||||
|
|
||||||
|
data = img.tobytes()
|
||||||
|
pix_w = width
|
||||||
|
pix_h = height * 2
|
||||||
|
# White (255) = empty space, dark (< threshold) = filled pixel
|
||||||
|
threshold = 128
|
||||||
|
|
||||||
|
# Half-block encode: walk pixel pairs
|
||||||
|
rows: list[str] = []
|
||||||
|
for y in range(0, pix_h, 2):
|
||||||
|
row: list[str] = []
|
||||||
|
for x in range(pix_w):
|
||||||
|
top = data[y * pix_w + x] < threshold
|
||||||
|
bot = data[(y + 1) * pix_w + x] < threshold if y + 1 < pix_h else False
|
||||||
|
if top and bot:
|
||||||
|
row.append("█")
|
||||||
|
elif top:
|
||||||
|
row.append("▀")
|
||||||
|
elif bot:
|
||||||
|
row.append("▄")
|
||||||
|
else:
|
||||||
|
row.append(" ")
|
||||||
|
rows.append("".join(row))
|
||||||
|
|
||||||
|
_cache[cache_key] = rows
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cache() -> None:
|
||||||
|
"""Clear the rasterization cache (e.g., on terminal resize)."""
|
||||||
|
_cache.clear()
|
||||||
36
engine/figment_trigger.py
Normal file
36
engine/figment_trigger.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Figment trigger protocol and command types.
|
||||||
|
|
||||||
|
Defines the extensible input abstraction for triggering figment displays
|
||||||
|
from any control surface (ntfy, MQTT, serial, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
class FigmentAction(Enum):
|
||||||
|
TRIGGER = "trigger"
|
||||||
|
SET_INTENSITY = "set_intensity"
|
||||||
|
SET_INTERVAL = "set_interval"
|
||||||
|
SET_COLOR = "set_color"
|
||||||
|
STOP = "stop"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FigmentCommand:
|
||||||
|
action: FigmentAction
|
||||||
|
value: float | str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FigmentTrigger(Protocol):
|
||||||
|
"""Protocol for figment trigger sources.
|
||||||
|
|
||||||
|
Any input source (ntfy, MQTT, serial) can implement this
|
||||||
|
to trigger and control figment displays.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def poll(self) -> FigmentCommand | None: ...
|
||||||
1
engine/fixtures/headlines.json
Normal file
1
engine/fixtures/headlines.json
Normal file
File diff suppressed because one or more lines are too long
@@ -50,8 +50,7 @@ from engine.pipeline.presets import (
|
|||||||
FIREHOSE_PRESET,
|
FIREHOSE_PRESET,
|
||||||
PIPELINE_VIZ_PRESET,
|
PIPELINE_VIZ_PRESET,
|
||||||
POETRY_PRESET,
|
POETRY_PRESET,
|
||||||
PRESETS,
|
UI_PRESET,
|
||||||
SIXEL_PRESET,
|
|
||||||
WEBSOCKET_PRESET,
|
WEBSOCKET_PRESET,
|
||||||
PipelinePreset,
|
PipelinePreset,
|
||||||
create_preset_from_params,
|
create_preset_from_params,
|
||||||
@@ -92,8 +91,8 @@ __all__ = [
|
|||||||
"POETRY_PRESET",
|
"POETRY_PRESET",
|
||||||
"PIPELINE_VIZ_PRESET",
|
"PIPELINE_VIZ_PRESET",
|
||||||
"WEBSOCKET_PRESET",
|
"WEBSOCKET_PRESET",
|
||||||
"SIXEL_PRESET",
|
|
||||||
"FIREHOSE_PRESET",
|
"FIREHOSE_PRESET",
|
||||||
|
"UI_PRESET",
|
||||||
"get_preset",
|
"get_preset",
|
||||||
"list_presets",
|
"list_presets",
|
||||||
"create_preset_from_params",
|
"create_preset_from_params",
|
||||||
|
|||||||
@@ -3,843 +3,48 @@ Stage adapters - Bridge existing components to the Stage interface.
|
|||||||
|
|
||||||
This module provides adapters that wrap existing components
|
This module provides adapters that wrap existing components
|
||||||
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
|
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
|
||||||
|
|
||||||
|
DEPRECATED: This file is now a compatibility wrapper.
|
||||||
|
Use `engine.pipeline.adapters` package instead.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
# Re-export from the new package structure for backward compatibility
|
||||||
|
from engine.pipeline.adapters import (
|
||||||
from engine.pipeline.core import PipelineContext, Stage
|
# Adapter classes
|
||||||
|
CameraStage,
|
||||||
|
CanvasStage,
|
||||||
class EffectPluginStage(Stage):
|
DataSourceStage,
|
||||||
"""Adapter wrapping EffectPlugin as a Stage."""
|
DisplayStage,
|
||||||
|
EffectPluginStage,
|
||||||
def __init__(self, effect_plugin, name: str = "effect"):
|
FontStage,
|
||||||
self._effect = effect_plugin
|
ImageToTextStage,
|
||||||
self.name = name
|
PassthroughStage,
|
||||||
self.category = "effect"
|
SourceItemsToBufferStage,
|
||||||
self.optional = False
|
ViewportFilterStage,
|
||||||
|
# Factory functions
|
||||||
@property
|
create_stage_from_camera,
|
||||||
def stage_type(self) -> str:
|
create_stage_from_display,
|
||||||
"""Return stage_type based on effect name.
|
create_stage_from_effect,
|
||||||
|
create_stage_from_font,
|
||||||
HUD effects are overlays.
|
create_stage_from_source,
|
||||||
"""
|
)
|
||||||
if self.name == "hud":
|
|
||||||
return "overlay"
|
__all__ = [
|
||||||
return self.category
|
# Adapter classes
|
||||||
|
"EffectPluginStage",
|
||||||
@property
|
"DisplayStage",
|
||||||
def render_order(self) -> int:
|
"DataSourceStage",
|
||||||
"""Return render_order based on effect type.
|
"PassthroughStage",
|
||||||
|
"SourceItemsToBufferStage",
|
||||||
HUD effects have high render_order to appear on top.
|
"CameraStage",
|
||||||
"""
|
"ViewportFilterStage",
|
||||||
if self.name == "hud":
|
"FontStage",
|
||||||
return 100 # High order for overlays
|
"ImageToTextStage",
|
||||||
return 0
|
"CanvasStage",
|
||||||
|
# Factory functions
|
||||||
@property
|
"create_stage_from_display",
|
||||||
def is_overlay(self) -> bool:
|
"create_stage_from_effect",
|
||||||
"""Return True for HUD effects.
|
"create_stage_from_source",
|
||||||
|
"create_stage_from_camera",
|
||||||
HUD is an overlay - it composes on top of the buffer
|
"create_stage_from_font",
|
||||||
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()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
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 {"render.output"} # Display needs rendered content
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER} # Display consumes rendered text
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.NONE} # Display is a terminal stage (no output)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.NONE} # Sources don't take input
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
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"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
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"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
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 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 {"render.output"} # Depend on rendered output from font or render stage
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER} # Camera works on rendered text
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
||||||
"""Apply camera transformation to data."""
|
|
||||||
if data is None or (isinstance(data, list) and len(data) == 0):
|
|
||||||
return data
|
|
||||||
if hasattr(self._camera, "apply"):
|
|
||||||
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
|
||||||
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
|
||||||
buffer_height = len(data) if isinstance(data, list) else 0
|
|
||||||
|
|
||||||
# Get global layout height for canvas (enables full scrolling range)
|
|
||||||
total_layout_height = ctx.get("total_layout_height", buffer_height)
|
|
||||||
|
|
||||||
# Preserve camera's configured canvas width, but ensure it's at least viewport_width
|
|
||||||
# This allows horizontal/omni/floating/bounce cameras to scroll properly
|
|
||||||
canvas_width = max(
|
|
||||||
viewport_width, getattr(self._camera, "canvas_width", viewport_width)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update camera's viewport dimensions so it knows its actual bounds
|
|
||||||
# Set canvas size to achieve desired viewport (viewport = canvas / zoom)
|
|
||||||
if hasattr(self._camera, "set_canvas_size"):
|
|
||||||
self._camera.set_canvas_size(
|
|
||||||
width=int(viewport_width * self._camera.zoom),
|
|
||||||
height=int(viewport_height * self._camera.zoom),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set canvas to full layout height so camera can scroll through all content
|
|
||||||
self._camera.set_canvas_size(width=canvas_width, height=total_layout_height)
|
|
||||||
|
|
||||||
# Update camera position (scroll) - uses global canvas for clamping
|
|
||||||
if hasattr(self._camera, "update"):
|
|
||||||
self._camera.update(1 / 60)
|
|
||||||
|
|
||||||
# Store camera_y in context for ViewportFilterStage (global y position)
|
|
||||||
ctx.set("camera_y", self._camera.y)
|
|
||||||
|
|
||||||
# Apply camera viewport slicing to the partial buffer
|
|
||||||
# The buffer starts at render_offset_y in global coordinates
|
|
||||||
render_offset_y = ctx.get("render_offset_y", 0)
|
|
||||||
|
|
||||||
# Temporarily shift camera to local buffer coordinates for apply()
|
|
||||||
real_y = self._camera.y
|
|
||||||
local_y = max(0, real_y - render_offset_y)
|
|
||||||
|
|
||||||
# Temporarily shrink canvas to local buffer size so apply() works correctly
|
|
||||||
self._camera.set_canvas_size(width=canvas_width, height=buffer_height)
|
|
||||||
self._camera.y = local_y
|
|
||||||
|
|
||||||
# Apply slicing
|
|
||||||
result = self._camera.apply(data, viewport_width, viewport_height)
|
|
||||||
|
|
||||||
# Restore global canvas and camera position for next frame
|
|
||||||
self._camera.set_canvas_size(width=canvas_width, height=total_layout_height)
|
|
||||||
self._camera.y = real_y
|
|
||||||
|
|
||||||
return result
|
|
||||||
return data
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
if hasattr(self._camera, "reset"):
|
|
||||||
self._camera.reset()
|
|
||||||
|
|
||||||
|
|
||||||
class ViewportFilterStage(Stage):
|
|
||||||
"""Stage that limits items based on layout calculation.
|
|
||||||
|
|
||||||
Computes cumulative y-offsets for all items using cheap height estimation,
|
|
||||||
then returns only items that overlap the camera's viewport window.
|
|
||||||
This prevents FontStage from rendering thousands of items when only a few
|
|
||||||
are visible, while still allowing camera scrolling through all content.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, name: str = "viewport-filter"):
|
|
||||||
self.name = name
|
|
||||||
self.category = "filter"
|
|
||||||
self.optional = False
|
|
||||||
self._cached_count = 0
|
|
||||||
self._layout: list[tuple[int, int]] = []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stage_type(self) -> str:
|
|
||||||
return "filter"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self) -> set[str]:
|
|
||||||
return {f"filter.{self.name}"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self) -> set[str]:
|
|
||||||
return {"source"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
||||||
"""Filter items based on layout and camera position."""
|
|
||||||
if data is None or not isinstance(data, list):
|
|
||||||
return data
|
|
||||||
|
|
||||||
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
|
||||||
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
|
||||||
camera_y = ctx.get("camera_y", 0)
|
|
||||||
|
|
||||||
# Recompute layout when item count OR viewport width changes
|
|
||||||
cached_width = getattr(self, "_cached_width", None)
|
|
||||||
if len(data) != self._cached_count or cached_width != viewport_width:
|
|
||||||
self._layout = []
|
|
||||||
y = 0
|
|
||||||
from engine.render.blocks import estimate_block_height
|
|
||||||
|
|
||||||
for item in data:
|
|
||||||
if hasattr(item, "content"):
|
|
||||||
title = item.content
|
|
||||||
elif isinstance(item, tuple):
|
|
||||||
title = str(item[0]) if item else ""
|
|
||||||
else:
|
|
||||||
title = str(item)
|
|
||||||
h = estimate_block_height(title, viewport_width)
|
|
||||||
self._layout.append((y, h))
|
|
||||||
y += h
|
|
||||||
self._cached_count = len(data)
|
|
||||||
self._cached_width = viewport_width
|
|
||||||
|
|
||||||
# Find items visible in [camera_y - buffer, camera_y + viewport_height + buffer]
|
|
||||||
buffer_zone = viewport_height
|
|
||||||
vis_start = max(0, camera_y - buffer_zone)
|
|
||||||
vis_end = camera_y + viewport_height + buffer_zone
|
|
||||||
|
|
||||||
visible_items = []
|
|
||||||
render_offset_y = 0
|
|
||||||
first_visible_found = False
|
|
||||||
for i, (start_y, height) in enumerate(self._layout):
|
|
||||||
item_end = start_y + height
|
|
||||||
if item_end > vis_start and start_y < vis_end:
|
|
||||||
if not first_visible_found:
|
|
||||||
render_offset_y = start_y
|
|
||||||
first_visible_found = True
|
|
||||||
visible_items.append(data[i])
|
|
||||||
|
|
||||||
# Compute total layout height for the canvas
|
|
||||||
total_layout_height = 0
|
|
||||||
if self._layout:
|
|
||||||
last_start, last_height = self._layout[-1]
|
|
||||||
total_layout_height = last_start + last_height
|
|
||||||
|
|
||||||
# Store metadata for CameraStage
|
|
||||||
ctx.set("render_offset_y", render_offset_y)
|
|
||||||
ctx.set("total_layout_height", total_layout_height)
|
|
||||||
|
|
||||||
# Always return at least one item to avoid empty buffer errors
|
|
||||||
return visible_items if visible_items else data[:1]
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
self._render_cache: dict[tuple[str, str, str, int], list[str]] = {}
|
|
||||||
|
|
||||||
@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"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
cache_key = (title, src, ts, w)
|
|
||||||
if cache_key in self._render_cache:
|
|
||||||
result.extend(self._render_cache[cache_key])
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
block_lines, color_code, meta_idx = make_block(title, src, ts, w)
|
|
||||||
self._render_cache[cache_key] = block_lines
|
|
||||||
result.extend(block_lines)
|
|
||||||
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 inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.PIL_IMAGE} # Accepts PIL Image objects or ImageItem
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self) -> set[str]:
|
|
||||||
return {f"transform.{self.name}", "render.output"}
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|||||||
55
engine/pipeline/adapters/__init__.py
Normal file
55
engine/pipeline/adapters/__init__.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Stage adapters - Bridge existing components to the Stage interface.
|
||||||
|
|
||||||
|
This module provides adapters that wrap existing components
|
||||||
|
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .camera import CameraClockStage, CameraStage
|
||||||
|
from .data_source import DataSourceStage, PassthroughStage, SourceItemsToBufferStage
|
||||||
|
from .display import DisplayStage
|
||||||
|
from .effect_plugin import EffectPluginStage
|
||||||
|
from .factory import (
|
||||||
|
create_stage_from_camera,
|
||||||
|
create_stage_from_display,
|
||||||
|
create_stage_from_effect,
|
||||||
|
create_stage_from_font,
|
||||||
|
create_stage_from_source,
|
||||||
|
)
|
||||||
|
from .message_overlay import MessageOverlayConfig, MessageOverlayStage
|
||||||
|
from .positioning import (
|
||||||
|
PositioningMode,
|
||||||
|
PositionStage,
|
||||||
|
create_position_stage,
|
||||||
|
)
|
||||||
|
from .transform import (
|
||||||
|
CanvasStage,
|
||||||
|
FontStage,
|
||||||
|
ImageToTextStage,
|
||||||
|
ViewportFilterStage,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Adapter classes
|
||||||
|
"EffectPluginStage",
|
||||||
|
"DisplayStage",
|
||||||
|
"DataSourceStage",
|
||||||
|
"PassthroughStage",
|
||||||
|
"SourceItemsToBufferStage",
|
||||||
|
"CameraStage",
|
||||||
|
"CameraClockStage",
|
||||||
|
"ViewportFilterStage",
|
||||||
|
"FontStage",
|
||||||
|
"ImageToTextStage",
|
||||||
|
"CanvasStage",
|
||||||
|
"MessageOverlayStage",
|
||||||
|
"MessageOverlayConfig",
|
||||||
|
"PositionStage",
|
||||||
|
"PositioningMode",
|
||||||
|
# Factory functions
|
||||||
|
"create_stage_from_display",
|
||||||
|
"create_stage_from_effect",
|
||||||
|
"create_stage_from_source",
|
||||||
|
"create_stage_from_camera",
|
||||||
|
"create_stage_from_font",
|
||||||
|
"create_position_stage",
|
||||||
|
]
|
||||||
219
engine/pipeline/adapters/camera.py
Normal file
219
engine/pipeline/adapters/camera.py
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
"""Adapter for camera stage."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
class CameraClockStage(Stage):
|
||||||
|
"""Per-frame clock stage that updates camera state.
|
||||||
|
|
||||||
|
This stage runs once per frame and updates the camera's internal state
|
||||||
|
(position, time). It makes camera_y/camera_x available to subsequent
|
||||||
|
stages via the pipeline context.
|
||||||
|
|
||||||
|
Unlike other stages, this is a pure clock stage and doesn't process
|
||||||
|
data - it just updates camera state and passes data through unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, camera, name: str = "camera-clock"):
|
||||||
|
self._camera = camera
|
||||||
|
self.name = name
|
||||||
|
self.category = "camera"
|
||||||
|
self.optional = False
|
||||||
|
self._last_frame_time: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
return "camera"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
# Provides camera state info only
|
||||||
|
# NOTE: Do NOT provide "source" as it conflicts with viewport_filter's "source.filtered"
|
||||||
|
return {"camera.state"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
# Clock stage - no dependencies (updates every frame regardless of data flow)
|
||||||
|
return set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
# Accept any data type - this is a pass-through stage
|
||||||
|
return {DataType.ANY}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
# Pass through whatever was received
|
||||||
|
return {DataType.ANY}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Update camera state and pass data through.
|
||||||
|
|
||||||
|
This stage updates the camera's internal state (position, time) and
|
||||||
|
makes the updated camera_y/camera_x available to subsequent stages
|
||||||
|
via the pipeline context.
|
||||||
|
|
||||||
|
The data is passed through unchanged - this stage only updates
|
||||||
|
camera state, it doesn't transform the data.
|
||||||
|
"""
|
||||||
|
if data is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Update camera speed from params if explicitly set (for dynamic modulation)
|
||||||
|
# Only update if camera_speed in params differs from the default (1.0)
|
||||||
|
# This preserves camera speed set during construction
|
||||||
|
if (
|
||||||
|
ctx.params
|
||||||
|
and hasattr(ctx.params, "camera_speed")
|
||||||
|
and ctx.params.camera_speed != 1.0
|
||||||
|
):
|
||||||
|
self._camera.set_speed(ctx.params.camera_speed)
|
||||||
|
|
||||||
|
current_time = time.perf_counter()
|
||||||
|
dt = 0.0
|
||||||
|
if self._last_frame_time is not None:
|
||||||
|
dt = current_time - self._last_frame_time
|
||||||
|
self._camera.update(dt)
|
||||||
|
self._last_frame_time = current_time
|
||||||
|
|
||||||
|
# Update context with current camera position
|
||||||
|
ctx.set_state("camera_y", self._camera.y)
|
||||||
|
ctx.set_state("camera_x", self._camera.x)
|
||||||
|
|
||||||
|
# Pass data through unchanged
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class CameraStage(Stage):
|
||||||
|
"""Adapter wrapping Camera as a Stage.
|
||||||
|
|
||||||
|
This stage applies camera viewport transformation to the rendered buffer.
|
||||||
|
Camera state updates are handled by CameraClockStage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, camera, name: str = "vertical"):
|
||||||
|
self._camera = camera
|
||||||
|
self.name = name
|
||||||
|
self.category = "camera"
|
||||||
|
self.optional = True
|
||||||
|
self._last_frame_time: float | None = None
|
||||||
|
|
||||||
|
def save_state(self) -> dict[str, Any]:
|
||||||
|
"""Save camera state for restoration after pipeline rebuild.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing camera state that can be restored
|
||||||
|
"""
|
||||||
|
state = {
|
||||||
|
"x": self._camera.x,
|
||||||
|
"y": self._camera.y,
|
||||||
|
"mode": self._camera.mode.value
|
||||||
|
if hasattr(self._camera.mode, "value")
|
||||||
|
else self._camera.mode,
|
||||||
|
"speed": self._camera.speed,
|
||||||
|
"zoom": self._camera.zoom,
|
||||||
|
"canvas_width": self._camera.canvas_width,
|
||||||
|
"canvas_height": self._camera.canvas_height,
|
||||||
|
"_x_float": getattr(self._camera, "_x_float", 0.0),
|
||||||
|
"_y_float": getattr(self._camera, "_y_float", 0.0),
|
||||||
|
"_time": getattr(self._camera, "_time", 0.0),
|
||||||
|
}
|
||||||
|
# Save radial camera state if present
|
||||||
|
if hasattr(self._camera, "_r_float"):
|
||||||
|
state["_r_float"] = self._camera._r_float
|
||||||
|
if hasattr(self._camera, "_theta_float"):
|
||||||
|
state["_theta_float"] = self._camera._theta_float
|
||||||
|
if hasattr(self._camera, "_radial_input"):
|
||||||
|
state["_radial_input"] = self._camera._radial_input
|
||||||
|
return state
|
||||||
|
|
||||||
|
def restore_state(self, state: dict[str, Any]) -> None:
|
||||||
|
"""Restore camera state from saved state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Dictionary containing camera state from save_state()
|
||||||
|
"""
|
||||||
|
from engine.camera import CameraMode
|
||||||
|
|
||||||
|
self._camera.x = state.get("x", 0)
|
||||||
|
self._camera.y = state.get("y", 0)
|
||||||
|
|
||||||
|
# Restore mode - handle both enum value and direct enum
|
||||||
|
mode_value = state.get("mode", 0)
|
||||||
|
if isinstance(mode_value, int):
|
||||||
|
self._camera.mode = CameraMode(mode_value)
|
||||||
|
else:
|
||||||
|
self._camera.mode = mode_value
|
||||||
|
|
||||||
|
self._camera.speed = state.get("speed", 1.0)
|
||||||
|
self._camera.zoom = state.get("zoom", 1.0)
|
||||||
|
self._camera.canvas_width = state.get("canvas_width", 200)
|
||||||
|
self._camera.canvas_height = state.get("canvas_height", 200)
|
||||||
|
|
||||||
|
# Restore internal state
|
||||||
|
if hasattr(self._camera, "_x_float"):
|
||||||
|
self._camera._x_float = state.get("_x_float", 0.0)
|
||||||
|
if hasattr(self._camera, "_y_float"):
|
||||||
|
self._camera._y_float = state.get("_y_float", 0.0)
|
||||||
|
if hasattr(self._camera, "_time"):
|
||||||
|
self._camera._time = state.get("_time", 0.0)
|
||||||
|
|
||||||
|
# Restore radial camera state if present
|
||||||
|
if hasattr(self._camera, "_r_float"):
|
||||||
|
self._camera._r_float = state.get("_r_float", 0.0)
|
||||||
|
if hasattr(self._camera, "_theta_float"):
|
||||||
|
self._camera._theta_float = state.get("_theta_float", 0.0)
|
||||||
|
if hasattr(self._camera, "_radial_input"):
|
||||||
|
self._camera._radial_input = state.get("_radial_input", 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
return "camera"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"camera"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return {"render.output", "camera.state"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Apply camera transformation to items."""
|
||||||
|
if data is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Camera state is updated by CameraClockStage
|
||||||
|
# We only apply the viewport transformation here
|
||||||
|
|
||||||
|
if hasattr(self._camera, "apply"):
|
||||||
|
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||||
|
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
||||||
|
|
||||||
|
# Use filtered camera position if available (from ViewportFilterStage)
|
||||||
|
# This handles the case where the buffer has been filtered and starts at row 0
|
||||||
|
filtered_camera_y = ctx.get("camera_y", self._camera.y)
|
||||||
|
|
||||||
|
# Temporarily adjust camera position for filtering
|
||||||
|
original_y = self._camera.y
|
||||||
|
self._camera.y = filtered_camera_y
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self._camera.apply(data, viewport_width, viewport_height)
|
||||||
|
finally:
|
||||||
|
# Restore original camera position
|
||||||
|
self._camera.y = original_y
|
||||||
|
|
||||||
|
return result
|
||||||
|
return data
|
||||||
143
engine/pipeline/adapters/data_source.py
Normal file
143
engine/pipeline/adapters/data_source.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
Stage adapters - Bridge existing components to the Stage interface.
|
||||||
|
|
||||||
|
This module provides adapters that wrap existing components
|
||||||
|
(DataSource) as Stage implementations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.data_sources import SourceItem
|
||||||
|
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.NONE} # Sources don't take input
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
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
|
||||||
|
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)]
|
||||||
108
engine/pipeline/adapters/display.py
Normal file
108
engine/pipeline/adapters/display.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Adapter wrapping Display as a Stage."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.pipeline.core import PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
class DisplayStage(Stage):
|
||||||
|
"""Adapter wrapping Display as a Stage."""
|
||||||
|
|
||||||
|
def __init__(self, display, name: str = "terminal", positioning: str = "mixed"):
|
||||||
|
self._display = display
|
||||||
|
self.name = name
|
||||||
|
self.category = "display"
|
||||||
|
self.optional = False
|
||||||
|
self._initialized = False
|
||||||
|
self._init_width = 80
|
||||||
|
self._init_height = 24
|
||||||
|
self._positioning = positioning
|
||||||
|
|
||||||
|
def save_state(self) -> dict[str, Any]:
|
||||||
|
"""Save display state for restoration after pipeline rebuild.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing display state that can be restored
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"initialized": self._initialized,
|
||||||
|
"init_width": self._init_width,
|
||||||
|
"init_height": self._init_height,
|
||||||
|
"width": getattr(self._display, "width", 80),
|
||||||
|
"height": getattr(self._display, "height", 24),
|
||||||
|
}
|
||||||
|
|
||||||
|
def restore_state(self, state: dict[str, Any]) -> None:
|
||||||
|
"""Restore display state from saved state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Dictionary containing display state from save_state()
|
||||||
|
"""
|
||||||
|
self._initialized = state.get("initialized", False)
|
||||||
|
self._init_width = state.get("init_width", 80)
|
||||||
|
self._init_height = state.get("init_height", 24)
|
||||||
|
|
||||||
|
# Restore display dimensions if the display supports it
|
||||||
|
if hasattr(self._display, "width"):
|
||||||
|
self._display.width = state.get("width", 80)
|
||||||
|
if hasattr(self._display, "height"):
|
||||||
|
self._display.height = state.get("height", 24)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"display.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
# Display needs rendered content and camera transformation
|
||||||
|
return {"render.output", "camera"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.TEXT_BUFFER} # Display consumes rendered text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.NONE} # Display is a terminal stage (no output)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Try to reuse display if already initialized
|
||||||
|
reuse = self._initialized
|
||||||
|
result = self._display.init(w, h, reuse=reuse)
|
||||||
|
|
||||||
|
# Update initialization state
|
||||||
|
if result is not False:
|
||||||
|
self._initialized = True
|
||||||
|
self._init_width = w
|
||||||
|
self._init_height = h
|
||||||
|
|
||||||
|
return result is not False
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Output data to display."""
|
||||||
|
if data is not None:
|
||||||
|
# Check if positioning mode is specified in context params
|
||||||
|
positioning = self._positioning
|
||||||
|
if ctx and ctx.params and hasattr(ctx.params, "positioning"):
|
||||||
|
positioning = ctx.params.positioning
|
||||||
|
|
||||||
|
# Pass positioning to display if supported
|
||||||
|
if (
|
||||||
|
hasattr(self._display, "show")
|
||||||
|
and "positioning" in self._display.show.__code__.co_varnames
|
||||||
|
):
|
||||||
|
self._display.show(data, positioning=positioning)
|
||||||
|
else:
|
||||||
|
# Fallback for displays that don't support positioning parameter
|
||||||
|
self._display.show(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
self._display.cleanup()
|
||||||
124
engine/pipeline/adapters/effect_plugin.py
Normal file
124
engine/pipeline/adapters/effect_plugin.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""Adapter wrapping EffectPlugin as a Stage."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.pipeline.core import PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
class EffectPluginStage(Stage):
|
||||||
|
"""Adapter wrapping EffectPlugin as a Stage.
|
||||||
|
|
||||||
|
Supports capability-based dependencies through the dependencies parameter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
effect_plugin,
|
||||||
|
name: str = "effect",
|
||||||
|
dependencies: set[str] | None = None,
|
||||||
|
):
|
||||||
|
self._effect = effect_plugin
|
||||||
|
self.name = name
|
||||||
|
self.category = "effect"
|
||||||
|
self.optional = False
|
||||||
|
self._dependencies = dependencies or set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
"""Return stage_type based on effect name.
|
||||||
|
|
||||||
|
Overlay effects have stage_type "overlay".
|
||||||
|
"""
|
||||||
|
if self.is_overlay:
|
||||||
|
return "overlay"
|
||||||
|
return self.category
|
||||||
|
|
||||||
|
@property
|
||||||
|
def render_order(self) -> int:
|
||||||
|
"""Return render_order based on effect type.
|
||||||
|
|
||||||
|
Overlay effects have high render_order to appear on top.
|
||||||
|
"""
|
||||||
|
if self.is_overlay:
|
||||||
|
return 100 # High order for overlays
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_overlay(self) -> bool:
|
||||||
|
"""Return True for overlay effects.
|
||||||
|
|
||||||
|
Overlay effects compose on top of the buffer
|
||||||
|
rather than transforming it for the next stage.
|
||||||
|
"""
|
||||||
|
# Check if the effect has an is_overlay attribute that is explicitly True
|
||||||
|
# (not just any truthy value from a mock object)
|
||||||
|
if hasattr(self._effect, "is_overlay"):
|
||||||
|
effect_overlay = self._effect.is_overlay
|
||||||
|
# Only return True if it's explicitly set to True
|
||||||
|
if effect_overlay is True:
|
||||||
|
return True
|
||||||
|
return self.name == "hud"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {f"effect.{self.name}"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return self._dependencies
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
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"])
|
||||||
|
|
||||||
|
# Copy pipeline_order from PipelineContext services to EffectContext state
|
||||||
|
pipeline_order = ctx.get("pipeline_order")
|
||||||
|
if pipeline_order:
|
||||||
|
effect_ctx.set_state("pipeline_order", pipeline_order)
|
||||||
|
|
||||||
|
# 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)
|
||||||
38
engine/pipeline/adapters/factory.py
Normal file
38
engine/pipeline/adapters/factory.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Factory functions for creating stage instances."""
|
||||||
|
|
||||||
|
from engine.pipeline.adapters.camera import CameraStage
|
||||||
|
from engine.pipeline.adapters.data_source import DataSourceStage
|
||||||
|
from engine.pipeline.adapters.display import DisplayStage
|
||||||
|
from engine.pipeline.adapters.effect_plugin import EffectPluginStage
|
||||||
|
from engine.pipeline.adapters.transform import FontStage
|
||||||
|
|
||||||
|
|
||||||
|
def create_stage_from_display(display, name: str = "terminal") -> DisplayStage:
|
||||||
|
"""Create a DisplayStage from a display instance."""
|
||||||
|
return DisplayStage(display, name=name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_stage_from_effect(effect_plugin, name: str) -> EffectPluginStage:
|
||||||
|
"""Create an EffectPluginStage from an effect plugin."""
|
||||||
|
return EffectPluginStage(effect_plugin, name=name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_stage_from_source(data_source, name: str = "headlines") -> DataSourceStage:
|
||||||
|
"""Create a DataSourceStage from a data source."""
|
||||||
|
return DataSourceStage(data_source, name=name)
|
||||||
|
|
||||||
|
|
||||||
|
def create_stage_from_camera(camera, name: str = "vertical") -> CameraStage:
|
||||||
|
"""Create a CameraStage from a camera instance."""
|
||||||
|
return CameraStage(camera, name=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 with specified font configuration."""
|
||||||
|
# FontStage currently doesn't use these parameters but keeps them for compatibility
|
||||||
|
return FontStage(name=name)
|
||||||
165
engine/pipeline/adapters/frame_capture.py
Normal file
165
engine/pipeline/adapters/frame_capture.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""
|
||||||
|
Frame Capture Stage Adapter
|
||||||
|
|
||||||
|
Wraps pipeline stages to capture frames for animation report generation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.display.backends.animation_report import AnimationReportDisplay
|
||||||
|
from engine.pipeline.core import PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
class FrameCaptureStage(Stage):
|
||||||
|
"""
|
||||||
|
Wrapper stage that captures frames before and after a wrapped stage.
|
||||||
|
|
||||||
|
This allows generating animation reports showing how each stage
|
||||||
|
transforms the data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
wrapped_stage: Stage,
|
||||||
|
display: AnimationReportDisplay,
|
||||||
|
name: str | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize frame capture stage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wrapped_stage: The stage to wrap and capture frames from
|
||||||
|
display: The animation report display to send frames to
|
||||||
|
name: Optional name for this capture stage
|
||||||
|
"""
|
||||||
|
self._wrapped_stage = wrapped_stage
|
||||||
|
self._display = display
|
||||||
|
self.name = name or f"capture_{wrapped_stage.name}"
|
||||||
|
self.category = wrapped_stage.category
|
||||||
|
self.optional = wrapped_stage.optional
|
||||||
|
|
||||||
|
# Capture state
|
||||||
|
self._captured_input = False
|
||||||
|
self._captured_output = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
return self._wrapped_stage.stage_type
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return self._wrapped_stage.capabilities
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return self._wrapped_stage.dependencies
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return self._wrapped_stage.inlet_types
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return self._wrapped_stage.outlet_types
|
||||||
|
|
||||||
|
def init(self, ctx: PipelineContext) -> bool:
|
||||||
|
"""Initialize the wrapped stage."""
|
||||||
|
return self._wrapped_stage.init(ctx)
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""
|
||||||
|
Process data through wrapped stage and capture frames.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Input data (typically a text buffer)
|
||||||
|
ctx: Pipeline context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Output data from wrapped stage
|
||||||
|
"""
|
||||||
|
# Capture input frame (before stage processing)
|
||||||
|
if isinstance(data, list) and all(isinstance(line, str) for line in data):
|
||||||
|
self._display.start_stage(f"{self._wrapped_stage.name}_input")
|
||||||
|
self._display.show(data)
|
||||||
|
self._captured_input = True
|
||||||
|
|
||||||
|
# Process through wrapped stage
|
||||||
|
result = self._wrapped_stage.process(data, ctx)
|
||||||
|
|
||||||
|
# Capture output frame (after stage processing)
|
||||||
|
if isinstance(result, list) and all(isinstance(line, str) for line in result):
|
||||||
|
self._display.start_stage(f"{self._wrapped_stage.name}_output")
|
||||||
|
self._display.show(result)
|
||||||
|
self._captured_output = True
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Cleanup the wrapped stage."""
|
||||||
|
self._wrapped_stage.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class FrameCaptureController:
|
||||||
|
"""
|
||||||
|
Controller for managing frame capture across the pipeline.
|
||||||
|
|
||||||
|
This class provides an easy way to enable frame capture for
|
||||||
|
specific stages or the entire pipeline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, display: AnimationReportDisplay):
|
||||||
|
"""
|
||||||
|
Initialize frame capture controller.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
display: The animation report display to use for capture
|
||||||
|
"""
|
||||||
|
self._display = display
|
||||||
|
self._captured_stages: list[FrameCaptureStage] = []
|
||||||
|
|
||||||
|
def wrap_stage(self, stage: Stage, name: str | None = None) -> FrameCaptureStage:
|
||||||
|
"""
|
||||||
|
Wrap a stage with frame capture.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stage: The stage to wrap
|
||||||
|
name: Optional name for the capture stage
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Wrapped stage that captures frames
|
||||||
|
"""
|
||||||
|
capture_stage = FrameCaptureStage(stage, self._display, name)
|
||||||
|
self._captured_stages.append(capture_stage)
|
||||||
|
return capture_stage
|
||||||
|
|
||||||
|
def wrap_stages(self, stages: dict[str, Stage]) -> dict[str, Stage]:
|
||||||
|
"""
|
||||||
|
Wrap multiple stages with frame capture.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stages: Dictionary of stage names to stages
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary of stage names to wrapped stages
|
||||||
|
"""
|
||||||
|
wrapped = {}
|
||||||
|
for name, stage in stages.items():
|
||||||
|
wrapped[name] = self.wrap_stage(stage, name)
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
def get_captured_stages(self) -> list[FrameCaptureStage]:
|
||||||
|
"""Get list of all captured stages."""
|
||||||
|
return self._captured_stages
|
||||||
|
|
||||||
|
def generate_report(self, title: str = "Pipeline Animation Report") -> str:
|
||||||
|
"""
|
||||||
|
Generate the animation report.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Title for the report
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the generated HTML file
|
||||||
|
"""
|
||||||
|
report_path = self._display.generate_report(title)
|
||||||
|
return str(report_path)
|
||||||
185
engine/pipeline/adapters/message_overlay.py
Normal file
185
engine/pipeline/adapters/message_overlay.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""
|
||||||
|
Message overlay stage - Renders ntfy messages as an overlay on the buffer.
|
||||||
|
|
||||||
|
This stage provides message overlay capability for displaying ntfy.sh messages
|
||||||
|
as a centered panel with pink/magenta gradient, matching upstream/main aesthetics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from engine import config
|
||||||
|
from engine.effects.legacy import vis_trunc
|
||||||
|
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||||
|
from engine.render.blocks import big_wrap
|
||||||
|
from engine.render.gradient import msg_gradient
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MessageOverlayConfig:
|
||||||
|
"""Configuration for MessageOverlayStage."""
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
display_secs: int = 30 # How long to display messages
|
||||||
|
topic_url: str | None = None # Ntfy topic URL (None = use config default)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageOverlayStage(Stage):
|
||||||
|
"""Stage that renders ntfy message overlay on the buffer.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- message.overlay capability (optional)
|
||||||
|
- Renders centered panel with pink/magenta gradient
|
||||||
|
- Shows title, body, timestamp, and remaining time
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "message_overlay"
|
||||||
|
category = "overlay"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, config: MessageOverlayConfig | None = None, name: str = "message_overlay"
|
||||||
|
):
|
||||||
|
self.config = config or MessageOverlayConfig()
|
||||||
|
self._ntfy_poller = None
|
||||||
|
self._msg_cache = (None, None) # (cache_key, rendered_rows)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
"""Provides message overlay capability."""
|
||||||
|
return {"message.overlay"} if self.config.enabled else set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
"""Needs rendered buffer and camera transformation to overlay onto."""
|
||||||
|
return {"render.output", "camera"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def init(self, ctx: PipelineContext) -> bool:
|
||||||
|
"""Initialize ntfy poller if topic URL is configured."""
|
||||||
|
if not self.config.enabled:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Get or create ntfy poller
|
||||||
|
topic_url = self.config.topic_url or config.NTFY_TOPIC
|
||||||
|
if topic_url:
|
||||||
|
from engine.ntfy import NtfyPoller
|
||||||
|
|
||||||
|
self._ntfy_poller = NtfyPoller(
|
||||||
|
topic_url=topic_url,
|
||||||
|
reconnect_delay=getattr(config, "NTFY_RECONNECT_DELAY", 5),
|
||||||
|
display_secs=self.config.display_secs,
|
||||||
|
)
|
||||||
|
self._ntfy_poller.start()
|
||||||
|
ctx.set("ntfy_poller", self._ntfy_poller)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def process(self, data: list[str], ctx: PipelineContext) -> list[str]:
|
||||||
|
"""Render message overlay on the buffer."""
|
||||||
|
if not self.config.enabled or not data:
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Get active message from poller
|
||||||
|
msg = None
|
||||||
|
if self._ntfy_poller:
|
||||||
|
msg = self._ntfy_poller.get_active_message()
|
||||||
|
|
||||||
|
if msg is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
# Render overlay
|
||||||
|
w = ctx.terminal_width if hasattr(ctx, "terminal_width") else 80
|
||||||
|
h = ctx.terminal_height if hasattr(ctx, "terminal_height") else 24
|
||||||
|
|
||||||
|
overlay, self._msg_cache = self._render_message_overlay(
|
||||||
|
msg, w, h, self._msg_cache
|
||||||
|
)
|
||||||
|
|
||||||
|
# Composite overlay onto buffer
|
||||||
|
result = list(data)
|
||||||
|
for line in overlay:
|
||||||
|
# Overlay uses ANSI cursor positioning, just append
|
||||||
|
result.append(line)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _render_message_overlay(
|
||||||
|
self,
|
||||||
|
msg: tuple[str, str, float] | None,
|
||||||
|
w: int,
|
||||||
|
h: int,
|
||||||
|
msg_cache: tuple,
|
||||||
|
) -> tuple[list[str], tuple]:
|
||||||
|
"""Render ntfy message overlay.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: (title, body, timestamp) or None
|
||||||
|
w: terminal width
|
||||||
|
h: terminal height
|
||||||
|
msg_cache: (cache_key, rendered_rows) for caching
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(list of ANSI strings, updated cache)
|
||||||
|
"""
|
||||||
|
overlay = []
|
||||||
|
if msg is None:
|
||||||
|
return overlay, msg_cache
|
||||||
|
|
||||||
|
m_title, m_body, m_ts = msg
|
||||||
|
display_text = m_body or m_title or "(empty)"
|
||||||
|
display_text = re.sub(r"\s+", " ", display_text.upper())
|
||||||
|
|
||||||
|
cache_key = (display_text, w)
|
||||||
|
if msg_cache[0] != cache_key:
|
||||||
|
msg_rows = big_wrap(display_text, w - 4)
|
||||||
|
msg_cache = (cache_key, msg_rows)
|
||||||
|
else:
|
||||||
|
msg_rows = msg_cache[1]
|
||||||
|
|
||||||
|
msg_rows = msg_gradient(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
|
||||||
|
|
||||||
|
elapsed_s = int(time.monotonic() - m_ts)
|
||||||
|
remaining = max(0, self.config.display_secs - elapsed_s)
|
||||||
|
ts_str = datetime.now().strftime("%H:%M:%S")
|
||||||
|
panel_h = len(msg_rows) + 2
|
||||||
|
panel_top = max(0, (h - panel_h) // 2)
|
||||||
|
|
||||||
|
row_idx = 0
|
||||||
|
for mr in msg_rows:
|
||||||
|
ln = vis_trunc(mr, w)
|
||||||
|
overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}\033[0m\033[K")
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
meta_parts = []
|
||||||
|
if m_title and m_title != m_body:
|
||||||
|
meta_parts.append(m_title)
|
||||||
|
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s")
|
||||||
|
meta = (
|
||||||
|
" " + " \u00b7 ".join(meta_parts)
|
||||||
|
if len(meta_parts) > 1
|
||||||
|
else " " + meta_parts[0]
|
||||||
|
)
|
||||||
|
overlay.append(
|
||||||
|
f"\033[{panel_top + row_idx + 1};1H\033[38;5;245m{meta}\033[0m\033[K"
|
||||||
|
)
|
||||||
|
row_idx += 1
|
||||||
|
|
||||||
|
bar = "\u2500" * (w - 4)
|
||||||
|
overlay.append(
|
||||||
|
f"\033[{panel_top + row_idx + 1};1H \033[2;38;5;37m{bar}\033[0m\033[K"
|
||||||
|
)
|
||||||
|
|
||||||
|
return overlay, msg_cache
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Cleanup resources."""
|
||||||
|
pass
|
||||||
185
engine/pipeline/adapters/positioning.py
Normal file
185
engine/pipeline/adapters/positioning.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""PositionStage - Configurable positioning mode for terminal rendering.
|
||||||
|
|
||||||
|
This module provides positioning stages that allow choosing between
|
||||||
|
different ANSI positioning approaches:
|
||||||
|
- ABSOLUTE: Use cursor positioning codes (\\033[row;colH) for all lines
|
||||||
|
- RELATIVE: Use newlines for all lines
|
||||||
|
- MIXED: Base content uses newlines, effects use cursor positioning (default)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
class PositioningMode(Enum):
|
||||||
|
"""Positioning mode for terminal rendering."""
|
||||||
|
|
||||||
|
ABSOLUTE = "absolute" # All lines have cursor positioning codes
|
||||||
|
RELATIVE = "relative" # Lines use newlines (no cursor codes)
|
||||||
|
MIXED = "mixed" # Mixed: newlines for base, cursor codes for overlays (default)
|
||||||
|
|
||||||
|
|
||||||
|
class PositionStage(Stage):
|
||||||
|
"""Applies positioning mode to buffer before display.
|
||||||
|
|
||||||
|
This stage allows configuring how lines are positioned in the terminal:
|
||||||
|
- ABSOLUTE: Each line has \\033[row;colH prefix (precise control)
|
||||||
|
- RELATIVE: Lines are joined with \\n (natural flow)
|
||||||
|
- MIXED: Leaves buffer as-is (effects add their own positioning)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, mode: PositioningMode = PositioningMode.RELATIVE, name: str = "position"
|
||||||
|
):
|
||||||
|
self.mode = mode
|
||||||
|
self.name = name
|
||||||
|
self.category = "position"
|
||||||
|
self._mode_str = mode.value
|
||||||
|
|
||||||
|
def save_state(self) -> dict[str, Any]:
|
||||||
|
"""Save positioning mode for restoration."""
|
||||||
|
return {"mode": self.mode.value}
|
||||||
|
|
||||||
|
def restore_state(self, state: dict[str, Any]) -> None:
|
||||||
|
"""Restore positioning mode from saved state."""
|
||||||
|
mode_value = state.get("mode", "relative")
|
||||||
|
self.mode = PositioningMode(mode_value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"position.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
# Position stage typically runs after render but before effects
|
||||||
|
# Effects may add their own positioning codes
|
||||||
|
return {"render.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def init(self, ctx: PipelineContext) -> bool:
|
||||||
|
"""Initialize the positioning stage."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Apply positioning mode to the buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: List of strings (buffer lines)
|
||||||
|
ctx: Pipeline context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Buffer with applied positioning mode
|
||||||
|
"""
|
||||||
|
if data is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return data
|
||||||
|
|
||||||
|
if self.mode == PositioningMode.ABSOLUTE:
|
||||||
|
return self._to_absolute(data, ctx)
|
||||||
|
elif self.mode == PositioningMode.RELATIVE:
|
||||||
|
return self._to_relative(data, ctx)
|
||||||
|
else: # MIXED
|
||||||
|
return data # No transformation
|
||||||
|
|
||||||
|
def _to_absolute(self, data: list[str], ctx: PipelineContext) -> list[str]:
|
||||||
|
"""Convert buffer to absolute positioning (all lines have cursor codes).
|
||||||
|
|
||||||
|
This mode prefixes each line with \\033[row;colH to move cursor
|
||||||
|
to the exact position before writing the line.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: List of buffer lines
|
||||||
|
ctx: Pipeline context (provides terminal dimensions)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Buffer with cursor positioning codes for each line
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
||||||
|
|
||||||
|
for i, line in enumerate(data):
|
||||||
|
if i >= viewport_height:
|
||||||
|
break # Don't exceed viewport
|
||||||
|
|
||||||
|
# Check if line already has cursor positioning
|
||||||
|
if "\033[" in line and "H" in line:
|
||||||
|
# Already has cursor positioning - leave as-is
|
||||||
|
result.append(line)
|
||||||
|
else:
|
||||||
|
# Add cursor positioning for this line
|
||||||
|
# Row is 1-indexed
|
||||||
|
result.append(f"\033[{i + 1};1H{line}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _to_relative(self, data: list[str], ctx: PipelineContext) -> list[str]:
|
||||||
|
"""Convert buffer to relative positioning (use newlines).
|
||||||
|
|
||||||
|
This mode removes explicit cursor positioning codes from lines
|
||||||
|
(except for effects that specifically add them).
|
||||||
|
|
||||||
|
Note: Effects like HUD add their own cursor positioning codes,
|
||||||
|
so we can't simply remove all of them. We rely on the terminal
|
||||||
|
display to join lines with newlines.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: List of buffer lines
|
||||||
|
ctx: Pipeline context (unused)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Buffer with minimal cursor positioning (only for overlays)
|
||||||
|
"""
|
||||||
|
# For relative mode, we leave the buffer as-is
|
||||||
|
# The terminal display handles joining with newlines
|
||||||
|
# Effects that need absolute positioning will add their own codes
|
||||||
|
|
||||||
|
# Filter out lines that would cause double-positioning
|
||||||
|
result = []
|
||||||
|
for i, line in enumerate(data):
|
||||||
|
# Check if this line looks like base content (no cursor code at start)
|
||||||
|
# vs an effect line (has cursor code at start)
|
||||||
|
if line.startswith("\033[") and "H" in line[:20]:
|
||||||
|
# This is an effect with positioning - keep it
|
||||||
|
result.append(line)
|
||||||
|
else:
|
||||||
|
# Base content - strip any inline cursor codes (rare)
|
||||||
|
# but keep color codes
|
||||||
|
result.append(line)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Clean up positioning stage."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Convenience function to create positioning stage
|
||||||
|
def create_position_stage(
|
||||||
|
mode: str = "relative", name: str = "position"
|
||||||
|
) -> PositionStage:
|
||||||
|
"""Create a positioning stage with the specified mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Positioning mode ("absolute", "relative", or "mixed")
|
||||||
|
name: Name for the stage
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PositionStage instance
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
positioning_mode = PositioningMode(mode)
|
||||||
|
except ValueError:
|
||||||
|
positioning_mode = PositioningMode.RELATIVE
|
||||||
|
|
||||||
|
return PositionStage(mode=positioning_mode, name=name)
|
||||||
293
engine/pipeline/adapters/transform.py
Normal file
293
engine/pipeline/adapters/transform.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""Adapters for transform stages (viewport, font, image, canvas)."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import engine.render
|
||||||
|
from engine.data_sources import SourceItem
|
||||||
|
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_simple_height(text: str, width: int) -> int:
|
||||||
|
"""Estimate height in terminal rows using simple word wrap.
|
||||||
|
|
||||||
|
Uses conservative estimation suitable for headlines.
|
||||||
|
Each wrapped line is approximately 6 terminal rows (big block rendering).
|
||||||
|
"""
|
||||||
|
words = text.split()
|
||||||
|
if not words:
|
||||||
|
return 6
|
||||||
|
|
||||||
|
lines = 1
|
||||||
|
current_len = 0
|
||||||
|
for word in words:
|
||||||
|
word_len = len(word)
|
||||||
|
if current_len + word_len + 1 > width - 4: # -4 for margins
|
||||||
|
lines += 1
|
||||||
|
current_len = word_len
|
||||||
|
else:
|
||||||
|
current_len += word_len + 1
|
||||||
|
|
||||||
|
return lines * 6 # 6 rows per line for big block rendering
|
||||||
|
|
||||||
|
|
||||||
|
class ViewportFilterStage(Stage):
|
||||||
|
"""Filter items to viewport height based on rendered height."""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "viewport-filter"):
|
||||||
|
self.name = name
|
||||||
|
self.category = "render"
|
||||||
|
self.optional = True
|
||||||
|
self._layout: list[int] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
return "render"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"source.filtered"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
# Always requires camera.state for viewport filtering
|
||||||
|
# CameraUpdateStage provides this (auto-injected if missing)
|
||||||
|
return {"source", "camera.state"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Filter items to viewport height based on rendered height."""
|
||||||
|
if data is None:
|
||||||
|
return data
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return data
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Get viewport parameters from context
|
||||||
|
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
||||||
|
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||||
|
camera_y = ctx.get("camera_y", 0)
|
||||||
|
|
||||||
|
# Estimate height for each item and cache layout
|
||||||
|
self._layout = []
|
||||||
|
cumulative_heights = []
|
||||||
|
current_height = 0
|
||||||
|
|
||||||
|
for item in data:
|
||||||
|
title = item.content if isinstance(item, SourceItem) else str(item)
|
||||||
|
# Use simple height estimation (not PIL-based)
|
||||||
|
estimated_height = estimate_simple_height(title, viewport_width)
|
||||||
|
self._layout.append(estimated_height)
|
||||||
|
current_height += estimated_height
|
||||||
|
cumulative_heights.append(current_height)
|
||||||
|
|
||||||
|
# Find visible range based on camera_y and viewport_height
|
||||||
|
# camera_y is the scroll offset (how many rows are scrolled up)
|
||||||
|
start_y = camera_y
|
||||||
|
end_y = camera_y + viewport_height
|
||||||
|
|
||||||
|
# Find start index (first item that intersects with visible range)
|
||||||
|
start_idx = 0
|
||||||
|
start_item_y = 0 # Y position where the first visible item starts
|
||||||
|
for i, total_h in enumerate(cumulative_heights):
|
||||||
|
if total_h > start_y:
|
||||||
|
start_idx = i
|
||||||
|
# Calculate the Y position of the start of this item
|
||||||
|
if i > 0:
|
||||||
|
start_item_y = cumulative_heights[i - 1]
|
||||||
|
break
|
||||||
|
|
||||||
|
# Find end index (first item that extends beyond visible range)
|
||||||
|
end_idx = len(data)
|
||||||
|
for i, total_h in enumerate(cumulative_heights):
|
||||||
|
if total_h >= end_y:
|
||||||
|
end_idx = i + 1
|
||||||
|
break
|
||||||
|
|
||||||
|
# Adjust camera_y for the filtered buffer
|
||||||
|
# The filtered buffer starts at row 0, but the camera position
|
||||||
|
# needs to be relative to where the first visible item starts
|
||||||
|
filtered_camera_y = camera_y - start_item_y
|
||||||
|
|
||||||
|
# Update context with the filtered camera position
|
||||||
|
# This ensures CameraStage can correctly slice the filtered buffer
|
||||||
|
ctx.set_state("camera_y", filtered_camera_y)
|
||||||
|
ctx.set_state("camera_x", ctx.get("camera_x", 0)) # Keep camera_x unchanged
|
||||||
|
|
||||||
|
# Return visible items
|
||||||
|
return data[start_idx:end_idx]
|
||||||
|
|
||||||
|
|
||||||
|
class FontStage(Stage):
|
||||||
|
"""Render items using font."""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "font"):
|
||||||
|
self.name = name
|
||||||
|
self.category = "render"
|
||||||
|
self.optional = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
return "render"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"render.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_dependencies(self) -> set[str]:
|
||||||
|
# Must connect to viewport_filter stage to get filtered source
|
||||||
|
return {"viewport_filter"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
# Depend on source.filtered (provided by viewport_filter)
|
||||||
|
# This ensures we get the filtered/processed source, not raw source
|
||||||
|
return {"source.filtered"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Render items to text buffer using font."""
|
||||||
|
if data is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return [str(data)]
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
if os.environ.get("DEBUG_CAMERA"):
|
||||||
|
print(f"FontStage: input items={len(data)}")
|
||||||
|
|
||||||
|
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, SourceItem):
|
||||||
|
title = item.content
|
||||||
|
src = item.source
|
||||||
|
ts = item.timestamp
|
||||||
|
content_lines, _, _ = engine.render.make_block(
|
||||||
|
title, src, ts, viewport_width
|
||||||
|
)
|
||||||
|
result.extend(content_lines)
|
||||||
|
elif hasattr(item, "content"):
|
||||||
|
title = str(item.content)
|
||||||
|
content_lines, _, _ = engine.render.make_block(
|
||||||
|
title, "", "", viewport_width
|
||||||
|
)
|
||||||
|
result.extend(content_lines)
|
||||||
|
else:
|
||||||
|
result.append(str(item))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class ImageToTextStage(Stage):
|
||||||
|
"""Convert image items to text."""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "image-to-text"):
|
||||||
|
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"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Convert image items to text representation."""
|
||||||
|
if data is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return [str(data)]
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for item in data:
|
||||||
|
# Check if item is an image
|
||||||
|
if hasattr(item, "image_path") or hasattr(item, "image_data"):
|
||||||
|
# Placeholder: would normally render image to ASCII art
|
||||||
|
result.append(f"[Image: {getattr(item, 'image_path', 'data')}]")
|
||||||
|
elif isinstance(item, SourceItem):
|
||||||
|
result.extend(item.content.split("\n"))
|
||||||
|
else:
|
||||||
|
result.append(str(item))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class CanvasStage(Stage):
|
||||||
|
"""Render items to canvas."""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "canvas"):
|
||||||
|
self.name = name
|
||||||
|
self.category = "render"
|
||||||
|
self.optional = False
|
||||||
|
|
||||||
|
@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"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Render items to canvas."""
|
||||||
|
if data is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return [str(data)]
|
||||||
|
|
||||||
|
# Simple canvas rendering
|
||||||
|
result = []
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, SourceItem):
|
||||||
|
result.extend(item.content.split("\n"))
|
||||||
|
else:
|
||||||
|
result.append(str(item))
|
||||||
|
return result
|
||||||
@@ -49,6 +49,8 @@ class Pipeline:
|
|||||||
|
|
||||||
Manages the execution of all stages in dependency order,
|
Manages the execution of all stages in dependency order,
|
||||||
handling initialization, processing, and cleanup.
|
handling initialization, processing, and cleanup.
|
||||||
|
|
||||||
|
Supports dynamic mutation during runtime via the mutation API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -61,30 +63,461 @@ class Pipeline:
|
|||||||
self._stages: dict[str, Stage] = {}
|
self._stages: dict[str, Stage] = {}
|
||||||
self._execution_order: list[str] = []
|
self._execution_order: list[str] = []
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
|
self._capability_map: dict[str, list[str]] = {}
|
||||||
|
|
||||||
self._metrics_enabled = self.config.enable_metrics
|
self._metrics_enabled = self.config.enable_metrics
|
||||||
self._frame_metrics: list[FrameMetrics] = []
|
self._frame_metrics: list[FrameMetrics] = []
|
||||||
self._max_metrics_frames = 60
|
self._max_metrics_frames = 60
|
||||||
|
|
||||||
|
# Minimum capabilities required for pipeline to function
|
||||||
|
# NOTE: Research later - allow presets to override these defaults
|
||||||
|
self._minimum_capabilities: set[str] = {
|
||||||
|
"source",
|
||||||
|
"render.output",
|
||||||
|
"display.output",
|
||||||
|
"camera.state", # Always required for viewport filtering
|
||||||
|
}
|
||||||
self._current_frame_number = 0
|
self._current_frame_number = 0
|
||||||
|
|
||||||
def add_stage(self, name: str, stage: Stage) -> "Pipeline":
|
def add_stage(self, name: str, stage: Stage, initialize: bool = True) -> "Pipeline":
|
||||||
"""Add a stage to the pipeline."""
|
"""Add a stage to the pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Unique name for the stage
|
||||||
|
stage: Stage instance to add
|
||||||
|
initialize: If True, initialize the stage immediately
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Self for method chaining
|
||||||
|
"""
|
||||||
self._stages[name] = stage
|
self._stages[name] = stage
|
||||||
|
if self._initialized and initialize:
|
||||||
|
stage.init(self.context)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def remove_stage(self, name: str) -> None:
|
def remove_stage(self, name: str, cleanup: bool = True) -> Stage | None:
|
||||||
"""Remove a stage from the pipeline."""
|
"""Remove a stage from the pipeline.
|
||||||
if name in self._stages:
|
|
||||||
del self._stages[name]
|
Args:
|
||||||
|
name: Name of the stage to remove
|
||||||
|
cleanup: If True, call cleanup() on the removed stage
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The removed stage, or None if not found
|
||||||
|
"""
|
||||||
|
stage = self._stages.pop(name, None)
|
||||||
|
if stage and cleanup:
|
||||||
|
try:
|
||||||
|
stage.cleanup()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Rebuild execution order and capability map if stage was removed
|
||||||
|
if stage and self._initialized:
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
return stage
|
||||||
|
|
||||||
|
def remove_stage_safe(self, name: str, cleanup: bool = True) -> Stage | None:
|
||||||
|
"""Remove a stage and rebuild execution order safely.
|
||||||
|
|
||||||
|
This is an alias for remove_stage() that explicitly rebuilds
|
||||||
|
the execution order after removal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Name of the stage to remove
|
||||||
|
cleanup: If True, call cleanup() on the removed stage
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The removed stage, or None if not found
|
||||||
|
"""
|
||||||
|
return self.remove_stage(name, cleanup)
|
||||||
|
|
||||||
|
def cleanup_stage(self, name: str) -> None:
|
||||||
|
"""Clean up a specific stage without removing it.
|
||||||
|
|
||||||
|
This is useful for stages that need to release resources
|
||||||
|
(like display connections) without being removed from the pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Name of the stage to clean up
|
||||||
|
"""
|
||||||
|
stage = self._stages.get(name)
|
||||||
|
if stage:
|
||||||
|
try:
|
||||||
|
stage.cleanup()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def can_hot_swap(self, name: str) -> bool:
|
||||||
|
"""Check if a stage can be safely hot-swapped.
|
||||||
|
|
||||||
|
A stage can be hot-swapped if:
|
||||||
|
1. It exists in the pipeline
|
||||||
|
2. It's not required for basic pipeline function
|
||||||
|
3. It doesn't have strict dependencies that can't be re-resolved
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Name of the stage to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the stage can be hot-swapped, False otherwise
|
||||||
|
"""
|
||||||
|
# Check if stage exists
|
||||||
|
if name not in self._stages:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if stage is a minimum capability provider
|
||||||
|
stage = self._stages[name]
|
||||||
|
stage_caps = stage.capabilities if hasattr(stage, "capabilities") else set()
|
||||||
|
minimum_caps = self._minimum_capabilities
|
||||||
|
|
||||||
|
# If stage provides a minimum capability, it's more critical
|
||||||
|
# but still potentially swappable if another stage provides the same capability
|
||||||
|
for cap in stage_caps:
|
||||||
|
if cap in minimum_caps:
|
||||||
|
# Check if another stage provides this capability
|
||||||
|
providers = self._capability_map.get(cap, [])
|
||||||
|
# This stage is the sole provider - might be critical
|
||||||
|
# but still allow hot-swap if pipeline is not initialized
|
||||||
|
if len(providers) <= 1 and self._initialized:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def replace_stage(
|
||||||
|
self, name: str, new_stage: Stage, preserve_state: bool = True
|
||||||
|
) -> Stage | None:
|
||||||
|
"""Replace a stage in the pipeline with a new one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Name of the stage to replace
|
||||||
|
new_stage: New stage instance
|
||||||
|
preserve_state: If True, copy relevant state from old stage
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The old stage, or None if not found
|
||||||
|
"""
|
||||||
|
old_stage = self._stages.get(name)
|
||||||
|
if not old_stage:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if preserve_state:
|
||||||
|
self._copy_stage_state(old_stage, new_stage)
|
||||||
|
|
||||||
|
old_stage.cleanup()
|
||||||
|
self._stages[name] = new_stage
|
||||||
|
new_stage.init(self.context)
|
||||||
|
|
||||||
|
if self._initialized:
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
return old_stage
|
||||||
|
|
||||||
|
def swap_stages(self, name1: str, name2: str) -> bool:
|
||||||
|
"""Swap two stages in the pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name1: First stage name
|
||||||
|
name2: Second stage name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False if either stage not found
|
||||||
|
"""
|
||||||
|
stage1 = self._stages.get(name1)
|
||||||
|
stage2 = self._stages.get(name2)
|
||||||
|
|
||||||
|
if not stage1 or not stage2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._stages[name1] = stage2
|
||||||
|
self._stages[name2] = stage1
|
||||||
|
|
||||||
|
if self._initialized:
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def move_stage(
|
||||||
|
self, name: str, after: str | None = None, before: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Move a stage's position in execution order.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Stage to move
|
||||||
|
after: Place this stage after this stage name
|
||||||
|
before: Place this stage before this stage name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False if stage not found
|
||||||
|
"""
|
||||||
|
if name not in self._stages:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self._initialized:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_order = list(self._execution_order)
|
||||||
|
if name not in current_order:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_order.remove(name)
|
||||||
|
|
||||||
|
if after and after in current_order:
|
||||||
|
idx = current_order.index(after) + 1
|
||||||
|
current_order.insert(idx, name)
|
||||||
|
elif before and before in current_order:
|
||||||
|
idx = current_order.index(before)
|
||||||
|
current_order.insert(idx, name)
|
||||||
|
else:
|
||||||
|
current_order.append(name)
|
||||||
|
|
||||||
|
self._execution_order = current_order
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _copy_stage_state(self, old_stage: Stage, new_stage: Stage) -> None:
|
||||||
|
"""Copy relevant state from old stage to new stage during replacement.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_stage: The old stage being replaced
|
||||||
|
new_stage: The new stage
|
||||||
|
"""
|
||||||
|
if hasattr(old_stage, "_enabled"):
|
||||||
|
new_stage._enabled = old_stage._enabled
|
||||||
|
|
||||||
|
# Preserve camera state
|
||||||
|
if hasattr(old_stage, "save_state") and hasattr(new_stage, "restore_state"):
|
||||||
|
try:
|
||||||
|
state = old_stage.save_state()
|
||||||
|
new_stage.restore_state(state)
|
||||||
|
except Exception:
|
||||||
|
# If state preservation fails, continue without it
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _rebuild(self) -> None:
|
||||||
|
"""Rebuild execution order after mutation or auto-injection."""
|
||||||
|
was_initialized = self._initialized
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
self._capability_map = self._build_capability_map()
|
||||||
|
self._execution_order = self._resolve_dependencies()
|
||||||
|
|
||||||
|
# Note: We intentionally DO NOT validate dependencies here.
|
||||||
|
# Mutation operations (remove/swap/move) might leave the pipeline
|
||||||
|
# temporarily invalid (e.g., removing a stage that others depend on).
|
||||||
|
# Validation is performed explicitly in build() or can be checked
|
||||||
|
# manually via validate_minimum_capabilities().
|
||||||
|
# try:
|
||||||
|
# self._validate_dependencies()
|
||||||
|
# self._validate_types()
|
||||||
|
# except StageError:
|
||||||
|
# pass
|
||||||
|
|
||||||
|
# Restore initialized state
|
||||||
|
self._initialized = was_initialized
|
||||||
|
|
||||||
def get_stage(self, name: str) -> Stage | None:
|
def get_stage(self, name: str) -> Stage | None:
|
||||||
"""Get a stage by name."""
|
"""Get a stage by name."""
|
||||||
return self._stages.get(name)
|
return self._stages.get(name)
|
||||||
|
|
||||||
def build(self) -> "Pipeline":
|
def enable_stage(self, name: str) -> bool:
|
||||||
"""Build execution order based on dependencies."""
|
"""Enable a stage in the pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Stage name to enable
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False if stage not found
|
||||||
|
"""
|
||||||
|
stage = self._stages.get(name)
|
||||||
|
if stage:
|
||||||
|
stage.set_enabled(True)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disable_stage(self, name: str) -> bool:
|
||||||
|
"""Disable a stage in the pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Stage name to disable
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False if stage not found
|
||||||
|
"""
|
||||||
|
stage = self._stages.get(name)
|
||||||
|
if stage:
|
||||||
|
stage.set_enabled(False)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_stage_info(self, name: str) -> dict | None:
|
||||||
|
"""Get detailed information about a stage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Stage name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with stage information, or None if not found
|
||||||
|
"""
|
||||||
|
stage = self._stages.get(name)
|
||||||
|
if not stage:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"category": stage.category,
|
||||||
|
"stage_type": stage.stage_type,
|
||||||
|
"enabled": stage.is_enabled(),
|
||||||
|
"optional": stage.optional,
|
||||||
|
"capabilities": list(stage.capabilities),
|
||||||
|
"dependencies": list(stage.dependencies),
|
||||||
|
"inlet_types": [dt.name for dt in stage.inlet_types],
|
||||||
|
"outlet_types": [dt.name for dt in stage.outlet_types],
|
||||||
|
"render_order": stage.render_order,
|
||||||
|
"is_overlay": stage.is_overlay,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_pipeline_info(self) -> dict:
|
||||||
|
"""Get comprehensive information about the pipeline.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with pipeline state
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"stages": {name: self.get_stage_info(name) for name in self._stages},
|
||||||
|
"execution_order": self._execution_order.copy(),
|
||||||
|
"initialized": self._initialized,
|
||||||
|
"stage_count": len(self._stages),
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def minimum_capabilities(self) -> set[str]:
|
||||||
|
"""Get minimum capabilities required for pipeline to function."""
|
||||||
|
return self._minimum_capabilities
|
||||||
|
|
||||||
|
@minimum_capabilities.setter
|
||||||
|
def minimum_capabilities(self, value: set[str]):
|
||||||
|
"""Set minimum required capabilities.
|
||||||
|
|
||||||
|
NOTE: Research later - allow presets to override these defaults
|
||||||
|
"""
|
||||||
|
self._minimum_capabilities = value
|
||||||
|
|
||||||
|
def validate_minimum_capabilities(self) -> tuple[bool, list[str]]:
|
||||||
|
"""Validate that all minimum capabilities are provided.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, missing_capabilities)
|
||||||
|
"""
|
||||||
|
missing = []
|
||||||
|
for cap in self._minimum_capabilities:
|
||||||
|
if not self._find_stage_with_capability(cap):
|
||||||
|
missing.append(cap)
|
||||||
|
return len(missing) == 0, missing
|
||||||
|
|
||||||
|
def ensure_minimum_capabilities(self) -> list[str]:
|
||||||
|
"""Automatically inject MVP stages if minimum capabilities are missing.
|
||||||
|
|
||||||
|
Auto-injection is always on, but defaults are trivial to override.
|
||||||
|
Returns:
|
||||||
|
List of stages that were injected
|
||||||
|
"""
|
||||||
|
from engine.camera import Camera
|
||||||
|
from engine.data_sources.sources import EmptyDataSource
|
||||||
|
from engine.display import DisplayRegistry
|
||||||
|
from engine.pipeline.adapters import (
|
||||||
|
CameraClockStage,
|
||||||
|
CameraStage,
|
||||||
|
DataSourceStage,
|
||||||
|
DisplayStage,
|
||||||
|
SourceItemsToBufferStage,
|
||||||
|
)
|
||||||
|
|
||||||
|
injected = []
|
||||||
|
|
||||||
|
# Check for source capability
|
||||||
|
if (
|
||||||
|
not self._find_stage_with_capability("source")
|
||||||
|
and "source" not in self._stages
|
||||||
|
):
|
||||||
|
empty_source = EmptyDataSource(width=80, height=24)
|
||||||
|
self.add_stage("source", DataSourceStage(empty_source, name="empty"))
|
||||||
|
injected.append("source")
|
||||||
|
|
||||||
|
# Check for camera.state capability (must be BEFORE render to accept SOURCE_ITEMS)
|
||||||
|
camera = None
|
||||||
|
if not self._find_stage_with_capability("camera.state"):
|
||||||
|
# Inject static camera (trivial, no movement)
|
||||||
|
camera = Camera.scroll(speed=0.0)
|
||||||
|
camera.set_canvas_size(200, 200)
|
||||||
|
if "camera_update" not in self._stages:
|
||||||
|
self.add_stage(
|
||||||
|
"camera_update", CameraClockStage(camera, name="camera-clock")
|
||||||
|
)
|
||||||
|
injected.append("camera_update")
|
||||||
|
|
||||||
|
# Check for render capability
|
||||||
|
if (
|
||||||
|
not self._find_stage_with_capability("render.output")
|
||||||
|
and "render" not in self._stages
|
||||||
|
):
|
||||||
|
self.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
||||||
|
injected.append("render")
|
||||||
|
|
||||||
|
# Check for camera stage (must be AFTER render to accept TEXT_BUFFER)
|
||||||
|
if camera and "camera" not in self._stages:
|
||||||
|
self.add_stage("camera", CameraStage(camera, name="static"))
|
||||||
|
injected.append("camera")
|
||||||
|
|
||||||
|
# Check for display capability
|
||||||
|
if (
|
||||||
|
not self._find_stage_with_capability("display.output")
|
||||||
|
and "display" not in self._stages
|
||||||
|
):
|
||||||
|
display_name = self.config.display or "terminal"
|
||||||
|
display = DisplayRegistry.create(display_name)
|
||||||
|
if display:
|
||||||
|
self.add_stage("display", DisplayStage(display, name=display_name))
|
||||||
|
injected.append("display")
|
||||||
|
|
||||||
|
# Rebuild pipeline if stages were injected
|
||||||
|
if injected:
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
return injected
|
||||||
|
|
||||||
|
def build(self, auto_inject: bool = True) -> "Pipeline":
|
||||||
|
"""Build execution order based on dependencies.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
auto_inject: If True, automatically inject MVP stages for missing capabilities
|
||||||
|
"""
|
||||||
self._capability_map = self._build_capability_map()
|
self._capability_map = self._build_capability_map()
|
||||||
self._execution_order = self._resolve_dependencies()
|
self._execution_order = self._resolve_dependencies()
|
||||||
|
|
||||||
|
# Validate minimum capabilities and auto-inject if needed
|
||||||
|
if auto_inject:
|
||||||
|
is_valid, missing = self.validate_minimum_capabilities()
|
||||||
|
if not is_valid:
|
||||||
|
injected = self.ensure_minimum_capabilities()
|
||||||
|
if injected:
|
||||||
|
print(
|
||||||
|
f" \033[38;5;226mAuto-injected stages for missing capabilities: {injected}\033[0m"
|
||||||
|
)
|
||||||
|
# Rebuild after auto-injection
|
||||||
|
self._capability_map = self._build_capability_map()
|
||||||
|
self._execution_order = self._resolve_dependencies()
|
||||||
|
|
||||||
|
# Re-validate after injection attempt (whether anything was injected or not)
|
||||||
|
# If injection didn't run (injected empty), we still need to check if we're valid
|
||||||
|
# If injection ran but failed to fix (injected empty), we need to check
|
||||||
|
is_valid, missing = self.validate_minimum_capabilities()
|
||||||
|
if not is_valid:
|
||||||
|
raise StageError(
|
||||||
|
"build",
|
||||||
|
f"Auto-injection failed to provide minimum capabilities: {missing}",
|
||||||
|
)
|
||||||
|
|
||||||
self._validate_dependencies()
|
self._validate_dependencies()
|
||||||
self._validate_types()
|
self._validate_types()
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
@@ -151,12 +584,24 @@ class Pipeline:
|
|||||||
temp_mark.add(name)
|
temp_mark.add(name)
|
||||||
stage = self._stages.get(name)
|
stage = self._stages.get(name)
|
||||||
if stage:
|
if stage:
|
||||||
|
# Handle capability-based dependencies
|
||||||
for dep in stage.dependencies:
|
for dep in stage.dependencies:
|
||||||
# Find a stage that provides this capability
|
# Find a stage that provides this capability
|
||||||
dep_stage_name = self._find_stage_with_capability(dep)
|
dep_stage_name = self._find_stage_with_capability(dep)
|
||||||
if dep_stage_name:
|
if dep_stage_name:
|
||||||
visit(dep_stage_name)
|
visit(dep_stage_name)
|
||||||
|
|
||||||
|
# Handle direct stage dependencies
|
||||||
|
for stage_dep in stage.stage_dependencies:
|
||||||
|
if stage_dep in self._stages:
|
||||||
|
visit(stage_dep)
|
||||||
|
else:
|
||||||
|
# Stage dependency not found - this is an error
|
||||||
|
raise StageError(
|
||||||
|
name,
|
||||||
|
f"Missing stage dependency: '{stage_dep}' not found in pipeline",
|
||||||
|
)
|
||||||
|
|
||||||
temp_mark.remove(name)
|
temp_mark.remove(name)
|
||||||
visited.add(name)
|
visited.add(name)
|
||||||
ordered.append(name)
|
ordered.append(name)
|
||||||
@@ -281,8 +726,9 @@ class Pipeline:
|
|||||||
frame_start = time.perf_counter() if self._metrics_enabled else 0
|
frame_start = time.perf_counter() if self._metrics_enabled else 0
|
||||||
stage_timings: list[StageMetrics] = []
|
stage_timings: list[StageMetrics] = []
|
||||||
|
|
||||||
# Separate overlay stages from regular stages
|
# Separate overlay stages and display stage from regular stages
|
||||||
overlay_stages: list[tuple[int, Stage]] = []
|
overlay_stages: list[tuple[int, Stage]] = []
|
||||||
|
display_stage: Stage | None = None
|
||||||
regular_stages: list[str] = []
|
regular_stages: list[str] = []
|
||||||
|
|
||||||
for name in self._execution_order:
|
for name in self._execution_order:
|
||||||
@@ -290,6 +736,11 @@ class Pipeline:
|
|||||||
if not stage or not stage.is_enabled():
|
if not stage or not stage.is_enabled():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Check if this is the display stage - execute last
|
||||||
|
if stage.category == "display":
|
||||||
|
display_stage = stage
|
||||||
|
continue
|
||||||
|
|
||||||
# Safely check is_overlay - handle MagicMock and other non-bool returns
|
# Safely check is_overlay - handle MagicMock and other non-bool returns
|
||||||
try:
|
try:
|
||||||
is_overlay = bool(getattr(stage, "is_overlay", False))
|
is_overlay = bool(getattr(stage, "is_overlay", False))
|
||||||
@@ -306,7 +757,7 @@ class Pipeline:
|
|||||||
else:
|
else:
|
||||||
regular_stages.append(name)
|
regular_stages.append(name)
|
||||||
|
|
||||||
# Execute regular stages in dependency order
|
# Execute regular stages in dependency order (excluding display)
|
||||||
for name in regular_stages:
|
for name in regular_stages:
|
||||||
stage = self._stages.get(name)
|
stage = self._stages.get(name)
|
||||||
if not stage or not stage.is_enabled():
|
if not stage or not stage.is_enabled():
|
||||||
@@ -397,6 +848,35 @@ class Pipeline:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Execute display stage LAST (after overlay stages)
|
||||||
|
# This ensures overlay effects like HUD are visible in the final output
|
||||||
|
if display_stage:
|
||||||
|
stage_start = time.perf_counter() if self._metrics_enabled else 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_data = display_stage.process(current_data, self.context)
|
||||||
|
except Exception as e:
|
||||||
|
if not display_stage.optional:
|
||||||
|
return StageResult(
|
||||||
|
success=False,
|
||||||
|
data=current_data,
|
||||||
|
error=str(e),
|
||||||
|
stage_name=display_stage.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._metrics_enabled:
|
||||||
|
stage_duration = (time.perf_counter() - stage_start) * 1000
|
||||||
|
chars_in = len(str(data)) if data else 0
|
||||||
|
chars_out = len(str(current_data)) if current_data else 0
|
||||||
|
stage_timings.append(
|
||||||
|
StageMetrics(
|
||||||
|
name=display_stage.name,
|
||||||
|
duration_ms=stage_duration,
|
||||||
|
chars_in=chars_in,
|
||||||
|
chars_out=chars_out,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if self._metrics_enabled:
|
if self._metrics_enabled:
|
||||||
total_duration = (time.perf_counter() - frame_start) * 1000
|
total_duration = (time.perf_counter() - frame_start) * 1000
|
||||||
self._frame_metrics.append(
|
self._frame_metrics.append(
|
||||||
@@ -504,6 +984,35 @@ class Pipeline:
|
|||||||
"""Get historical frame times for sparklines/charts."""
|
"""Get historical frame times for sparklines/charts."""
|
||||||
return [f.total_ms for f in self._frame_metrics]
|
return [f.total_ms for f in self._frame_metrics]
|
||||||
|
|
||||||
|
def set_effect_intensity(self, effect_name: str, intensity: float) -> bool:
|
||||||
|
"""Set the intensity of an effect in the pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
effect_name: Name of the effect to modify
|
||||||
|
intensity: New intensity value (0.0 to 1.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False if effect not found or not an effect stage
|
||||||
|
"""
|
||||||
|
if not 0.0 <= intensity <= 1.0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
stage = self._stages.get(effect_name)
|
||||||
|
if not stage:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if this is an EffectPluginStage
|
||||||
|
from engine.pipeline.adapters.effect_plugin import EffectPluginStage
|
||||||
|
|
||||||
|
if isinstance(stage, EffectPluginStage):
|
||||||
|
# Access the underlying effect plugin
|
||||||
|
effect = stage._effect
|
||||||
|
if hasattr(effect, "config"):
|
||||||
|
effect.config.intensity = intensity
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
class PipelineRunner:
|
class PipelineRunner:
|
||||||
"""High-level pipeline runner with animation support."""
|
"""High-level pipeline runner with animation support."""
|
||||||
|
|||||||
@@ -155,6 +155,21 @@ class Stage(ABC):
|
|||||||
"""
|
"""
|
||||||
return set()
|
return set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_dependencies(self) -> set[str]:
|
||||||
|
"""Return set of stage names this stage must connect to directly.
|
||||||
|
|
||||||
|
This allows explicit stage-to-stage dependencies, useful for enforcing
|
||||||
|
pipeline structure when capability matching alone is insufficient.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- {"viewport_filter"} # Must connect to viewport_filter stage
|
||||||
|
- {"camera_update"} # Must connect to camera_update stage
|
||||||
|
|
||||||
|
NOTE: These are stage names (as added to pipeline), not capabilities.
|
||||||
|
"""
|
||||||
|
return set()
|
||||||
|
|
||||||
def init(self, ctx: "PipelineContext") -> bool:
|
def init(self, ctx: "PipelineContext") -> bool:
|
||||||
"""Initialize stage with pipeline context.
|
"""Initialize stage with pipeline context.
|
||||||
|
|
||||||
|
|||||||
205
engine/pipeline/graph.py
Normal file
205
engine/pipeline/graph.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""Graph-based pipeline configuration and orchestration.
|
||||||
|
|
||||||
|
This module provides a graph abstraction for defining pipelines as nodes
|
||||||
|
and connections, replacing the verbose XYZStage naming convention.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Declarative (TOML-like)
|
||||||
|
graph = Graph.from_dict({
|
||||||
|
"nodes": {
|
||||||
|
"source": "headlines",
|
||||||
|
"camera": {"type": "camera", "mode": "scroll"},
|
||||||
|
"display": {"type": "terminal", "positioning": "mixed"}
|
||||||
|
},
|
||||||
|
"connections": ["source -> camera -> display"]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Imperative
|
||||||
|
graph = Graph()
|
||||||
|
graph.node("source", "headlines")
|
||||||
|
graph.node("camera", type="camera", mode="scroll")
|
||||||
|
graph.connect("source", "camera", "display")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class NodeType(Enum):
|
||||||
|
"""Types of pipeline nodes."""
|
||||||
|
|
||||||
|
SOURCE = "source"
|
||||||
|
RENDER = "render"
|
||||||
|
CAMERA = "camera"
|
||||||
|
EFFECT = "effect"
|
||||||
|
OVERLAY = "overlay"
|
||||||
|
POSITION = "position"
|
||||||
|
DISPLAY = "display"
|
||||||
|
CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Node:
|
||||||
|
"""A node in the pipeline graph."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
type: NodeType
|
||||||
|
config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
enabled: bool = True
|
||||||
|
optional: bool = False
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"Node({self.name}, type={self.type.value})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Connection:
|
||||||
|
"""A connection between two nodes."""
|
||||||
|
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
data_type: str | None = None # Optional data type constraint
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Graph:
|
||||||
|
"""Pipeline graph representation."""
|
||||||
|
|
||||||
|
nodes: dict[str, Node] = field(default_factory=dict)
|
||||||
|
connections: list[Connection] = field(default_factory=list)
|
||||||
|
|
||||||
|
def node(self, name: str, node_type: NodeType | str, **config) -> "Graph":
|
||||||
|
"""Add a node to the graph."""
|
||||||
|
if isinstance(node_type, str):
|
||||||
|
# Try to parse as NodeType
|
||||||
|
try:
|
||||||
|
node_type = NodeType(node_type)
|
||||||
|
except ValueError:
|
||||||
|
node_type = NodeType.CUSTOM
|
||||||
|
|
||||||
|
self.nodes[name] = Node(name=name, type=node_type, config=config)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def connect(
|
||||||
|
self, source: str, target: str, data_type: str | None = None
|
||||||
|
) -> "Graph":
|
||||||
|
"""Add a connection between nodes."""
|
||||||
|
if source not in self.nodes:
|
||||||
|
raise ValueError(f"Source node '{source}' not found")
|
||||||
|
if target not in self.nodes:
|
||||||
|
raise ValueError(f"Target node '{target}' not found")
|
||||||
|
|
||||||
|
self.connections.append(Connection(source, target, data_type))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def chain(self, *names: str) -> "Graph":
|
||||||
|
"""Connect nodes in a chain."""
|
||||||
|
for i in range(len(names) - 1):
|
||||||
|
self.connect(names[i], names[i + 1])
|
||||||
|
return self
|
||||||
|
|
||||||
|
def from_dict(self, data: dict[str, Any]) -> "Graph":
|
||||||
|
"""Load graph from dictionary (TOML-compatible)."""
|
||||||
|
# Parse nodes
|
||||||
|
nodes_data = data.get("nodes", {})
|
||||||
|
for name, node_info in nodes_data.items():
|
||||||
|
if isinstance(node_info, str):
|
||||||
|
# Simple format: "source": "headlines"
|
||||||
|
self.node(name, NodeType.SOURCE, source=node_info)
|
||||||
|
elif isinstance(node_info, dict):
|
||||||
|
# Full format: {"type": "camera", "mode": "scroll"}
|
||||||
|
node_type = node_info.get("type", "custom")
|
||||||
|
config = {k: v for k, v in node_info.items() if k != "type"}
|
||||||
|
self.node(name, node_type, **config)
|
||||||
|
|
||||||
|
# Parse connections
|
||||||
|
connections_data = data.get("connections", [])
|
||||||
|
for conn in connections_data:
|
||||||
|
if isinstance(conn, str):
|
||||||
|
# Parse "source -> target" format
|
||||||
|
parts = conn.split("->")
|
||||||
|
if len(parts) == 2:
|
||||||
|
self.connect(parts[0].strip(), parts[1].strip())
|
||||||
|
elif isinstance(conn, dict):
|
||||||
|
# Parse dict format: {"source": "a", "target": "b"}
|
||||||
|
self.connect(conn["source"], conn["target"])
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""Convert graph to dictionary."""
|
||||||
|
return {
|
||||||
|
"nodes": {
|
||||||
|
name: {"type": node.type.value, **node.config}
|
||||||
|
for name, node in self.nodes.items()
|
||||||
|
},
|
||||||
|
"connections": [
|
||||||
|
{"source": conn.source, "target": conn.target}
|
||||||
|
for conn in self.connections
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def validate(self) -> list[str]:
|
||||||
|
"""Validate graph structure and return list of errors."""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Check for disconnected nodes
|
||||||
|
connected_nodes = set()
|
||||||
|
for conn in self.connections:
|
||||||
|
connected_nodes.add(conn.source)
|
||||||
|
connected_nodes.add(conn.target)
|
||||||
|
|
||||||
|
for node_name in self.nodes:
|
||||||
|
if node_name not in connected_nodes:
|
||||||
|
errors.append(f"Node '{node_name}' is not connected")
|
||||||
|
|
||||||
|
# Check for cycles (simplified)
|
||||||
|
visited = set()
|
||||||
|
temp = set()
|
||||||
|
|
||||||
|
def has_cycle(node_name: str) -> bool:
|
||||||
|
if node_name in temp:
|
||||||
|
return True
|
||||||
|
if node_name in visited:
|
||||||
|
return False
|
||||||
|
|
||||||
|
temp.add(node_name)
|
||||||
|
for conn in self.connections:
|
||||||
|
if conn.source == node_name and has_cycle(conn.target):
|
||||||
|
return True
|
||||||
|
temp.remove(node_name)
|
||||||
|
visited.add(node_name)
|
||||||
|
return False
|
||||||
|
|
||||||
|
for node_name in self.nodes:
|
||||||
|
if has_cycle(node_name):
|
||||||
|
errors.append(f"Cycle detected involving node '{node_name}'")
|
||||||
|
break
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
nodes_str = ", ".join(str(n) for n in self.nodes.values())
|
||||||
|
return f"Graph(nodes=[{nodes_str}])"
|
||||||
|
|
||||||
|
|
||||||
|
# Factory functions for common node types
|
||||||
|
def source(name: str, source_type: str, **config) -> Node:
|
||||||
|
"""Create a source node."""
|
||||||
|
return Node(name, NodeType.SOURCE, {"source": source_type, **config})
|
||||||
|
|
||||||
|
|
||||||
|
def camera(name: str, mode: str = "scroll", **config) -> Node:
|
||||||
|
"""Create a camera node."""
|
||||||
|
return Node(name, NodeType.CAMERA, {"mode": mode, **config})
|
||||||
|
|
||||||
|
|
||||||
|
def display(name: str, backend: str = "terminal", **config) -> Node:
|
||||||
|
"""Create a display node."""
|
||||||
|
return Node(name, NodeType.DISPLAY, {"backend": backend, **config})
|
||||||
|
|
||||||
|
|
||||||
|
def effect(name: str, effect_name: str, **config) -> Node:
|
||||||
|
"""Create an effect node."""
|
||||||
|
return Node(name, NodeType.EFFECT, {"effect": effect_name, **config})
|
||||||
158
engine/pipeline/graph_adapter.py
Normal file
158
engine/pipeline/graph_adapter.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
"""Adapter to convert Graph to Pipeline stages.
|
||||||
|
|
||||||
|
This module bridges the new graph-based abstraction with the existing
|
||||||
|
Stage-based pipeline system for backward compatibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from engine.camera import Camera
|
||||||
|
from engine.data_sources.sources import EmptyDataSource, HeadlinesDataSource
|
||||||
|
from engine.display import DisplayRegistry
|
||||||
|
from engine.effects import get_registry
|
||||||
|
from engine.pipeline.adapters import (
|
||||||
|
CameraStage,
|
||||||
|
DataSourceStage,
|
||||||
|
DisplayStage,
|
||||||
|
EffectPluginStage,
|
||||||
|
FontStage,
|
||||||
|
MessageOverlayStage,
|
||||||
|
PositionStage,
|
||||||
|
)
|
||||||
|
from engine.pipeline.adapters.positioning import PositioningMode
|
||||||
|
from engine.pipeline.controller import Pipeline, PipelineConfig
|
||||||
|
from engine.pipeline.core import PipelineContext
|
||||||
|
from engine.pipeline.graph import Graph, NodeType
|
||||||
|
from engine.pipeline.params import PipelineParams
|
||||||
|
|
||||||
|
|
||||||
|
class GraphAdapter:
|
||||||
|
"""Converts Graph to Pipeline with existing Stage classes."""
|
||||||
|
|
||||||
|
def __init__(self, graph: Graph):
|
||||||
|
self.graph = graph
|
||||||
|
self.pipeline: Pipeline | None = None
|
||||||
|
self.context: PipelineContext | None = None
|
||||||
|
|
||||||
|
def build_pipeline(
|
||||||
|
self, viewport_width: int = 80, viewport_height: int = 24
|
||||||
|
) -> Pipeline:
|
||||||
|
"""Build a Pipeline from the Graph."""
|
||||||
|
# Create pipeline context
|
||||||
|
self.context = PipelineContext()
|
||||||
|
self.context.terminal_width = viewport_width
|
||||||
|
self.context.terminal_height = viewport_height
|
||||||
|
|
||||||
|
# Create params
|
||||||
|
params = PipelineParams(
|
||||||
|
viewport_width=viewport_width,
|
||||||
|
viewport_height=viewport_height,
|
||||||
|
)
|
||||||
|
self.context.params = params
|
||||||
|
|
||||||
|
# Create pipeline config
|
||||||
|
config = PipelineConfig()
|
||||||
|
|
||||||
|
# Create pipeline
|
||||||
|
self.pipeline = Pipeline(config=config, context=self.context)
|
||||||
|
|
||||||
|
# Map graph nodes to pipeline stages
|
||||||
|
self._map_nodes_to_stages()
|
||||||
|
|
||||||
|
# Build pipeline
|
||||||
|
self.pipeline.build()
|
||||||
|
|
||||||
|
return self.pipeline
|
||||||
|
|
||||||
|
def _map_nodes_to_stages(self) -> None:
|
||||||
|
"""Map graph nodes to pipeline stages."""
|
||||||
|
for name, node in self.graph.nodes.items():
|
||||||
|
if not node.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
stage = self._create_stage_from_node(name, node)
|
||||||
|
if stage:
|
||||||
|
self.pipeline.add_stage(name, stage)
|
||||||
|
|
||||||
|
def _create_stage_from_node(self, name: str, node) -> Optional:
|
||||||
|
"""Create a pipeline stage from a graph node."""
|
||||||
|
stage = None
|
||||||
|
|
||||||
|
if node.type == NodeType.SOURCE:
|
||||||
|
source_type = node.config.get("source", "headlines")
|
||||||
|
if source_type == "headlines":
|
||||||
|
source = HeadlinesDataSource()
|
||||||
|
elif source_type == "empty":
|
||||||
|
source = EmptyDataSource(
|
||||||
|
width=self.context.terminal_width,
|
||||||
|
height=self.context.terminal_height,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
source = EmptyDataSource(
|
||||||
|
width=self.context.terminal_width,
|
||||||
|
height=self.context.terminal_height,
|
||||||
|
)
|
||||||
|
stage = DataSourceStage(source, name=name)
|
||||||
|
|
||||||
|
elif node.type == NodeType.CAMERA:
|
||||||
|
mode = node.config.get("mode", "scroll")
|
||||||
|
speed = node.config.get("speed", 1.0)
|
||||||
|
# Map mode string to Camera factory method
|
||||||
|
mode_lower = mode.lower()
|
||||||
|
if hasattr(Camera, mode_lower):
|
||||||
|
camera_factory = getattr(Camera, mode_lower)
|
||||||
|
camera = camera_factory(speed=speed)
|
||||||
|
else:
|
||||||
|
# Fallback to scroll mode
|
||||||
|
camera = Camera.scroll(speed=speed)
|
||||||
|
stage = CameraStage(camera, name=name)
|
||||||
|
|
||||||
|
elif node.type == NodeType.DISPLAY:
|
||||||
|
backend = node.config.get("backend", "terminal")
|
||||||
|
positioning = node.config.get("positioning", "mixed")
|
||||||
|
display = DisplayRegistry.create(backend)
|
||||||
|
if display:
|
||||||
|
stage = DisplayStage(display, name=name, positioning=positioning)
|
||||||
|
|
||||||
|
elif node.type == NodeType.EFFECT:
|
||||||
|
effect_name = node.config.get("effect", "")
|
||||||
|
intensity = node.config.get("intensity", 1.0)
|
||||||
|
effect = get_registry().get(effect_name)
|
||||||
|
if effect:
|
||||||
|
# Set effect intensity (modifies global effect state)
|
||||||
|
effect.config.intensity = intensity
|
||||||
|
# Effects typically depend on rendered output
|
||||||
|
dependencies = {"render.output"}
|
||||||
|
stage = EffectPluginStage(effect, name=name, dependencies=dependencies)
|
||||||
|
|
||||||
|
elif node.type == NodeType.RENDER:
|
||||||
|
stage = FontStage(name=name)
|
||||||
|
|
||||||
|
elif node.type == NodeType.OVERLAY:
|
||||||
|
stage = MessageOverlayStage(name=name)
|
||||||
|
|
||||||
|
elif node.type == NodeType.POSITION:
|
||||||
|
mode_str = node.config.get("mode", "mixed")
|
||||||
|
try:
|
||||||
|
mode = PositioningMode(mode_str)
|
||||||
|
except ValueError:
|
||||||
|
mode = PositioningMode.MIXED
|
||||||
|
stage = PositionStage(mode=mode, name=name)
|
||||||
|
|
||||||
|
return stage
|
||||||
|
|
||||||
|
|
||||||
|
def graph_to_pipeline(
|
||||||
|
graph: Graph, viewport_width: int = 80, viewport_height: int = 24
|
||||||
|
) -> Pipeline:
|
||||||
|
"""Convert a Graph to a Pipeline."""
|
||||||
|
adapter = GraphAdapter(graph)
|
||||||
|
return adapter.build_pipeline(viewport_width, viewport_height)
|
||||||
|
|
||||||
|
|
||||||
|
def dict_to_pipeline(
|
||||||
|
data: dict[str, Any], viewport_width: int = 80, viewport_height: int = 24
|
||||||
|
) -> Pipeline:
|
||||||
|
"""Convert a dictionary to a Pipeline."""
|
||||||
|
graph = Graph().from_dict(data)
|
||||||
|
return graph_to_pipeline(graph, viewport_width, viewport_height)
|
||||||
113
engine/pipeline/graph_toml.py
Normal file
113
engine/pipeline/graph_toml.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""TOML-based graph configuration loader."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
from engine.pipeline.graph import Graph, NodeType
|
||||||
|
from engine.pipeline.graph_adapter import graph_to_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def load_graph_from_toml(toml_path: str | Path) -> Graph:
|
||||||
|
"""Load a graph from a TOML file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
toml_path: Path to the TOML file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Graph instance loaded from the TOML file
|
||||||
|
"""
|
||||||
|
with open(toml_path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
return graph_from_dict(data)
|
||||||
|
|
||||||
|
|
||||||
|
def graph_from_dict(data: dict[str, Any]) -> Graph:
|
||||||
|
"""Create a graph from a dictionary (TOML-compatible structure).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Dictionary with 'nodes' and 'connections' keys
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Graph instance
|
||||||
|
"""
|
||||||
|
graph = Graph()
|
||||||
|
|
||||||
|
# Parse nodes
|
||||||
|
nodes_data = data.get("nodes", {})
|
||||||
|
for name, node_info in nodes_data.items():
|
||||||
|
if isinstance(node_info, str):
|
||||||
|
# Simple format: "source": "headlines"
|
||||||
|
graph.node(name, NodeType.SOURCE, source=node_info)
|
||||||
|
elif isinstance(node_info, dict):
|
||||||
|
# Full format: {"type": "camera", "mode": "scroll"}
|
||||||
|
node_type = node_info.get("type", "custom")
|
||||||
|
config = {k: v for k, v in node_info.items() if k != "type"}
|
||||||
|
graph.node(name, node_type, **config)
|
||||||
|
|
||||||
|
# Parse connections
|
||||||
|
connections_data = data.get("connections", {})
|
||||||
|
if isinstance(connections_data, dict):
|
||||||
|
# Format: {"list": ["source -> camera -> display"]}
|
||||||
|
connections_list = connections_data.get("list", [])
|
||||||
|
else:
|
||||||
|
# Format: ["source -> camera -> display"]
|
||||||
|
connections_list = connections_data
|
||||||
|
|
||||||
|
for conn in connections_list:
|
||||||
|
if isinstance(conn, str):
|
||||||
|
# Parse "source -> target" format
|
||||||
|
parts = conn.split("->")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
# Connect all nodes in the chain
|
||||||
|
for i in range(len(parts) - 1):
|
||||||
|
source = parts[i].strip()
|
||||||
|
target = parts[i + 1].strip()
|
||||||
|
graph.connect(source, target)
|
||||||
|
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
def load_pipeline_from_toml(
|
||||||
|
toml_path: str | Path, viewport_width: int = 80, viewport_height: int = 24
|
||||||
|
):
|
||||||
|
"""Load a pipeline from a TOML file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
toml_path: Path to the TOML file
|
||||||
|
viewport_width: Terminal width for the pipeline
|
||||||
|
viewport_height: Terminal height for the pipeline
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Pipeline instance loaded from the TOML file
|
||||||
|
"""
|
||||||
|
graph = load_graph_from_toml(toml_path)
|
||||||
|
return graph_to_pipeline(graph, viewport_width, viewport_height)
|
||||||
|
|
||||||
|
|
||||||
|
# Example TOML structure:
|
||||||
|
EXAMPLE_TOML = """
|
||||||
|
# Graph-based pipeline configuration
|
||||||
|
[nodes.source]
|
||||||
|
type = "source"
|
||||||
|
source = "headlines"
|
||||||
|
|
||||||
|
[nodes.camera]
|
||||||
|
type = "camera"
|
||||||
|
mode = "scroll"
|
||||||
|
speed = 1.0
|
||||||
|
|
||||||
|
[nodes.noise]
|
||||||
|
type = "effect"
|
||||||
|
effect = "noise"
|
||||||
|
intensity = 0.3
|
||||||
|
|
||||||
|
[nodes.display]
|
||||||
|
type = "display"
|
||||||
|
backend = "terminal"
|
||||||
|
|
||||||
|
[connections]
|
||||||
|
list = ["source -> camera -> noise -> display"]
|
||||||
|
"""
|
||||||
282
engine/pipeline/hybrid_config.py
Normal file
282
engine/pipeline/hybrid_config.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
"""Hybrid Preset-Graph Configuration System
|
||||||
|
|
||||||
|
This module provides a configuration format that combines the simplicity
|
||||||
|
of presets with the flexibility of graphs.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
[pipeline]
|
||||||
|
source = "headlines"
|
||||||
|
camera = { mode = "scroll", speed = 1.0 }
|
||||||
|
effects = [
|
||||||
|
{ name = "noise", intensity = 0.3 },
|
||||||
|
{ name = "fade", intensity = 0.5 }
|
||||||
|
]
|
||||||
|
display = { backend = "terminal" }
|
||||||
|
|
||||||
|
This is much more concise than the verbose node-based graph DSL while
|
||||||
|
providing the same flexibility.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.pipeline.graph import Graph, NodeType
|
||||||
|
from engine.pipeline.graph_adapter import graph_to_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EffectConfig:
|
||||||
|
"""Configuration for a single effect."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
intensity: float = 1.0
|
||||||
|
enabled: bool = True
|
||||||
|
params: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CameraConfig:
|
||||||
|
"""Configuration for camera."""
|
||||||
|
|
||||||
|
mode: str = "scroll"
|
||||||
|
speed: float = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DisplayConfig:
|
||||||
|
"""Configuration for display."""
|
||||||
|
|
||||||
|
backend: str = "terminal"
|
||||||
|
positioning: str = "mixed"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PipelineConfig:
|
||||||
|
"""Hybrid pipeline configuration combining preset simplicity with graph flexibility.
|
||||||
|
|
||||||
|
This format provides a concise way to define pipelines that's 70% smaller
|
||||||
|
than the verbose node-based DSL while maintaining full flexibility.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
[pipeline]
|
||||||
|
source = "headlines"
|
||||||
|
camera = { mode = "scroll", speed = 1.0 }
|
||||||
|
effects = [
|
||||||
|
{ name = "noise", intensity = 0.3 },
|
||||||
|
{ name = "fade", intensity = 0.5 }
|
||||||
|
]
|
||||||
|
display = { backend = "terminal", positioning = "mixed" }
|
||||||
|
"""
|
||||||
|
|
||||||
|
source: str = "headlines"
|
||||||
|
camera: CameraConfig | None = None
|
||||||
|
effects: list[EffectConfig] = field(default_factory=list)
|
||||||
|
display: DisplayConfig | None = None
|
||||||
|
viewport_width: int = 80
|
||||||
|
viewport_height: int = 24
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_preset(cls, preset_name: str) -> "PipelineConfig":
|
||||||
|
"""Create PipelineConfig from a preset name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset_name: Name of preset (e.g., "upstream-default")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PipelineConfig instance
|
||||||
|
"""
|
||||||
|
from engine.pipeline import get_preset
|
||||||
|
|
||||||
|
preset = get_preset(preset_name)
|
||||||
|
if not preset:
|
||||||
|
raise ValueError(f"Preset '{preset_name}' not found")
|
||||||
|
|
||||||
|
# Convert preset to PipelineConfig
|
||||||
|
effects = [EffectConfig(name=e, intensity=1.0) for e in preset.effects]
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
source=preset.source,
|
||||||
|
camera=CameraConfig(mode=preset.camera, speed=preset.camera_speed),
|
||||||
|
effects=effects,
|
||||||
|
display=DisplayConfig(
|
||||||
|
backend=preset.display, positioning=preset.positioning
|
||||||
|
),
|
||||||
|
viewport_width=preset.viewport_width,
|
||||||
|
viewport_height=preset.viewport_height,
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_graph(self) -> Graph:
|
||||||
|
"""Convert hybrid config to Graph representation."""
|
||||||
|
graph = Graph()
|
||||||
|
|
||||||
|
# Add source node
|
||||||
|
graph.node("source", NodeType.SOURCE, source=self.source)
|
||||||
|
|
||||||
|
# Add camera node if configured
|
||||||
|
if self.camera:
|
||||||
|
graph.node(
|
||||||
|
"camera",
|
||||||
|
NodeType.CAMERA,
|
||||||
|
mode=self.camera.mode,
|
||||||
|
speed=self.camera.speed,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add effect nodes
|
||||||
|
for effect in self.effects:
|
||||||
|
# Handle both EffectConfig objects and dictionaries
|
||||||
|
if isinstance(effect, dict):
|
||||||
|
name = effect.get("name", "")
|
||||||
|
intensity = effect.get("intensity", 1.0)
|
||||||
|
enabled = effect.get("enabled", True)
|
||||||
|
params = effect.get("params", {})
|
||||||
|
else:
|
||||||
|
name = effect.name
|
||||||
|
intensity = effect.intensity
|
||||||
|
enabled = effect.enabled
|
||||||
|
params = effect.params
|
||||||
|
|
||||||
|
if name:
|
||||||
|
graph.node(
|
||||||
|
name,
|
||||||
|
NodeType.EFFECT,
|
||||||
|
effect=name,
|
||||||
|
intensity=intensity,
|
||||||
|
enabled=enabled,
|
||||||
|
**params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add display node
|
||||||
|
if isinstance(self.display, dict):
|
||||||
|
display_backend = self.display.get("backend", "terminal")
|
||||||
|
display_positioning = self.display.get("positioning", "mixed")
|
||||||
|
elif self.display:
|
||||||
|
display_backend = self.display.backend
|
||||||
|
display_positioning = self.display.positioning
|
||||||
|
else:
|
||||||
|
display_backend = "terminal"
|
||||||
|
display_positioning = "mixed"
|
||||||
|
|
||||||
|
graph.node(
|
||||||
|
"display",
|
||||||
|
NodeType.DISPLAY,
|
||||||
|
backend=display_backend,
|
||||||
|
positioning=display_positioning,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create linear connections
|
||||||
|
# Build chain: source -> camera -> effects... -> display
|
||||||
|
chain = ["source"]
|
||||||
|
|
||||||
|
if self.camera:
|
||||||
|
chain.append("camera")
|
||||||
|
|
||||||
|
# Add all effects in order
|
||||||
|
for effect in self.effects:
|
||||||
|
name = effect.get("name", "") if isinstance(effect, dict) else effect.name
|
||||||
|
if name:
|
||||||
|
chain.append(name)
|
||||||
|
|
||||||
|
chain.append("display")
|
||||||
|
|
||||||
|
# Connect all nodes in chain
|
||||||
|
for i in range(len(chain) - 1):
|
||||||
|
graph.connect(chain[i], chain[i + 1])
|
||||||
|
|
||||||
|
return graph
|
||||||
|
|
||||||
|
def to_pipeline(self, viewport_width: int = 80, viewport_height: int = 24):
|
||||||
|
"""Convert to Pipeline instance."""
|
||||||
|
graph = self.to_graph()
|
||||||
|
return graph_to_pipeline(graph, viewport_width, viewport_height)
|
||||||
|
|
||||||
|
|
||||||
|
def load_hybrid_config(toml_path: str | Path) -> PipelineConfig:
|
||||||
|
"""Load hybrid configuration from TOML file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
toml_path: Path to TOML file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PipelineConfig instance
|
||||||
|
"""
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
with open(toml_path, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
return parse_hybrid_config(data)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_hybrid_config(data: dict[str, Any]) -> PipelineConfig:
|
||||||
|
"""Parse hybrid configuration from dictionary.
|
||||||
|
|
||||||
|
Expected format:
|
||||||
|
{
|
||||||
|
"pipeline": {
|
||||||
|
"source": "headlines",
|
||||||
|
"camera": {"mode": "scroll", "speed": 1.0},
|
||||||
|
"effects": [
|
||||||
|
{"name": "noise", "intensity": 0.3},
|
||||||
|
{"name": "fade", "intensity": 0.5}
|
||||||
|
],
|
||||||
|
"display": {"backend": "terminal"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
pipeline_data = data.get("pipeline", {})
|
||||||
|
|
||||||
|
# Parse camera config
|
||||||
|
camera = None
|
||||||
|
if "camera" in pipeline_data:
|
||||||
|
camera_data = pipeline_data["camera"]
|
||||||
|
if isinstance(camera_data, dict):
|
||||||
|
camera = CameraConfig(
|
||||||
|
mode=camera_data.get("mode", "scroll"),
|
||||||
|
speed=camera_data.get("speed", 1.0),
|
||||||
|
)
|
||||||
|
elif isinstance(camera_data, str):
|
||||||
|
camera = CameraConfig(mode=camera_data)
|
||||||
|
|
||||||
|
# Parse effects list
|
||||||
|
effects = []
|
||||||
|
if "effects" in pipeline_data:
|
||||||
|
effects_data = pipeline_data["effects"]
|
||||||
|
if isinstance(effects_data, list):
|
||||||
|
for effect_item in effects_data:
|
||||||
|
if isinstance(effect_item, dict):
|
||||||
|
effects.append(
|
||||||
|
EffectConfig(
|
||||||
|
name=effect_item.get("name", ""),
|
||||||
|
intensity=effect_item.get("intensity", 1.0),
|
||||||
|
enabled=effect_item.get("enabled", True),
|
||||||
|
params=effect_item.get("params", {}),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif isinstance(effect_item, str):
|
||||||
|
effects.append(EffectConfig(name=effect_item))
|
||||||
|
|
||||||
|
# Parse display config
|
||||||
|
display = None
|
||||||
|
if "display" in pipeline_data:
|
||||||
|
display_data = pipeline_data["display"]
|
||||||
|
if isinstance(display_data, dict):
|
||||||
|
display = DisplayConfig(
|
||||||
|
backend=display_data.get("backend", "terminal"),
|
||||||
|
positioning=display_data.get("positioning", "mixed"),
|
||||||
|
)
|
||||||
|
elif isinstance(display_data, str):
|
||||||
|
display = DisplayConfig(backend=display_data)
|
||||||
|
|
||||||
|
# Parse viewport settings
|
||||||
|
viewport_width = pipeline_data.get("viewport_width", 80)
|
||||||
|
viewport_height = pipeline_data.get("viewport_height", 24)
|
||||||
|
|
||||||
|
return PipelineConfig(
|
||||||
|
source=pipeline_data.get("source", "headlines"),
|
||||||
|
camera=camera,
|
||||||
|
effects=effects,
|
||||||
|
display=display,
|
||||||
|
viewport_width=viewport_width,
|
||||||
|
viewport_height=viewport_height,
|
||||||
|
)
|
||||||
@@ -8,6 +8,11 @@ modify these params, which the pipeline then applies to its stages.
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
from engine.display import BorderMode
|
||||||
|
except ImportError:
|
||||||
|
BorderMode = object # Fallback for type checking
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PipelineParams:
|
class PipelineParams:
|
||||||
@@ -23,11 +28,12 @@ class PipelineParams:
|
|||||||
|
|
||||||
# Display config
|
# Display config
|
||||||
display: str = "terminal"
|
display: str = "terminal"
|
||||||
border: bool = False
|
border: bool | BorderMode = False
|
||||||
|
positioning: str = "mixed" # Positioning mode: "absolute", "relative", "mixed"
|
||||||
|
|
||||||
# Camera config
|
# Camera config
|
||||||
camera_mode: str = "vertical"
|
camera_mode: str = "vertical"
|
||||||
camera_speed: float = 1.0
|
camera_speed: float = 1.0 # Default speed
|
||||||
camera_x: int = 0 # For horizontal scrolling
|
camera_x: int = 0 # For horizontal scrolling
|
||||||
|
|
||||||
# Effect config
|
# Effect config
|
||||||
@@ -79,6 +85,7 @@ class PipelineParams:
|
|||||||
return {
|
return {
|
||||||
"source": self.source,
|
"source": self.source,
|
||||||
"display": self.display,
|
"display": self.display,
|
||||||
|
"positioning": self.positioning,
|
||||||
"camera_mode": self.camera_mode,
|
"camera_mode": self.camera_mode,
|
||||||
"camera_speed": self.camera_speed,
|
"camera_speed": self.camera_speed,
|
||||||
"effect_order": self.effect_order,
|
"effect_order": self.effect_order,
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ Loading order:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from engine.display import BorderMode
|
||||||
from engine.pipeline.params import PipelineParams
|
from engine.pipeline.params import PipelineParams
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from engine.pipeline.controller import PipelineConfig
|
||||||
|
|
||||||
|
|
||||||
def _load_toml_presets() -> dict[str, Any]:
|
def _load_toml_presets() -> dict[str, Any]:
|
||||||
"""Load presets from TOML file."""
|
"""Load presets from TOML file."""
|
||||||
@@ -26,7 +30,6 @@ def _load_toml_presets() -> dict[str, Any]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
# Pre-load TOML presets
|
|
||||||
_YAML_PRESETS = _load_toml_presets()
|
_YAML_PRESETS = _load_toml_presets()
|
||||||
|
|
||||||
|
|
||||||
@@ -47,18 +50,56 @@ class PipelinePreset:
|
|||||||
display: str = "terminal"
|
display: str = "terminal"
|
||||||
camera: str = "scroll"
|
camera: str = "scroll"
|
||||||
effects: list[str] = field(default_factory=list)
|
effects: list[str] = field(default_factory=list)
|
||||||
border: bool = False
|
border: bool | BorderMode = (
|
||||||
|
False # Border mode: False=off, True=simple, BorderMode.UI for panel
|
||||||
|
)
|
||||||
|
# Extended fields for fine-tuning
|
||||||
|
camera_speed: float = 1.0 # Camera movement speed
|
||||||
|
viewport_width: int = 80 # Viewport width in columns
|
||||||
|
viewport_height: int = 24 # Viewport height in rows
|
||||||
|
source_items: list[dict[str, Any]] | None = None # For ListDataSource
|
||||||
|
enable_metrics: bool = True # Enable performance metrics collection
|
||||||
|
enable_message_overlay: bool = False # Enable ntfy message overlay
|
||||||
|
positioning: str = "mixed" # Positioning mode: "absolute", "relative", "mixed"
|
||||||
|
|
||||||
def to_params(self) -> PipelineParams:
|
def to_params(self) -> PipelineParams:
|
||||||
"""Convert to PipelineParams."""
|
"""Convert to PipelineParams (runtime configuration)."""
|
||||||
|
from engine.display import BorderMode
|
||||||
|
|
||||||
params = PipelineParams()
|
params = PipelineParams()
|
||||||
params.source = self.source
|
params.source = self.source
|
||||||
params.display = self.display
|
params.display = self.display
|
||||||
params.border = self.border
|
params.positioning = self.positioning
|
||||||
|
params.border = (
|
||||||
|
self.border
|
||||||
|
if isinstance(self.border, bool)
|
||||||
|
else BorderMode.UI
|
||||||
|
if self.border == BorderMode.UI
|
||||||
|
else False
|
||||||
|
)
|
||||||
params.camera_mode = self.camera
|
params.camera_mode = self.camera
|
||||||
params.effect_order = self.effects.copy()
|
params.effect_order = self.effects.copy()
|
||||||
|
params.camera_speed = self.camera_speed
|
||||||
|
# Note: viewport_width/height are read from PipelinePreset directly
|
||||||
|
# in pipeline_runner.py, not from PipelineParams
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
def to_config(self) -> "PipelineConfig":
|
||||||
|
"""Convert to PipelineConfig (static pipeline construction config).
|
||||||
|
|
||||||
|
PipelineConfig is used once at pipeline initialization and contains
|
||||||
|
the core settings that don't change during execution.
|
||||||
|
"""
|
||||||
|
from engine.pipeline.controller import PipelineConfig
|
||||||
|
|
||||||
|
return PipelineConfig(
|
||||||
|
source=self.source,
|
||||||
|
display=self.display,
|
||||||
|
camera=self.camera,
|
||||||
|
effects=self.effects.copy(),
|
||||||
|
enable_metrics=self.enable_metrics,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_yaml(cls, name: str, data: dict[str, Any]) -> "PipelinePreset":
|
def from_yaml(cls, name: str, data: dict[str, Any]) -> "PipelinePreset":
|
||||||
"""Create a PipelinePreset from YAML data."""
|
"""Create a PipelinePreset from YAML data."""
|
||||||
@@ -70,17 +111,55 @@ class PipelinePreset:
|
|||||||
camera=data.get("camera", "vertical"),
|
camera=data.get("camera", "vertical"),
|
||||||
effects=data.get("effects", []),
|
effects=data.get("effects", []),
|
||||||
border=data.get("border", False),
|
border=data.get("border", False),
|
||||||
|
camera_speed=data.get("camera_speed", 1.0),
|
||||||
|
viewport_width=data.get("viewport_width", 80),
|
||||||
|
viewport_height=data.get("viewport_height", 24),
|
||||||
|
source_items=data.get("source_items"),
|
||||||
|
enable_metrics=data.get("enable_metrics", True),
|
||||||
|
enable_message_overlay=data.get("enable_message_overlay", False),
|
||||||
|
positioning=data.get("positioning", "mixed"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Built-in presets
|
# Built-in presets
|
||||||
|
# Upstream-default preset: Matches the default upstream Mainline operation
|
||||||
|
UPSTREAM_PRESET = PipelinePreset(
|
||||||
|
name="upstream-default",
|
||||||
|
description="Upstream default operation (terminal display, legacy behavior)",
|
||||||
|
source="headlines",
|
||||||
|
display="terminal",
|
||||||
|
camera="scroll",
|
||||||
|
effects=["noise", "fade", "glitch", "firehose"],
|
||||||
|
enable_message_overlay=False,
|
||||||
|
positioning="mixed",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Demo preset: Showcases hotswappable effects and sensors
|
||||||
|
# This preset demonstrates the sideline features:
|
||||||
|
# - Hotswappable effects via effect plugins
|
||||||
|
# - Sensor integration (oscillator LFO for modulation)
|
||||||
|
# - Mixed positioning mode
|
||||||
|
# - Message overlay with ntfy integration
|
||||||
DEMO_PRESET = PipelinePreset(
|
DEMO_PRESET = PipelinePreset(
|
||||||
name="demo",
|
name="demo",
|
||||||
description="Demo mode with effect cycling and camera modes",
|
description="Demo: Hotswappable effects, LFO sensor modulation, mixed positioning",
|
||||||
source="headlines",
|
source="headlines",
|
||||||
display="pygame",
|
display="pygame",
|
||||||
camera="scroll",
|
camera="scroll",
|
||||||
effects=["noise", "fade", "glitch", "firehose"],
|
effects=["noise", "fade", "glitch", "firehose", "hud"],
|
||||||
|
enable_message_overlay=True,
|
||||||
|
positioning="mixed",
|
||||||
|
)
|
||||||
|
|
||||||
|
UI_PRESET = PipelinePreset(
|
||||||
|
name="ui",
|
||||||
|
description="Interactive UI mode with right-side control panel",
|
||||||
|
source="fixture",
|
||||||
|
display="pygame",
|
||||||
|
camera="scroll",
|
||||||
|
effects=["noise", "fade", "glitch"],
|
||||||
|
border=BorderMode.UI,
|
||||||
|
enable_message_overlay=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
POETRY_PRESET = PipelinePreset(
|
POETRY_PRESET = PipelinePreset(
|
||||||
@@ -110,15 +189,6 @@ WEBSOCKET_PRESET = PipelinePreset(
|
|||||||
effects=["noise", "fade", "glitch"],
|
effects=["noise", "fade", "glitch"],
|
||||||
)
|
)
|
||||||
|
|
||||||
SIXEL_PRESET = PipelinePreset(
|
|
||||||
name="sixel",
|
|
||||||
description="Sixel graphics display mode",
|
|
||||||
source="headlines",
|
|
||||||
display="sixel",
|
|
||||||
camera="scroll",
|
|
||||||
effects=["noise", "fade", "glitch"],
|
|
||||||
)
|
|
||||||
|
|
||||||
FIREHOSE_PRESET = PipelinePreset(
|
FIREHOSE_PRESET = PipelinePreset(
|
||||||
name="firehose",
|
name="firehose",
|
||||||
description="High-speed firehose mode",
|
description="High-speed firehose mode",
|
||||||
@@ -126,6 +196,17 @@ FIREHOSE_PRESET = PipelinePreset(
|
|||||||
display="pygame",
|
display="pygame",
|
||||||
camera="scroll",
|
camera="scroll",
|
||||||
effects=["noise", "fade", "glitch", "firehose"],
|
effects=["noise", "fade", "glitch", "firehose"],
|
||||||
|
enable_message_overlay=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
FIXTURE_PRESET = PipelinePreset(
|
||||||
|
name="fixture",
|
||||||
|
description="Use cached headline fixtures",
|
||||||
|
source="fixture",
|
||||||
|
display="pygame",
|
||||||
|
camera="scroll",
|
||||||
|
effects=["noise", "fade"],
|
||||||
|
border=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,11 +223,13 @@ def _build_presets() -> dict[str, PipelinePreset]:
|
|||||||
# Add built-in presets as fallback (if not in YAML)
|
# Add built-in presets as fallback (if not in YAML)
|
||||||
builtins = {
|
builtins = {
|
||||||
"demo": DEMO_PRESET,
|
"demo": DEMO_PRESET,
|
||||||
|
"upstream-default": UPSTREAM_PRESET,
|
||||||
"poetry": POETRY_PRESET,
|
"poetry": POETRY_PRESET,
|
||||||
"pipeline": PIPELINE_VIZ_PRESET,
|
"pipeline": PIPELINE_VIZ_PRESET,
|
||||||
"websocket": WEBSOCKET_PRESET,
|
"websocket": WEBSOCKET_PRESET,
|
||||||
"sixel": SIXEL_PRESET,
|
|
||||||
"firehose": FIREHOSE_PRESET,
|
"firehose": FIREHOSE_PRESET,
|
||||||
|
"ui": UI_PRESET,
|
||||||
|
"fixture": FIXTURE_PRESET,
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, preset in builtins.items():
|
for name, preset in builtins.items():
|
||||||
|
|||||||
@@ -118,6 +118,14 @@ def discover_stages() -> None:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Register buffer stages (framebuffer, etc.)
|
||||||
|
try:
|
||||||
|
from engine.pipeline.stages.framebuffer import FrameBufferStage
|
||||||
|
|
||||||
|
StageRegistry.register("effect", FrameBufferStage)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Register display stages
|
# Register display stages
|
||||||
_register_display_stages()
|
_register_display_stages()
|
||||||
|
|
||||||
|
|||||||
174
engine/pipeline/stages/framebuffer.py
Normal file
174
engine/pipeline/stages/framebuffer.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Frame buffer stage - stores previous frames for temporal effects.
|
||||||
|
|
||||||
|
Provides (per-instance, using instance name):
|
||||||
|
- framebuffer.{name}.history: list of previous buffers (most recent first)
|
||||||
|
- framebuffer.{name}.intensity_history: list of corresponding intensity maps
|
||||||
|
- framebuffer.{name}.current_intensity: intensity map for current frame
|
||||||
|
|
||||||
|
Capability: "framebuffer.history.{name}"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.display import _strip_ansi
|
||||||
|
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrameBufferConfig:
|
||||||
|
"""Configuration for FrameBufferStage."""
|
||||||
|
|
||||||
|
history_depth: int = 2 # Number of previous frames to keep
|
||||||
|
name: str = "default" # Unique instance name for capability and context keys
|
||||||
|
|
||||||
|
|
||||||
|
class FrameBufferStage(Stage):
|
||||||
|
"""Stores frame history and computes intensity maps.
|
||||||
|
|
||||||
|
Supports multiple instances with unique capabilities and context keys.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = "framebuffer"
|
||||||
|
category = "effect" # It's an effect that enriches context with frame history
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: FrameBufferConfig | None = None,
|
||||||
|
history_depth: int = 2,
|
||||||
|
name: str = "default",
|
||||||
|
):
|
||||||
|
self.config = config or FrameBufferConfig(
|
||||||
|
history_depth=history_depth, name=name
|
||||||
|
)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {f"framebuffer.history.{self.config.name}"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
# Depends on rendered output (since we want to capture final buffer)
|
||||||
|
return {"render.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER} # Pass through unchanged
|
||||||
|
|
||||||
|
def init(self, ctx: PipelineContext) -> bool:
|
||||||
|
"""Initialize framebuffer state in context."""
|
||||||
|
prefix = f"framebuffer.{self.config.name}"
|
||||||
|
ctx.set(f"{prefix}.history", [])
|
||||||
|
ctx.set(f"{prefix}.intensity_history", [])
|
||||||
|
return True
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Store frame in history and compute intensity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Current text buffer (list[str])
|
||||||
|
ctx: Pipeline context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Same buffer (pass-through)
|
||||||
|
"""
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return data
|
||||||
|
|
||||||
|
prefix = f"framebuffer.{self.config.name}"
|
||||||
|
|
||||||
|
# Compute intensity map for current buffer (per-row, length = buffer rows)
|
||||||
|
intensity_map = self._compute_buffer_intensity(data, len(data))
|
||||||
|
|
||||||
|
# Store in context
|
||||||
|
ctx.set(f"{prefix}.current_intensity", intensity_map)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# Get existing histories
|
||||||
|
history = ctx.get(f"{prefix}.history", [])
|
||||||
|
intensity_hist = ctx.get(f"{prefix}.intensity_history", [])
|
||||||
|
|
||||||
|
# Prepend current frame to history
|
||||||
|
history.insert(0, data.copy())
|
||||||
|
intensity_hist.insert(0, intensity_map)
|
||||||
|
|
||||||
|
# Trim to configured depth
|
||||||
|
max_depth = self.config.history_depth
|
||||||
|
ctx.set(f"{prefix}.history", history[:max_depth])
|
||||||
|
ctx.set(f"{prefix}.intensity_history", intensity_hist[:max_depth])
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _compute_buffer_intensity(
|
||||||
|
self, buf: list[str], max_rows: int = 24
|
||||||
|
) -> list[float]:
|
||||||
|
"""Compute average intensity per row in buffer.
|
||||||
|
|
||||||
|
Uses ANSI color if available; falls back to character density.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buf: Text buffer (list of strings)
|
||||||
|
max_rows: Maximum number of rows to process
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of intensity values (0.0-1.0) per row
|
||||||
|
"""
|
||||||
|
intensities = []
|
||||||
|
# Limit to viewport height
|
||||||
|
lines = buf[:max_rows]
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
# Strip ANSI codes for length calc
|
||||||
|
|
||||||
|
plain = _strip_ansi(line)
|
||||||
|
if not plain:
|
||||||
|
intensities.append(0.0)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Simple heuristic: ratio of non-space characters
|
||||||
|
# More sophisticated version could parse ANSI RGB brightness
|
||||||
|
filled = sum(1 for c in plain if c not in (" ", "\t"))
|
||||||
|
total = len(plain)
|
||||||
|
intensity = filled / total if total > 0 else 0.0
|
||||||
|
intensities.append(max(0.0, min(1.0, intensity)))
|
||||||
|
|
||||||
|
# Pad to max_rows if needed
|
||||||
|
while len(intensities) < max_rows:
|
||||||
|
intensities.append(0.0)
|
||||||
|
|
||||||
|
return intensities
|
||||||
|
|
||||||
|
def get_frame(
|
||||||
|
self, index: int = 0, ctx: PipelineContext | None = None
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""Get frame from history by index (0 = current, 1 = previous, etc)."""
|
||||||
|
if ctx is None:
|
||||||
|
return None
|
||||||
|
prefix = f"framebuffer.{self.config.name}"
|
||||||
|
history = ctx.get(f"{prefix}.history", [])
|
||||||
|
if 0 <= index < len(history):
|
||||||
|
return history[index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_intensity(
|
||||||
|
self, index: int = 0, ctx: PipelineContext | None = None
|
||||||
|
) -> list[float] | None:
|
||||||
|
"""Get intensity map from history by index."""
|
||||||
|
if ctx is None:
|
||||||
|
return None
|
||||||
|
prefix = f"framebuffer.{self.config.name}"
|
||||||
|
intensity_hist = ctx.get(f"{prefix}.intensity_history", [])
|
||||||
|
if 0 <= index < len(intensity_hist):
|
||||||
|
return intensity_hist[index]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""Cleanup resources."""
|
||||||
|
pass
|
||||||
674
engine/pipeline/ui.py
Normal file
674
engine/pipeline/ui.py
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
"""
|
||||||
|
Pipeline UI panel - Interactive controls for pipeline configuration.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- Stage list with enable/disable toggles
|
||||||
|
- Parameter sliders for selected effect
|
||||||
|
- Keyboard/mouse interaction
|
||||||
|
|
||||||
|
This module implements the right-side UI panel that appears in border="ui" mode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UIConfig:
|
||||||
|
"""Configuration for the UI panel."""
|
||||||
|
|
||||||
|
panel_width: int = 24 # Characters wide
|
||||||
|
stage_list_height: int = 12 # Number of stages to show at once
|
||||||
|
param_height: int = 8 # Space for parameter controls
|
||||||
|
scroll_offset: int = 0 # Scroll position in stage list
|
||||||
|
start_with_preset_picker: bool = False # Show preset picker immediately
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StageControl:
|
||||||
|
"""Represents a stage in the UI panel with its toggle state."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
stage_name: str # Actual pipeline stage name
|
||||||
|
category: str
|
||||||
|
enabled: bool = True
|
||||||
|
selected: bool = False
|
||||||
|
params: dict[str, Any] = field(default_factory=dict) # Current param values
|
||||||
|
param_schema: dict[str, dict] = field(default_factory=dict) # Param metadata
|
||||||
|
|
||||||
|
def toggle(self) -> None:
|
||||||
|
"""Toggle enabled state."""
|
||||||
|
self.enabled = not self.enabled
|
||||||
|
|
||||||
|
def get_param(self, name: str) -> Any:
|
||||||
|
"""Get current parameter value."""
|
||||||
|
return self.params.get(name)
|
||||||
|
|
||||||
|
def set_param(self, name: str, value: Any) -> None:
|
||||||
|
"""Set parameter value."""
|
||||||
|
self.params[name] = value
|
||||||
|
|
||||||
|
|
||||||
|
class UIPanel:
|
||||||
|
"""Interactive UI panel for pipeline configuration.
|
||||||
|
|
||||||
|
Manages:
|
||||||
|
- Stage list with enable/disable checkboxes
|
||||||
|
- Parameter sliders for selected stage
|
||||||
|
- Keyboard/mouse event handling
|
||||||
|
- Scroll state for long stage lists
|
||||||
|
|
||||||
|
The panel is rendered as a right border (panel_width characters wide)
|
||||||
|
alongside the main viewport.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: UIConfig | None = None):
|
||||||
|
self.config = config or UIConfig()
|
||||||
|
self.stages: dict[str, StageControl] = {} # stage_name -> StageControl
|
||||||
|
self.scroll_offset = 0
|
||||||
|
self.selected_stage: str | None = None
|
||||||
|
self._focused_param: str | None = None # For slider adjustment
|
||||||
|
self._callbacks: dict[str, Callable] = {} # Event callbacks
|
||||||
|
self._presets: list[str] = [] # Available preset names
|
||||||
|
self._current_preset: str = "" # Current preset name
|
||||||
|
self._show_preset_picker: bool = (
|
||||||
|
config.start_with_preset_picker if config else False
|
||||||
|
) # Picker overlay visible
|
||||||
|
self._show_panel: bool = True # UI panel visibility
|
||||||
|
self._preset_scroll_offset: int = 0 # Scroll in preset list
|
||||||
|
|
||||||
|
def save_state(self) -> dict[str, Any]:
|
||||||
|
"""Save UI panel state for restoration after pipeline rebuild.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing UI panel state that can be restored
|
||||||
|
"""
|
||||||
|
# Save stage control states (enabled, params, etc.)
|
||||||
|
stage_states = {}
|
||||||
|
for name, ctrl in self.stages.items():
|
||||||
|
stage_states[name] = {
|
||||||
|
"enabled": ctrl.enabled,
|
||||||
|
"selected": ctrl.selected,
|
||||||
|
"params": dict(ctrl.params), # Copy params dict
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"stage_states": stage_states,
|
||||||
|
"scroll_offset": self.scroll_offset,
|
||||||
|
"selected_stage": self.selected_stage,
|
||||||
|
"_focused_param": self._focused_param,
|
||||||
|
"_show_panel": self._show_panel,
|
||||||
|
"_show_preset_picker": self._show_preset_picker,
|
||||||
|
"_preset_scroll_offset": self._preset_scroll_offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
def restore_state(self, state: dict[str, Any]) -> None:
|
||||||
|
"""Restore UI panel state from saved state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Dictionary containing UI panel state from save_state()
|
||||||
|
"""
|
||||||
|
# Restore stage control states
|
||||||
|
stage_states = state.get("stage_states", {})
|
||||||
|
for name, stage_state in stage_states.items():
|
||||||
|
if name in self.stages:
|
||||||
|
ctrl = self.stages[name]
|
||||||
|
ctrl.enabled = stage_state.get("enabled", True)
|
||||||
|
ctrl.selected = stage_state.get("selected", False)
|
||||||
|
# Restore params
|
||||||
|
saved_params = stage_state.get("params", {})
|
||||||
|
for param_name, param_value in saved_params.items():
|
||||||
|
if param_name in ctrl.params:
|
||||||
|
ctrl.params[param_name] = param_value
|
||||||
|
|
||||||
|
# Restore UI panel state
|
||||||
|
self.scroll_offset = state.get("scroll_offset", 0)
|
||||||
|
self.selected_stage = state.get("selected_stage")
|
||||||
|
self._focused_param = state.get("_focused_param")
|
||||||
|
self._show_panel = state.get("_show_panel", True)
|
||||||
|
self._show_preset_picker = state.get("_show_preset_picker", False)
|
||||||
|
self._preset_scroll_offset = state.get("_preset_scroll_offset", 0)
|
||||||
|
|
||||||
|
def register_stage(self, stage: Any, enabled: bool = True) -> StageControl:
|
||||||
|
"""Register a stage for UI control.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stage: Stage instance (must have .name, .category attributes)
|
||||||
|
enabled: Initial enabled state
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created StageControl instance
|
||||||
|
"""
|
||||||
|
control = StageControl(
|
||||||
|
name=stage.name,
|
||||||
|
stage_name=stage.name,
|
||||||
|
category=stage.category,
|
||||||
|
enabled=enabled,
|
||||||
|
)
|
||||||
|
self.stages[stage.name] = control
|
||||||
|
return control
|
||||||
|
|
||||||
|
def unregister_stage(self, stage_name: str) -> None:
|
||||||
|
"""Remove a stage from UI control."""
|
||||||
|
if stage_name in self.stages:
|
||||||
|
del self.stages[stage_name]
|
||||||
|
|
||||||
|
def get_enabled_stages(self) -> list[str]:
|
||||||
|
"""Get list of stage names that are currently enabled."""
|
||||||
|
return [name for name, ctrl in self.stages.items() if ctrl.enabled]
|
||||||
|
|
||||||
|
def select_stage(self, stage_name: str | None = None) -> None:
|
||||||
|
"""Select a stage (for parameter editing)."""
|
||||||
|
if stage_name in self.stages:
|
||||||
|
self.selected_stage = stage_name
|
||||||
|
self.stages[stage_name].selected = True
|
||||||
|
# Deselect others
|
||||||
|
for name, ctrl in self.stages.items():
|
||||||
|
if name != stage_name:
|
||||||
|
ctrl.selected = False
|
||||||
|
# Auto-focus first parameter when stage selected
|
||||||
|
if self.stages[stage_name].params:
|
||||||
|
self._focused_param = next(iter(self.stages[stage_name].params.keys()))
|
||||||
|
else:
|
||||||
|
self._focused_param = None
|
||||||
|
|
||||||
|
def toggle_stage(self, stage_name: str) -> bool:
|
||||||
|
"""Toggle a stage's enabled state.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New enabled state
|
||||||
|
"""
|
||||||
|
if stage_name in self.stages:
|
||||||
|
ctrl = self.stages[stage_name]
|
||||||
|
ctrl.enabled = not ctrl.enabled
|
||||||
|
return ctrl.enabled
|
||||||
|
return False
|
||||||
|
|
||||||
|
def adjust_selected_param(self, delta: float) -> None:
|
||||||
|
"""Adjust the currently focused parameter of selected stage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delta: Amount to add (positive or negative)
|
||||||
|
"""
|
||||||
|
if self.selected_stage and self._focused_param:
|
||||||
|
ctrl = self.stages[self.selected_stage]
|
||||||
|
if self._focused_param in ctrl.params:
|
||||||
|
current = ctrl.params[self._focused_param]
|
||||||
|
# Determine step size from schema
|
||||||
|
schema = ctrl.param_schema.get(self._focused_param, {})
|
||||||
|
step = schema.get("step", 0.1 if isinstance(current, float) else 1)
|
||||||
|
new_val = current + delta * step
|
||||||
|
# Clamp to min/max if specified
|
||||||
|
if "min" in schema:
|
||||||
|
new_val = max(schema["min"], new_val)
|
||||||
|
if "max" in schema:
|
||||||
|
new_val = min(schema["max"], new_val)
|
||||||
|
# Only emit if value actually changed
|
||||||
|
if new_val != current:
|
||||||
|
ctrl.params[self._focused_param] = new_val
|
||||||
|
self._emit_event(
|
||||||
|
"param_changed",
|
||||||
|
stage_name=self.selected_stage,
|
||||||
|
param_name=self._focused_param,
|
||||||
|
value=new_val,
|
||||||
|
)
|
||||||
|
|
||||||
|
def scroll_stages(self, delta: int) -> None:
|
||||||
|
"""Scroll the stage list."""
|
||||||
|
max_offset = max(0, len(self.stages) - self.config.stage_list_height)
|
||||||
|
self.scroll_offset = max(0, min(max_offset, self.scroll_offset + delta))
|
||||||
|
|
||||||
|
def render(self, width: int, height: int) -> list[str]:
|
||||||
|
"""Render the UI panel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Total display width (panel uses last `panel_width` cols)
|
||||||
|
height: Total display height
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of strings, each of length `panel_width`, to overlay on right side
|
||||||
|
"""
|
||||||
|
panel_width = min(
|
||||||
|
self.config.panel_width, width - 4
|
||||||
|
) # Reserve at least 2 for main
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# If panel is hidden, render empty space
|
||||||
|
if not self._show_panel:
|
||||||
|
return [" " * panel_width for _ in range(height)]
|
||||||
|
|
||||||
|
# If preset picker is active, render that overlay instead of normal panel
|
||||||
|
if self._show_preset_picker:
|
||||||
|
picker_lines = self._render_preset_picker(panel_width)
|
||||||
|
# Pad to full panel height if needed
|
||||||
|
while len(picker_lines) < height:
|
||||||
|
picker_lines.append(" " * panel_width)
|
||||||
|
return [
|
||||||
|
line.ljust(panel_width)[:panel_width] for line in picker_lines[:height]
|
||||||
|
]
|
||||||
|
|
||||||
|
# Header
|
||||||
|
title_line = "┌" + "─" * (panel_width - 2) + "┐"
|
||||||
|
lines.append(title_line)
|
||||||
|
|
||||||
|
# Stage list section (occupies most of the panel)
|
||||||
|
list_height = self.config.stage_list_height
|
||||||
|
stage_names = list(self.stages.keys())
|
||||||
|
for i in range(list_height):
|
||||||
|
idx = i + self.scroll_offset
|
||||||
|
if idx < len(stage_names):
|
||||||
|
stage_name = stage_names[idx]
|
||||||
|
ctrl = self.stages[stage_name]
|
||||||
|
status = "✓" if ctrl.enabled else "✗"
|
||||||
|
sel = ">" if ctrl.selected else " "
|
||||||
|
# Truncate to fit panel (leave room for ">✓ " prefix and padding)
|
||||||
|
max_name_len = panel_width - 5
|
||||||
|
display_name = ctrl.name[:max_name_len]
|
||||||
|
line = f"│{sel}{status} {display_name:<{max_name_len}}"
|
||||||
|
lines.append(line[:panel_width])
|
||||||
|
else:
|
||||||
|
lines.append("│" + " " * (panel_width - 2) + "│")
|
||||||
|
|
||||||
|
# Separator
|
||||||
|
lines.append("├" + "─" * (panel_width - 2) + "┤")
|
||||||
|
|
||||||
|
# Parameter section (if stage selected)
|
||||||
|
if self.selected_stage and self.selected_stage in self.stages:
|
||||||
|
ctrl = self.stages[self.selected_stage]
|
||||||
|
if ctrl.params:
|
||||||
|
# Render each parameter as "name: [=====] value" with focus indicator
|
||||||
|
for param_name, param_value in ctrl.params.items():
|
||||||
|
schema = ctrl.param_schema.get(param_name, {})
|
||||||
|
is_focused = param_name == self._focused_param
|
||||||
|
# Format value based on type
|
||||||
|
if isinstance(param_value, float):
|
||||||
|
val_str = f"{param_value:.2f}"
|
||||||
|
elif isinstance(param_value, int):
|
||||||
|
val_str = f"{param_value}"
|
||||||
|
elif isinstance(param_value, bool):
|
||||||
|
val_str = str(param_value)
|
||||||
|
else:
|
||||||
|
val_str = str(param_value)
|
||||||
|
|
||||||
|
# Build parameter line
|
||||||
|
if (
|
||||||
|
isinstance(param_value, (int, float))
|
||||||
|
and "min" in schema
|
||||||
|
and "max" in schema
|
||||||
|
):
|
||||||
|
# Render as slider
|
||||||
|
min_val = schema["min"]
|
||||||
|
max_val = schema["max"]
|
||||||
|
# Normalize to 0-1 for bar length
|
||||||
|
if max_val != min_val:
|
||||||
|
ratio = (param_value - min_val) / (max_val - min_val)
|
||||||
|
else:
|
||||||
|
ratio = 0
|
||||||
|
bar_width = (
|
||||||
|
panel_width - len(param_name) - len(val_str) - 10
|
||||||
|
) # approx space for "[] : ="
|
||||||
|
if bar_width < 1:
|
||||||
|
bar_width = 1
|
||||||
|
filled = int(round(ratio * bar_width))
|
||||||
|
bar = "[" + "=" * filled + " " * (bar_width - filled) + "]"
|
||||||
|
param_line = f"│ {param_name}: {bar} {val_str}"
|
||||||
|
else:
|
||||||
|
# Simple name=value
|
||||||
|
param_line = f"│ {param_name}={val_str}"
|
||||||
|
|
||||||
|
# Highlight focused parameter
|
||||||
|
if is_focused:
|
||||||
|
# Invert colors conceptually - for now use > prefix
|
||||||
|
param_line = "│> " + param_line[2:]
|
||||||
|
|
||||||
|
# Truncate to fit panel width
|
||||||
|
if len(param_line) > panel_width - 1:
|
||||||
|
param_line = param_line[: panel_width - 1]
|
||||||
|
lines.append(param_line + "│")
|
||||||
|
else:
|
||||||
|
lines.append("│ (no params)".ljust(panel_width - 1) + "│")
|
||||||
|
else:
|
||||||
|
lines.append("│ (select a stage)".ljust(panel_width - 1) + "│")
|
||||||
|
|
||||||
|
# Info line before footer
|
||||||
|
info_parts = []
|
||||||
|
if self._current_preset:
|
||||||
|
info_parts.append(f"Preset: {self._current_preset}")
|
||||||
|
if self._presets:
|
||||||
|
info_parts.append("[P] presets")
|
||||||
|
info_str = " | ".join(info_parts) if info_parts else ""
|
||||||
|
if info_str:
|
||||||
|
padded = info_str.ljust(panel_width - 2)
|
||||||
|
lines.append("│" + padded + "│")
|
||||||
|
|
||||||
|
# Footer with instructions
|
||||||
|
footer_line = self._render_footer(panel_width)
|
||||||
|
lines.append(footer_line)
|
||||||
|
|
||||||
|
# Ensure all lines are exactly panel_width
|
||||||
|
return [line.ljust(panel_width)[:panel_width] for line in lines]
|
||||||
|
|
||||||
|
def _render_footer(self, width: int) -> str:
|
||||||
|
"""Render footer with key hints."""
|
||||||
|
if width >= 40:
|
||||||
|
# Show preset name and key hints
|
||||||
|
preset_info = (
|
||||||
|
f"Preset: {self._current_preset}" if self._current_preset else ""
|
||||||
|
)
|
||||||
|
hints = " [S]elect [Space]UI [Tab]Params [Arrows/HJKL]Adjust "
|
||||||
|
if self._presets:
|
||||||
|
hints += "[P]Preset "
|
||||||
|
combined = f"{preset_info}{hints}"
|
||||||
|
if len(combined) > width - 4:
|
||||||
|
combined = combined[: width - 4]
|
||||||
|
footer = "└" + "─" * (width - 2) + "┘"
|
||||||
|
return footer # Just the line, we'll add info above in render
|
||||||
|
else:
|
||||||
|
return "└" + "─" * (width - 2) + "┘"
|
||||||
|
|
||||||
|
def execute_command(self, command: dict) -> bool:
|
||||||
|
"""Execute a command from external control (e.g., WebSocket).
|
||||||
|
|
||||||
|
Supported UI commands:
|
||||||
|
- {"action": "toggle_stage", "stage": "stage_name"}
|
||||||
|
- {"action": "select_stage", "stage": "stage_name"}
|
||||||
|
- {"action": "adjust_param", "stage": "stage_name", "param": "param_name", "delta": 0.1}
|
||||||
|
- {"action": "change_preset", "preset": "preset_name"}
|
||||||
|
- {"action": "cycle_preset", "direction": 1}
|
||||||
|
|
||||||
|
Pipeline Mutation commands are handled by the WebSocket/runner handler:
|
||||||
|
- {"action": "add_stage", "stage": "stage_name", "type": "source|display|camera|effect"}
|
||||||
|
- {"action": "remove_stage", "stage": "stage_name"}
|
||||||
|
- {"action": "replace_stage", "stage": "old_stage_name", "with": "new_stage_type"}
|
||||||
|
- {"action": "swap_stages", "stage1": "name1", "stage2": "name2"}
|
||||||
|
- {"action": "move_stage", "stage": "stage_name", "after": "other_stage"|"before": "other_stage"}
|
||||||
|
- {"action": "enable_stage", "stage": "stage_name"}
|
||||||
|
- {"action": "disable_stage", "stage": "stage_name"}
|
||||||
|
- {"action": "cleanup_stage", "stage": "stage_name"}
|
||||||
|
- {"action": "can_hot_swap", "stage": "stage_name"}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if command was handled, False if not
|
||||||
|
"""
|
||||||
|
action = command.get("action")
|
||||||
|
|
||||||
|
if action == "toggle_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name in self.stages:
|
||||||
|
self.toggle_stage(stage_name)
|
||||||
|
self._emit_event(
|
||||||
|
"stage_toggled",
|
||||||
|
stage_name=stage_name,
|
||||||
|
enabled=self.stages[stage_name].enabled,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "select_stage":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
if stage_name in self.stages:
|
||||||
|
self.select_stage(stage_name)
|
||||||
|
self._emit_event("stage_selected", stage_name=stage_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "adjust_param":
|
||||||
|
stage_name = command.get("stage")
|
||||||
|
param_name = command.get("param")
|
||||||
|
delta = command.get("delta", 0.1)
|
||||||
|
if stage_name == self.selected_stage and param_name:
|
||||||
|
self._focused_param = param_name
|
||||||
|
self.adjust_selected_param(delta)
|
||||||
|
self._emit_event(
|
||||||
|
"param_changed",
|
||||||
|
stage_name=stage_name,
|
||||||
|
param_name=param_name,
|
||||||
|
value=self.stages[stage_name].params.get(param_name),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "change_preset":
|
||||||
|
preset_name = command.get("preset")
|
||||||
|
if preset_name in self._presets:
|
||||||
|
self._current_preset = preset_name
|
||||||
|
self._emit_event("preset_changed", preset_name=preset_name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
elif action == "cycle_preset":
|
||||||
|
direction = command.get("direction", 1)
|
||||||
|
self.cycle_preset(direction)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def process_key_event(self, key: str | int, modifiers: int = 0) -> bool:
|
||||||
|
"""Process a keyboard event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key: Key symbol (e.g., ' ', 's', pygame.K_UP, etc.)
|
||||||
|
modifiers: Modifier bits (Shift, Ctrl, Alt)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if event was handled, False if not
|
||||||
|
"""
|
||||||
|
# Normalize to string for simplicity
|
||||||
|
key_str = self._normalize_key(key, modifiers)
|
||||||
|
|
||||||
|
# Space: toggle UI panel visibility (only when preset picker not active)
|
||||||
|
if key_str == " " and not self._show_preset_picker:
|
||||||
|
self._show_panel = not getattr(self, "_show_panel", True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Space: toggle UI panel visibility (only when preset picker not active)
|
||||||
|
if key_str == " " and not self._show_preset_picker:
|
||||||
|
self._show_panel = not getattr(self, "_show_panel", True)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# S: select stage (cycle)
|
||||||
|
if key_str == "s" and modifiers == 0:
|
||||||
|
stages = list(self.stages.keys())
|
||||||
|
if not stages:
|
||||||
|
return False
|
||||||
|
if self.selected_stage:
|
||||||
|
current_idx = stages.index(self.selected_stage)
|
||||||
|
next_idx = (current_idx + 1) % len(stages)
|
||||||
|
else:
|
||||||
|
next_idx = 0
|
||||||
|
self.select_stage(stages[next_idx])
|
||||||
|
return True
|
||||||
|
|
||||||
|
# P: toggle preset picker (only when panel is visible)
|
||||||
|
if key_str == "p" and self._show_panel:
|
||||||
|
self._show_preset_picker = not self._show_preset_picker
|
||||||
|
if self._show_preset_picker:
|
||||||
|
self._preset_scroll_offset = 0
|
||||||
|
return True
|
||||||
|
|
||||||
|
# HJKL or Arrow Keys: scroll stage list, preset list, or adjust param
|
||||||
|
# vi-style: K=up, J=down (J is actually next line in vi, but we use for down)
|
||||||
|
# We'll use J for down, K for up, H for left, L for right
|
||||||
|
elif key_str in ("up", "down", "kp8", "kp2", "j", "k"):
|
||||||
|
# If preset picker is open, scroll preset list
|
||||||
|
if self._show_preset_picker:
|
||||||
|
delta = -1 if key_str in ("up", "kp8", "k") else 1
|
||||||
|
self._preset_scroll_offset = max(0, self._preset_scroll_offset + delta)
|
||||||
|
# Ensure scroll doesn't go past end
|
||||||
|
max_offset = max(0, len(self._presets) - 1)
|
||||||
|
self._preset_scroll_offset = min(max_offset, self._preset_scroll_offset)
|
||||||
|
return True
|
||||||
|
# If param is focused, adjust param value
|
||||||
|
elif self.selected_stage and self._focused_param:
|
||||||
|
delta = -1.0 if key_str in ("up", "kp8", "k") else 1.0
|
||||||
|
self.adjust_selected_param(delta)
|
||||||
|
return True
|
||||||
|
# Otherwise scroll stages
|
||||||
|
else:
|
||||||
|
delta = -1 if key_str in ("up", "kp8", "k") else 1
|
||||||
|
self.scroll_stages(delta)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Left/Right or H/L: adjust param (if param selected)
|
||||||
|
elif key_str in ("left", "right", "kp4", "kp6", "h", "l"):
|
||||||
|
if self.selected_stage:
|
||||||
|
delta = -0.1 if key_str in ("left", "kp4", "h") else 0.1
|
||||||
|
self.adjust_selected_param(delta)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Tab: cycle through parameters
|
||||||
|
if key_str == "tab" and self.selected_stage:
|
||||||
|
ctrl = self.stages[self.selected_stage]
|
||||||
|
param_names = list(ctrl.params.keys())
|
||||||
|
if param_names:
|
||||||
|
if self._focused_param in param_names:
|
||||||
|
current_idx = param_names.index(self._focused_param)
|
||||||
|
next_idx = (current_idx + 1) % len(param_names)
|
||||||
|
else:
|
||||||
|
next_idx = 0
|
||||||
|
self._focused_param = param_names[next_idx]
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Preset picker navigation
|
||||||
|
if self._show_preset_picker:
|
||||||
|
# Enter: select currently highlighted preset
|
||||||
|
if key_str == "return":
|
||||||
|
if self._presets:
|
||||||
|
idx = self._preset_scroll_offset
|
||||||
|
if idx < len(self._presets):
|
||||||
|
self._current_preset = self._presets[idx]
|
||||||
|
self._emit_event(
|
||||||
|
"preset_changed", preset_name=self._current_preset
|
||||||
|
)
|
||||||
|
self._show_preset_picker = False
|
||||||
|
return True
|
||||||
|
# Escape: close picker without changing
|
||||||
|
elif key_str == "escape":
|
||||||
|
self._show_preset_picker = False
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Escape: deselect stage (only when picker not active)
|
||||||
|
elif key_str == "escape" and self.selected_stage:
|
||||||
|
self.selected_stage = None
|
||||||
|
for ctrl in self.stages.values():
|
||||||
|
ctrl.selected = False
|
||||||
|
self._focused_param = None
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _normalize_key(self, key: str | int, modifiers: int) -> str:
|
||||||
|
"""Normalize key to a string identifier."""
|
||||||
|
# Handle pygame keysyms if imported
|
||||||
|
try:
|
||||||
|
import pygame
|
||||||
|
|
||||||
|
if isinstance(key, int):
|
||||||
|
# Map pygame constants to strings
|
||||||
|
key_map = {
|
||||||
|
pygame.K_UP: "up",
|
||||||
|
pygame.K_DOWN: "down",
|
||||||
|
pygame.K_LEFT: "left",
|
||||||
|
pygame.K_RIGHT: "right",
|
||||||
|
pygame.K_SPACE: " ",
|
||||||
|
pygame.K_ESCAPE: "escape",
|
||||||
|
pygame.K_s: "s",
|
||||||
|
pygame.K_w: "w",
|
||||||
|
# HJKL navigation (vi-style)
|
||||||
|
pygame.K_h: "h",
|
||||||
|
pygame.K_j: "j",
|
||||||
|
pygame.K_k: "k",
|
||||||
|
pygame.K_l: "l",
|
||||||
|
}
|
||||||
|
# Check for keypad keys with KP prefix
|
||||||
|
if hasattr(pygame, "K_KP8") and key == pygame.K_KP8:
|
||||||
|
return "kp8"
|
||||||
|
if hasattr(pygame, "K_KP2") and key == pygame.K_KP2:
|
||||||
|
return "kp2"
|
||||||
|
if hasattr(pygame, "K_KP4") and key == pygame.K_KP4:
|
||||||
|
return "kp4"
|
||||||
|
if hasattr(pygame, "K_KP6") and key == pygame.K_KP6:
|
||||||
|
return "kp6"
|
||||||
|
return key_map.get(key, f"pygame_{key}")
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Already a string?
|
||||||
|
if isinstance(key, str):
|
||||||
|
return key.lower()
|
||||||
|
|
||||||
|
return str(key)
|
||||||
|
|
||||||
|
def set_event_callback(self, event_type: str, callback: Callable) -> None:
|
||||||
|
"""Register a callback for UI events.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event_type: Event type ("stage_toggled", "param_changed", "stage_selected", "preset_changed")
|
||||||
|
callback: Function to call when event occurs
|
||||||
|
"""
|
||||||
|
self._callbacks[event_type] = callback
|
||||||
|
|
||||||
|
def _emit_event(self, event_type: str, **data) -> None:
|
||||||
|
"""Emit an event to registered callbacks."""
|
||||||
|
callback = self._callbacks.get(event_type)
|
||||||
|
if callback:
|
||||||
|
try:
|
||||||
|
callback(**data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def set_presets(self, presets: list[str], current: str) -> None:
|
||||||
|
"""Set available presets and current selection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
presets: List of preset names
|
||||||
|
current: Currently active preset name
|
||||||
|
"""
|
||||||
|
self._presets = presets
|
||||||
|
self._current_preset = current
|
||||||
|
|
||||||
|
def cycle_preset(self, direction: int = 1) -> str:
|
||||||
|
"""Cycle to next/previous preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
direction: 1 for next, -1 for previous
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New preset name
|
||||||
|
"""
|
||||||
|
if not self._presets:
|
||||||
|
return self._current_preset
|
||||||
|
try:
|
||||||
|
current_idx = self._presets.index(self._current_preset)
|
||||||
|
except ValueError:
|
||||||
|
current_idx = 0
|
||||||
|
next_idx = (current_idx + direction) % len(self._presets)
|
||||||
|
self._current_preset = self._presets[next_idx]
|
||||||
|
self._emit_event("preset_changed", preset_name=self._current_preset)
|
||||||
|
return self._current_preset
|
||||||
|
|
||||||
|
def _render_preset_picker(self, panel_width: int) -> list[str]:
|
||||||
|
"""Render a full-screen preset picker overlay."""
|
||||||
|
lines = []
|
||||||
|
picker_height = min(len(self._presets) + 2, self.config.stage_list_height)
|
||||||
|
# Create a centered box
|
||||||
|
title = " Select Preset "
|
||||||
|
box_width = min(40, panel_width - 2)
|
||||||
|
lines.append("┌" + "─" * (box_width - 2) + "┐")
|
||||||
|
lines.append("│" + title.center(box_width - 2) + "│")
|
||||||
|
lines.append("├" + "─" * (box_width - 2) + "┤")
|
||||||
|
# List presets with selection
|
||||||
|
visible_start = self._preset_scroll_offset
|
||||||
|
visible_end = visible_start + picker_height - 2
|
||||||
|
for i in range(visible_start, min(visible_end, len(self._presets))):
|
||||||
|
preset_name = self._presets[i]
|
||||||
|
is_current = preset_name == self._current_preset
|
||||||
|
prefix = "▶ " if is_current else " "
|
||||||
|
line = f"│ {prefix}{preset_name}"
|
||||||
|
if len(line) < box_width - 1:
|
||||||
|
line = line.ljust(box_width - 1)
|
||||||
|
lines.append(line[: box_width - 1] + "│")
|
||||||
|
# Footer with help
|
||||||
|
help_text = "[P] close [↑↓] navigate [Enter] select"
|
||||||
|
footer = "├" + "─" * (box_width - 2) + "┤"
|
||||||
|
lines.append(footer)
|
||||||
|
lines.append("│" + help_text.center(box_width - 2) + "│")
|
||||||
|
lines.append("└" + "─" * (box_width - 2) + "┘")
|
||||||
|
return lines
|
||||||
221
engine/pipeline/validation.py
Normal file
221
engine/pipeline/validation.py
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
"""
|
||||||
|
Pipeline validation and MVP (Minimum Viable Pipeline) injection.
|
||||||
|
|
||||||
|
Provides validation functions to ensure pipelines meet minimum requirements
|
||||||
|
and can auto-inject sensible defaults when fields are missing or invalid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.display import BorderMode, DisplayRegistry
|
||||||
|
from engine.effects import get_registry
|
||||||
|
from engine.pipeline.params import PipelineParams
|
||||||
|
|
||||||
|
# Known valid values
|
||||||
|
VALID_SOURCES = ["headlines", "poetry", "fixture", "empty", "pipeline-inspect"]
|
||||||
|
VALID_CAMERAS = [
|
||||||
|
"feed",
|
||||||
|
"scroll",
|
||||||
|
"vertical",
|
||||||
|
"horizontal",
|
||||||
|
"omni",
|
||||||
|
"floating",
|
||||||
|
"bounce",
|
||||||
|
"radial",
|
||||||
|
"static",
|
||||||
|
"none",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
VALID_DISPLAYS = None # Will be populated at runtime from DisplayRegistry
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationResult:
|
||||||
|
"""Result of validation with changes and warnings."""
|
||||||
|
|
||||||
|
valid: bool
|
||||||
|
warnings: list[str]
|
||||||
|
changes: list[str]
|
||||||
|
config: Any # PipelineConfig (forward ref)
|
||||||
|
params: PipelineParams
|
||||||
|
|
||||||
|
|
||||||
|
# MVP defaults
|
||||||
|
MVP_DEFAULTS = {
|
||||||
|
"source": "fixture",
|
||||||
|
"display": "terminal",
|
||||||
|
"camera": "static", # Static camera provides camera_y=0 for viewport filtering
|
||||||
|
"effects": [],
|
||||||
|
"border": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_pipeline_config(
|
||||||
|
config: Any, params: PipelineParams, allow_unsafe: bool = False
|
||||||
|
) -> ValidationResult:
|
||||||
|
"""Validate pipeline configuration against MVP requirements.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: PipelineConfig object (has source, display, camera, effects fields)
|
||||||
|
params: PipelineParams object (has border field)
|
||||||
|
allow_unsafe: If True, don't inject defaults or enforce MVP
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ValidationResult with validity, warnings, changes, and validated config/params
|
||||||
|
"""
|
||||||
|
warnings = []
|
||||||
|
changes = []
|
||||||
|
|
||||||
|
if allow_unsafe:
|
||||||
|
# Still do basic validation but don't inject defaults
|
||||||
|
# Always return valid=True when allow_unsafe is set
|
||||||
|
warnings.extend(_validate_source(config.source))
|
||||||
|
warnings.extend(_validate_display(config.display))
|
||||||
|
warnings.extend(_validate_camera(config.camera))
|
||||||
|
warnings.extend(_validate_effects(config.effects))
|
||||||
|
warnings.extend(_validate_border(params.border))
|
||||||
|
return ValidationResult(
|
||||||
|
valid=True, # Always valid with allow_unsafe
|
||||||
|
warnings=warnings,
|
||||||
|
changes=[],
|
||||||
|
config=config,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# MVP injection mode
|
||||||
|
# Source
|
||||||
|
source_issues = _validate_source(config.source)
|
||||||
|
if source_issues:
|
||||||
|
warnings.extend(source_issues)
|
||||||
|
config.source = MVP_DEFAULTS["source"]
|
||||||
|
changes.append(f"source → {MVP_DEFAULTS['source']}")
|
||||||
|
|
||||||
|
# Display
|
||||||
|
display_issues = _validate_display(config.display)
|
||||||
|
if display_issues:
|
||||||
|
warnings.extend(display_issues)
|
||||||
|
config.display = MVP_DEFAULTS["display"]
|
||||||
|
changes.append(f"display → {MVP_DEFAULTS['display']}")
|
||||||
|
|
||||||
|
# Camera
|
||||||
|
camera_issues = _validate_camera(config.camera)
|
||||||
|
if camera_issues:
|
||||||
|
warnings.extend(camera_issues)
|
||||||
|
config.camera = MVP_DEFAULTS["camera"]
|
||||||
|
changes.append("camera → static (no camera stage)")
|
||||||
|
|
||||||
|
# Effects
|
||||||
|
effect_issues = _validate_effects(config.effects)
|
||||||
|
if effect_issues:
|
||||||
|
warnings.extend(effect_issues)
|
||||||
|
# Only change if all effects are invalid
|
||||||
|
if len(config.effects) == 0 or all(
|
||||||
|
e not in _get_valid_effects() for e in config.effects
|
||||||
|
):
|
||||||
|
config.effects = MVP_DEFAULTS["effects"]
|
||||||
|
changes.append("effects → [] (none)")
|
||||||
|
else:
|
||||||
|
# Remove invalid effects, keep valid ones
|
||||||
|
valid_effects = [e for e in config.effects if e in _get_valid_effects()]
|
||||||
|
if valid_effects != config.effects:
|
||||||
|
config.effects = valid_effects
|
||||||
|
changes.append(f"effects → {valid_effects}")
|
||||||
|
|
||||||
|
# Border (in params)
|
||||||
|
border_issues = _validate_border(params.border)
|
||||||
|
if border_issues:
|
||||||
|
warnings.extend(border_issues)
|
||||||
|
params.border = MVP_DEFAULTS["border"]
|
||||||
|
changes.append(f"border → {MVP_DEFAULTS['border']}")
|
||||||
|
|
||||||
|
valid = len(warnings) == 0
|
||||||
|
if changes:
|
||||||
|
# If we made changes, pipeline should be valid now
|
||||||
|
valid = True
|
||||||
|
|
||||||
|
return ValidationResult(
|
||||||
|
valid=valid,
|
||||||
|
warnings=warnings,
|
||||||
|
changes=changes,
|
||||||
|
config=config,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_source(source: str) -> list[str]:
|
||||||
|
"""Validate source field."""
|
||||||
|
if not source:
|
||||||
|
return ["source is empty"]
|
||||||
|
if source not in VALID_SOURCES:
|
||||||
|
return [f"unknown source '{source}', valid sources: {VALID_SOURCES}"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_display(display: str) -> list[str]:
|
||||||
|
"""Validate display field."""
|
||||||
|
if not display:
|
||||||
|
return ["display is empty"]
|
||||||
|
# Check if display is available (lazy load registry)
|
||||||
|
try:
|
||||||
|
available = DisplayRegistry.list_backends()
|
||||||
|
if display not in available:
|
||||||
|
return [f"display '{display}' not available, available: {available}"]
|
||||||
|
except Exception as e:
|
||||||
|
return [f"error checking display availability: {e}"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_camera(camera: str | None) -> list[str]:
|
||||||
|
"""Validate camera field."""
|
||||||
|
if camera is None:
|
||||||
|
return ["camera is None"]
|
||||||
|
# Empty string is valid (static, no camera stage)
|
||||||
|
if camera == "":
|
||||||
|
return []
|
||||||
|
if camera not in VALID_CAMERAS:
|
||||||
|
return [f"unknown camera '{camera}', valid cameras: {VALID_CAMERAS}"]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _get_valid_effects() -> set[str]:
|
||||||
|
"""Get set of valid effect names."""
|
||||||
|
registry = get_registry()
|
||||||
|
return set(registry.list_all().keys())
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_effects(effects: list[str]) -> list[str]:
|
||||||
|
"""Validate effects list."""
|
||||||
|
if effects is None:
|
||||||
|
return ["effects is None"]
|
||||||
|
valid_effects = _get_valid_effects()
|
||||||
|
issues = []
|
||||||
|
for effect in effects:
|
||||||
|
if effect not in valid_effects:
|
||||||
|
issues.append(
|
||||||
|
f"unknown effect '{effect}', valid effects: {sorted(valid_effects)}"
|
||||||
|
)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_border(border: bool | BorderMode) -> list[str]:
|
||||||
|
"""Validate border field."""
|
||||||
|
if isinstance(border, bool):
|
||||||
|
return []
|
||||||
|
if isinstance(border, BorderMode):
|
||||||
|
return []
|
||||||
|
return [f"invalid border value, must be bool or BorderMode, got {type(border)}"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_mvp_summary(config: Any, params: PipelineParams) -> str:
|
||||||
|
"""Get a human-readable summary of the MVP pipeline configuration."""
|
||||||
|
camera_text = "none" if not config.camera else config.camera
|
||||||
|
effects_text = "none" if not config.effects else ", ".join(config.effects)
|
||||||
|
return (
|
||||||
|
f"MVP Pipeline Configuration:\n"
|
||||||
|
f" Source: {config.source}\n"
|
||||||
|
f" Display: {config.display}\n"
|
||||||
|
f" Camera: {camera_text} (static if empty)\n"
|
||||||
|
f" Effects: {effects_text}\n"
|
||||||
|
f" Border: {params.border}"
|
||||||
|
)
|
||||||
@@ -80,3 +80,57 @@ def lr_gradient_opposite(rows, offset=0.0):
|
|||||||
List of lines with complementary gradient coloring applied
|
List of lines with complementary gradient coloring applied
|
||||||
"""
|
"""
|
||||||
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||||
|
|
||||||
|
|
||||||
|
def msg_gradient(rows, offset):
|
||||||
|
"""Apply message (ntfy) gradient using theme complementary colors.
|
||||||
|
|
||||||
|
Returns colored rows using ACTIVE_THEME.message_gradient if available,
|
||||||
|
falling back to default magenta if no theme is set.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rows: List of text strings to colorize
|
||||||
|
offset: Gradient offset (0.0-1.0) for animation
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of rows with ANSI color codes applied
|
||||||
|
"""
|
||||||
|
from engine import config
|
||||||
|
|
||||||
|
# Check if theme is set and use it
|
||||||
|
if config.ACTIVE_THEME:
|
||||||
|
cols = _color_codes_to_ansi(config.ACTIVE_THEME.message_gradient)
|
||||||
|
else:
|
||||||
|
# Fallback to default magenta gradient
|
||||||
|
cols = MSG_GRAD_COLS
|
||||||
|
|
||||||
|
return lr_gradient(rows, offset, cols)
|
||||||
|
|
||||||
|
|
||||||
|
def _color_codes_to_ansi(color_codes):
|
||||||
|
"""Convert a list of 256-color codes to ANSI escape code strings.
|
||||||
|
|
||||||
|
Pattern: first 2 are bold, middle 8 are normal, last 2 are dim.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
color_codes: List of 12 integers (256-color palette codes)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of ANSI escape code strings
|
||||||
|
"""
|
||||||
|
if not color_codes or len(color_codes) != 12:
|
||||||
|
# Fallback to default green if invalid
|
||||||
|
return GRAD_COLS
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for i, code in enumerate(color_codes):
|
||||||
|
if i < 2:
|
||||||
|
# Bold for first 2 (bright leading edge)
|
||||||
|
result.append(f"\033[1;38;5;{code}m")
|
||||||
|
elif i < 10:
|
||||||
|
# Normal for middle 8
|
||||||
|
result.append(f"\033[38;5;{code}m")
|
||||||
|
else:
|
||||||
|
# Dim for last 2 (dark trailing edge)
|
||||||
|
result.append(f"\033[2;38;5;{code}m")
|
||||||
|
return result
|
||||||
|
|||||||
60
engine/themes.py
Normal file
60
engine/themes.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""
|
||||||
|
Theme definitions with color gradients for terminal rendering.
|
||||||
|
|
||||||
|
This module is data-only and does not import config or render
|
||||||
|
to prevent circular dependencies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Theme:
|
||||||
|
"""Represents a color theme with two gradients."""
|
||||||
|
|
||||||
|
def __init__(self, name, main_gradient, message_gradient):
|
||||||
|
"""Initialize a theme with name and color gradients.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Theme identifier string
|
||||||
|
main_gradient: List of 12 ANSI 256-color codes for main gradient
|
||||||
|
message_gradient: List of 12 ANSI 256-color codes for message gradient
|
||||||
|
"""
|
||||||
|
self.name = name
|
||||||
|
self.main_gradient = main_gradient
|
||||||
|
self.message_gradient = message_gradient
|
||||||
|
|
||||||
|
|
||||||
|
# ─── GRADIENT DEFINITIONS ─────────────────────────────────────────────────
|
||||||
|
# Each gradient is 12 ANSI 256-color codes in sequence
|
||||||
|
# Format: [light...] → [medium...] → [dark...] → [black]
|
||||||
|
|
||||||
|
_GREEN_MAIN = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
|
||||||
|
_GREEN_MSG = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
|
||||||
|
|
||||||
|
_ORANGE_MAIN = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
|
||||||
|
_ORANGE_MSG = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
|
||||||
|
|
||||||
|
_PURPLE_MAIN = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
|
||||||
|
_PURPLE_MSG = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
THEME_REGISTRY = {
|
||||||
|
"green": Theme("green", _GREEN_MAIN, _GREEN_MSG),
|
||||||
|
"orange": Theme("orange", _ORANGE_MAIN, _ORANGE_MSG),
|
||||||
|
"purple": Theme("purple", _PURPLE_MAIN, _PURPLE_MSG),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_theme(theme_id):
|
||||||
|
"""Retrieve a theme by ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
theme_id: Theme identifier string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Theme object matching the ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: If theme_id is not in registry
|
||||||
|
"""
|
||||||
|
return THEME_REGISTRY[theme_id]
|
||||||
98
examples/README.md
Normal file
98
examples/README.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Examples
|
||||||
|
|
||||||
|
This directory contains example scripts demonstrating how to use Mainline's features.
|
||||||
|
|
||||||
|
## Hybrid Configuration (Recommended)
|
||||||
|
|
||||||
|
**`hybrid_visualization.py`** - Renders visualization using the hybrid preset-graph format.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python examples/hybrid_visualization.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses **70% less space** than verbose node DSL while providing the same flexibility.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The hybrid format uses inline objects and arrays:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[pipeline]
|
||||||
|
source = "headlines"
|
||||||
|
camera = { mode = "scroll", speed = 1.0 }
|
||||||
|
effects = [
|
||||||
|
{ name = "noise", intensity = 0.3 },
|
||||||
|
{ name = "fade", intensity = 0.5 }
|
||||||
|
]
|
||||||
|
display = { backend = "terminal", positioning = "mixed" }
|
||||||
|
```
|
||||||
|
|
||||||
|
See `docs/hybrid-config.md` for complete documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Default Visualization (Verbose Node DSL)
|
||||||
|
|
||||||
|
**`default_visualization.py`** - Renders the standard Mainline visualization using the verbose graph DSL.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python examples/default_visualization.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This demonstrates the verbose node-based syntax (more flexible for complex DAGs):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[nodes.source] type = "source" source = "headlines"
|
||||||
|
[nodes.camera] type = "camera" mode = "scroll"
|
||||||
|
[nodes.noise] type = "effect" effect = "noise" intensity = 0.3
|
||||||
|
[nodes.display] type = "display" backend = "terminal"
|
||||||
|
[connections] list = ["source -> camera -> noise -> display"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Graph DSL Demonstration
|
||||||
|
|
||||||
|
**`graph_dsl_demo.py`** - Demonstrates the graph-based DSL in multiple ways:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python examples/graph_dsl_demo.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows:
|
||||||
|
- Imperative Python API for building graphs
|
||||||
|
- Dictionary-based API
|
||||||
|
- Graph validation (cycles, disconnected nodes)
|
||||||
|
- Different node types and configurations
|
||||||
|
|
||||||
|
## Integration Test
|
||||||
|
|
||||||
|
**`test_graph_integration.py`** - Tests the graph system with actual pipeline execution:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python examples/test_graph_integration.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifies:
|
||||||
|
- Graph loading from TOML
|
||||||
|
- Pipeline execution
|
||||||
|
- Output rendering
|
||||||
|
- Comparison with preset-based pipelines
|
||||||
|
|
||||||
|
## Other Demos
|
||||||
|
|
||||||
|
- **`demo-lfo-effects.py`** - LFO modulation of effect intensities (Pygame display)
|
||||||
|
- **`demo_oscilloscope.py`** - Oscilloscope visualization
|
||||||
|
- **`demo_image_oscilloscope.py`** - Image-based oscilloscope
|
||||||
|
|
||||||
|
## Configuration Format Comparison
|
||||||
|
|
||||||
|
| Format | Use Case | Lines | Example |
|
||||||
|
|--------|----------|-------|---------|
|
||||||
|
| **Hybrid** | Recommended for most use cases | 20 | `hybrid_config.toml` |
|
||||||
|
| **Verbose Node DSL** | Complex DAGs, branching | 39 | `default_visualization.toml` |
|
||||||
|
| **Preset** | Simple configurations | 10 | `presets.toml` |
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
- `docs/hybrid-config.md` - Hybrid preset-graph configuration
|
||||||
|
- `docs/graph-dsl.md` - Verbose node-based graph DSL
|
||||||
|
- `docs/presets-usage.md` - Preset system usage
|
||||||
86
examples/default_visualization.py
Normal file
86
examples/default_visualization.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Default Mainline Visualization
|
||||||
|
|
||||||
|
Renders the standard Mainline visualization using the graph-based DSL.
|
||||||
|
This demonstrates the default behavior: headlines source, scroll camera,
|
||||||
|
terminal display, with classic effects (noise, fade, glitch, firehose).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python examples/default_visualization.py
|
||||||
|
|
||||||
|
The visualization will be rendered once and printed to stdout.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add the project root to Python path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from engine.effects.plugins import discover_plugins
|
||||||
|
from engine.pipeline.graph_toml import load_pipeline_from_toml
|
||||||
|
from engine.pipeline.params import PipelineParams
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Render the default Mainline visualization."""
|
||||||
|
print("Loading default Mainline visualization...")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Discover effect plugins
|
||||||
|
discover_plugins()
|
||||||
|
|
||||||
|
# Path to the TOML configuration
|
||||||
|
toml_path = Path(__file__).parent / "default_visualization.toml"
|
||||||
|
|
||||||
|
if not toml_path.exists():
|
||||||
|
print(f"Error: Configuration file not found: {toml_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Load pipeline from TOML configuration
|
||||||
|
try:
|
||||||
|
pipeline = load_pipeline_from_toml(
|
||||||
|
toml_path, viewport_width=80, viewport_height=24
|
||||||
|
)
|
||||||
|
print(f"✓ Pipeline loaded from {toml_path.name}")
|
||||||
|
print(f" Stages: {list(pipeline._stages.keys())}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading pipeline: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Initialize the pipeline
|
||||||
|
if not pipeline.initialize():
|
||||||
|
print("Error: Failed to initialize pipeline", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print("✓ Pipeline initialized")
|
||||||
|
|
||||||
|
# Set up execution context
|
||||||
|
ctx = pipeline.context
|
||||||
|
ctx.terminal_width = 80
|
||||||
|
ctx.terminal_height = 24
|
||||||
|
|
||||||
|
# Create params for the execution
|
||||||
|
params = PipelineParams(viewport_width=80, viewport_height=24)
|
||||||
|
ctx.params = params
|
||||||
|
|
||||||
|
# Execute the pipeline (empty items list - source will provide content)
|
||||||
|
print("Executing pipeline...")
|
||||||
|
result = pipeline.execute([])
|
||||||
|
|
||||||
|
# Render output
|
||||||
|
if result.success:
|
||||||
|
print("=" * 70)
|
||||||
|
print("Visualization Output:")
|
||||||
|
print("=" * 70)
|
||||||
|
for i, line in enumerate(result.data):
|
||||||
|
print(line)
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"✓ Successfully rendered {len(result.data)} lines")
|
||||||
|
else:
|
||||||
|
print(f"Error: Pipeline execution failed: {result.error}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
39
examples/default_visualization.toml
Normal file
39
examples/default_visualization.toml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Default Mainline Visualization
|
||||||
|
# This configuration renders the standard Mainline visualization using the
|
||||||
|
# graph-based DSL. It matches the upstream-default preset behavior.
|
||||||
|
|
||||||
|
[nodes.source]
|
||||||
|
type = "source"
|
||||||
|
source = "headlines"
|
||||||
|
|
||||||
|
[nodes.camera]
|
||||||
|
type = "camera"
|
||||||
|
mode = "scroll"
|
||||||
|
speed = 1.0
|
||||||
|
|
||||||
|
[nodes.noise]
|
||||||
|
type = "effect"
|
||||||
|
effect = "noise"
|
||||||
|
intensity = 0.3
|
||||||
|
|
||||||
|
[nodes.fade]
|
||||||
|
type = "effect"
|
||||||
|
effect = "fade"
|
||||||
|
intensity = 0.5
|
||||||
|
|
||||||
|
[nodes.glitch]
|
||||||
|
type = "effect"
|
||||||
|
effect = "glitch"
|
||||||
|
intensity = 0.2
|
||||||
|
|
||||||
|
[nodes.firehose]
|
||||||
|
type = "effect"
|
||||||
|
effect = "firehose"
|
||||||
|
intensity = 0.4
|
||||||
|
|
||||||
|
[nodes.display]
|
||||||
|
type = "display"
|
||||||
|
backend = "terminal"
|
||||||
|
|
||||||
|
[connections]
|
||||||
|
list = ["source -> camera -> noise -> fade -> glitch -> firehose -> display"]
|
||||||
136
examples/graph_dsl_demo.py
Normal file
136
examples/graph_dsl_demo.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Demo script showing the new graph-based DSL for pipeline configuration.
|
||||||
|
|
||||||
|
This demonstrates how to define pipelines using the graph abstraction,
|
||||||
|
which is more intuitive than the verbose XYZStage naming convention.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.effects.plugins import discover_plugins
|
||||||
|
from engine.pipeline.graph import Graph, NodeType
|
||||||
|
from engine.pipeline.graph_adapter import graph_to_pipeline, dict_to_pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def demo_imperative_api():
|
||||||
|
"""Demo: Imperative Python API for building graphs."""
|
||||||
|
print("=== Imperative Python API ===")
|
||||||
|
|
||||||
|
graph = Graph()
|
||||||
|
graph.node("source", NodeType.SOURCE, source="headlines")
|
||||||
|
graph.node("camera", NodeType.CAMERA, mode="scroll", speed=1.0)
|
||||||
|
graph.node("noise", NodeType.EFFECT, effect="noise", intensity=0.3)
|
||||||
|
graph.node("display", NodeType.DISPLAY, backend="null")
|
||||||
|
|
||||||
|
# Connect nodes in a chain
|
||||||
|
graph.chain("source", "camera", "noise", "display")
|
||||||
|
|
||||||
|
# Validate the graph
|
||||||
|
errors = graph.validate()
|
||||||
|
if errors:
|
||||||
|
print(f"Validation errors: {errors}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Convert to pipeline
|
||||||
|
pipeline = graph_to_pipeline(graph, viewport_width=80, viewport_height=24)
|
||||||
|
|
||||||
|
print(f"Pipeline created with {len(pipeline._stages)} stages:")
|
||||||
|
for name, stage in pipeline._stages.items():
|
||||||
|
print(f" - {name}: {stage.__class__.__name__}")
|
||||||
|
|
||||||
|
return pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def demo_dict_api():
|
||||||
|
"""Demo: Dictionary-based API for building graphs."""
|
||||||
|
print("\n=== Dictionary API ===")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"nodes": {
|
||||||
|
"source": "headlines",
|
||||||
|
"camera": {"type": "camera", "mode": "scroll", "speed": 1.0},
|
||||||
|
"noise": {"type": "effect", "effect": "noise", "intensity": 0.5},
|
||||||
|
"fade": {"type": "effect", "effect": "fade", "intensity": 0.8},
|
||||||
|
"display": {"type": "display", "backend": "null"},
|
||||||
|
},
|
||||||
|
"connections": ["source -> camera -> noise -> fade -> display"],
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline = dict_to_pipeline(data, viewport_width=80, viewport_height=24)
|
||||||
|
|
||||||
|
print(f"Pipeline created with {len(pipeline._stages)} stages:")
|
||||||
|
for name, stage in pipeline._stages.items():
|
||||||
|
print(f" - {name}: {stage.__class__.__name__}")
|
||||||
|
|
||||||
|
return pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def demo_graph_validation():
|
||||||
|
"""Demo: Graph validation."""
|
||||||
|
print("\n=== Graph Validation ===")
|
||||||
|
|
||||||
|
# Create a graph with a cycle
|
||||||
|
graph = Graph()
|
||||||
|
graph.node("a", NodeType.SOURCE)
|
||||||
|
graph.node("b", NodeType.CAMERA)
|
||||||
|
graph.node("c", NodeType.DISPLAY)
|
||||||
|
graph.connect("a", "b")
|
||||||
|
graph.connect("b", "c")
|
||||||
|
graph.connect("c", "a") # Creates cycle
|
||||||
|
|
||||||
|
errors = graph.validate()
|
||||||
|
print(f"Cycle detection errors: {errors}")
|
||||||
|
|
||||||
|
# Create a valid graph
|
||||||
|
graph2 = Graph()
|
||||||
|
graph2.node("source", NodeType.SOURCE, source="headlines")
|
||||||
|
graph2.node("display", NodeType.DISPLAY, backend="null")
|
||||||
|
graph2.connect("source", "display")
|
||||||
|
|
||||||
|
errors2 = graph2.validate()
|
||||||
|
print(f"Valid graph errors: {errors2}")
|
||||||
|
|
||||||
|
|
||||||
|
def demo_node_types():
|
||||||
|
"""Demo: Different node types."""
|
||||||
|
print("\n=== Node Types ===")
|
||||||
|
|
||||||
|
graph = Graph()
|
||||||
|
|
||||||
|
# Source node
|
||||||
|
graph.node("headlines", NodeType.SOURCE, source="headlines")
|
||||||
|
print("✓ Source node created")
|
||||||
|
|
||||||
|
# Camera node with different modes
|
||||||
|
graph.node("camera_scroll", NodeType.CAMERA, mode="scroll", speed=1.0)
|
||||||
|
graph.node("camera_feed", NodeType.CAMERA, mode="feed", speed=0.5)
|
||||||
|
graph.node("camera_horizontal", NodeType.CAMERA, mode="horizontal", speed=1.0)
|
||||||
|
print("✓ Camera nodes created (scroll, feed, horizontal)")
|
||||||
|
|
||||||
|
# Effect nodes
|
||||||
|
graph.node("noise", NodeType.EFFECT, effect="noise", intensity=0.3)
|
||||||
|
graph.node("fade", NodeType.EFFECT, effect="fade", intensity=0.8)
|
||||||
|
print("✓ Effect nodes created (noise, fade)")
|
||||||
|
|
||||||
|
# Positioning node
|
||||||
|
graph.node("position", NodeType.POSITION, mode="mixed")
|
||||||
|
print("✓ Positioning node created")
|
||||||
|
|
||||||
|
# Display nodes
|
||||||
|
graph.node("terminal", NodeType.DISPLAY, backend="terminal")
|
||||||
|
graph.node("null", NodeType.DISPLAY, backend="null")
|
||||||
|
print("✓ Display nodes created")
|
||||||
|
|
||||||
|
print(f"\nTotal nodes: {len(graph.nodes)}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Discover effect plugins first
|
||||||
|
discover_plugins()
|
||||||
|
|
||||||
|
# Run demos
|
||||||
|
demo_imperative_api()
|
||||||
|
demo_dict_api()
|
||||||
|
demo_graph_validation()
|
||||||
|
demo_node_types()
|
||||||
|
|
||||||
|
print("\n=== Demo Complete ===")
|
||||||
20
examples/hybrid_config.toml
Normal file
20
examples/hybrid_config.toml
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Hybrid Preset-Graph Configuration
|
||||||
|
# Combines preset simplicity with graph flexibility
|
||||||
|
# Uses 70% less space than verbose node-based DSL
|
||||||
|
|
||||||
|
[pipeline]
|
||||||
|
source = "headlines"
|
||||||
|
|
||||||
|
camera = { mode = "scroll", speed = 1.0 }
|
||||||
|
|
||||||
|
effects = [
|
||||||
|
{ name = "noise", intensity = 0.3 },
|
||||||
|
{ name = "fade", intensity = 0.5 },
|
||||||
|
{ name = "glitch", intensity = 0.2 },
|
||||||
|
{ name = "firehose", intensity = 0.4 }
|
||||||
|
]
|
||||||
|
|
||||||
|
display = { backend = "terminal", positioning = "mixed" }
|
||||||
|
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
95
examples/hybrid_visualization.py
Normal file
95
examples/hybrid_visualization.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Hybrid Preset-Graph Visualization
|
||||||
|
|
||||||
|
Demonstrates the new hybrid configuration format that combines
|
||||||
|
preset simplicity with graph flexibility.
|
||||||
|
|
||||||
|
This uses 70% less space than the verbose node-based DSL while
|
||||||
|
providing the same functionality.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python examples/hybrid_visualization.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from engine.effects.plugins import discover_plugins
|
||||||
|
from engine.pipeline.hybrid_config import load_hybrid_config
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Render visualization using hybrid configuration."""
|
||||||
|
print("Loading hybrid configuration...")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
# Discover effect plugins
|
||||||
|
discover_plugins()
|
||||||
|
|
||||||
|
# Path to the hybrid configuration
|
||||||
|
toml_path = Path(__file__).parent / "hybrid_config.toml"
|
||||||
|
|
||||||
|
if not toml_path.exists():
|
||||||
|
print(f"Error: Configuration file not found: {toml_path}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Load hybrid configuration
|
||||||
|
try:
|
||||||
|
config = load_hybrid_config(toml_path)
|
||||||
|
print(f"✓ Hybrid config loaded from {toml_path.name}")
|
||||||
|
print(f" Source: {config.source}")
|
||||||
|
print(f" Camera: {config.camera.mode if config.camera else 'none'}")
|
||||||
|
print(f" Effects: {len(config.effects)}")
|
||||||
|
for effect in config.effects:
|
||||||
|
print(f" - {effect.name}: intensity={effect.intensity}")
|
||||||
|
print(f" Display: {config.display.backend if config.display else 'terminal'}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading config: {e}", file=sys.stderr)
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Convert to pipeline
|
||||||
|
try:
|
||||||
|
pipeline = config.to_pipeline(
|
||||||
|
viewport_width=config.viewport_width, viewport_height=config.viewport_height
|
||||||
|
)
|
||||||
|
print(f"✓ Pipeline created with {len(pipeline._stages)} stages")
|
||||||
|
print(f" Stages: {list(pipeline._stages.keys())}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error creating pipeline: {e}", file=sys.stderr)
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Initialize the pipeline
|
||||||
|
if not pipeline.initialize():
|
||||||
|
print("Error: Failed to initialize pipeline", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print("✓ Pipeline initialized")
|
||||||
|
|
||||||
|
# Execute the pipeline
|
||||||
|
print("Executing pipeline...")
|
||||||
|
result = pipeline.execute([])
|
||||||
|
|
||||||
|
# Render output
|
||||||
|
if result.success:
|
||||||
|
print("=" * 70)
|
||||||
|
print("Visualization Output:")
|
||||||
|
print("=" * 70)
|
||||||
|
for i, line in enumerate(result.data):
|
||||||
|
print(line)
|
||||||
|
print("=" * 70)
|
||||||
|
print(f"✓ Successfully rendered {len(result.data)} lines")
|
||||||
|
else:
|
||||||
|
print(f"Error: Pipeline execution failed: {result.error}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
28
examples/pipeline_graph.toml
Normal file
28
examples/pipeline_graph.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Graph-based pipeline configuration example
|
||||||
|
# This defines a pipeline using the new graph DSL
|
||||||
|
|
||||||
|
[nodes.source]
|
||||||
|
type = "source"
|
||||||
|
source = "headlines"
|
||||||
|
|
||||||
|
[nodes.camera]
|
||||||
|
type = "camera"
|
||||||
|
mode = "scroll"
|
||||||
|
speed = 1.0
|
||||||
|
|
||||||
|
[nodes.noise]
|
||||||
|
type = "effect"
|
||||||
|
effect = "noise"
|
||||||
|
intensity = 0.3
|
||||||
|
|
||||||
|
[nodes.fade]
|
||||||
|
type = "effect"
|
||||||
|
effect = "fade"
|
||||||
|
intensity = 0.8
|
||||||
|
|
||||||
|
[nodes.display]
|
||||||
|
type = "display"
|
||||||
|
backend = "null"
|
||||||
|
|
||||||
|
[connections]
|
||||||
|
list = ["source -> camera -> noise -> fade -> display"]
|
||||||
145
examples/repl_demo.py
Normal file
145
examples/repl_demo.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
REPL Demo - Interactive command-line interface for pipeline control
|
||||||
|
|
||||||
|
This demo shows how to use the REPL effect plugin to interact with
|
||||||
|
the Mainline pipeline in real-time.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- HUD-style overlay showing FPS, frame time, command history
|
||||||
|
- Command history navigation (Up/Down arrows)
|
||||||
|
- Pipeline inspection and control commands
|
||||||
|
- Parameter adjustment in real-time
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python examples/repl_demo.py
|
||||||
|
|
||||||
|
Keyboard Controls:
|
||||||
|
Enter - Execute command
|
||||||
|
Up/Down - Navigate command history
|
||||||
|
Backspace - Delete character
|
||||||
|
Ctrl+C - Exit
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from engine.effects.plugins import discover_plugins
|
||||||
|
from engine.pipeline.hybrid_config import PipelineConfig
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run the REPL demo."""
|
||||||
|
print("REPL Demo - Interactive Pipeline Control")
|
||||||
|
print("=" * 50)
|
||||||
|
print()
|
||||||
|
print("This demo will:")
|
||||||
|
print("1. Create a pipeline with REPL effect")
|
||||||
|
print("2. Enable raw terminal mode for input")
|
||||||
|
print("3. Show REPL interface with HUD overlay")
|
||||||
|
print()
|
||||||
|
print("Keyboard controls:")
|
||||||
|
print(" Enter - Execute command")
|
||||||
|
print(" Up/Down - Navigate command history")
|
||||||
|
print(" Backspace - Delete character")
|
||||||
|
print(" Ctrl+C - Exit")
|
||||||
|
print()
|
||||||
|
print("Commands to try:")
|
||||||
|
print(" help - Show available commands")
|
||||||
|
print(" status - Show pipeline status")
|
||||||
|
print(" effects - List effects")
|
||||||
|
print(" pipeline - Show pipeline order")
|
||||||
|
print()
|
||||||
|
input("Press Enter to start...")
|
||||||
|
|
||||||
|
# Discover plugins
|
||||||
|
discover_plugins()
|
||||||
|
|
||||||
|
# Create pipeline with REPL effect
|
||||||
|
config = PipelineConfig(
|
||||||
|
source="headlines",
|
||||||
|
camera={"mode": "scroll", "speed": 1.0},
|
||||||
|
effects=[
|
||||||
|
{"name": "noise", "intensity": 0.3},
|
||||||
|
{"name": "fade", "intensity": 0.5},
|
||||||
|
{"name": "repl", "intensity": 1.0}, # Add REPL effect
|
||||||
|
],
|
||||||
|
display={"backend": "terminal", "positioning": "mixed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline = config.to_pipeline(viewport_width=80, viewport_height=24)
|
||||||
|
|
||||||
|
# Initialize pipeline
|
||||||
|
if not pipeline.initialize():
|
||||||
|
print("Failed to initialize pipeline")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the REPL effect instance
|
||||||
|
repl_effect = None
|
||||||
|
for stage in pipeline._stages.values():
|
||||||
|
if hasattr(stage, "_effect") and stage._effect.name == "repl":
|
||||||
|
repl_effect = stage._effect
|
||||||
|
break
|
||||||
|
|
||||||
|
if not repl_effect:
|
||||||
|
print("REPL effect not found in pipeline")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Enable raw mode for input
|
||||||
|
display = pipeline.context.get("display")
|
||||||
|
if display and hasattr(display, "set_raw_mode"):
|
||||||
|
display.set_raw_mode(True)
|
||||||
|
|
||||||
|
# Main loop
|
||||||
|
try:
|
||||||
|
frame_count = 0
|
||||||
|
while True:
|
||||||
|
# Get keyboard input
|
||||||
|
if display and hasattr(display, "get_input_keys"):
|
||||||
|
keys = display.get_input_keys(timeout=0.01)
|
||||||
|
for key in keys:
|
||||||
|
if key == "return":
|
||||||
|
repl_effect.process_command(
|
||||||
|
repl_effect.state.current_command, pipeline.context
|
||||||
|
)
|
||||||
|
elif key == "up":
|
||||||
|
repl_effect.navigate_history(-1)
|
||||||
|
elif key == "down":
|
||||||
|
repl_effect.navigate_history(1)
|
||||||
|
elif key == "backspace":
|
||||||
|
repl_effect.backspace()
|
||||||
|
elif key == "ctrl_c":
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
elif len(key) == 1:
|
||||||
|
repl_effect.append_to_command(key)
|
||||||
|
|
||||||
|
# Execute pipeline
|
||||||
|
result = pipeline.execute([])
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
print(f"Pipeline error: {result.error}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Check for pending commands
|
||||||
|
pending = repl_effect.get_pending_command()
|
||||||
|
if pending:
|
||||||
|
print(f"\nPending command: {pending}\n")
|
||||||
|
|
||||||
|
frame_count += 1
|
||||||
|
time.sleep(0.033) # ~30 FPS
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n\nExiting REPL demo...")
|
||||||
|
finally:
|
||||||
|
# Restore terminal mode
|
||||||
|
if display and hasattr(display, "set_raw_mode"):
|
||||||
|
display.set_raw_mode(False)
|
||||||
|
# Cleanup pipeline
|
||||||
|
pipeline.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
54
examples/repl_demo_terminal.py
Normal file
54
examples/repl_demo_terminal.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
REPL Demo with Terminal Display - Shows how to use the REPL effect
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python examples/repl_demo_terminal.py
|
||||||
|
|
||||||
|
This demonstrates the REPL effect with terminal display and interactive input.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from engine.effects.plugins import discover_plugins
|
||||||
|
from engine.pipeline.hybrid_config import PipelineConfig
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run REPL demo with terminal display."""
|
||||||
|
print("REPL Demo with Terminal Display")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Discover plugins
|
||||||
|
discover_plugins()
|
||||||
|
|
||||||
|
# Create a pipeline with REPL effect
|
||||||
|
# Using empty source so there's content to overlay on
|
||||||
|
config = PipelineConfig(
|
||||||
|
source="empty",
|
||||||
|
effects=[{"name": "repl", "intensity": 1.0}],
|
||||||
|
display="terminal",
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline = config.to_pipeline(viewport_width=80, viewport_height=24)
|
||||||
|
|
||||||
|
# Initialize pipeline
|
||||||
|
if not pipeline.initialize():
|
||||||
|
print("Failed to initialize pipeline")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\nREPL is now active!")
|
||||||
|
print("Try typing commands:")
|
||||||
|
print(" help - Show available commands")
|
||||||
|
print(" status - Show pipeline status")
|
||||||
|
print(" effects - List all effects")
|
||||||
|
print(" pipeline - Show current pipeline order")
|
||||||
|
print(" clear - Clear output buffer")
|
||||||
|
print("\nPress Ctrl+C to exit")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
78
examples/repl_simple.py
Normal file
78
examples/repl_simple.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Simple REPL Demo - Just shows the REPL effect rendering
|
||||||
|
|
||||||
|
This is a simpler version that doesn't require raw terminal mode,
|
||||||
|
just demonstrates the REPL effect rendering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
from engine.effects.plugins import discover_plugins
|
||||||
|
from engine.effects.registry import get_registry
|
||||||
|
from engine.effects.types import EffectContext
|
||||||
|
from engine.pipeline.hybrid_config import PipelineConfig
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run simple REPL demo."""
|
||||||
|
print("Simple REPL Demo")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
# Discover plugins
|
||||||
|
discover_plugins()
|
||||||
|
|
||||||
|
# Create a simple pipeline with REPL
|
||||||
|
config = PipelineConfig(
|
||||||
|
source="headlines",
|
||||||
|
effects=[{"name": "repl", "intensity": 1.0}],
|
||||||
|
display={"backend": "null"},
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline = config.to_pipeline(viewport_width=80, viewport_height=24)
|
||||||
|
|
||||||
|
# Initialize pipeline
|
||||||
|
if not pipeline.initialize():
|
||||||
|
print("Failed to initialize pipeline")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the REPL effect
|
||||||
|
repl_effect = None
|
||||||
|
for stage in pipeline._stages.values():
|
||||||
|
if hasattr(stage, "_effect") and stage._effect.name == "repl":
|
||||||
|
repl_effect = stage._effect
|
||||||
|
break
|
||||||
|
|
||||||
|
if not repl_effect:
|
||||||
|
print("REPL effect not found")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the EffectContext for REPL
|
||||||
|
# Note: In a real pipeline, the EffectContext is created per-stage
|
||||||
|
# For this demo, we'll simulate by adding commands
|
||||||
|
|
||||||
|
# Add some commands to the output
|
||||||
|
repl_effect.process_command("help")
|
||||||
|
repl_effect.process_command("status")
|
||||||
|
repl_effect.process_command("effects")
|
||||||
|
repl_effect.process_command("pipeline")
|
||||||
|
|
||||||
|
# Execute pipeline to see REPL output
|
||||||
|
result = pipeline.execute([])
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
print("\nPipeline Output:")
|
||||||
|
print("-" * 50)
|
||||||
|
for line in result.data:
|
||||||
|
print(line)
|
||||||
|
print("-" * 50)
|
||||||
|
print(f"\n✓ Successfully rendered {len(result.data)} lines")
|
||||||
|
else:
|
||||||
|
print(f"✗ Pipeline error: {result.error}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
110
examples/test_graph_integration.py
Normal file
110
examples/test_graph_integration.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Test script to verify graph-based pipeline integration.
|
||||||
|
|
||||||
|
This script tests that the graph DSL can be used to create working pipelines
|
||||||
|
that produce output similar to preset-based pipelines.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from engine.effects.plugins import discover_plugins
|
||||||
|
from engine.pipeline.graph_toml import load_pipeline_from_toml
|
||||||
|
from engine.pipeline.params import PipelineParams
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_pipeline_execution():
|
||||||
|
"""Test that a graph-based pipeline can execute and produce output."""
|
||||||
|
print("=== Testing Graph Pipeline Execution ===")
|
||||||
|
|
||||||
|
# Discover plugins
|
||||||
|
discover_plugins()
|
||||||
|
|
||||||
|
# Load pipeline from TOML
|
||||||
|
pipeline = load_pipeline_from_toml(
|
||||||
|
"examples/pipeline_graph.toml", viewport_width=80, viewport_height=24
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Pipeline loaded with {len(pipeline._stages)} stages")
|
||||||
|
print(f"Stages: {list(pipeline._stages.keys())}")
|
||||||
|
|
||||||
|
# Initialize pipeline
|
||||||
|
if not pipeline.initialize():
|
||||||
|
print("Failed to initialize pipeline")
|
||||||
|
return False
|
||||||
|
|
||||||
|
print("Pipeline initialized successfully")
|
||||||
|
|
||||||
|
# Set up context
|
||||||
|
ctx = pipeline.context
|
||||||
|
params = PipelineParams(viewport_width=80, viewport_height=24)
|
||||||
|
ctx.params = params
|
||||||
|
|
||||||
|
# Execute pipeline with empty items (source will provide content)
|
||||||
|
result = pipeline.execute([])
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
print(f"Pipeline executed successfully")
|
||||||
|
print(f"Output type: {type(result.data)}")
|
||||||
|
if isinstance(result.data, list):
|
||||||
|
print(f"Output lines: {len(result.data)}")
|
||||||
|
if len(result.data) > 0:
|
||||||
|
print(f"First line: {result.data[0][:50]}...")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"Pipeline execution failed: {result.error}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def test_graph_vs_preset():
|
||||||
|
"""Compare graph-based and preset-based pipelines."""
|
||||||
|
print("\n=== Comparing Graph vs Preset ===")
|
||||||
|
|
||||||
|
from engine.pipeline import get_preset
|
||||||
|
|
||||||
|
# Load graph-based pipeline
|
||||||
|
graph_pipeline = load_pipeline_from_toml(
|
||||||
|
"examples/pipeline_graph.toml", viewport_width=80, viewport_height=24
|
||||||
|
)
|
||||||
|
|
||||||
|
# Load preset-based pipeline (using test-basic as a base)
|
||||||
|
preset = get_preset("test-basic")
|
||||||
|
if not preset:
|
||||||
|
print("test-basic preset not found")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Create pipeline from preset config
|
||||||
|
from engine.pipeline import Pipeline
|
||||||
|
|
||||||
|
preset_pipeline = Pipeline(config=preset.to_config())
|
||||||
|
|
||||||
|
print(f"Graph pipeline stages: {len(graph_pipeline._stages)}")
|
||||||
|
print(f"Preset pipeline stages: {len(preset_pipeline._stages)}")
|
||||||
|
|
||||||
|
# Compare stage types
|
||||||
|
graph_stage_types = {
|
||||||
|
name: stage.__class__.__name__ for name, stage in graph_pipeline._stages.items()
|
||||||
|
}
|
||||||
|
preset_stage_types = {
|
||||||
|
name: stage.__class__.__name__
|
||||||
|
for name, stage in preset_pipeline._stages.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
print("\nGraph pipeline stages:")
|
||||||
|
for name, stage_type in graph_stage_types.items():
|
||||||
|
print(f" - {name}: {stage_type}")
|
||||||
|
|
||||||
|
print("\nPreset pipeline stages:")
|
||||||
|
for name, stage_type in preset_stage_types.items():
|
||||||
|
print(f" - {name}: {stage_type}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success1 = test_graph_pipeline_execution()
|
||||||
|
success2 = test_graph_vs_preset()
|
||||||
|
|
||||||
|
if success1 and success2:
|
||||||
|
print("\n✓ All tests passed!")
|
||||||
|
else:
|
||||||
|
print("\n✗ Some tests failed")
|
||||||
|
exit(1)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 577.362 577.362"
|
||||||
|
xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g id="Layer_2_21_">
|
||||||
|
<path d="M547.301,156.98c-23.113-2.132-181.832-24.174-314.358,5.718c-37.848-16.734-57.337-21.019-85.269-31.078
|
||||||
|
c-12.47-4.494-28.209-7.277-41.301-9.458c-26.01-4.322-45.89,1.253-54.697,31.346C36.94,203.846,19.201,253.293,0,311.386
|
||||||
|
c15.118-0.842,40.487-8.836,40.487-8.836l48.214-7.966l-9.964,66.938l57.777-19.526v57.776l66.938-29.883l19.125,49.41
|
||||||
|
c0,0,44.647-34.081,57.375-49.41c28.076,83.634,104.595,105.981,175.71,70.122c21.42-10.806,39.914-46.637,48.129-65.255
|
||||||
|
c23.926-54.229,11.6-93.712-5.891-137.155c20.254-9.562,34.061-13.464,66.344-30.628
|
||||||
|
C582.365,197.764,585.951,161.904,547.301,156.98z M63.352,196.119c11.924-8.396,18.599,0.889,34.511-10.308
|
||||||
|
c6.971-5.183,4.581-18.924-4.542-21.908c-3.997-1.31-6.722-2.897-12.049-5.192c-7.449-2.984-0.851-20.082,7.325-18.676
|
||||||
|
c15.443,2.572,24.575,3.012,32.159,12.125c8.702,10.452,9.008,37.074,4.991,45.843c-9.553,20.885-35.257,19.087-53.923,17.241
|
||||||
|
C57.624,214.097,56.744,201.034,63.352,196.119z M284.073,346.938c-51.915,6.685-102.921,0.794-142.462-42.313
|
||||||
|
c-25.331-27.616-57.231-46.187-88.654-68.611c28.84-11.121,64.49-5.078,84.781,25.704
|
||||||
|
c45.383,68.841,106.344,71.279,176.887,56.247c24.127-5.145,52.9-8.052,76.807-2.983c26.297,5.574,29.279,31.24,12.039,48.118
|
||||||
|
c-18.227,19.775-39.045-0.794-29.482-6.378c7.967-4.38,12.643-10.997,10.482-19.259c-6.197-9.668-21.707-2.975-31.586-1.425
|
||||||
|
C324.953,340.437,312.023,343.344,284.073,346.938z M472.188,381.049c-24.176,34.31-54.775,55.969-100.789,47.602
|
||||||
|
c-27.846-5.059-61.41-30.179-53.789-65.14c34.061,41.836,95.625,35.859,114.75,1.195c16.533-29.969-4.141-62.5-23.793-66.852
|
||||||
|
c-30.676-6.779-69.891-0.134-101.381,4.408c-58.58,8.444-104.48,7.812-152.579-43.844c-26.067-27.99,15.376-53.493-7.736-107.282
|
||||||
|
c44.351,8.578,72.121,22.711,89.247,79.292c11.293,37.294,59.096,61.325,110.762,53.387
|
||||||
|
c38.031-5.842,81.912-22.873,119.703-31.853C499.66,299.786,498.293,343.984,472.188,381.049z M288.195,243.568
|
||||||
|
c31.805-12.135,64.67-9.151,94.362,0C350.475,273.26,301.467,268.479,288.195,243.568z M528.979,198.959
|
||||||
|
c-35.459,17.337-60.961,25.102-98.809,37.055c-5.146,1.626-13.895,1.042-18.438-2.17c-47.803-33.813-114.846-27.425-142.338-6.292
|
||||||
|
c-18.522-11.456-21.038-42.582,8.406-49.304c83.834-19.125,179.45-13.646,248.788,0.793
|
||||||
|
C540.529,183.42,538.674,194.876,528.979,198.959z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
60
figments/mayan-mask-of-mexico-svgrepo-com.svg
Normal file
60
figments/mayan-mask-of-mexico-svgrepo-com.svg
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 559.731 559.731"
|
||||||
|
xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g id="Layer_2_36_">
|
||||||
|
<path d="M295.414,162.367l-15.061-39.302l-14.918,39.34c5.049-0.507,10.165-0.774,15.339-0.774
|
||||||
|
C285.718,161.621,290.595,161.898,295.414,162.367z"/>
|
||||||
|
<path d="M522.103,244.126c-20.062-0.631-36.71,12.67-55.787,21.937c-25.111,12.192-17.548-7.526-17.548-7.526l56.419-107.186
|
||||||
|
c-31.346-31.967-127.869-68.324-127.869-68.324l-38.968,85.957L280.774,27.249L221.295,168.84l-38.9-85.804
|
||||||
|
c0,0-96.533,36.356-127.87,68.324l56.418,107.186c0,0,7.564,19.718-17.547,7.525c-19.077-9.266-35.726-22.567-55.788-21.936
|
||||||
|
C17.547,244.767,0,275.481,0,305.565c0,30.084,7.525,68.955,39.493,68.955c31.967,0,47.64-16.926,58.924-23.188
|
||||||
|
c11.284-6.273,20.062,1.252,14.105,12.536S49.524,465.412,49.524,465.412s57.041,40.115,130.375,67.071l33.22-84.083
|
||||||
|
c-49.601-24.91-83.796-76.127-83.796-135.31c0-61.372,36.758-114.214,89.352-137.986c1.511-0.688,3.002-1.406,4.542-2.037
|
||||||
|
c9.964-4.112,20.483-7.095,31.384-9.008l25.732-67.836l25.943,67.731c10.576,1.807,20.779,4.657,30.495,8.53
|
||||||
|
c1.176,0.468,2.391,0.88,3.557,1.377c53.99,23.18,91.925,76.844,91.925,139.229c0,59.795-34.913,111.441-85.346,136.056
|
||||||
|
l32.924,83.337c73.335-26.956,130.375-67.071,130.375-67.071s-57.04-90.26-62.998-101.544
|
||||||
|
c-5.957-11.284,2.821-18.81,14.105-12.536c11.283,6.272,26.956,23.188,58.924,23.188s39.493-38.861,39.493-68.955
|
||||||
|
C559.712,275.472,542.165,244.757,522.103,244.126z"/>
|
||||||
|
<path d="M256.131,173.478c-1.836,0.325-3.682,0.612-5.499,1.004c-8.912,1.932-17.518,4.676-25.723,8.205
|
||||||
|
c-4.045,1.74-7.995,3.634-11.839,5.728c-44.159,24.078-74.195,70.925-74.195,124.667c0,55.146,31.681,102.931,77.743,126.396
|
||||||
|
c19.297,9.831,41.052,15.491,64.146,15.491c22.481,0,43.682-5.393,62.596-14.745c46.895-23.18,79.302-71.394,79.302-127.152
|
||||||
|
c0-54.851-31.336-102.434-77.007-126.043c-3.557-1.836-7.172-3.576-10.892-5.116c-7.86-3.242-16.056-5.814-24.547-7.622
|
||||||
|
c-1.808-0.382-3.652-0.622-5.479-0.937c-1.807-0.306-3.614-0.593-5.44-0.832c-6.082-0.793-12.24-1.348-18.532-1.348
|
||||||
|
c-6.541,0-12.919,0.602-19.221,1.463C259.736,172.895,257.929,173.163,256.131,173.478z M280.783,196.084
|
||||||
|
c10.433,0,20.493,1.501,30.132,4.074c8.559,2.285,16.754,5.441,24.423,9.496c37.093,19.641,62.443,58.608,62.443,103.418
|
||||||
|
c0,43.155-23.543,80.832-58.408,101.114c-17.251,10.04-37.227,15.883-58.59,15.883c-22.127,0-42.753-6.282-60.416-16.992
|
||||||
|
c-33.842-20.531-56.581-57.614-56.581-100.005c0-44.064,24.499-82.486,60.578-102.434c14.889-8.233,31.776-13.196,49.715-14.22
|
||||||
|
C276.309,196.294,278.518,196.084,280.783,196.084z"/>
|
||||||
|
<path d="M236.997,354.764c-6.694,0-12.145,5.45-12.145,12.145v4.398c0,6.694,5.441,12.145,12.145,12.145h16.457
|
||||||
|
c-1.683-11.743-0.717-22.376,0.268-28.688H236.997z"/>
|
||||||
|
<path d="M327.458,383.452c5.001,0,9.295-3.041,11.15-7.373c0.641-1.473,0.994-3.079,0.994-4.771v-4.398
|
||||||
|
c0-1.874-0.507-3.605-1.271-5.192c-1.961-4.074-6.054-6.952-10.873-6.952h-17.882c2.592,8.415,3.5,18.303,1.683,28.688H327.458z"
|
||||||
|
/>
|
||||||
|
<path d="M173.339,313.082c0,36.949,18.752,69.596,47.239,88.94c14.516,9.859,31.566,16.237,49.945,17.978
|
||||||
|
c-7.879-8.176-12.527-17.633-15.089-26.985h-18.437c-6.407,0-12.116-2.85-16.084-7.277c-3.461-3.844-5.623-8.874-5.623-14.43
|
||||||
|
v-4.398c0-5.938,2.41-11.322,6.283-15.243c3.939-3.987,9.39-6.464,15.424-6.464h18.809h49.974h21.697
|
||||||
|
c3.863,0,7.449,1.1,10.595,2.888c6.579,3.729,11.093,10.72,11.093,18.819v4.398c0,7.765-4.131,14.535-10.279,18.379
|
||||||
|
c-3.328,2.075-7.22,3.328-11.428,3.328h-18.676c-3.088,9.056-8.463,18.227-16.791,26.909c17.27-1.798,33.296-7.756,47.162-16.772
|
||||||
|
c29.48-19.173,49.056-52.355,49.056-90.069c0-39.216-21.19-73.498-52.661-92.259c-16.064-9.572-34.75-15.176-54.765-15.176
|
||||||
|
c-20.798,0-40.172,6.043-56.638,16.313C193.698,240.942,173.339,274.64,173.339,313.082z M306.287,274.583
|
||||||
|
c4.513-9.027,15.156-14.64,27.778-14.64c0.775,0,1.502,0.201,2.257,0.249c11.026,0.622,21.22,5.499,27.53,13.598l2.238,2.888
|
||||||
|
l-2.19,2.926c-6.789,9.036-16.667,14.688-26.89,15.597c-0.956,0.086-1.912,0.19-2.878,0.19c-11.284,0-21.362-5.89-27.664-16.16
|
||||||
|
l-1.387-2.257L306.287,274.583z M268.353,311.484l1.271,3.691c1.501,4.398,6.206,13.493,11.159,13.493
|
||||||
|
c4.915,0,9.649-9.372,11.055-13.646l1.138-3.48l3.653,0.201c9.658,0.517,12.594-1.454,13.244-2.065
|
||||||
|
c0.392-0.363,0.641-0.794,0.641-1.722c0-2.639,2.142-4.781,4.781-4.781c2.639,0,4.781,2.143,4.781,4.781
|
||||||
|
c0,3.414-1.253,6.417-3.624,8.664c-3.396,3.223-8.731,4.666-16.84,4.781c-2.534,5.852-8.635,16.839-18.838,16.839
|
||||||
|
c-10.06,0-16.19-10.595-18.81-16.428c-5.756,0.315-13.368-0.249-18.216-4.514c-2.716-2.391-4.16-5.623-4.16-9.343
|
||||||
|
c0-2.639,2.142-4.781,4.781-4.781s4.781,2.143,4.781,4.781c0,0.976,0.258,1.597,0.908,2.171c2.2,1.932,8.004,2.696,14.42,1.855
|
||||||
|
L268.353,311.484z M257.9,273.789l2.238,2.878l-2.19,2.916c-7.411,9.888-18.532,15.788-29.758,15.788
|
||||||
|
c-1.875,0-3.701-0.22-5.499-0.535c-9.018-1.598-16.916-7.058-22.166-15.625l-1.396-2.266l1.186-2.372
|
||||||
|
c3.94-7.87,12.546-13.148,23.055-14.363c1.54-0.182,3.127-0.277,4.733-0.277C240.028,259.942,251.168,265.116,257.9,273.789z"/>
|
||||||
|
<path d="M301.468,383.452c2.228-10.596,1.08-20.636-1.961-28.688h-36.06c-0.918,5.489-2.171,16.591-0.191,28.688
|
||||||
|
c0.517,3.146,1.272,6.359,2.295,9.562c2.763,8.664,7.563,17.231,15.73,24.088c8.443-7.707,13.941-15.94,17.26-24.088
|
||||||
|
C299.86,389.801,300.808,386.607,301.468,383.452z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.6 KiB |
110
figments/mayan-symbol-of-mexico-svgrepo-com.svg
Normal file
110
figments/mayan-symbol-of-mexico-svgrepo-com.svg
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
width="800px" height="800px" viewBox="0 0 589.748 589.748"
|
||||||
|
xml:space="preserve">
|
||||||
|
<g>
|
||||||
|
<g id="Layer_2_2_">
|
||||||
|
<path d="M498.658,267.846c-9.219-9.744-20.59-14.382-33.211-15.491c-13.914-1.234-26.719,3.098-37.514,12.278
|
||||||
|
c-4.82,4.093-15.416,2.763-16.916-5.413c-0.795-4.303-0.096-7.602,2.305-11.246c3.854-5.862,6.98-12.202,10.422-18.331
|
||||||
|
c3.73-6.646,7.508-13.263,11.16-19.947c5.26-9.61,10.375-19.307,15.672-28.898c3.76-6.799,7.785-13.445,11.486-20.273
|
||||||
|
c0.459-0.851,0.104-3.031-0.594-3.48c-7.898-5.106-15.777-10.28-23.982-14.86c-7.602-4.236-15.502-7.975-23.447-11.542
|
||||||
|
c-8.348-3.739-16.889-7.076-25.418-10.404c-0.879-0.344-2.869,0.191-3.299,0.928c-5.26,9.008-10.346,18.111-15.443,27.215
|
||||||
|
c-4.006,7.153-7.918,14.363-11.924,21.516c-2.381,4.255-4.877,8.434-7.297,12.661c-3.193,5.575-6.215,11.255-9.609,16.715
|
||||||
|
c-1.234,1.989-0.363,2.467,1.07,3.232c5.25,2.812,11.016,5.001,15.586,8.673c7.736,6.225,15.109,13.034,21.879,20.301
|
||||||
|
c4.629,4.963,8.598,10.796,11.725,16.82c3.824,7.373,6.865,15.233,9.477,23.132c2.094,6.34,4.006,13.024,4.283,19.632
|
||||||
|
c0.441,10.317,1.473,20.837-1.291,31.04c-2.352,8.645-4.484,17.423-7.764,25.723c-2.41,6.101-6.445,11.58-9.879,17.27
|
||||||
|
c-6.225,10.309-14.354,18.943-24.115,25.925c-6.428,4.599-13.207,8.701-20.035,13.157c14.621,26.584,29.396,53.436,44.266,80.459
|
||||||
|
c4.762-1.788,9.256-3.375,13.664-5.154c7.412-2.974,14.918-5.766,22.129-9.189c6.082-2.888,11.857-6.464,17.662-9.906
|
||||||
|
c7.41-4.399,14.734-8.932,22.012-13.541c0.604-0.382,1.043-2.056,0.717-2.706c-1.768-3.5-3.748-6.904-5.766-10.271
|
||||||
|
c-4.246-7.085-8.635-14.095-12.812-21.219c-3.5-5.967-6.752-12.077-10.166-18.083c-3.711-6.512-7.525-12.957-11.207-19.488
|
||||||
|
c-2.611-4.638-4.887-9.477-7.65-14.019c-2.008-3.299-3.91-6.292-3.768-10.528c0.152-4.6,2.18-7.583,5.824-9.668
|
||||||
|
c3.613-2.056,7.391-1.864,10.814,0.546c2.945,2.074,5.412,5.077,8.615,6.492c5.527,2.438,11.408,4.122,17.232,5.834
|
||||||
|
c7.602,2.228,15.328,0.927,22.586-1.062c7.268-1.989,14.258-5.394,19.861-10.806c2.85-2.754,5.939-5.441,8.09-8.712
|
||||||
|
c4.285-6.493,7.432-13.426,8.885-21.324c1.51-8.195,0.688-16.065-1.645-23.61C508.957,280.516,504.404,273.927,498.658,267.846z"
|
||||||
|
/>
|
||||||
|
<path d="M183.983,301.85c0.421-46.885,24.174-79.417,64.69-100.846c-1.817-3.471-3.461-6.761-5.24-9.983
|
||||||
|
c-3.423-6.177-6.99-12.278-10.375-18.475c-5.518-10.117-10.882-20.32-16.438-30.418c-3.577-6.502-7.574-12.766-10.987-19.345
|
||||||
|
c-1.454-2.802-2.802-3.137-5.613-2.142c-12.642,4.466-25.016,9.543-36.979,15.606c-11.915,6.043-23.418,12.728-34.32,20.492
|
||||||
|
c-1.778,1.262-1.96,2.104-1.004,3.777c2.792,4.848,5.537,9.725,8.271,14.611c4.973,8.874,9.955,17.739,14.86,26.632
|
||||||
|
c3.242,5.871,6.282,11.857,9.572,17.7c5.843,10.375,12.02,20.579,17.643,31.078c2.448,4.571,2.247,10.604-2.639,14.009
|
||||||
|
c-5.011,3.491-9.486,3.596-14.22-0.115c-6.311-4.953-13.167-8.424-20.913-10.509c-11.59-3.127-22.711-1.894-33.564,2.802
|
||||||
|
c-2.18,0.946-4.112,2.429-6.244,3.48c-6.216,3.079-10.815,7.994-14.755,13.455c-4.447,6.168-7.076,13.158-8.683,20.655
|
||||||
|
c-1.73,8.071-1.052,16.008,1.167,23.677c2.878,9.955,8.807,18.149,16.677,24.996c5.613,4.887,12.192,8.339,19.096,9.975
|
||||||
|
c6.666,1.577,13.933,1.367,20.866,0.898c7.621-0.507,14.621-3.528,20.817-8.176c5.699-4.274,11.16-9.209,18.905-3.558
|
||||||
|
c3.242,2.362,5.431,10.375,3.414,13.751c-7.937,13.272-15.816,26.584-23.524,39.99c-4.169,7.249-7.851,14.774-11.915,22.09
|
||||||
|
c-4.456,8.013-9.151,15.902-13.646,23.896c-2.362,4.207-2.094,4.724,2.142,7.277c4.8,2.878,9.505,5.947,14.373,8.711
|
||||||
|
c8.09,4.6,16.18,9.237,24.48,13.436c5.556,2.812,11.427,5.011,17.241,7.286c5.393,2.113,10.892,3.969,16.524,6.006
|
||||||
|
c14.908-27.119,29.653-53.942,44.322-80.631C207.775,381.381,183.563,349.012,183.983,301.85z"/>
|
||||||
|
<path d="M283.979,220.368c-36.777,4.839-64.327,32.302-72.245,60.99c55.348,0,110.629,0,166.129,0
|
||||||
|
C364.667,233.545,324.189,215.08,283.979,220.368z"/>
|
||||||
|
<path d="M381.019,300.482c-9.82,0-19.201,0-28.889,0c0.727,9.562-3.203,28.143-13.1,40.028
|
||||||
|
c-9.926,11.915-22.529,18.207-37.658,19.68c-16.983,1.645-32.694-1.692-45.546-13.464c-13.655-12.498-20.129-27.119-18.81-46.244
|
||||||
|
c-9.763,0-18.972,0-29.223,0c-0.239,38.25,14.688,62.089,45.719,78.986c29.863,16.266,60.559,15.242,88.883-3.433
|
||||||
|
C369.066,358.45,382.291,329.17,381.019,300.482z"/>
|
||||||
|
<path d="M260.656,176.715c3.242,5.948,6.474,11.886,9.477,17.404c6.541-0.88,12.622-2.458,18.675-2.343
|
||||||
|
c9.313,0.182,18.59,1.559,27.893,2.314c0.957,0.077,2.486-0.296,2.869-0.975c2.486-4.332,4.695-8.817,7.057-13.215
|
||||||
|
c2.238-4.169,4.543-8.3,6.752-12.316c-12.719-24.203-25.389-48.319-38.451-73.172c-0.822,1.482-1.358,2.381-1.836,3.309
|
||||||
|
c-1.96,3.825-3.854,7.688-5.862,11.484c-2.438,4.628-4.954,9.218-7.459,13.818c-2.228,4.083-4.456,8.157-6.722,12.221
|
||||||
|
c-2.381,4.274-4.858,8.501-7.201,12.804c-2.381,4.361-4.418,8.932-7.028,13.148c-2.611,4.208-2.917,7.526-0.249,11.762
|
||||||
|
C259.336,174.171,259.967,175.462,260.656,176.715z"/>
|
||||||
|
<path d="M272.991,331.341c10.949,8.501,29.424,10.643,42.047,1.157c10.566-7.938,16.734-22.453,13.721-32.016
|
||||||
|
c-22.807,0-45.632,0-68.41,0C257.127,310.045,263.008,323.595,272.991,331.341z"/>
|
||||||
|
<path d="M322.248,413.836c-1.281-2.447-2.811-3.356-6.119-2.515c-5.699,1.444-11.676,2.133-17.566,2.381
|
||||||
|
c-10.175,0.431-20.388,0.479-30.486-2.696c-2.62,6.034-5.125,11.8-7.688,17.69c22.96,8.894,45.729,8.894,68.889,0.899
|
||||||
|
c-0.049-0.794,0.105-1.492-0.145-1.999C326.886,422.987,324.638,418.379,322.248,413.836z"/>
|
||||||
|
<path d="M541.498,355.343c10.613-15.654,15.863-33.345,15.586-52.556c-0.43-30.237-12.9-55.721-36.088-73.708
|
||||||
|
c-12.527-9.715-25.887-16.065-39.914-18.972c0.469-0.794,0.928-1.597,1.377-2.4c2.295-4.15,4.514-8.338,6.74-12.527
|
||||||
|
c1.914-3.605,3.836-7.21,5.795-10.796c1.482-2.716,3.014-5.403,4.543-8.09c2.295-4.036,4.59-8.081,6.76-12.183
|
||||||
|
c4.189-7.908,3.031-18.59-2.744-25.398c-2.781-3.28-5.785-5.25-7.773-6.56l-0.871-0.583l-4.465-3.213
|
||||||
|
c-3.883-2.812-7.908-5.709-12.184-8.491c-7.707-5.011-14.793-9.343-21.668-13.244c-4.17-2.362-8.387-4.236-12.105-5.891
|
||||||
|
l-3.08-1.377c-1.988-0.909-3.969-1.846-5.957-2.773c-5.633-2.658-11.455-5.402-17.795-7.707c-7.422-2.697-14.861-5.001-22.07-7.22
|
||||||
|
c-3.672-1.138-7.354-2.276-11.008-3.462c-2.236-0.727-5.66-1.683-9.609-1.683c-5.375,0-15.367,1.855-21.832,14.248
|
||||||
|
c-1.338,2.562-2.658,5.125-3.977,7.698L311.625,30.59L294.708,0l-16.639,30.743l-36.873,68.124
|
||||||
|
c-1.884-3.232-3.749-6.474-5.575-9.735c-4.523-8.07-12.125-12.699-20.865-12.699c-2.305,0-4.657,0.334-7,1.004
|
||||||
|
c-4.208,1.195-9.113,2.601-14.038,4.293l-5.747,1.941c-6.866,2.305-13.961,4.686-21.057,7.641
|
||||||
|
c-12.393,5.154-23.543,9.916-34.616,15.902c-9.333,5.049-17.968,10.815-26.316,16.39l-5.106,3.404
|
||||||
|
c-3.796,2.515-7.172,5.25-10.146,7.669c-1.176,0.947-2.343,1.903-3.519,2.821l-12.852,10.002l7.832,14.287l26.479,48.291
|
||||||
|
c-14.86,2.993-28.745,9.763-41.463,20.225c-21.994,18.102-33.938,42.773-34.53,71.355c-0.526,25.293,8.186,48.195,25.178,66.249
|
||||||
|
c14.248,15.128,31.049,24.538,50.107,28.086c-2.936,5.288-5.872,10.575-8.798,15.863c-1.3,2.362-2.562,4.733-3.834,7.115
|
||||||
|
c-1.625,3.05-3.251,6.11-4.963,9.112c-1.214,2.133-2.524,4.218-3.834,6.293c-1.281,2.046-2.563,4.102-3.796,6.187
|
||||||
|
c-5.891,10.012-1.568,21.649,6.015,27.119c7.851,5.671,15.73,11.303,23.677,16.858c12.451,8.702,25.408,15.864,38.508,21.286
|
||||||
|
l4.676,1.941c7.468,3.117,15.195,6.331,23.227,9.123c7.631,2.648,15.3,4.915,22.711,7.104c3.137,0.928,6.264,1.855,9.391,2.812
|
||||||
|
l9.955,4.657c3.892,32.751,35.324,58.283,73.526,58.283c38.508,0,70.112-25.943,73.592-59.058l10.49-3.51l4.715-1.683
|
||||||
|
l10.107-3.118c2.018-0.593,4.035-1.214,6.062-1.778c4.973-1.367,10.117-2.821,15.396-4.743
|
||||||
|
c7.889-2.878,16.352-6.368,26.641-10.949c6.588-2.936,12.938-6.206,18.877-9.696c8.883-5.23,17.566-10.662,25.789-16.142
|
||||||
|
c5.184-3.452,9.707-7.172,14.076-10.776l1.463-1.205c8.492-6.962,9.18-19.153,4.936-26.909c-2.229-4.073-4.562-8.09-6.895-12.097
|
||||||
|
l-2.42-4.159l-3.271-5.651c-3.107-5.374-6.225-10.748-9.295-16.142c-1.156-2.037-2.303-4.073-3.441-6.12
|
||||||
|
c6.961-1.301,13.637-3.404,19.957-6.292C517.552,382.251,531.093,370.69,541.498,355.343z M463.82,378.465
|
||||||
|
c-4.809,0-9.734-0.411-14.764-1.167c3.461,6.254,6.396,11.552,9.332,16.84c3.232,5.823,6.436,11.656,9.727,17.441
|
||||||
|
c4.168,7.325,8.404,14.612,12.621,21.908c3.051,5.278,6.168,10.519,9.096,15.864c0.41,0.746,0.268,2.496-0.287,2.955
|
||||||
|
c-4.562,3.748-9.094,7.573-14,10.844c-8.148,5.422-16.457,10.604-24.891,15.567c-5.471,3.223-11.16,6.12-16.965,8.702
|
||||||
|
c-8.357,3.729-16.811,7.296-25.408,10.433c-6.617,2.409-13.512,4.035-20.281,6.024c-4.82,1.415-9.629,2.83-14.85,4.37
|
||||||
|
c-2.736-4.753-5.49-9.371-8.072-14.066c-2.477-4.504-4.732-9.123-7.172-13.646c-4.34-8.033-8.807-16.008-13.109-24.069
|
||||||
|
c-1.598-2.993-2.133-3.997-3.576-3.997c-0.871,0-2.076,0.363-4.045,0.87c-8.148,2.104-16.324,3.873-24.309,5.661
|
||||||
|
c22.223,7.659,38.221,28.735,38.221,53.607c0,31.326-25.35,56.725-56.609,56.725c-31.27,0-56.61-25.398-56.61-56.725
|
||||||
|
c0-24.566,15.606-45.422,37.409-53.312c-7.516-2.065-15.472-4.341-23.572-6.54c-0.918-0.249-1.721-0.584-2.448-0.584
|
||||||
|
c-1.301,0-2.362,0.546-3.366,2.592c-4.581,9.267-9.744,18.217-14.697,27.301c-3.911,7.182-7.86,14.325-11.791,21.497
|
||||||
|
c-0.804,1.463-1.645,2.897-2.812,4.972c-10.49-3.203-21.076-6.11-31.422-9.696c-9.094-3.155-17.949-6.99-26.852-10.671
|
||||||
|
c-12.345-5.106-23.925-11.638-34.865-19.288c-7.86-5.498-15.664-11.083-23.438-16.696c-0.478-0.344-0.947-1.529-0.717-1.912
|
||||||
|
c2.515-4.274,5.288-8.396,7.746-12.699c3.098-5.422,5.909-10.997,8.931-16.467c5.919-10.729,11.896-21.42,17.834-32.14
|
||||||
|
c1.979-3.576,3.892-7.2,6.264-11.58c-4.848,0.736-9.562,1.109-14.143,1.109c-20.952,0-39.082-7.755-54.085-23.687
|
||||||
|
c-13.78-14.63-20.406-32.607-19.986-52.737c0.478-23.074,9.811-42.38,27.559-56.992c13.952-11.484,29.663-17.643,47.354-17.643
|
||||||
|
c4.523,0,9.17,0.401,13.952,1.224c-14.028-25.589-27.75-50.615-41.692-76.06c4.112-3.204,8.1-6.723,12.479-9.63
|
||||||
|
c9.85-6.521,19.594-13.311,29.959-18.915c10.585-5.718,21.745-10.433,32.866-15.07c8.367-3.481,17.06-6.197,25.646-9.142
|
||||||
|
c4.303-1.472,8.683-2.744,13.053-3.987c0.641-0.182,1.233-0.277,1.788-0.277c1.721,0,3.05,0.908,4.179,2.926
|
||||||
|
c5.393,9.62,11.092,19.067,16.629,28.611c2.018,3.481,3.901,7.048,6.11,11.054c17.853-32.981,35.41-65.426,53.206-98.312
|
||||||
|
c18.322,33.134,36.348,65.732,54.65,98.819c2.467-4.485,4.828-8.597,7.018-12.804c4.553-8.74,8.98-17.538,13.531-26.268
|
||||||
|
c1.463-2.812,2.773-3.968,4.867-3.968c1.014,0,2.219,0.268,3.711,0.755c10.814,3.5,21.773,6.588,32.445,10.461
|
||||||
|
c7.65,2.773,14.938,6.531,22.367,9.916c4.59,2.085,9.285,4.007,13.654,6.483c7.029,3.988,13.914,8.243,20.684,12.651
|
||||||
|
c5.471,3.557,10.682,7.487,15.998,11.265c1.77,1.252,3.777,2.314,5.145,3.92c0.756,0.889,0.977,3.031,0.432,4.074
|
||||||
|
c-3.576,6.751-7.498,13.32-11.18,20.024c-4.236,7.717-8.252,15.558-12.508,23.266c-2.246,4.064-4.895,7.898-7.182,11.943
|
||||||
|
c-3.309,5.862-6.445,11.819-10.012,18.389c4.973-0.947,9.803-1.406,14.498-1.406c17.174,0,32.502,6.13,46.254,16.802
|
||||||
|
c18.951,14.707,28.352,35.065,28.688,58.866c0.209,14.803-3.74,28.927-12.299,41.559c-8.309,12.26-19.039,21.602-32.379,27.693
|
||||||
|
C483.902,376.6,474.101,378.465,463.82,378.465z"/>
|
||||||
|
<path d="M261.746,512.598c0,18.102,14.669,32.818,32.704,32.818c18.034,0,32.704-14.726,32.704-32.818
|
||||||
|
c0-18.092-14.67-32.818-32.704-32.818C276.415,479.779,261.746,494.506,261.746,512.598z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
@@ -10,7 +10,8 @@ uv = "latest"
|
|||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
test = "uv run pytest"
|
test = "uv run pytest"
|
||||||
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing", depends = ["sync-all"] }
|
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing -m \"not benchmark\"", depends = ["sync-all"] }
|
||||||
|
benchmark = { run = "uv run python -m engine.benchmark", depends = ["sync-all"] }
|
||||||
lint = "uv run ruff check engine/ mainline.py"
|
lint = "uv run ruff check engine/ mainline.py"
|
||||||
format = "uv run ruff format engine/ mainline.py"
|
format = "uv run ruff format engine/ mainline.py"
|
||||||
|
|
||||||
@@ -18,7 +19,8 @@ format = "uv run ruff format engine/ mainline.py"
|
|||||||
# Run
|
# Run
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
run = "uv run mainline.py"
|
mainline = "uv run mainline.py"
|
||||||
|
run = { run = "uv run mainline.py", depends = ["sync-all"] }
|
||||||
run-pygame = { run = "uv run mainline.py --display pygame", depends = ["sync-all"] }
|
run-pygame = { run = "uv run mainline.py --display pygame", depends = ["sync-all"] }
|
||||||
run-terminal = { run = "uv run mainline.py --display terminal", depends = ["sync-all"] }
|
run-terminal = { run = "uv run mainline.py --display terminal", depends = ["sync-all"] }
|
||||||
|
|
||||||
@@ -50,7 +52,7 @@ clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache
|
|||||||
# CI
|
# CI
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] }
|
ci = "mise run topics-init && mise run lint && mise run test-cov && mise run benchmark"
|
||||||
topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null"
|
topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
|
|||||||
1
opencode-instructions.md
Normal file
1
opencode-instructions.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/home/david/.skills/opencode-instructions/SKILL.md
|
||||||
1870
output/sideline_demo.json
Normal file
1870
output/sideline_demo.json
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user