forked from genewildish/Mainline
Compare commits
82 Commits
integratio
...
60ae4f7dfb
| Author | SHA1 | Date | |
|---|---|---|---|
| 60ae4f7dfb | |||
| 4b26c947e8 | |||
| b37b2ccc73 | |||
| b926b346ad | |||
| a65fb50464 | |||
| 10e2f00edd | |||
| 05d261273e | |||
| 57de835ae0 | |||
| 4c97cfe6aa | |||
| 10c1d057a9 | |||
| 7f6413c83b | |||
| d54147cfb4 | |||
| affafe810c | |||
| 85d8b29bab | |||
| d14f850711 | |||
| 6fc3cbc0d2 | |||
| 3e73ea0adb | |||
| 7c69086fa5 | |||
| 0980279332 | |||
| cda13584c5 | |||
| 526e5ae47d | |||
| dfe42b0883 | |||
| 1d244cf76a | |||
| 0aa80f92de | |||
| 5762d5e845 | |||
| 28203bac4b | |||
| 952b73cdf0 | |||
| d9c7138fe3 | |||
| c976b99da6 | |||
| 8d066edcca | |||
| b20b4973b5 | |||
| 73ca72d920 | |||
| 015d563c4a | |||
| 4a08b474c1 | |||
| 637cbc5515 | |||
| e0bbfea26c | |||
| 3a3d0c0607 | |||
| f638fb7597 | |||
| 2a41a90d79 | |||
| f43920e2f0 | |||
| b27ddbccb8 | |||
| bfd94fe046 | |||
| 76126bdaac | |||
| 4616a21359 | |||
| ce9d888cf5 | |||
| 1a42fca507 | |||
| e23ba81570 | |||
| 997bffab68 | |||
| 2e96b7cd83 | |||
| a370c7e1a0 | |||
| ea379f5aca | |||
| 828b8489e1 | |||
| 31cabe9128 | |||
| bcb4ef0cfe | |||
| 996ba14b1d | |||
| a1dcceac47 | |||
| c2d77ee358 | |||
| 8e27f89fa4 | |||
| 4d28f286db | |||
| 9b139a40f7 | |||
| e1408dcf16 | |||
| 0152e32115 | |||
| dc1adb2558 | |||
| fada11b58d | |||
| 3e9c1be6d2 | |||
| 0f2d8bf5c2 | |||
| f5de2c62e0 | |||
| f9991c24af | |||
| 20ed014491 | |||
| 9e4d54a82e | |||
| dcd31469a5 | |||
| 829c4ab63d | |||
| 22dd063baa | |||
| 0f7203e4e0 | |||
| ba050ada24 | |||
| d7b044ceae | |||
| ac1306373d | |||
| 2650f7245e | |||
| b1f2b9d2be | |||
| c08a7d3cb0 | |||
| d5a3edba97 | |||
| fb35458718 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,4 +12,3 @@ htmlcov/
|
|||||||
coverage.xml
|
coverage.xml
|
||||||
*.dot
|
*.dot
|
||||||
*.png
|
*.png
|
||||||
test-reports/
|
|
||||||
|
|||||||
@@ -29,28 +29,17 @@ class Stage(ABC):
|
|||||||
return set()
|
return set()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def dependencies(self) -> set[str]:
|
def dependencies(self) -> list[str]:
|
||||||
"""What this stage needs (e.g., {'source'})"""
|
"""What this stage needs (e.g., ['source'])"""
|
||||||
return set()
|
return []
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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:
|
||||||
@@ -87,11 +76,3 @@ 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,14 +19,7 @@ All backends implement a common Display protocol (in `engine/display/__init__.py
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
class Display(Protocol):
|
class Display(Protocol):
|
||||||
width: int
|
def show(self, buf: list[str]) -> None:
|
||||||
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"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -34,11 +27,7 @@ class Display(Protocol):
|
|||||||
"""Clear the display"""
|
"""Clear the display"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
def size(self) -> tuple[int, int]:
|
||||||
"""Clean up resources"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def get_dimensions(self) -> tuple[int, int]:
|
|
||||||
"""Return (width, height)"""
|
"""Return (width, height)"""
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
@@ -48,8 +37,8 @@ class Display(Protocol):
|
|||||||
Discovers and manages backends:
|
Discovers and manages backends:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from engine.display import DisplayRegistry
|
from engine.display import get_monitor
|
||||||
display = DisplayRegistry.create("terminal") # or "websocket", "null", "multi"
|
display = get_monitor("terminal") # or "websocket", "sixel", "null", "multi"
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Backends
|
### Available Backends
|
||||||
@@ -58,9 +47,9 @@ display = DisplayRegistry.create("terminal") # or "websocket", "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
|
||||||
|
|
||||||
@@ -79,11 +68,9 @@ 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:
|
||||||
- `init(width: int, height: int, reuse: bool = False)` - Initialize display
|
- `show(buf: list[str])` - Display buffer
|
||||||
- `show(buf: list[str], border: bool = False)` - Display buffer
|
|
||||||
- `clear()` - Clear screen
|
- `clear()` - Clear screen
|
||||||
- `cleanup()` - Clean up resources
|
- `size() -> tuple[int, int]` - Terminal dimensions
|
||||||
- `get_dimensions() -> tuple[int, int]` - Get terminal dimensions
|
|
||||||
|
|
||||||
Optional methods:
|
Optional methods:
|
||||||
- `title(text: str)` - Set window title
|
- `title(text: str)` - Set window title
|
||||||
@@ -94,70 +81,6 @@ 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 moderngl # GPU-accelerated (requires moderngl)
|
python mainline.py --display sixel
|
||||||
|
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 support
|
# Or: uv sync --all-extras # includes mic, websocket, sixel support
|
||||||
```
|
```
|
||||||
|
|
||||||
### Available Commands
|
### Available Commands
|
||||||
@@ -206,6 +206,20 @@ 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`.
|
||||||
@@ -267,45 +281,15 @@ 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
|
||||||
@@ -352,9 +336,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
|
||||||
@@ -365,7 +349,8 @@ 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)
|
||||||
- `moderngl` - GPU-accelerated rendering (requires moderngl package)
|
- `sixel` - Sixel graphics in supported terminals (iTerm2, mintty, etc.)
|
||||||
|
- `both` - Terminal + WebSocket simultaneously
|
||||||
|
|
||||||
### Effect Plugin System
|
### Effect Plugin System
|
||||||
|
|
||||||
@@ -392,43 +377,6 @@ 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/`.
|
||||||
@@ -436,23 +384,23 @@ A skills library MCP server (`skills`) is available for capturing and tracking l
|
|||||||
### Workflow
|
### Workflow
|
||||||
|
|
||||||
**Before starting work:**
|
**Before starting work:**
|
||||||
1. Run `local_skills_list_skills` to see available skills
|
1. Run `skills_list_skills` to see available skills
|
||||||
2. Use `local_skills_peek_skill({name: "skill-name"})` to preview relevant skills
|
2. Use `skills_peek_skill({name: "skill-name"})` to preview relevant skills
|
||||||
3. Use `local_skills_skill_slice({name: "skill-name", query: "your question"})` to get relevant sections
|
3. Use `skills_skill_slice({name: "skill-name", query: "your question"})` to get relevant sections
|
||||||
|
|
||||||
**While working:**
|
**While working:**
|
||||||
- If a skill was wrong or incomplete: `local_skills_update_skill` → `local_skills_record_assessment` → `local_skills_report_outcome({quality: 1})`
|
- If a skill was wrong or incomplete: `skills_update_skill` → `skills_record_assessment` → `skills_report_outcome({quality: 1})`
|
||||||
- If a skill worked correctly: `local_skills_report_outcome({quality: 4})` (normal) or `quality: 5` (perfect)
|
- If a skill worked correctly: `skills_report_outcome({quality: 4})` (normal) or `quality: 5` (perfect)
|
||||||
|
|
||||||
**End of session:**
|
**End of session:**
|
||||||
- Run `local_skills_reflect_on_session({context_summary: "what you did"})` to identify new skills to capture
|
- Run `skills_reflect_on_session({context_summary: "what you did"})` to identify new skills to capture
|
||||||
- Use `local_skills_create_skill` to add new skills
|
- Use `skills_create_skill` to add new skills
|
||||||
- Use `local_skills_record_assessment` to score them
|
- Use `skills_record_assessment` to score them
|
||||||
|
|
||||||
### Useful Tools
|
### Useful Tools
|
||||||
- `local_skills_review_stale_skills()` - Skills due for review (negative days_until_due)
|
- `skills_review_stale_skills()` - Skills due for review (negative days_until_due)
|
||||||
- `local_skills_skills_report()` - Overview of entire collection
|
- `skills_skills_report()` - Overview of entire collection
|
||||||
- `local_skills_validate_skill({name: "skill-name"})` - Load skill for review with sources
|
- `skills_validate_skill({name: "skill-name"})` - Load skill for review with sources
|
||||||
|
|
||||||
### Agent Skills
|
### Agent Skills
|
||||||
|
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -16,6 +16,7 @@ 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
|
||||||
@@ -74,7 +75,8 @@ 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
|
||||||
- **ModernGL** (`--display moderngl`): GPU-accelerated rendering (optional)
|
- **Sixel** (`--display sixel`): Sixel graphics in supported terminals (iTerm2, mintty)
|
||||||
|
- **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.
|
||||||
|
|
||||||
@@ -158,9 +160,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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -192,7 +194,9 @@ 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-client # terminal + web
|
mise run run-sixel # sixel graphics
|
||||||
|
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
|
||||||
|
|||||||
27
TODO.md
27
TODO.md
@@ -1,27 +0,0 @@
|
|||||||
# 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)
|
|
||||||
|
|
||||||
## 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
|
|
||||||
@@ -1,313 +0,0 @@
|
|||||||
<!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,9 +277,6 @@
|
|||||||
} 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);
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ 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
|
||||||
@@ -138,6 +139,8 @@ Display(Protocol)
|
|||||||
├── NullDisplay
|
├── NullDisplay
|
||||||
├── PygameDisplay
|
├── PygameDisplay
|
||||||
├── WebSocketDisplay
|
├── WebSocketDisplay
|
||||||
|
├── SixelDisplay
|
||||||
|
├── KittyDisplay
|
||||||
└── MultiDisplay
|
└── MultiDisplay
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
234
docs/PIPELINE.md
234
docs/PIPELINE.md
@@ -2,160 +2,136 @@
|
|||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Source Stage → Render Stage → Effect Stages → Display Stage
|
Sources (static/dynamic) → Fetch → Prepare → Scroll → Effects → Render → Display
|
||||||
↓
|
↓
|
||||||
Camera Stage (provides camera.state capability)
|
NtfyPoller ← MicMonitor (async)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Capability-Based Dependency Resolution
|
### Data Source Abstraction (sources_v2.py)
|
||||||
|
|
||||||
Stages declare capabilities and dependencies:
|
- **Static sources**: Data fetched once and cached (HeadlinesDataSource, PoetryDataSource)
|
||||||
- **Capabilities**: What the stage provides (e.g., `source`, `render.output`, `display.output`, `camera.state`)
|
- **Dynamic sources**: Idempotent fetch for runtime updates (PipelineDataSource)
|
||||||
- **Dependencies**: What the stage needs (e.g., `source`, `render.output`, `camera.state`)
|
- **SourceRegistry**: Discovery and management of data sources
|
||||||
|
|
||||||
The Pipeline resolves dependencies using **prefix matching**:
|
### Camera Modes
|
||||||
- `"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
|
|
||||||
|
|
||||||
### Minimum Capabilities
|
- **Vertical**: Scroll up (default)
|
||||||
|
- **Horizontal**: Scroll left
|
||||||
|
- **Omni**: Diagonal scroll
|
||||||
|
- **Floating**: Sinusoidal bobbing
|
||||||
|
- **Trace**: Follow network path node-by-node (for pipeline viz)
|
||||||
|
|
||||||
The pipeline requires these minimum capabilities to function:
|
## Content to Display Rendering Pipeline
|
||||||
- `"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 Stages["Stage Pipeline"]
|
subgraph Sources["Data Sources (v2)"]
|
||||||
subgraph SourceStage["Source Stage (provides: source.*)"]
|
Headlines[HeadlinesDataSource]
|
||||||
Headlines[HeadlinesSource]
|
Poetry[PoetryDataSource]
|
||||||
Poetry[PoetrySource]
|
Pipeline[PipelineDataSource]
|
||||||
Pipeline[PipelineSource]
|
Registry[SourceRegistry]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph RenderStage["Render Stage (provides: render.*)"]
|
subgraph SourcesLegacy["Data Sources (legacy)"]
|
||||||
Render[RenderStage]
|
RSS[("RSS Feeds")]
|
||||||
Canvas[Canvas]
|
PoetryFeed[("Poetry Feed")]
|
||||||
Camera[Camera]
|
Ntfy[("Ntfy Messages")]
|
||||||
end
|
Mic[("Microphone")]
|
||||||
|
end
|
||||||
|
|
||||||
subgraph EffectStages["Effect Stages (provides: effect.*)"]
|
subgraph Fetch["Fetch Layer"]
|
||||||
|
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]
|
||||||
subgraph DisplayStage["Display Stage (provides: display.*)"]
|
ER[EffectRegistry]
|
||||||
Terminal[TerminalDisplay]
|
|
||||||
Pygame[PygameDisplay]
|
|
||||||
WebSocket[WebSocketDisplay]
|
|
||||||
Null[NullDisplay]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph Capabilities["Capability Map"]
|
subgraph Render["Render Layer"]
|
||||||
SourceCaps["source.headlines<br/>source.poetry<br/>source.pipeline"]
|
BW[big_wrap]
|
||||||
RenderCaps["render.output<br/>render.canvas"]
|
RL[render_line]
|
||||||
EffectCaps["effect.noise<br/>effect.fade<br/>effect.glitch"]
|
|
||||||
DisplayCaps["display.output<br/>display.terminal"]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
SourceStage --> RenderStage
|
subgraph Display["Display Backends"]
|
||||||
RenderStage --> EffectStages
|
TD[TerminalDisplay]
|
||||||
EffectStages --> DisplayStage
|
PD[PygameDisplay]
|
||||||
|
SD[SixelDisplay]
|
||||||
|
KD[KittyDisplay]
|
||||||
|
WSD[WebSocketDisplay]
|
||||||
|
ND[NullDisplay]
|
||||||
|
end
|
||||||
|
|
||||||
SourceStage --> SourceCaps
|
subgraph Async["Async Sources"]
|
||||||
RenderStage --> RenderCaps
|
NTFY[NtfyPoller]
|
||||||
EffectStages --> EffectCaps
|
MIC[MicMonitor]
|
||||||
DisplayStage --> DisplayCaps
|
end
|
||||||
|
|
||||||
style SourceStage fill:#f9f,stroke:#333
|
subgraph Animation["Animation System"]
|
||||||
style RenderStage fill:#bbf,stroke:#333
|
AC[AnimationController]
|
||||||
style EffectStages fill:#fbf,stroke:#333
|
PR[Preset]
|
||||||
style DisplayStage fill:#bfb,stroke:#333
|
end
|
||||||
|
|
||||||
|
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
|
||||||
@@ -185,7 +161,7 @@ flowchart LR
|
|||||||
Triggers --> Events
|
Triggers --> Events
|
||||||
```
|
```
|
||||||
|
|
||||||
## Camera Modes State Diagram
|
## Camera Modes
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
stateDiagram-v2
|
stateDiagram-v2
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
# 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`
|
|
||||||
145
docs/superpowers/specs/2026-03-15-readme-update-design.md
Normal file
145
docs/superpowers/specs/2026-03-15-readme-update-design.md
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# 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,10 +1 @@
|
|||||||
# 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,14 +1,282 @@
|
|||||||
"""
|
"""
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Re-export from the new package structure
|
import sys
|
||||||
from engine.app import main, run_pipeline_mode, run_pipeline_mode_direct
|
import time
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
"""
|
|
||||||
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",
|
|
||||||
]
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
"""
|
|
||||||
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 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
|
|
||||||
display = DisplayRegistry.create(display_name)
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# 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")
|
|
||||||
@@ -1,852 +0,0 @@
|
|||||||
"""
|
|
||||||
Pipeline runner - handles preset-based pipeline construction and execution.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
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, get_preset
|
|
||||||
from engine.pipeline.adapters import (
|
|
||||||
EffectPluginStage,
|
|
||||||
SourceItemsToBufferStage,
|
|
||||||
create_stage_from_display,
|
|
||||||
create_stage_from_effect,
|
|
||||||
)
|
|
||||||
from engine.pipeline.ui import UIConfig, UIPanel
|
|
||||||
|
|
||||||
try:
|
|
||||||
from engine.display.backends.websocket import WebSocketDisplay
|
|
||||||
except ImportError:
|
|
||||||
WebSocketDisplay = None
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_pipeline_mutation(pipeline: Pipeline, command: dict) -> bool:
|
|
||||||
"""Handle pipeline mutation commands from WebSocket 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":
|
|
||||||
# For now, this just returns True to acknowledge the command
|
|
||||||
# In a full implementation, we'd need to create the appropriate stage
|
|
||||||
print(f" [Pipeline] add_stage command received: {command}")
|
|
||||||
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")
|
|
||||||
# For now, this just returns True to acknowledge the command
|
|
||||||
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 run_pipeline_mode(preset_name: str = "demo"):
|
|
||||||
"""Run using the new unified pipeline architecture."""
|
|
||||||
import engine.effects.plugins as effects_plugins
|
|
||||||
from engine.effects import PerformanceMonitor, set_monitor
|
|
||||||
|
|
||||||
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()
|
|
||||||
# Use preset viewport if available, else default to 80x24
|
|
||||||
params.viewport_width = getattr(preset, "viewport_width", 80)
|
|
||||||
params.viewport_height = getattr(preset, "viewport_height", 24)
|
|
||||||
|
|
||||||
if "--viewport" in sys.argv:
|
|
||||||
idx = sys.argv.index("--viewport")
|
|
||||||
if idx + 1 < len(sys.argv):
|
|
||||||
vp = sys.argv[idx + 1]
|
|
||||||
try:
|
|
||||||
params.viewport_width, params.viewport_height = map(int, vp.split("x"))
|
|
||||||
except ValueError:
|
|
||||||
print("Error: Invalid viewport format. Use WxH (e.g., 40x15)")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
pipeline = Pipeline(config=preset.to_config())
|
|
||||||
|
|
||||||
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")
|
|
||||||
elif preset.source == "fixture":
|
|
||||||
items = load_cache()
|
|
||||||
if not items:
|
|
||||||
print(" \033[38;5;196mNo fixture cache available\033[0m")
|
|
||||||
sys.exit(1)
|
|
||||||
print(f" \033[38;5;82mLoaded {len(items)} items from fixture\033[0m")
|
|
||||||
else:
|
|
||||||
cached = load_cache()
|
|
||||||
if cached:
|
|
||||||
items = cached
|
|
||||||
print(f" \033[38;5;82mLoaded {len(items)} items from cache\033[0m")
|
|
||||||
elif preset.source == "poetry":
|
|
||||||
items, _, _ = fetch_poetry()
|
|
||||||
else:
|
|
||||||
items = fetch_all_fast()
|
|
||||||
if items:
|
|
||||||
print(
|
|
||||||
f" \033[38;5;82mFast start: {len(items)} items from first 5 sources\033[0m"
|
|
||||||
)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Determine if we need UI controller for WebSocket or border=UI
|
|
||||||
need_ui_controller = False
|
|
||||||
web_control_active = False
|
|
||||||
if WebSocketDisplay and isinstance(display, WebSocketDisplay):
|
|
||||||
need_ui_controller = True
|
|
||||||
web_control_active = True
|
|
||||||
elif isinstance(params.border, BorderMode) and params.border == BorderMode.UI:
|
|
||||||
need_ui_controller = True
|
|
||||||
|
|
||||||
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 camera state update stage if specified in preset (must run before viewport filter)
|
|
||||||
camera = None
|
|
||||||
if preset.camera:
|
|
||||||
from engine.camera import Camera
|
|
||||||
from engine.pipeline.adapters import CameraClockStage, CameraStage
|
|
||||||
|
|
||||||
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)
|
|
||||||
elif preset.camera == "radial":
|
|
||||||
camera = Camera.radial(speed=speed)
|
|
||||||
elif preset.camera == "static" or preset.camera == "":
|
|
||||||
# Static camera: no movement, but provides camera_y=0 for viewport filter
|
|
||||||
camera = Camera.scroll(speed=0.0) # Speed 0 = no movement
|
|
||||||
camera.set_canvas_size(200, 200)
|
|
||||||
|
|
||||||
if camera:
|
|
||||||
# Add camera update stage to ensure camera_y is available for viewport filter
|
|
||||||
pipeline.add_stage(
|
|
||||||
"camera_update", CameraClockStage(camera, name="camera-clock")
|
|
||||||
)
|
|
||||||
|
|
||||||
# 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 (after font/render stage)
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Initialize UI panel if needed (border mode or WebSocket control)
|
|
||||||
ui_panel = None
|
|
||||||
render_ui_panel_in_terminal = False
|
|
||||||
|
|
||||||
if need_ui_controller:
|
|
||||||
from engine.display import render_ui_panel
|
|
||||||
|
|
||||||
ui_panel = UIPanel(UIConfig(panel_width=24, start_with_preset_picker=True))
|
|
||||||
|
|
||||||
# Determine if we should render UI panel in terminal
|
|
||||||
# Only render if border mode is UI (not for WebSocket-only mode)
|
|
||||||
render_ui_panel_in_terminal = (
|
|
||||||
isinstance(params.border, BorderMode) and params.border == BorderMode.UI
|
|
||||||
)
|
|
||||||
|
|
||||||
# Enable raw mode for terminal input if supported
|
|
||||||
if hasattr(display, "set_raw_mode"):
|
|
||||||
display.set_raw_mode(True)
|
|
||||||
|
|
||||||
# Register effect plugin stages from pipeline for UI control
|
|
||||||
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)
|
|
||||||
# Store reference to effect for easier access
|
|
||||||
stage_control.effect = effect # type: ignore[attr-defined]
|
|
||||||
|
|
||||||
# Select first stage by default
|
|
||||||
if ui_panel.stages:
|
|
||||||
first_stage = next(iter(ui_panel.stages))
|
|
||||||
ui_panel.select_stage(first_stage)
|
|
||||||
# Populate param schema from EffectConfig if it's a dataclass
|
|
||||||
ctrl = ui_panel.stages[first_stage]
|
|
||||||
if hasattr(ctrl, "effect"):
|
|
||||||
effect = ctrl.effect
|
|
||||||
if hasattr(effect, "config"):
|
|
||||||
config = effect.config
|
|
||||||
# Try to get fields via dataclasses if available
|
|
||||||
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 # No dataclass fields, skip param UI
|
|
||||||
|
|
||||||
# Set up callback for stage toggles
|
|
||||||
def on_stage_toggled(stage_name: str, enabled: bool):
|
|
||||||
"""Update the actual stage's enabled state when UI toggles."""
|
|
||||||
stage = pipeline.get_stage(stage_name)
|
|
||||||
if stage:
|
|
||||||
# Set stage enabled flag for pipeline execution
|
|
||||||
stage._enabled = enabled
|
|
||||||
# Also update effect config if it's an EffectPluginStage
|
|
||||||
if isinstance(stage, EffectPluginStage):
|
|
||||||
stage._effect.config.enabled = enabled
|
|
||||||
|
|
||||||
# Broadcast state update if WebSocket is active
|
|
||||||
if web_control_active and isinstance(display, WebSocketDisplay):
|
|
||||||
state = display._get_state_snapshot()
|
|
||||||
if state:
|
|
||||||
display.broadcast_state(state)
|
|
||||||
|
|
||||||
ui_panel.set_event_callback("stage_toggled", on_stage_toggled)
|
|
||||||
|
|
||||||
# Set up callback for parameter changes
|
|
||||||
def on_param_changed(stage_name: str, param_name: str, value: Any):
|
|
||||||
"""Update the effect config when UI adjusts a parameter."""
|
|
||||||
stage = pipeline.get_stage(stage_name)
|
|
||||||
if stage and isinstance(stage, EffectPluginStage):
|
|
||||||
effect = stage._effect
|
|
||||||
if hasattr(effect, "config"):
|
|
||||||
setattr(effect.config, param_name, value)
|
|
||||||
# Mark effect as needing reconfiguration if it has a configure method
|
|
||||||
if hasattr(effect, "configure"):
|
|
||||||
try:
|
|
||||||
effect.configure(effect.config)
|
|
||||||
except Exception:
|
|
||||||
pass # Ignore reconfiguration errors
|
|
||||||
|
|
||||||
# Broadcast state update if WebSocket is active
|
|
||||||
if web_control_active and isinstance(display, WebSocketDisplay):
|
|
||||||
state = display._get_state_snapshot()
|
|
||||||
if state:
|
|
||||||
display.broadcast_state(state)
|
|
||||||
|
|
||||||
ui_panel.set_event_callback("param_changed", on_param_changed)
|
|
||||||
|
|
||||||
# Set up preset list and handle preset changes
|
|
||||||
from engine.pipeline import list_presets
|
|
||||||
|
|
||||||
ui_panel.set_presets(list_presets(), preset_name)
|
|
||||||
|
|
||||||
# Connect WebSocket to UI panel for remote control
|
|
||||||
if web_control_active and isinstance(display, WebSocketDisplay):
|
|
||||||
display.set_controller(ui_panel)
|
|
||||||
|
|
||||||
def handle_websocket_command(command: dict) -> None:
|
|
||||||
"""Handle commands from WebSocket clients."""
|
|
||||||
action = command.get("action")
|
|
||||||
|
|
||||||
# Handle pipeline mutation commands directly
|
|
||||||
if action in (
|
|
||||||
"add_stage",
|
|
||||||
"remove_stage",
|
|
||||||
"replace_stage",
|
|
||||||
"swap_stages",
|
|
||||||
"move_stage",
|
|
||||||
"enable_stage",
|
|
||||||
"disable_stage",
|
|
||||||
"cleanup_stage",
|
|
||||||
"can_hot_swap",
|
|
||||||
):
|
|
||||||
result = _handle_pipeline_mutation(pipeline, command)
|
|
||||||
if result:
|
|
||||||
state = display._get_state_snapshot()
|
|
||||||
if state:
|
|
||||||
display.broadcast_state(state)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Handle UI panel commands
|
|
||||||
if ui_panel.execute_command(command):
|
|
||||||
# Broadcast updated state after command execution
|
|
||||||
state = display._get_state_snapshot()
|
|
||||||
if state:
|
|
||||||
display.broadcast_state(state)
|
|
||||||
|
|
||||||
display.set_command_callback(handle_websocket_command)
|
|
||||||
|
|
||||||
def on_preset_changed(preset_name: str):
|
|
||||||
"""Handle preset change from UI - rebuild pipeline."""
|
|
||||||
nonlocal \
|
|
||||||
pipeline, \
|
|
||||||
display, \
|
|
||||||
items, \
|
|
||||||
params, \
|
|
||||||
ui_panel, \
|
|
||||||
current_width, \
|
|
||||||
current_height, \
|
|
||||||
web_control_active, \
|
|
||||||
render_ui_panel_in_terminal
|
|
||||||
|
|
||||||
print(f" \033[38;5;245mSwitching to preset: {preset_name}\033[0m")
|
|
||||||
|
|
||||||
# Save current UI panel state before rebuild
|
|
||||||
ui_state = ui_panel.save_state() if ui_panel else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Clean up old pipeline
|
|
||||||
pipeline.cleanup()
|
|
||||||
|
|
||||||
# Get new preset
|
|
||||||
new_preset = get_preset(preset_name)
|
|
||||||
if not new_preset:
|
|
||||||
print(f" \033[38;5;196mUnknown preset: {preset_name}\033[0m")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Update params for new preset
|
|
||||||
params = new_preset.to_params()
|
|
||||||
params.viewport_width = current_width
|
|
||||||
params.viewport_height = current_height
|
|
||||||
|
|
||||||
# Reconstruct pipeline configuration
|
|
||||||
new_config = PipelineConfig(
|
|
||||||
source=new_preset.source,
|
|
||||||
display=new_preset.display,
|
|
||||||
camera=new_preset.camera,
|
|
||||||
effects=new_preset.effects,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create new pipeline instance
|
|
||||||
pipeline = Pipeline(config=new_config, context=PipelineContext())
|
|
||||||
|
|
||||||
# Re-add stages (similar to initial construction)
|
|
||||||
# Source stage
|
|
||||||
if new_preset.source == "pipeline-inspect":
|
|
||||||
from engine.data_sources.pipeline_introspection import (
|
|
||||||
PipelineIntrospectionSource,
|
|
||||||
)
|
|
||||||
from engine.pipeline.adapters import DataSourceStage
|
|
||||||
|
|
||||||
introspection_source = PipelineIntrospectionSource(
|
|
||||||
pipeline=None,
|
|
||||||
viewport_width=current_width,
|
|
||||||
viewport_height=current_height,
|
|
||||||
)
|
|
||||||
pipeline.add_stage(
|
|
||||||
"source",
|
|
||||||
DataSourceStage(introspection_source, name="pipeline-inspect"),
|
|
||||||
)
|
|
||||||
elif new_preset.source == "empty":
|
|
||||||
from engine.data_sources.sources import EmptyDataSource
|
|
||||||
from engine.pipeline.adapters import DataSourceStage
|
|
||||||
|
|
||||||
empty_source = EmptyDataSource(
|
|
||||||
width=current_width, height=current_height
|
|
||||||
)
|
|
||||||
pipeline.add_stage(
|
|
||||||
"source", DataSourceStage(empty_source, name="empty")
|
|
||||||
)
|
|
||||||
elif new_preset.source == "fixture":
|
|
||||||
items = load_cache()
|
|
||||||
if not items:
|
|
||||||
print(" \033[38;5;196mNo fixture cache available\033[0m")
|
|
||||||
return
|
|
||||||
from engine.data_sources.sources import ListDataSource
|
|
||||||
from engine.pipeline.adapters import DataSourceStage
|
|
||||||
|
|
||||||
list_source = ListDataSource(items, name="fixture")
|
|
||||||
pipeline.add_stage(
|
|
||||||
"source", DataSourceStage(list_source, name="fixture")
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Fetch or use cached items
|
|
||||||
cached = load_cache()
|
|
||||||
if cached:
|
|
||||||
items = cached
|
|
||||||
elif new_preset.source == "poetry":
|
|
||||||
items, _, _ = fetch_poetry()
|
|
||||||
else:
|
|
||||||
items, _, _ = fetch_all()
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
print(" \033[38;5;196mNo content available\033[0m")
|
|
||||||
return
|
|
||||||
|
|
||||||
from engine.data_sources.sources import ListDataSource
|
|
||||||
from engine.pipeline.adapters import DataSourceStage
|
|
||||||
|
|
||||||
list_source = ListDataSource(items, name=new_preset.source)
|
|
||||||
pipeline.add_stage(
|
|
||||||
"source", DataSourceStage(list_source, name=new_preset.source)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add viewport filter and font for headline/poetry sources
|
|
||||||
if new_preset.source in ["headlines", "poetry", "fixture"]:
|
|
||||||
from engine.pipeline.adapters import FontStage, ViewportFilterStage
|
|
||||||
|
|
||||||
pipeline.add_stage(
|
|
||||||
"viewport_filter", ViewportFilterStage(name="viewport-filter")
|
|
||||||
)
|
|
||||||
pipeline.add_stage("font", FontStage(name="font"))
|
|
||||||
|
|
||||||
# Add camera if specified
|
|
||||||
if new_preset.camera:
|
|
||||||
from engine.camera import Camera
|
|
||||||
from engine.pipeline.adapters import CameraClockStage, CameraStage
|
|
||||||
|
|
||||||
speed = getattr(new_preset, "camera_speed", 1.0)
|
|
||||||
camera = None
|
|
||||||
cam_type = new_preset.camera
|
|
||||||
if cam_type == "feed":
|
|
||||||
camera = Camera.feed(speed=speed)
|
|
||||||
elif cam_type == "scroll" or cam_type == "vertical":
|
|
||||||
camera = Camera.scroll(speed=speed)
|
|
||||||
elif cam_type == "horizontal":
|
|
||||||
camera = Camera.horizontal(speed=speed)
|
|
||||||
elif cam_type == "omni":
|
|
||||||
camera = Camera.omni(speed=speed)
|
|
||||||
elif cam_type == "floating":
|
|
||||||
camera = Camera.floating(speed=speed)
|
|
||||||
elif cam_type == "bounce":
|
|
||||||
camera = Camera.bounce(speed=speed)
|
|
||||||
elif cam_type == "radial":
|
|
||||||
camera = Camera.radial(speed=speed)
|
|
||||||
elif cam_type == "static" or cam_type == "":
|
|
||||||
# Static camera: no movement, but provides camera_y=0 for viewport filter
|
|
||||||
camera = Camera.scroll(speed=0.0)
|
|
||||||
camera.set_canvas_size(200, 200)
|
|
||||||
|
|
||||||
if camera:
|
|
||||||
# Add camera update stage to ensure camera_y is available for viewport filter
|
|
||||||
pipeline.add_stage(
|
|
||||||
"camera_update",
|
|
||||||
CameraClockStage(camera, name="camera-clock"),
|
|
||||||
)
|
|
||||||
pipeline.add_stage("camera", CameraStage(camera, name=cam_type))
|
|
||||||
|
|
||||||
# Add effects
|
|
||||||
effect_registry = get_registry()
|
|
||||||
for effect_name in new_preset.effects:
|
|
||||||
effect = effect_registry.get(effect_name)
|
|
||||||
if effect:
|
|
||||||
pipeline.add_stage(
|
|
||||||
f"effect_{effect_name}",
|
|
||||||
create_stage_from_effect(effect, effect_name),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add display (respect CLI override)
|
|
||||||
display_name = new_preset.display
|
|
||||||
if "--display" in sys.argv:
|
|
||||||
idx = sys.argv.index("--display")
|
|
||||||
if idx + 1 < len(sys.argv):
|
|
||||||
display_name = sys.argv[idx + 1]
|
|
||||||
|
|
||||||
new_display = DisplayRegistry.create(display_name)
|
|
||||||
if not new_display and not display_name.startswith("multi"):
|
|
||||||
print(
|
|
||||||
f" \033[38;5;196mFailed to create display: {display_name}\033[0m"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not new_display and display_name.startswith("multi"):
|
|
||||||
parts = display_name[6:].split(",")
|
|
||||||
new_display = DisplayRegistry.create_multi(parts)
|
|
||||||
if not new_display:
|
|
||||||
print(
|
|
||||||
f" \033[38;5;196mFailed to create multi display: {parts}\033[0m"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not new_display:
|
|
||||||
print(
|
|
||||||
f" \033[38;5;196mFailed to create display: {display_name}\033[0m"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
new_display.init(0, 0)
|
|
||||||
|
|
||||||
pipeline.add_stage(
|
|
||||||
"display", create_stage_from_display(new_display, display_name)
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline.build()
|
|
||||||
|
|
||||||
# Set pipeline for introspection source if needed
|
|
||||||
if (
|
|
||||||
new_preset.source == "pipeline-inspect"
|
|
||||||
and introspection_source is not None
|
|
||||||
):
|
|
||||||
introspection_source.set_pipeline(pipeline)
|
|
||||||
|
|
||||||
if not pipeline.initialize():
|
|
||||||
print(" \033[38;5;196mFailed to initialize pipeline\033[0m")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Replace global references with new pipeline and display
|
|
||||||
display = new_display
|
|
||||||
|
|
||||||
# Reinitialize UI panel with new effect stages
|
|
||||||
# Update web_control_active for new display
|
|
||||||
web_control_active = WebSocketDisplay is not None and isinstance(
|
|
||||||
display, WebSocketDisplay
|
|
||||||
)
|
|
||||||
# Update render_ui_panel_in_terminal
|
|
||||||
render_ui_panel_in_terminal = (
|
|
||||||
isinstance(params.border, BorderMode)
|
|
||||||
and params.border == BorderMode.UI
|
|
||||||
)
|
|
||||||
|
|
||||||
if need_ui_controller:
|
|
||||||
ui_panel = UIPanel(
|
|
||||||
UIConfig(panel_width=24, start_with_preset_picker=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]
|
|
||||||
|
|
||||||
# Restore UI panel state if it was saved
|
|
||||||
if ui_state:
|
|
||||||
ui_panel.restore_state(ui_state)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
# Reconnect WebSocket to UI panel if needed
|
|
||||||
if web_control_active and isinstance(display, WebSocketDisplay):
|
|
||||||
display.set_controller(ui_panel)
|
|
||||||
|
|
||||||
def handle_websocket_command(command: dict) -> None:
|
|
||||||
"""Handle commands from WebSocket clients."""
|
|
||||||
if ui_panel.execute_command(command):
|
|
||||||
# Broadcast updated state after command execution
|
|
||||||
state = display._get_state_snapshot()
|
|
||||||
if state:
|
|
||||||
display.broadcast_state(state)
|
|
||||||
|
|
||||||
display.set_command_callback(handle_websocket_command)
|
|
||||||
|
|
||||||
# Broadcast initial state after preset change
|
|
||||||
state = display._get_state_snapshot()
|
|
||||||
if state:
|
|
||||||
display.broadcast_state(state)
|
|
||||||
|
|
||||||
print(f" \033[38;5;82mPreset switched to {preset_name}\033[0m")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f" \033[38;5;196mError switching preset: {e}\033[0m")
|
|
||||||
|
|
||||||
ui_panel.set_event_callback("preset_changed", on_preset_changed)
|
|
||||||
|
|
||||||
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 = 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
|
|
||||||
|
|
||||||
try:
|
|
||||||
frame = 0
|
|
||||||
while True:
|
|
||||||
params.frame_number = frame
|
|
||||||
ctx.params = params
|
|
||||||
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
if result.success:
|
|
||||||
# Handle UI panel compositing if enabled
|
|
||||||
if ui_panel is not None and render_ui_panel_in_terminal:
|
|
||||||
from engine.display import render_ui_panel
|
|
||||||
|
|
||||||
buf = render_ui_panel(
|
|
||||||
result.data,
|
|
||||||
current_width,
|
|
||||||
current_height,
|
|
||||||
ui_panel,
|
|
||||||
fps=params.fps if hasattr(params, "fps") else 60.0,
|
|
||||||
frame_time=0.0,
|
|
||||||
)
|
|
||||||
# Render with border=OFF since we already added borders
|
|
||||||
display.show(buf, border=False)
|
|
||||||
# Handle pygame events for UI
|
|
||||||
if display_name == "pygame":
|
|
||||||
import pygame
|
|
||||||
|
|
||||||
for event in pygame.event.get():
|
|
||||||
if event.type == pygame.KEYDOWN:
|
|
||||||
ui_panel.process_key_event(event.key, event.mod)
|
|
||||||
# If space toggled stage, we could rebuild here (TODO)
|
|
||||||
else:
|
|
||||||
# Normal border handling
|
|
||||||
show_border = (
|
|
||||||
params.border if isinstance(params.border, bool) else False
|
|
||||||
)
|
|
||||||
display.show(result.data, border=show_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 "--viewport" not in sys.argv and 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")
|
|
||||||
129
engine/camera.py
129
engine/camera.py
@@ -23,7 +23,6 @@ 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
|
||||||
@@ -72,17 +71,6 @@ 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."""
|
||||||
@@ -104,17 +92,14 @@ class Camera:
|
|||||||
"""
|
"""
|
||||||
return max(1, int(self.canvas_height / self.zoom))
|
return max(1, int(self.canvas_height / self.zoom))
|
||||||
|
|
||||||
def get_viewport(self, viewport_height: int | None = None) -> CameraViewport:
|
def get_viewport(self) -> 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 = viewport_height if viewport_height is not None else self.viewport_height
|
vh = 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))
|
||||||
@@ -126,13 +111,6 @@ 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.
|
||||||
|
|
||||||
@@ -165,8 +143,6 @@ 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:
|
||||||
@@ -247,85 +223,12 @@ 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 and state."""
|
"""Reset camera position."""
|
||||||
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.
|
||||||
@@ -360,7 +263,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_height)
|
viewport = self.get_viewport()
|
||||||
|
|
||||||
# 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
|
||||||
@@ -384,11 +287,10 @@ 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 and visible_len > 0:
|
if visible_len < viewport_width:
|
||||||
truncated_line += " " * (viewport_width - visible_len)
|
truncated_line += " " * (viewport_width - visible_len)
|
||||||
|
|
||||||
horizontal_slice.append(truncated_line)
|
horizontal_slice.append(truncated_line)
|
||||||
@@ -446,27 +348,6 @@ 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."""
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
"""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,59 +5,102 @@ 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
|
||||||
|
|
||||||
# Optional backend - requires moderngl package
|
from engine.display.backends.kitty import KittyDisplay
|
||||||
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.replay import ReplayDisplay
|
from engine.display.backends.sixel import SixelDisplay
|
||||||
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.
|
||||||
|
|
||||||
Required attributes:
|
All display backends must implement:
|
||||||
- width: int
|
- width, height: Terminal dimensions
|
||||||
- height: int
|
- init(width, height, reuse=False): Initialize the display
|
||||||
|
- show(buffer): Render buffer to display
|
||||||
|
- clear(): Clear the display
|
||||||
|
- cleanup(): Shutdown the display
|
||||||
|
|
||||||
Required methods (duck typing - actual signatures may vary):
|
Optional methods for keyboard input:
|
||||||
- init(width, height, reuse=False)
|
- is_quit_requested(): Returns True if user pressed Ctrl+C/Q or Escape
|
||||||
- show(buffer, border=False)
|
- clear_quit_request(): Clears the quit request flag
|
||||||
- clear()
|
|
||||||
- cleanup()
|
|
||||||
- get_dimensions() -> (width, height)
|
|
||||||
|
|
||||||
Optional attributes (for UI mode):
|
The reuse flag allows attaching to an existing display instance
|
||||||
- ui_panel: UIPanel instance (set by app when border=UI)
|
rather than creating a new window/connection.
|
||||||
|
|
||||||
Optional methods:
|
Keyboard input support by backend:
|
||||||
- is_quit_requested() -> bool
|
- terminal: No native input (relies on signal handler for Ctrl+C)
|
||||||
- clear_quit_request() -> None
|
- pygame: Supports Ctrl+C, Ctrl+Q, Escape for graceful shutdown
|
||||||
|
- 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."""
|
||||||
@@ -67,18 +110,22 @@ 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:
|
||||||
@@ -87,19 +134,31 @@ 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]) -> MultiDisplay | None:
|
def create_multi(cls, names: list[str]) -> "Display | 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)
|
||||||
@@ -107,8 +166,10 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
@@ -129,28 +190,44 @@ 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_simple_border(
|
def render_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 traditional border around the buffer."""
|
"""Render a 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:
|
||||||
@@ -171,8 +248,10 @@ def _render_simple_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:
|
||||||
@@ -183,108 +262,14 @@ def _render_simple_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")
|
|
||||||
|
|||||||
180
engine/display/backends/kitty.py
Normal file
180
engine/display/backends/kitty.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""
|
||||||
|
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,10 +2,7 @@
|
|||||||
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:
|
||||||
@@ -13,8 +10,7 @@ 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. Supports frame recording for replay
|
for testing purposes.
|
||||||
and file export/import.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
width: int = 80
|
width: int = 80
|
||||||
@@ -23,9 +19,6 @@ 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.
|
||||||
@@ -40,10 +33,9 @@ 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()
|
||||||
@@ -55,111 +47,17 @@ 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,6 +99,10 @@ 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:
|
||||||
@@ -132,21 +136,6 @@ 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:
|
||||||
@@ -195,26 +184,14 @@ 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]):
|
||||||
@@ -222,7 +199,7 @@ class PygameDisplay:
|
|||||||
break
|
break
|
||||||
|
|
||||||
tokens = parse_ansi(line)
|
tokens = parse_ansi(line)
|
||||||
x_pos = content_offset_x
|
x_pos = 0
|
||||||
|
|
||||||
for text, fg, bg, _bold in tokens:
|
for text, fg, bg, _bold in tokens:
|
||||||
if not text:
|
if not text:
|
||||||
@@ -242,17 +219,10 @@ 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(
|
blit_list.append((surface, (x_pos, row_idx * self.cell_height)))
|
||||||
(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
|
||||||
@@ -261,56 +231,6 @@ 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))
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
228
engine/display/backends/sixel.py
Normal file
228
engine/display/backends/sixel.py
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
"""
|
||||||
|
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,6 +3,7 @@ ANSI terminal display backend.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
class TerminalDisplay:
|
class TerminalDisplay:
|
||||||
@@ -88,8 +89,16 @@ class TerminalDisplay:
|
|||||||
|
|
||||||
from engine.display import get_monitor, render_border
|
from engine.display import get_monitor, render_border
|
||||||
|
|
||||||
# Note: Frame rate limiting is handled by the caller (e.g., FrameTimer).
|
t0 = time.perf_counter()
|
||||||
# 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
|
||||||
@@ -104,15 +113,19 @@ class TerminalDisplay:
|
|||||||
frame_time = avg_ms
|
frame_time = avg_ms
|
||||||
|
|
||||||
# Apply border if requested
|
# Apply border if requested
|
||||||
from engine.display import BorderMode
|
if border:
|
||||||
|
|
||||||
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
|
# Write buffer with cursor home + erase down to avoid flicker
|
||||||
|
# \033[H = cursor home, \033[J = erase from cursor to end of screen
|
||||||
output = "\033[H\033[J" + "".join(buffer)
|
output = "\033[H\033[J" + "".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
|
||||||
|
|||||||
@@ -1,44 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
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
|
||||||
@@ -67,7 +34,6 @@ 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
|
||||||
@@ -83,15 +49,7 @@ 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
|
||||||
@@ -120,7 +78,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 using streaming protocol."""
|
"""Broadcast buffer to all connected clients."""
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
|
|
||||||
# Get metrics for border display
|
# Get metrics for border display
|
||||||
@@ -141,82 +99,33 @@ 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 not self._clients:
|
if self._clients:
|
||||||
self._last_buffer = buffer
|
frame_data = {
|
||||||
return
|
"type": "frame",
|
||||||
|
"width": self.width,
|
||||||
|
"height": self.height,
|
||||||
|
"lines": buffer,
|
||||||
|
}
|
||||||
|
message = json.dumps(frame_data)
|
||||||
|
|
||||||
# Send to each client based on their capabilities
|
disconnected = set()
|
||||||
disconnected = set()
|
for client in list(self._clients):
|
||||||
for client in list(self._clients):
|
try:
|
||||||
try:
|
asyncio.run(client.send(message))
|
||||||
client_id = id(client)
|
except Exception:
|
||||||
client_mode = self._client_capabilities.get(
|
disconnected.add(client)
|
||||||
client_id, StreamingMode.JSON
|
|
||||||
)
|
|
||||||
|
|
||||||
if client_mode & StreamingMode.DIFF:
|
for client in disconnected:
|
||||||
self._send_diff_frame(client, buffer)
|
self._clients.discard(client)
|
||||||
elif client_mode & StreamingMode.BINARY:
|
if self._client_disconnected_callback:
|
||||||
self._send_binary_frame(client, buffer)
|
self._client_disconnected_callback(client)
|
||||||
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:
|
||||||
@@ -247,21 +156,9 @@ class WebSocketDisplay:
|
|||||||
async for message in websocket:
|
async for message in websocket:
|
||||||
try:
|
try:
|
||||||
data = json.loads(message)
|
data = json.loads(message)
|
||||||
msg_type = data.get("type")
|
if data.get("type") == "resize":
|
||||||
|
|
||||||
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:
|
||||||
@@ -273,8 +170,6 @@ 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)
|
||||||
@@ -284,23 +179,9 @@ class WebSocketDisplay:
|
|||||||
import os
|
import os
|
||||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
|
|
||||||
# Find the project root by locating 'engine' directory in the path
|
client_dir = os.path.join(
|
||||||
websocket_file = os.path.abspath(__file__)
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "client"
|
||||||
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):
|
||||||
@@ -310,10 +191,8 @@ class WebSocketDisplay:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
httpd = HTTPServer((self.host, self.http_port), Handler)
|
httpd = HTTPServer((self.host, self.http_port), Handler)
|
||||||
# Store reference for shutdown
|
while self._http_running:
|
||||||
self._httpd = httpd
|
httpd.handle_request()
|
||||||
# Serve requests continuously
|
|
||||||
httpd.serve_forever()
|
|
||||||
|
|
||||||
def _run_async(self, coro):
|
def _run_async(self, coro):
|
||||||
"""Run coroutine in background."""
|
"""Run coroutine in background."""
|
||||||
@@ -358,8 +237,6 @@ 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:
|
||||||
@@ -390,71 +267,6 @@ 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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,268 +0,0 @@
|
|||||||
"""
|
|
||||||
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]
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
"""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, entropy=0.1)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@@ -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, entropy=0.9)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
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, entropy=0.8)
|
config = EffectConfig(enabled=True, intensity=1.0)
|
||||||
|
|
||||||
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] = line + result[i][len(line) :]
|
||||||
else:
|
else:
|
||||||
result.append(line)
|
result.append(line)
|
||||||
|
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
"""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, entropy=0.4)
|
config = EffectConfig(enabled=True, intensity=0.15)
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@@ -44,11 +44,6 @@ 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
|
||||||
@@ -61,26 +56,6 @@ 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.
|
||||||
|
|
||||||
@@ -100,17 +75,11 @@ 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,7 +7,6 @@ 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
|
||||||
|
|
||||||
@@ -18,98 +17,54 @@ 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) -> tuple[str, Any] | tuple[None, None]:
|
def fetch_feed(url: str) -> Any | None:
|
||||||
"""Fetch and parse a single RSS feed URL. Returns (url, feed) tuple."""
|
"""Fetch and parse a single RSS feed URL."""
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||||
timeout = FAST_START_TIMEOUT if url in _fast_start_urls else config.FEED_TIMEOUT
|
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
||||||
resp = urllib.request.urlopen(req, timeout=timeout)
|
return feedparser.parse(resp.read())
|
||||||
return (url, feedparser.parse(resp.read()))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return (url, None)
|
return 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 concurrently and return items, linked count, failed count."""
|
"""Fetch all RSS feeds 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():
|
||||||
with ThreadPoolExecutor(max_workers=DEFAULT_MAX_WORKERS) as executor:
|
feed = fetch_feed(url)
|
||||||
futures = {executor.submit(fetch_feed, url): src for src, url in FEEDS.items()}
|
if feed is None or (feed.bozo and not feed.entries):
|
||||||
for future in as_completed(futures):
|
boot_ln(src, "DARK", False)
|
||||||
src = futures[future]
|
failed += 1
|
||||||
url, feed = future.result()
|
continue
|
||||||
if feed is None or (feed.bozo and not feed.entries):
|
n = 0
|
||||||
boot_ln(src, "DARK", False)
|
for e in feed.entries:
|
||||||
failed += 1
|
t = strip_tags(e.get("title", ""))
|
||||||
|
if not t or skip(t):
|
||||||
continue
|
continue
|
||||||
parsed = _parse_feed(feed, src)
|
pub = e.get("published_parsed") or e.get("updated_parsed")
|
||||||
if parsed:
|
try:
|
||||||
items.extend(parsed)
|
ts = datetime(*pub[:6]).strftime("%H:%M") if pub else "——:——"
|
||||||
boot_ln(src, f"LINKED [{len(parsed)}]", True)
|
except Exception:
|
||||||
linked += 1
|
ts = "——:——"
|
||||||
else:
|
items.append((t, src, ts))
|
||||||
boot_ln(src, "EMPTY", False)
|
n += 1
|
||||||
failed += 1
|
if n:
|
||||||
|
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:
|
||||||
@@ -121,21 +76,23 @@ 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())
|
blk = " ".join(blk.split()) # flatten to one line
|
||||||
if len(blk) < 20 or len(blk) > 280:
|
if len(blk) < 20 or len(blk) > 280:
|
||||||
continue
|
continue
|
||||||
if blk.isupper():
|
if blk.isupper(): # skip all-caps headers
|
||||||
continue
|
continue
|
||||||
if re.match(r"^[IVXLCDM]+\.?\s*$", blk):
|
if re.match(r"^[IVXLCDM]+\.?\s*$", blk): # roman numerals
|
||||||
continue
|
continue
|
||||||
items.append((blk, label, ""))
|
items.append((blk, label, ""))
|
||||||
return items
|
return items
|
||||||
@@ -143,35 +100,28 @@ def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def fetch_poetry() -> tuple[list[HeadlineTuple], int, int]:
|
def fetch_poetry():
|
||||||
"""Fetch all poetry/literature sources concurrently."""
|
"""Fetch all poetry/literature sources."""
|
||||||
items = []
|
items = []
|
||||||
linked = failed = 0
|
linked = failed = 0
|
||||||
|
for label, url in POETRY_SOURCES.items():
|
||||||
with ThreadPoolExecutor(max_workers=DEFAULT_MAX_WORKERS) as executor:
|
stanzas = _fetch_gutenberg(url, label)
|
||||||
futures = {
|
if stanzas:
|
||||||
executor.submit(_fetch_gutenberg, url, label): label
|
boot_ln(label, f"LOADED [{len(stanzas)}]", True)
|
||||||
for label, url in POETRY_SOURCES.items()
|
items.extend(stanzas)
|
||||||
}
|
linked += 1
|
||||||
for future in as_completed(futures):
|
else:
|
||||||
label = futures[future]
|
boot_ln(label, "DARK", False)
|
||||||
stanzas = future.result()
|
failed += 1
|
||||||
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_dir = pathlib.Path(__file__).resolve().parent / "fixtures"
|
# ─── CACHE ────────────────────────────────────────────────
|
||||||
|
_CACHE_DIR = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
def _cache_path():
|
def _cache_path():
|
||||||
return _cache_dir / "headlines.json"
|
return _CACHE_DIR / f".mainline_cache_{config.MODE}.json"
|
||||||
|
|
||||||
|
|
||||||
def load_cache():
|
def load_cache():
|
||||||
@@ -193,6 +143,3 @@ 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()
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
{"items": []}
|
|
||||||
@@ -50,7 +50,8 @@ from engine.pipeline.presets import (
|
|||||||
FIREHOSE_PRESET,
|
FIREHOSE_PRESET,
|
||||||
PIPELINE_VIZ_PRESET,
|
PIPELINE_VIZ_PRESET,
|
||||||
POETRY_PRESET,
|
POETRY_PRESET,
|
||||||
UI_PRESET,
|
PRESETS,
|
||||||
|
SIXEL_PRESET,
|
||||||
WEBSOCKET_PRESET,
|
WEBSOCKET_PRESET,
|
||||||
PipelinePreset,
|
PipelinePreset,
|
||||||
create_preset_from_params,
|
create_preset_from_params,
|
||||||
@@ -91,8 +92,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,48 +3,843 @@ 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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Re-export from the new package structure for backward compatibility
|
from typing import Any
|
||||||
from engine.pipeline.adapters import (
|
|
||||||
# Adapter classes
|
|
||||||
CameraStage,
|
|
||||||
CanvasStage,
|
|
||||||
DataSourceStage,
|
|
||||||
DisplayStage,
|
|
||||||
EffectPluginStage,
|
|
||||||
FontStage,
|
|
||||||
ImageToTextStage,
|
|
||||||
PassthroughStage,
|
|
||||||
SourceItemsToBufferStage,
|
|
||||||
ViewportFilterStage,
|
|
||||||
# Factory functions
|
|
||||||
create_stage_from_camera,
|
|
||||||
create_stage_from_display,
|
|
||||||
create_stage_from_effect,
|
|
||||||
create_stage_from_font,
|
|
||||||
create_stage_from_source,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
from engine.pipeline.core import PipelineContext, Stage
|
||||||
# Adapter classes
|
|
||||||
"EffectPluginStage",
|
|
||||||
"DisplayStage",
|
class EffectPluginStage(Stage):
|
||||||
"DataSourceStage",
|
"""Adapter wrapping EffectPlugin as a Stage."""
|
||||||
"PassthroughStage",
|
|
||||||
"SourceItemsToBufferStage",
|
def __init__(self, effect_plugin, name: str = "effect"):
|
||||||
"CameraStage",
|
self._effect = effect_plugin
|
||||||
"ViewportFilterStage",
|
self.name = name
|
||||||
"FontStage",
|
self.category = "effect"
|
||||||
"ImageToTextStage",
|
self.optional = False
|
||||||
"CanvasStage",
|
|
||||||
# Factory functions
|
@property
|
||||||
"create_stage_from_display",
|
def stage_type(self) -> str:
|
||||||
"create_stage_from_effect",
|
"""Return stage_type based on effect name.
|
||||||
"create_stage_from_source",
|
|
||||||
"create_stage_from_camera",
|
HUD effects are overlays.
|
||||||
"create_stage_from_font",
|
"""
|
||||||
]
|
if self.name == "hud":
|
||||||
|
return "overlay"
|
||||||
|
return self.category
|
||||||
|
|
||||||
|
@property
|
||||||
|
def render_order(self) -> int:
|
||||||
|
"""Return render_order based on effect type.
|
||||||
|
|
||||||
|
HUD effects have high render_order to appear on top.
|
||||||
|
"""
|
||||||
|
if self.name == "hud":
|
||||||
|
return 100 # High order for overlays
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_overlay(self) -> bool:
|
||||||
|
"""Return True for HUD effects.
|
||||||
|
|
||||||
|
HUD is an overlay - it composes on top of the buffer
|
||||||
|
rather than transforming it for the next stage.
|
||||||
|
"""
|
||||||
|
return self.name == "hud"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {f"effect.{self.name}"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
@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
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
"""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 .transform import (
|
|
||||||
CanvasStage,
|
|
||||||
FontStage,
|
|
||||||
ImageToTextStage,
|
|
||||||
ViewportFilterStage,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
# Adapter classes
|
|
||||||
"EffectPluginStage",
|
|
||||||
"DisplayStage",
|
|
||||||
"DataSourceStage",
|
|
||||||
"PassthroughStage",
|
|
||||||
"SourceItemsToBufferStage",
|
|
||||||
"CameraStage",
|
|
||||||
"CameraClockStage",
|
|
||||||
"ViewportFilterStage",
|
|
||||||
"FontStage",
|
|
||||||
"ImageToTextStage",
|
|
||||||
"CanvasStage",
|
|
||||||
# Factory functions
|
|
||||||
"create_stage_from_display",
|
|
||||||
"create_stage_from_effect",
|
|
||||||
"create_stage_from_source",
|
|
||||||
"create_stage_from_camera",
|
|
||||||
"create_stage_from_font",
|
|
||||||
]
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
"""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"}
|
|
||||||
|
|
||||||
@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
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
"""
|
|
||||||
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)]
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"""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"):
|
|
||||||
self._display = display
|
|
||||||
self.name = name
|
|
||||||
self.category = "display"
|
|
||||||
self.optional = False
|
|
||||||
self._initialized = False
|
|
||||||
self._init_width = 80
|
|
||||||
self._init_height = 24
|
|
||||||
|
|
||||||
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]:
|
|
||||||
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
|
|
||||||
|
|
||||||
# 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:
|
|
||||||
self._display.show(data)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
self._display.cleanup()
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
"""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.
|
|
||||||
|
|
||||||
HUD effects are overlays.
|
|
||||||
"""
|
|
||||||
if self.name == "hud":
|
|
||||||
return "overlay"
|
|
||||||
return self.category
|
|
||||||
|
|
||||||
@property
|
|
||||||
def render_order(self) -> int:
|
|
||||||
"""Return render_order based on effect type.
|
|
||||||
|
|
||||||
HUD effects have high render_order to appear on top.
|
|
||||||
"""
|
|
||||||
if self.name == "hud":
|
|
||||||
return 100 # High order for overlays
|
|
||||||
return 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_overlay(self) -> bool:
|
|
||||||
"""Return True for HUD effects.
|
|
||||||
|
|
||||||
HUD is an overlay - it composes on top of the buffer
|
|
||||||
rather than transforming it for the next stage.
|
|
||||||
"""
|
|
||||||
return self.name == "hud"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self) -> set[str]:
|
|
||||||
return {f"effect.{self.name}"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self) -> set[str]:
|
|
||||||
return 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)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
"""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,8 +49,6 @@ 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__(
|
||||||
@@ -63,460 +61,30 @@ 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, initialize: bool = True) -> "Pipeline":
|
def add_stage(self, name: str, stage: Stage) -> "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, cleanup: bool = True) -> Stage | None:
|
def remove_stage(self, name: str) -> None:
|
||||||
"""Remove a stage from the pipeline.
|
"""Remove a stage from the pipeline."""
|
||||||
|
if name in self._stages:
|
||||||
Args:
|
del self._stages[name]
|
||||||
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 enable_stage(self, name: str) -> bool:
|
def build(self) -> "Pipeline":
|
||||||
"""Enable a stage in the pipeline.
|
"""Build execution order based on dependencies."""
|
||||||
|
|
||||||
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 = DisplayRegistry.create("terminal")
|
|
||||||
if display:
|
|
||||||
self.add_stage("display", DisplayStage(display, name="terminal"))
|
|
||||||
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
|
||||||
@@ -583,24 +151,12 @@ 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)
|
||||||
@@ -725,9 +281,8 @@ 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 and display stage from regular stages
|
# Separate overlay stages 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:
|
||||||
@@ -735,11 +290,6 @@ 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))
|
||||||
@@ -756,7 +306,7 @@ class Pipeline:
|
|||||||
else:
|
else:
|
||||||
regular_stages.append(name)
|
regular_stages.append(name)
|
||||||
|
|
||||||
# Execute regular stages in dependency order (excluding display)
|
# Execute regular stages in dependency order
|
||||||
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():
|
||||||
@@ -847,35 +397,6 @@ 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(
|
||||||
|
|||||||
@@ -155,21 +155,6 @@ 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.
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,6 @@ 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:
|
||||||
@@ -28,11 +23,11 @@ class PipelineParams:
|
|||||||
|
|
||||||
# Display config
|
# Display config
|
||||||
display: str = "terminal"
|
display: str = "terminal"
|
||||||
border: bool | BorderMode = False
|
border: bool = False
|
||||||
|
|
||||||
# Camera config
|
# Camera config
|
||||||
camera_mode: str = "vertical"
|
camera_mode: str = "vertical"
|
||||||
camera_speed: float = 1.0 # Default speed
|
camera_speed: float = 1.0
|
||||||
camera_x: int = 0 # For horizontal scrolling
|
camera_x: int = 0 # For horizontal scrolling
|
||||||
|
|
||||||
# Effect config
|
# Effect config
|
||||||
|
|||||||
@@ -11,14 +11,10 @@ Loading order:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import 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."""
|
||||||
@@ -30,6 +26,7 @@ def _load_toml_presets() -> dict[str, Any]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
# Pre-load TOML presets
|
||||||
_YAML_PRESETS = _load_toml_presets()
|
_YAML_PRESETS = _load_toml_presets()
|
||||||
|
|
||||||
|
|
||||||
@@ -50,53 +47,18 @@ 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 | BorderMode = (
|
border: bool = False
|
||||||
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
|
|
||||||
|
|
||||||
def to_params(self) -> PipelineParams:
|
def to_params(self) -> PipelineParams:
|
||||||
"""Convert to PipelineParams (runtime configuration)."""
|
"""Convert to PipelineParams."""
|
||||||
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 = (
|
params.border = self.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."""
|
||||||
@@ -108,11 +70,6 @@ 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),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -126,16 +83,6 @@ DEMO_PRESET = PipelinePreset(
|
|||||||
effects=["noise", "fade", "glitch", "firehose"],
|
effects=["noise", "fade", "glitch", "firehose"],
|
||||||
)
|
)
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
POETRY_PRESET = PipelinePreset(
|
POETRY_PRESET = PipelinePreset(
|
||||||
name="poetry",
|
name="poetry",
|
||||||
description="Poetry feed with subtle effects",
|
description="Poetry feed with subtle effects",
|
||||||
@@ -163,6 +110,15 @@ 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",
|
||||||
@@ -172,16 +128,6 @@ FIREHOSE_PRESET = PipelinePreset(
|
|||||||
effects=["noise", "fade", "glitch", "firehose"],
|
effects=["noise", "fade", "glitch", "firehose"],
|
||||||
)
|
)
|
||||||
|
|
||||||
FIXTURE_PRESET = PipelinePreset(
|
|
||||||
name="fixture",
|
|
||||||
description="Use cached headline fixtures",
|
|
||||||
source="fixture",
|
|
||||||
display="pygame",
|
|
||||||
camera="scroll",
|
|
||||||
effects=["noise", "fade"],
|
|
||||||
border=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Build presets from YAML data
|
# Build presets from YAML data
|
||||||
def _build_presets() -> dict[str, PipelinePreset]:
|
def _build_presets() -> dict[str, PipelinePreset]:
|
||||||
@@ -199,9 +145,8 @@ def _build_presets() -> dict[str, PipelinePreset]:
|
|||||||
"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,14 +118,6 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,674 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
"""
|
|
||||||
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}"
|
|
||||||
)
|
|
||||||
@@ -10,8 +10,7 @@ uv = "latest"
|
|||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
test = "uv run pytest"
|
test = "uv run pytest"
|
||||||
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing -m \"not benchmark\"", depends = ["sync-all"] }
|
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing", 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"
|
||||||
|
|
||||||
@@ -51,7 +50,7 @@ clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache
|
|||||||
# CI
|
# CI
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
ci = "mise run topics-init && mise run lint && mise run test-cov && mise run benchmark"
|
ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] }
|
||||||
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 +0,0 @@
|
|||||||
/home/david/.skills/opencode-instructions/SKILL.md
|
|
||||||
327
presets.toml
327
presets.toml
@@ -9,68 +9,292 @@
|
|||||||
# - ./presets.toml (local override)
|
# - ./presets.toml (local override)
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# TEST PRESETS (for CI and development)
|
# TEST PRESETS
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
[presets.test-basic]
|
[presets.test-single-item]
|
||||||
description = "Test: Basic pipeline with no effects"
|
description = "Test: Single item to isolate rendering stage issues"
|
||||||
source = "empty"
|
source = "empty"
|
||||||
display = "null"
|
|
||||||
camera = "feed"
|
|
||||||
effects = []
|
|
||||||
viewport_width = 100 # Custom size for testing
|
|
||||||
viewport_height = 30
|
|
||||||
|
|
||||||
[presets.test-border]
|
|
||||||
description = "Test: Single item with border effect"
|
|
||||||
source = "empty"
|
|
||||||
display = "null"
|
|
||||||
camera = "feed"
|
|
||||||
effects = ["border"]
|
|
||||||
viewport_width = 80
|
|
||||||
viewport_height = 24
|
|
||||||
|
|
||||||
[presets.test-scroll-camera]
|
|
||||||
description = "Test: Scrolling camera movement"
|
|
||||||
source = "empty"
|
|
||||||
display = "null"
|
|
||||||
camera = "scroll"
|
|
||||||
effects = []
|
|
||||||
camera_speed = 0.5
|
|
||||||
viewport_width = 80
|
|
||||||
viewport_height = 24
|
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# DEMO PRESETS (for demonstration and exploration)
|
|
||||||
# ============================================
|
|
||||||
|
|
||||||
[presets.demo-base]
|
|
||||||
description = "Demo: Base preset for effect hot-swapping"
|
|
||||||
source = "headlines"
|
|
||||||
display = "terminal"
|
display = "terminal"
|
||||||
camera = "feed"
|
camera = "feed"
|
||||||
effects = [] # Demo script will add/remove effects dynamically
|
effects = []
|
||||||
camera_speed = 0.1
|
camera_speed = 0.1
|
||||||
viewport_width = 80
|
viewport_width = 80
|
||||||
viewport_height = 24
|
viewport_height = 24
|
||||||
|
|
||||||
[presets.demo-pygame]
|
[presets.test-single-item-border]
|
||||||
description = "Demo: Pygame display version"
|
description = "Test: Single item with border effect only"
|
||||||
|
source = "empty"
|
||||||
|
display = "terminal"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["border"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.test-headlines]
|
||||||
|
description = "Test: Headlines from cache with border effect"
|
||||||
|
source = "headlines"
|
||||||
|
display = "terminal"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["border"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.test-headlines-noise]
|
||||||
|
description = "Test: Headlines from cache with noise effect"
|
||||||
|
source = "headlines"
|
||||||
|
display = "terminal"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["noise"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.test-demo-effects]
|
||||||
|
description = "Test: All demo effects with terminal display"
|
||||||
|
source = "headlines"
|
||||||
|
display = "terminal"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["noise", "fade", "firehose"]
|
||||||
|
camera_speed = 0.3
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# DATA SOURCE GALLERY
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
[presets.gallery-sources]
|
||||||
|
description = "Gallery: Headlines data source"
|
||||||
source = "headlines"
|
source = "headlines"
|
||||||
display = "pygame"
|
display = "pygame"
|
||||||
camera = "feed"
|
camera = "feed"
|
||||||
effects = [] # Demo script will add/remove effects dynamically
|
effects = []
|
||||||
camera_speed = 0.1
|
camera_speed = 0.1
|
||||||
viewport_width = 80
|
viewport_width = 80
|
||||||
viewport_height = 24
|
viewport_height = 24
|
||||||
|
|
||||||
[presets.demo-camera-showcase]
|
[presets.gallery-sources-poetry]
|
||||||
description = "Demo: Camera mode showcase"
|
description = "Gallery: Poetry data source"
|
||||||
|
source = "poetry"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["fade"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-sources-pipeline]
|
||||||
|
description = "Gallery: Pipeline introspection"
|
||||||
|
source = "pipeline-inspect"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "scroll"
|
||||||
|
effects = []
|
||||||
|
camera_speed = 0.3
|
||||||
|
viewport_width = 100
|
||||||
|
viewport_height = 35
|
||||||
|
|
||||||
|
[presets.gallery-sources-empty]
|
||||||
|
description = "Gallery: Empty source (for border tests)"
|
||||||
|
source = "empty"
|
||||||
|
display = "terminal"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["border"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# EFFECT GALLERY
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
[presets.gallery-effect-noise]
|
||||||
|
description = "Gallery: Noise effect"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["noise"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-effect-fade]
|
||||||
|
description = "Gallery: Fade effect"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["fade"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-effect-glitch]
|
||||||
|
description = "Gallery: Glitch effect"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["glitch"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-effect-firehose]
|
||||||
|
description = "Gallery: Firehose effect"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["firehose"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-effect-hud]
|
||||||
|
description = "Gallery: HUD effect"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["hud"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-effect-tint]
|
||||||
|
description = "Gallery: Tint effect"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["tint"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-effect-border]
|
||||||
|
description = "Gallery: Border effect"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["border"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-effect-crop]
|
||||||
|
description = "Gallery: Crop effect"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["crop"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# CAMERA GALLERY
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
[presets.gallery-camera-feed]
|
||||||
|
description = "Gallery: Feed camera (rapid single-item)"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["noise"]
|
||||||
|
camera_speed = 1.0
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-camera-scroll]
|
||||||
|
description = "Gallery: Scroll camera (smooth)"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "scroll"
|
||||||
|
effects = ["noise"]
|
||||||
|
camera_speed = 0.3
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-camera-horizontal]
|
||||||
|
description = "Gallery: Horizontal camera"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "horizontal"
|
||||||
|
effects = ["noise"]
|
||||||
|
camera_speed = 0.5
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-camera-omni]
|
||||||
|
description = "Gallery: Omni camera"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "omni"
|
||||||
|
effects = ["noise"]
|
||||||
|
camera_speed = 0.5
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-camera-floating]
|
||||||
|
description = "Gallery: Floating camera"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "floating"
|
||||||
|
effects = ["noise"]
|
||||||
|
camera_speed = 1.0
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-camera-bounce]
|
||||||
|
description = "Gallery: Bounce camera"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "bounce"
|
||||||
|
effects = ["noise"]
|
||||||
|
camera_speed = 1.0
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# DISPLAY GALLERY
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
[presets.gallery-display-terminal]
|
||||||
|
description = "Gallery: Terminal display"
|
||||||
source = "headlines"
|
source = "headlines"
|
||||||
display = "terminal"
|
display = "terminal"
|
||||||
camera = "feed"
|
camera = "feed"
|
||||||
effects = [] # Demo script will cycle through camera modes
|
effects = ["noise"]
|
||||||
camera_speed = 0.5
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-display-pygame]
|
||||||
|
description = "Gallery: Pygame display"
|
||||||
|
source = "headlines"
|
||||||
|
display = "pygame"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["noise"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-display-websocket]
|
||||||
|
description = "Gallery: WebSocket display"
|
||||||
|
source = "headlines"
|
||||||
|
display = "websocket"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["noise"]
|
||||||
|
camera_speed = 0.1
|
||||||
|
viewport_width = 80
|
||||||
|
viewport_height = 24
|
||||||
|
|
||||||
|
[presets.gallery-display-multi]
|
||||||
|
description = "Gallery: MultiDisplay (terminal + pygame)"
|
||||||
|
source = "headlines"
|
||||||
|
display = "multi:terminal,pygame"
|
||||||
|
camera = "feed"
|
||||||
|
effects = ["noise"]
|
||||||
|
camera_speed = 0.1
|
||||||
viewport_width = 80
|
viewport_width = 80
|
||||||
viewport_height = 24
|
viewport_height = 24
|
||||||
|
|
||||||
@@ -83,10 +307,9 @@ enabled = false
|
|||||||
threshold_db = 50.0
|
threshold_db = 50.0
|
||||||
|
|
||||||
[sensors.oscillator]
|
[sensors.oscillator]
|
||||||
enabled = true # Enable for demo script gentle oscillation
|
enabled = false
|
||||||
waveform = "sine"
|
waveform = "sine"
|
||||||
frequency = 0.05 # ~20 second cycle (gentle)
|
frequency = 1.0
|
||||||
amplitude = 0.5 # 50% modulation
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# EFFECT CONFIGURATIONS
|
# EFFECT CONFIGURATIONS
|
||||||
@@ -111,15 +334,3 @@ intensity = 1.0
|
|||||||
[effect_configs.hud]
|
[effect_configs.hud]
|
||||||
enabled = true
|
enabled = true
|
||||||
intensity = 1.0
|
intensity = 1.0
|
||||||
|
|
||||||
[effect_configs.tint]
|
|
||||||
enabled = true
|
|
||||||
intensity = 1.0
|
|
||||||
|
|
||||||
[effect_configs.border]
|
|
||||||
enabled = true
|
|
||||||
intensity = 1.0
|
|
||||||
|
|
||||||
[effect_configs.crop]
|
|
||||||
enabled = true
|
|
||||||
intensity = 1.0
|
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ mic = [
|
|||||||
websocket = [
|
websocket = [
|
||||||
"websockets>=12.0",
|
"websockets>=12.0",
|
||||||
]
|
]
|
||||||
|
sixel = [
|
||||||
|
"Pillow>=10.0.0",
|
||||||
|
]
|
||||||
pygame = [
|
pygame = [
|
||||||
"pygame>=2.0.0",
|
"pygame>=2.0.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Demo script for testing pipeline hot-rebuild and state preservation.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python scripts/demo_hot_rebuild.py
|
|
||||||
python scripts/demo_hot_rebuild.py --viewport 40x15
|
|
||||||
|
|
||||||
This script:
|
|
||||||
1. Creates a small viewport (40x15) for easier capture
|
|
||||||
2. Uses NullDisplay with recording enabled
|
|
||||||
3. Runs the pipeline for N frames (capturing initial state)
|
|
||||||
4. Triggers a "hot-rebuild" (e.g., toggling an effect stage)
|
|
||||||
5. Runs the pipeline for M more frames
|
|
||||||
6. Verifies state preservation by comparing frames before/after rebuild
|
|
||||||
7. Prints visual comparison to stdout
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from engine.display import DisplayRegistry
|
|
||||||
from engine.effects import get_registry
|
|
||||||
from engine.fetch import load_cache
|
|
||||||
from engine.pipeline import Pipeline, PipelineConfig, PipelineContext
|
|
||||||
from engine.pipeline.adapters import (
|
|
||||||
EffectPluginStage,
|
|
||||||
FontStage,
|
|
||||||
SourceItemsToBufferStage,
|
|
||||||
ViewportFilterStage,
|
|
||||||
create_stage_from_display,
|
|
||||||
create_stage_from_effect,
|
|
||||||
)
|
|
||||||
from engine.pipeline.params import PipelineParams
|
|
||||||
|
|
||||||
|
|
||||||
def run_demo(viewport_width: int = 40, viewport_height: int = 15):
|
|
||||||
"""Run the hot-rebuild demo."""
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print(f"Pipeline Hot-Rebuild Demo")
|
|
||||||
print(f"Viewport: {viewport_width}x{viewport_height}")
|
|
||||||
print(f"{'=' * 60}\n")
|
|
||||||
|
|
||||||
import engine.effects.plugins as effects_plugins
|
|
||||||
|
|
||||||
effects_plugins.discover_plugins()
|
|
||||||
|
|
||||||
print("[1/6] Loading source items...")
|
|
||||||
items = load_cache()
|
|
||||||
if not items:
|
|
||||||
print(" ERROR: No fixture cache available")
|
|
||||||
sys.exit(1)
|
|
||||||
print(f" Loaded {len(items)} items")
|
|
||||||
|
|
||||||
print("[2/6] Creating NullDisplay with recording...")
|
|
||||||
display = DisplayRegistry.create("null")
|
|
||||||
display.init(viewport_width, viewport_height)
|
|
||||||
display.start_recording()
|
|
||||||
print(" Recording started")
|
|
||||||
|
|
||||||
print("[3/6] Building pipeline...")
|
|
||||||
params = PipelineParams()
|
|
||||||
params.viewport_width = viewport_width
|
|
||||||
params.viewport_height = viewport_height
|
|
||||||
|
|
||||||
config = PipelineConfig(
|
|
||||||
source="fixture",
|
|
||||||
display="null",
|
|
||||||
camera="scroll",
|
|
||||||
effects=["noise", "fade"],
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline = Pipeline(config=config, context=PipelineContext())
|
|
||||||
|
|
||||||
from engine.data_sources.sources import ListDataSource
|
|
||||||
from engine.pipeline.adapters import DataSourceStage
|
|
||||||
|
|
||||||
list_source = ListDataSource(items, name="fixture")
|
|
||||||
pipeline.add_stage("source", DataSourceStage(list_source, name="fixture"))
|
|
||||||
pipeline.add_stage("viewport_filter", ViewportFilterStage(name="viewport-filter"))
|
|
||||||
pipeline.add_stage("font", FontStage(name="font"))
|
|
||||||
|
|
||||||
effect_registry = get_registry()
|
|
||||||
for effect_name in config.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, "null"))
|
|
||||||
pipeline.build()
|
|
||||||
|
|
||||||
if not pipeline.initialize():
|
|
||||||
print(" ERROR: Failed to initialize pipeline")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(" Pipeline built and initialized")
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
print("[4/6] Running pipeline for 10 frames (before rebuild)...")
|
|
||||||
frames_before = []
|
|
||||||
for frame in range(10):
|
|
||||||
params.frame_number = frame
|
|
||||||
ctx.params = params
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
if result.success:
|
|
||||||
frames_before.append(display._last_buffer)
|
|
||||||
print(f" Captured {len(frames_before)} frames")
|
|
||||||
|
|
||||||
print("[5/6] Triggering hot-rebuild (toggling 'fade' effect)...")
|
|
||||||
fade_stage = pipeline.get_stage("effect_fade")
|
|
||||||
if fade_stage and isinstance(fade_stage, EffectPluginStage):
|
|
||||||
new_enabled = not fade_stage.is_enabled()
|
|
||||||
fade_stage.set_enabled(new_enabled)
|
|
||||||
fade_stage._effect.config.enabled = new_enabled
|
|
||||||
print(f" Fade effect enabled: {new_enabled}")
|
|
||||||
else:
|
|
||||||
print(" WARNING: Could not find fade effect stage")
|
|
||||||
|
|
||||||
print("[6/6] Running pipeline for 10 more frames (after rebuild)...")
|
|
||||||
frames_after = []
|
|
||||||
for frame in range(10, 20):
|
|
||||||
params.frame_number = frame
|
|
||||||
ctx.params = params
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
if result.success:
|
|
||||||
frames_after.append(display._last_buffer)
|
|
||||||
print(f" Captured {len(frames_after)} frames")
|
|
||||||
|
|
||||||
display.stop_recording()
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("RESULTS")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
print("\n[State Preservation Check]")
|
|
||||||
if frames_before and frames_after:
|
|
||||||
last_before = frames_before[-1]
|
|
||||||
first_after = frames_after[0]
|
|
||||||
|
|
||||||
if last_before == first_after:
|
|
||||||
print(" PASS: Buffer state preserved across rebuild")
|
|
||||||
else:
|
|
||||||
print(" INFO: Buffer changed after rebuild (expected - effect toggled)")
|
|
||||||
|
|
||||||
print("\n[Frame Continuity Check]")
|
|
||||||
recorded_frames = display.get_frames()
|
|
||||||
print(f" Total recorded frames: {len(recorded_frames)}")
|
|
||||||
print(f" Frames before rebuild: {len(frames_before)}")
|
|
||||||
print(f" Frames after rebuild: {len(frames_after)}")
|
|
||||||
|
|
||||||
if len(recorded_frames) == 20:
|
|
||||||
print(" PASS: All frames recorded")
|
|
||||||
else:
|
|
||||||
print(" WARNING: Frame count mismatch")
|
|
||||||
|
|
||||||
print("\n[Visual Comparison - First frame before vs after rebuild]")
|
|
||||||
print("\n--- Before rebuild (frame 9) ---")
|
|
||||||
for i, line in enumerate(frames_before[0][:viewport_height]):
|
|
||||||
print(f"{i:2}: {line}")
|
|
||||||
|
|
||||||
print("\n--- After rebuild (frame 10) ---")
|
|
||||||
for i, line in enumerate(frames_after[0][:viewport_height]):
|
|
||||||
print(f"{i:2}: {line}")
|
|
||||||
|
|
||||||
print("\n[Recording Save/Load Test]")
|
|
||||||
test_file = Path("/tmp/test_recording.json")
|
|
||||||
display.save_recording(test_file)
|
|
||||||
print(f" Saved recording to: {test_file}")
|
|
||||||
|
|
||||||
display2 = DisplayRegistry.create("null")
|
|
||||||
display2.init(viewport_width, viewport_height)
|
|
||||||
display2.load_recording(test_file)
|
|
||||||
loaded_frames = display2.get_frames()
|
|
||||||
print(f" Loaded {len(loaded_frames)} frames from file")
|
|
||||||
|
|
||||||
if len(loaded_frames) == len(recorded_frames):
|
|
||||||
print(" PASS: Recording save/load works correctly")
|
|
||||||
else:
|
|
||||||
print(" WARNING: Frame count mismatch after load")
|
|
||||||
|
|
||||||
test_file.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
pipeline.cleanup()
|
|
||||||
display.cleanup()
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("Demo complete!")
|
|
||||||
print("=" * 60 + "\n")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
viewport_width = 40
|
|
||||||
viewport_height = 15
|
|
||||||
|
|
||||||
if "--viewport" in sys.argv:
|
|
||||||
idx = sys.argv.index("--viewport")
|
|
||||||
if idx + 1 < len(sys.argv):
|
|
||||||
vp = sys.argv[idx + 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)
|
|
||||||
|
|
||||||
run_demo(viewport_width, viewport_height)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,378 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Oscilloscope with Image Data Source Integration
|
|
||||||
|
|
||||||
This demo:
|
|
||||||
1. Uses pygame to render oscillator waveforms
|
|
||||||
2. Converts to PIL Image (8-bit grayscale with transparency)
|
|
||||||
3. Renders to ANSI using image data source patterns
|
|
||||||
4. Features LFO modulation chain
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
uv run python scripts/demo_image_oscilloscope.py --lfo --modulate
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Add mainline to path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from engine.data_sources.sources import DataSource, ImageItem
|
|
||||||
from engine.sensors.oscillator import OscillatorSensor, register_oscillator_sensor
|
|
||||||
|
|
||||||
|
|
||||||
class ModulatedOscillator:
|
|
||||||
"""Oscillator with frequency modulation from another oscillator."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
waveform: str = "sine",
|
|
||||||
base_frequency: float = 1.0,
|
|
||||||
modulator: "OscillatorSensor | None" = None,
|
|
||||||
modulation_depth: float = 0.5,
|
|
||||||
):
|
|
||||||
self.name = name
|
|
||||||
self.waveform = waveform
|
|
||||||
self.base_frequency = base_frequency
|
|
||||||
self.modulator = modulator
|
|
||||||
self.modulation_depth = modulation_depth
|
|
||||||
|
|
||||||
register_oscillator_sensor(
|
|
||||||
name=name, waveform=waveform, frequency=base_frequency
|
|
||||||
)
|
|
||||||
self.osc = OscillatorSensor(
|
|
||||||
name=name, waveform=waveform, frequency=base_frequency
|
|
||||||
)
|
|
||||||
self.osc.start()
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
if self.modulator:
|
|
||||||
mod_reading = self.modulator.read()
|
|
||||||
if mod_reading:
|
|
||||||
mod_offset = (mod_reading.value - 0.5) * 2 * self.modulation_depth
|
|
||||||
effective_freq = self.base_frequency + mod_offset
|
|
||||||
effective_freq = max(0.1, min(effective_freq, 20.0))
|
|
||||||
self.osc._frequency = effective_freq
|
|
||||||
return self.osc.read()
|
|
||||||
|
|
||||||
def get_phase(self):
|
|
||||||
return self.osc._phase
|
|
||||||
|
|
||||||
def get_effective_frequency(self):
|
|
||||||
if self.modulator and self.modulator.read():
|
|
||||||
mod_reading = self.modulator.read()
|
|
||||||
mod_offset = (mod_reading.value - 0.5) * 2 * self.modulation_depth
|
|
||||||
return max(0.1, min(self.base_frequency + mod_offset, 20.0))
|
|
||||||
return self.base_frequency
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
self.osc.stop()
|
|
||||||
|
|
||||||
|
|
||||||
class OscilloscopeDataSource(DataSource):
|
|
||||||
"""Dynamic data source that generates oscilloscope images from oscillators."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
modulator: OscillatorSensor,
|
|
||||||
modulated: ModulatedOscillator,
|
|
||||||
width: int = 200,
|
|
||||||
height: int = 100,
|
|
||||||
):
|
|
||||||
self.modulator = modulator
|
|
||||||
self.modulated = modulated
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.frame = 0
|
|
||||||
|
|
||||||
# Check if pygame and PIL are available
|
|
||||||
import importlib.util
|
|
||||||
|
|
||||||
self.pygame_available = importlib.util.find_spec("pygame") is not None
|
|
||||||
self.pil_available = importlib.util.find_spec("PIL") is not None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "oscilloscope_image"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_dynamic(self) -> bool:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def fetch(self) -> list[ImageItem]:
|
|
||||||
"""Generate oscilloscope image from oscillators."""
|
|
||||||
if not self.pygame_available or not self.pil_available:
|
|
||||||
# Fallback to text-based source
|
|
||||||
return []
|
|
||||||
|
|
||||||
import pygame
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
# Create Pygame surface
|
|
||||||
surface = pygame.Surface((self.width, self.height))
|
|
||||||
surface.fill((10, 10, 20)) # Dark background
|
|
||||||
|
|
||||||
# Get readings
|
|
||||||
mod_reading = self.modulator.read()
|
|
||||||
mod_val = mod_reading.value if mod_reading else 0.5
|
|
||||||
modulated_reading = self.modulated.read()
|
|
||||||
modulated_val = modulated_reading.value if modulated_reading else 0.5
|
|
||||||
|
|
||||||
# Draw modulator waveform (top half)
|
|
||||||
top_height = self.height // 2
|
|
||||||
waveform_fn = self.modulator.WAVEFORMS[self.modulator.waveform]
|
|
||||||
mod_time_offset = self.modulator._phase * self.modulator.frequency * 0.3
|
|
||||||
|
|
||||||
prev_x, prev_y = 0, 0
|
|
||||||
for x in range(self.width):
|
|
||||||
col_fraction = x / self.width
|
|
||||||
time_pos = mod_time_offset + col_fraction
|
|
||||||
sample = waveform_fn(time_pos * self.modulator.frequency * 2)
|
|
||||||
y = int(top_height - (sample * (top_height - 10)) - 5)
|
|
||||||
if x > 0:
|
|
||||||
pygame.draw.line(surface, (100, 200, 255), (prev_x, prev_y), (x, y), 1)
|
|
||||||
prev_x, prev_y = x, y
|
|
||||||
|
|
||||||
# Draw separator
|
|
||||||
pygame.draw.line(
|
|
||||||
surface, (80, 80, 100), (0, top_height), (self.width, top_height), 1
|
|
||||||
)
|
|
||||||
|
|
||||||
# Draw modulated waveform (bottom half)
|
|
||||||
bottom_start = top_height + 1
|
|
||||||
bottom_height = self.height - bottom_start - 1
|
|
||||||
waveform_fn = self.modulated.osc.WAVEFORMS[self.modulated.waveform]
|
|
||||||
modulated_time_offset = (
|
|
||||||
self.modulated.get_phase() * self.modulated.get_effective_frequency() * 0.3
|
|
||||||
)
|
|
||||||
|
|
||||||
prev_x, prev_y = 0, 0
|
|
||||||
for x in range(self.width):
|
|
||||||
col_fraction = x / self.width
|
|
||||||
time_pos = modulated_time_offset + col_fraction
|
|
||||||
sample = waveform_fn(
|
|
||||||
time_pos * self.modulated.get_effective_frequency() * 2
|
|
||||||
)
|
|
||||||
y = int(
|
|
||||||
bottom_start + (bottom_height - (sample * (bottom_height - 10))) - 5
|
|
||||||
)
|
|
||||||
if x > 0:
|
|
||||||
pygame.draw.line(surface, (255, 150, 100), (prev_x, prev_y), (x, y), 1)
|
|
||||||
prev_x, prev_y = x, y
|
|
||||||
|
|
||||||
# Convert Pygame surface to PIL Image (8-bit grayscale with alpha)
|
|
||||||
img_str = pygame.image.tostring(surface, "RGB")
|
|
||||||
pil_rgb = Image.frombytes("RGB", (self.width, self.height), img_str)
|
|
||||||
|
|
||||||
# Convert to 8-bit grayscale
|
|
||||||
pil_gray = pil_rgb.convert("L")
|
|
||||||
|
|
||||||
# Create alpha channel (full opacity for now)
|
|
||||||
alpha = Image.new("L", (self.width, self.height), 255)
|
|
||||||
|
|
||||||
# Combine into RGBA
|
|
||||||
pil_rgba = Image.merge("RGBA", (pil_gray, pil_gray, pil_gray, alpha))
|
|
||||||
|
|
||||||
# Create ImageItem
|
|
||||||
item = ImageItem(
|
|
||||||
image=pil_rgba,
|
|
||||||
source="oscilloscope_image",
|
|
||||||
timestamp=str(time.time()),
|
|
||||||
path=None,
|
|
||||||
metadata={
|
|
||||||
"frame": self.frame,
|
|
||||||
"mod_value": mod_val,
|
|
||||||
"modulated_value": modulated_val,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.frame += 1
|
|
||||||
return [item]
|
|
||||||
|
|
||||||
|
|
||||||
def render_pil_to_ansi(
|
|
||||||
pil_image, terminal_width: int = 80, terminal_height: int = 30
|
|
||||||
) -> str:
|
|
||||||
"""Convert PIL image (8-bit grayscale with transparency) to ANSI."""
|
|
||||||
# Resize for terminal display
|
|
||||||
resized = pil_image.resize((terminal_width * 2, terminal_height * 2))
|
|
||||||
|
|
||||||
# Extract grayscale and alpha channels
|
|
||||||
gray = resized.convert("L")
|
|
||||||
alpha = resized.split()[3] if len(resized.split()) > 3 else None
|
|
||||||
|
|
||||||
# ANSI character ramp (dark to light)
|
|
||||||
chars = " .:-=+*#%@"
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
for y in range(0, resized.height, 2): # Sample every 2nd row for aspect ratio
|
|
||||||
line = ""
|
|
||||||
for x in range(0, resized.width, 2):
|
|
||||||
pixel = gray.getpixel((x, y))
|
|
||||||
|
|
||||||
# Check alpha if available
|
|
||||||
if alpha:
|
|
||||||
a = alpha.getpixel((x, y))
|
|
||||||
if a < 128: # Transparent
|
|
||||||
line += " "
|
|
||||||
continue
|
|
||||||
|
|
||||||
char_index = int((pixel / 255) * (len(chars) - 1))
|
|
||||||
line += chars[char_index]
|
|
||||||
lines.append(line)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def demo_image_oscilloscope(
|
|
||||||
waveform: str = "sine",
|
|
||||||
base_freq: float = 0.5,
|
|
||||||
modulate: bool = False,
|
|
||||||
mod_waveform: str = "sine",
|
|
||||||
mod_freq: float = 0.5,
|
|
||||||
mod_depth: float = 0.5,
|
|
||||||
frames: int = 0,
|
|
||||||
):
|
|
||||||
"""Run oscilloscope with image data source integration."""
|
|
||||||
frame_interval = 1.0 / 15.0 # 15 FPS
|
|
||||||
|
|
||||||
print("Oscilloscope with Image Data Source Integration")
|
|
||||||
print("Frame rate: 15 FPS")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Create oscillators
|
|
||||||
modulator = OscillatorSensor(
|
|
||||||
name="modulator", waveform=mod_waveform, frequency=mod_freq
|
|
||||||
)
|
|
||||||
modulator.start()
|
|
||||||
|
|
||||||
modulated = ModulatedOscillator(
|
|
||||||
name="modulated",
|
|
||||||
waveform=waveform,
|
|
||||||
base_frequency=base_freq,
|
|
||||||
modulator=modulator if modulate else None,
|
|
||||||
modulation_depth=mod_depth,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create image data source
|
|
||||||
image_source = OscilloscopeDataSource(
|
|
||||||
modulator=modulator,
|
|
||||||
modulated=modulated,
|
|
||||||
width=200,
|
|
||||||
height=100,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Run demo loop
|
|
||||||
try:
|
|
||||||
frame = 0
|
|
||||||
last_time = time.time()
|
|
||||||
|
|
||||||
while frames == 0 or frame < frames:
|
|
||||||
# Fetch image from data source
|
|
||||||
images = image_source.fetch()
|
|
||||||
|
|
||||||
if images:
|
|
||||||
# Convert to ANSI
|
|
||||||
visualization = render_pil_to_ansi(
|
|
||||||
images[0].image, terminal_width=80, terminal_height=30
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Fallback to text message
|
|
||||||
visualization = (
|
|
||||||
"Pygame or PIL not available\n\n[Image rendering disabled]"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add header
|
|
||||||
header = f"IMAGE SOURCE MODE | Frame: {frame}"
|
|
||||||
header_line = "─" * 80
|
|
||||||
visualization = f"{header}\n{header_line}\n" + visualization
|
|
||||||
|
|
||||||
# Display
|
|
||||||
print("\033[H" + visualization)
|
|
||||||
|
|
||||||
# Frame timing
|
|
||||||
elapsed = time.time() - last_time
|
|
||||||
sleep_time = max(0, frame_interval - elapsed)
|
|
||||||
time.sleep(sleep_time)
|
|
||||||
last_time = time.time()
|
|
||||||
|
|
||||||
frame += 1
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\nDemo stopped by user")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
modulator.stop()
|
|
||||||
modulated.stop()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Oscilloscope with image data source integration"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--waveform",
|
|
||||||
choices=["sine", "square", "sawtooth", "triangle", "noise"],
|
|
||||||
default="sine",
|
|
||||||
help="Main waveform type",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--frequency",
|
|
||||||
type=float,
|
|
||||||
default=0.5,
|
|
||||||
help="Main oscillator frequency",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--lfo",
|
|
||||||
action="store_true",
|
|
||||||
help="Use slow LFO frequency (0.5Hz)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--modulate",
|
|
||||||
action="store_true",
|
|
||||||
help="Enable LFO modulation chain",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mod-waveform",
|
|
||||||
choices=["sine", "square", "sawtooth", "triangle", "noise"],
|
|
||||||
default="sine",
|
|
||||||
help="Modulator waveform type",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mod-freq",
|
|
||||||
type=float,
|
|
||||||
default=0.5,
|
|
||||||
help="Modulator frequency in Hz",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mod-depth",
|
|
||||||
type=float,
|
|
||||||
default=0.5,
|
|
||||||
help="Modulation depth",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--frames",
|
|
||||||
type=int,
|
|
||||||
default=0,
|
|
||||||
help="Number of frames to render",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
base_freq = args.frequency
|
|
||||||
if args.lfo:
|
|
||||||
base_freq = 0.5
|
|
||||||
|
|
||||||
demo_image_oscilloscope(
|
|
||||||
waveform=args.waveform,
|
|
||||||
base_freq=base_freq,
|
|
||||||
modulate=args.modulate,
|
|
||||||
mod_waveform=args.mod_waveform,
|
|
||||||
mod_freq=args.mod_freq,
|
|
||||||
mod_depth=args.mod_depth,
|
|
||||||
frames=args.frames,
|
|
||||||
)
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Simple Oscillator Sensor Demo
|
|
||||||
|
|
||||||
This script demonstrates the oscillator sensor by:
|
|
||||||
1. Creating an oscillator sensor with various waveforms
|
|
||||||
2. Printing the waveform data in real-time
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
uv run python scripts/demo_oscillator_simple.py --waveform sine --frequency 1.0
|
|
||||||
uv run python scripts/demo_oscillator_simple.py --waveform square --frequency 2.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import math
|
|
||||||
import time
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Add mainline to path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from engine.sensors.oscillator import OscillatorSensor, register_oscillator_sensor
|
|
||||||
|
|
||||||
|
|
||||||
def render_waveform(width: int, height: int, osc: OscillatorSensor, frame: int) -> str:
|
|
||||||
"""Render a waveform visualization."""
|
|
||||||
# Get current reading
|
|
||||||
current_reading = osc.read()
|
|
||||||
current_value = current_reading.value if current_reading else 0.0
|
|
||||||
|
|
||||||
# Generate waveform data - sample the waveform function directly
|
|
||||||
# This shows what the waveform looks like, not the live reading
|
|
||||||
samples = []
|
|
||||||
waveform_fn = osc.WAVEFORMS[osc._waveform]
|
|
||||||
|
|
||||||
for i in range(width):
|
|
||||||
# Sample across one complete cycle (0 to 1)
|
|
||||||
phase = i / width
|
|
||||||
value = waveform_fn(phase)
|
|
||||||
samples.append(value)
|
|
||||||
|
|
||||||
# Build visualization
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
# Header with sensor info
|
|
||||||
header = (
|
|
||||||
f"Oscillator: {osc.name} | Waveform: {osc.waveform} | Freq: {osc.frequency}Hz"
|
|
||||||
)
|
|
||||||
lines.append(header)
|
|
||||||
lines.append("─" * width)
|
|
||||||
|
|
||||||
# Waveform plot (scaled to fit height)
|
|
||||||
num_rows = height - 3 # Header, separator, footer
|
|
||||||
for row in range(num_rows):
|
|
||||||
# Calculate the sample value that corresponds to this row
|
|
||||||
# 0.0 is bottom, 1.0 is top
|
|
||||||
row_value = 1.0 - (row / (num_rows - 1)) if num_rows > 1 else 0.5
|
|
||||||
|
|
||||||
line_chars = []
|
|
||||||
for x, sample in enumerate(samples):
|
|
||||||
# Determine if this sample should be drawn in this row
|
|
||||||
# Map sample (0.0-1.0) to row (0 to num_rows-1)
|
|
||||||
# 0.0 -> row 0 (bottom), 1.0 -> row num_rows-1 (top)
|
|
||||||
sample_row = int(sample * (num_rows - 1))
|
|
||||||
if sample_row == row:
|
|
||||||
# Use different characters for waveform vs current position marker
|
|
||||||
# Check if this is the current reading position
|
|
||||||
if abs(x / width - (osc._phase % 1.0)) < 0.02:
|
|
||||||
line_chars.append("◎") # Current position marker
|
|
||||||
else:
|
|
||||||
line_chars.append("█")
|
|
||||||
else:
|
|
||||||
line_chars.append(" ")
|
|
||||||
lines.append("".join(line_chars))
|
|
||||||
|
|
||||||
# Footer with current value and phase info
|
|
||||||
footer = f"Value: {current_value:.3f} | Frame: {frame} | Phase: {osc._phase:.2f}"
|
|
||||||
lines.append(footer)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def demo_oscillator(waveform: str = "sine", frequency: float = 1.0, frames: int = 0):
|
|
||||||
"""Run oscillator demo."""
|
|
||||||
print(f"Starting oscillator demo: {waveform} wave at {frequency}Hz")
|
|
||||||
if frames > 0:
|
|
||||||
print(f"Running for {frames} frames")
|
|
||||||
else:
|
|
||||||
print("Press Ctrl+C to stop")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Create oscillator sensor
|
|
||||||
register_oscillator_sensor(name="demo_osc", waveform=waveform, frequency=frequency)
|
|
||||||
osc = OscillatorSensor(name="demo_osc", waveform=waveform, frequency=frequency)
|
|
||||||
osc.start()
|
|
||||||
|
|
||||||
# Run demo loop
|
|
||||||
try:
|
|
||||||
frame = 0
|
|
||||||
while frames == 0 or frame < frames:
|
|
||||||
# Render waveform
|
|
||||||
visualization = render_waveform(80, 20, osc, frame)
|
|
||||||
|
|
||||||
# Print with ANSI escape codes to clear screen and move cursor
|
|
||||||
print("\033[H\033[J" + visualization)
|
|
||||||
|
|
||||||
time.sleep(0.05) # 20 FPS
|
|
||||||
frame += 1
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\nDemo stopped by user")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
osc.stop()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="Oscillator sensor demo")
|
|
||||||
parser.add_argument(
|
|
||||||
"--waveform",
|
|
||||||
choices=["sine", "square", "sawtooth", "triangle", "noise"],
|
|
||||||
default="sine",
|
|
||||||
help="Waveform type",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--frequency", type=float, default=1.0, help="Oscillator frequency in Hz"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--frames",
|
|
||||||
type=int,
|
|
||||||
default=0,
|
|
||||||
help="Number of frames to render (0 = infinite until Ctrl+C)",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
demo_oscillator(args.waveform, args.frequency, args.frames)
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Oscilloscope Demo - Real-time waveform visualization
|
|
||||||
|
|
||||||
This demonstrates a real oscilloscope-style display where:
|
|
||||||
1. A complete waveform is drawn on the canvas
|
|
||||||
2. The camera scrolls horizontally (time axis)
|
|
||||||
3. The "pen" traces the waveform vertically at the center
|
|
||||||
|
|
||||||
Think of it as:
|
|
||||||
- Canvas: Contains the waveform pattern (like a stamp)
|
|
||||||
- Camera: Moves left-to-right, revealing different parts of the waveform
|
|
||||||
- Pen: Always at center X, moves vertically with the signal value
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
uv run python scripts/demo_oscilloscope.py --frequency 1.0 --speed 10
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import math
|
|
||||||
import time
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Add mainline to path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from engine.sensors.oscillator import OscillatorSensor, register_oscillator_sensor
|
|
||||||
|
|
||||||
|
|
||||||
def render_oscilloscope(
|
|
||||||
width: int,
|
|
||||||
height: int,
|
|
||||||
osc: OscillatorSensor,
|
|
||||||
frame: int,
|
|
||||||
) -> str:
|
|
||||||
"""Render an oscilloscope-style display."""
|
|
||||||
# Get current reading (0.0 to 1.0)
|
|
||||||
reading = osc.read()
|
|
||||||
current_value = reading.value if reading else 0.5
|
|
||||||
phase = osc._phase
|
|
||||||
frequency = osc.frequency
|
|
||||||
|
|
||||||
# Build visualization
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
# Header with sensor info
|
|
||||||
header = (
|
|
||||||
f"Oscilloscope: {osc.name} | Wave: {osc.waveform} | "
|
|
||||||
f"Freq: {osc.frequency}Hz | Phase: {phase:.2f}"
|
|
||||||
)
|
|
||||||
lines.append(header)
|
|
||||||
lines.append("─" * width)
|
|
||||||
|
|
||||||
# Center line (zero reference)
|
|
||||||
center_row = height // 2
|
|
||||||
|
|
||||||
# Draw oscilloscope trace
|
|
||||||
waveform_fn = osc.WAVEFORMS[osc._waveform]
|
|
||||||
|
|
||||||
# Calculate time offset for scrolling
|
|
||||||
# The trace scrolls based on phase - this creates the time axis movement
|
|
||||||
# At frequency 1.0, the trace completes one full sweep per frequency cycle
|
|
||||||
time_offset = phase * frequency * 2.0
|
|
||||||
|
|
||||||
# Pre-calculate all sample values for this frame
|
|
||||||
# Each column represents a time point on the X axis
|
|
||||||
samples = []
|
|
||||||
for col in range(width):
|
|
||||||
# Time position for this column (0.0 to 1.0 across width)
|
|
||||||
col_fraction = col / width
|
|
||||||
# Combine with time offset for scrolling effect
|
|
||||||
time_pos = time_offset + col_fraction
|
|
||||||
|
|
||||||
# Sample the waveform at this time point
|
|
||||||
# Multiply by frequency to get correct number of cycles shown
|
|
||||||
sample_value = waveform_fn(time_pos * frequency * 2)
|
|
||||||
samples.append(sample_value)
|
|
||||||
|
|
||||||
# Draw the trace
|
|
||||||
# For each row, check which columns have their sample value in this row
|
|
||||||
for row in range(height - 3): # Reserve 3 lines for header/footer
|
|
||||||
# Calculate vertical position (0.0 at bottom, 1.0 at top)
|
|
||||||
row_pos = 1.0 - (row / (height - 4))
|
|
||||||
|
|
||||||
line_chars = []
|
|
||||||
for col in range(width):
|
|
||||||
sample = samples[col]
|
|
||||||
|
|
||||||
# Check if this sample falls in this row
|
|
||||||
tolerance = 1.0 / (height - 4)
|
|
||||||
if abs(sample - row_pos) < tolerance:
|
|
||||||
line_chars.append("█")
|
|
||||||
else:
|
|
||||||
line_chars.append(" ")
|
|
||||||
lines.append("".join(line_chars))
|
|
||||||
|
|
||||||
# Draw center indicator line
|
|
||||||
center_line = list(" " * width)
|
|
||||||
# Position the indicator based on current value
|
|
||||||
indicator_x = int((current_value) * (width - 1))
|
|
||||||
if 0 <= indicator_x < width:
|
|
||||||
center_line[indicator_x] = "◎"
|
|
||||||
lines.append("".join(center_line))
|
|
||||||
|
|
||||||
# Footer with current value
|
|
||||||
footer = f"Value: {current_value:.3f} | Frame: {frame} | Phase: {phase:.2f}"
|
|
||||||
lines.append(footer)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def demo_oscilloscope(
|
|
||||||
waveform: str = "sine",
|
|
||||||
frequency: float = 1.0,
|
|
||||||
frames: int = 0,
|
|
||||||
):
|
|
||||||
"""Run oscilloscope demo."""
|
|
||||||
# Determine if this is LFO range
|
|
||||||
is_lfo = frequency <= 20.0 and frequency >= 0.1
|
|
||||||
freq_type = "LFO" if is_lfo else "Audio"
|
|
||||||
|
|
||||||
print(f"Oscilloscope demo: {waveform} wave")
|
|
||||||
print(f"Frequency: {frequency}Hz ({freq_type} range)")
|
|
||||||
if frames > 0:
|
|
||||||
print(f"Running for {frames} frames")
|
|
||||||
else:
|
|
||||||
print("Press Ctrl+C to stop")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Create oscillator sensor
|
|
||||||
register_oscillator_sensor(
|
|
||||||
name="oscilloscope_osc", waveform=waveform, frequency=frequency
|
|
||||||
)
|
|
||||||
osc = OscillatorSensor(
|
|
||||||
name="oscilloscope_osc", waveform=waveform, frequency=frequency
|
|
||||||
)
|
|
||||||
osc.start()
|
|
||||||
|
|
||||||
# Run demo loop
|
|
||||||
try:
|
|
||||||
frame = 0
|
|
||||||
while frames == 0 or frame < frames:
|
|
||||||
# Render oscilloscope display
|
|
||||||
visualization = render_oscilloscope(80, 22, osc, frame)
|
|
||||||
|
|
||||||
# Print with ANSI escape codes to clear screen and move cursor
|
|
||||||
print("\033[H\033[J" + visualization)
|
|
||||||
|
|
||||||
time.sleep(1.0 / 60.0) # 60 FPS
|
|
||||||
frame += 1
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\nDemo stopped by user")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
osc.stop()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="Oscilloscope demo")
|
|
||||||
parser.add_argument(
|
|
||||||
"--waveform",
|
|
||||||
choices=["sine", "square", "sawtooth", "triangle", "noise"],
|
|
||||||
default="sine",
|
|
||||||
help="Waveform type",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--frequency",
|
|
||||||
type=float,
|
|
||||||
default=1.0,
|
|
||||||
help="Oscillator frequency in Hz (LFO: 0.1-20Hz, Audio: >20Hz)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--lfo",
|
|
||||||
action="store_true",
|
|
||||||
help="Use LFO frequency (0.5Hz - slow modulation)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--fast-lfo",
|
|
||||||
action="store_true",
|
|
||||||
help="Use fast LFO frequency (5Hz - rhythmic modulation)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--frames",
|
|
||||||
type=int,
|
|
||||||
default=0,
|
|
||||||
help="Number of frames to render (0 = infinite until Ctrl+C)",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Determine frequency based on mode
|
|
||||||
frequency = args.frequency
|
|
||||||
if args.lfo:
|
|
||||||
frequency = 0.5 # Slow LFO for modulation
|
|
||||||
elif args.fast_lfo:
|
|
||||||
frequency = 5.0 # Fast LFO for rhythmic modulation
|
|
||||||
|
|
||||||
demo_oscilloscope(
|
|
||||||
waveform=args.waveform,
|
|
||||||
frequency=frequency,
|
|
||||||
frames=args.frames,
|
|
||||||
)
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Enhanced Oscilloscope with LFO Modulation Chain
|
|
||||||
|
|
||||||
This demo features:
|
|
||||||
1. Slower frame rate (15 FPS) for human appreciation
|
|
||||||
2. Reduced flicker using cursor positioning
|
|
||||||
3. LFO modulation chain: LFO1 modulates LFO2 frequency
|
|
||||||
4. Multiple visualization modes
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
# Simple LFO
|
|
||||||
uv run python scripts/demo_oscilloscope_mod.py --lfo
|
|
||||||
|
|
||||||
# LFO modulation chain: LFO1 modulates LFO2 frequency
|
|
||||||
uv run python scripts/demo_oscilloscope_mod.py --modulate --lfo
|
|
||||||
|
|
||||||
# Custom modulation depth and rate
|
|
||||||
uv run python scripts/demo_oscilloscope_mod.py --modulate --lfo --mod-depth 0.5 --mod-rate 0.25
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Add mainline to path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from engine.sensors.oscillator import OscillatorSensor, register_oscillator_sensor
|
|
||||||
|
|
||||||
|
|
||||||
class ModulatedOscillator:
|
|
||||||
"""
|
|
||||||
Oscillator with frequency modulation from another oscillator.
|
|
||||||
|
|
||||||
Frequency = base_frequency + (modulator_value * modulation_depth)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
waveform: str = "sine",
|
|
||||||
base_frequency: float = 1.0,
|
|
||||||
modulator: "OscillatorSensor | None" = None,
|
|
||||||
modulation_depth: float = 0.5,
|
|
||||||
):
|
|
||||||
self.name = name
|
|
||||||
self.waveform = waveform
|
|
||||||
self.base_frequency = base_frequency
|
|
||||||
self.modulator = modulator
|
|
||||||
self.modulation_depth = modulation_depth
|
|
||||||
|
|
||||||
# Create the oscillator sensor
|
|
||||||
register_oscillator_sensor(
|
|
||||||
name=name, waveform=waveform, frequency=base_frequency
|
|
||||||
)
|
|
||||||
self.osc = OscillatorSensor(
|
|
||||||
name=name, waveform=waveform, frequency=base_frequency
|
|
||||||
)
|
|
||||||
self.osc.start()
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
"""Read current value, applying modulation if present."""
|
|
||||||
# Update frequency based on modulator
|
|
||||||
if self.modulator:
|
|
||||||
mod_reading = self.modulator.read()
|
|
||||||
if mod_reading:
|
|
||||||
# Modulator value (0-1) affects frequency
|
|
||||||
# Map 0-1 to -modulation_depth to +modulation_depth
|
|
||||||
mod_offset = (mod_reading.value - 0.5) * 2 * self.modulation_depth
|
|
||||||
effective_freq = self.base_frequency + mod_offset
|
|
||||||
# Clamp to reasonable range
|
|
||||||
effective_freq = max(0.1, min(effective_freq, 20.0))
|
|
||||||
self.osc._frequency = effective_freq
|
|
||||||
|
|
||||||
return self.osc.read()
|
|
||||||
|
|
||||||
def get_phase(self):
|
|
||||||
"""Get current phase."""
|
|
||||||
return self.osc._phase
|
|
||||||
|
|
||||||
def get_effective_frequency(self):
|
|
||||||
"""Get current effective frequency (after modulation)."""
|
|
||||||
if self.modulator and self.modulator.read():
|
|
||||||
mod_reading = self.modulator.read()
|
|
||||||
mod_offset = (mod_reading.value - 0.5) * 2 * self.modulation_depth
|
|
||||||
return max(0.1, min(self.base_frequency + mod_offset, 20.0))
|
|
||||||
return self.base_frequency
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
"""Stop the oscillator."""
|
|
||||||
self.osc.stop()
|
|
||||||
|
|
||||||
|
|
||||||
def render_dual_waveform(
|
|
||||||
width: int,
|
|
||||||
height: int,
|
|
||||||
modulator: OscillatorSensor,
|
|
||||||
modulated: ModulatedOscillator,
|
|
||||||
frame: int,
|
|
||||||
) -> str:
|
|
||||||
"""Render both modulator and modulated waveforms."""
|
|
||||||
# Get readings
|
|
||||||
mod_reading = modulator.read()
|
|
||||||
mod_val = mod_reading.value if mod_reading else 0.5
|
|
||||||
|
|
||||||
modulated_reading = modulated.read()
|
|
||||||
modulated_val = modulated_reading.value if modulated_reading else 0.5
|
|
||||||
|
|
||||||
# Build visualization
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
# Header with sensor info
|
|
||||||
header1 = f"MODULATOR: {modulator.name} | Wave: {modulator.waveform} | Freq: {modulator.frequency:.2f}Hz"
|
|
||||||
header2 = f"MODULATED: {modulated.name} | Wave: {modulated.waveform} | Base: {modulated.base_frequency:.2f}Hz | Eff: {modulated.get_effective_frequency():.2f}Hz"
|
|
||||||
lines.append(header1)
|
|
||||||
lines.append(header2)
|
|
||||||
lines.append("─" * width)
|
|
||||||
|
|
||||||
# Render modulator waveform (top half)
|
|
||||||
top_height = (height - 5) // 2
|
|
||||||
waveform_fn = modulator.WAVEFORMS[modulator.waveform]
|
|
||||||
|
|
||||||
# Calculate time offset for scrolling
|
|
||||||
mod_time_offset = modulator._phase * modulator.frequency * 0.3
|
|
||||||
|
|
||||||
for row in range(top_height):
|
|
||||||
row_pos = 1.0 - (row / (top_height - 1))
|
|
||||||
line_chars = []
|
|
||||||
for col in range(width):
|
|
||||||
col_fraction = col / width
|
|
||||||
time_pos = mod_time_offset + col_fraction
|
|
||||||
sample = waveform_fn(time_pos * modulator.frequency * 2)
|
|
||||||
tolerance = 1.0 / (top_height - 1)
|
|
||||||
if abs(sample - row_pos) < tolerance:
|
|
||||||
line_chars.append("█")
|
|
||||||
else:
|
|
||||||
line_chars.append(" ")
|
|
||||||
lines.append("".join(line_chars))
|
|
||||||
|
|
||||||
# Separator line with modulation info
|
|
||||||
lines.append(
|
|
||||||
f"─ MODULATION: depth={modulated.modulation_depth:.2f} | mod_value={mod_val:.2f} ─"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Render modulated waveform (bottom half)
|
|
||||||
bottom_height = height - top_height - 5
|
|
||||||
waveform_fn = modulated.osc.WAVEFORMS[modulated.waveform]
|
|
||||||
|
|
||||||
# Calculate time offset for scrolling
|
|
||||||
modulated_time_offset = (
|
|
||||||
modulated.get_phase() * modulated.get_effective_frequency() * 0.3
|
|
||||||
)
|
|
||||||
|
|
||||||
for row in range(bottom_height):
|
|
||||||
row_pos = 1.0 - (row / (bottom_height - 1))
|
|
||||||
line_chars = []
|
|
||||||
for col in range(width):
|
|
||||||
col_fraction = col / width
|
|
||||||
time_pos = modulated_time_offset + col_fraction
|
|
||||||
sample = waveform_fn(time_pos * modulated.get_effective_frequency() * 2)
|
|
||||||
tolerance = 1.0 / (bottom_height - 1)
|
|
||||||
if abs(sample - row_pos) < tolerance:
|
|
||||||
line_chars.append("█")
|
|
||||||
else:
|
|
||||||
line_chars.append(" ")
|
|
||||||
lines.append("".join(line_chars))
|
|
||||||
|
|
||||||
# Footer with current values
|
|
||||||
footer = f"Mod Value: {mod_val:.3f} | Modulated Value: {modulated_val:.3f} | Frame: {frame}"
|
|
||||||
lines.append(footer)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def render_single_waveform(
|
|
||||||
width: int,
|
|
||||||
height: int,
|
|
||||||
osc: OscillatorSensor,
|
|
||||||
frame: int,
|
|
||||||
) -> str:
|
|
||||||
"""Render a single waveform (for non-modulated mode)."""
|
|
||||||
reading = osc.read()
|
|
||||||
current_value = reading.value if reading else 0.5
|
|
||||||
phase = osc._phase
|
|
||||||
frequency = osc.frequency
|
|
||||||
|
|
||||||
# Build visualization
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
# Header with sensor info
|
|
||||||
header = (
|
|
||||||
f"Oscilloscope: {osc.name} | Wave: {osc.waveform} | "
|
|
||||||
f"Freq: {frequency:.2f}Hz | Phase: {phase:.2f}"
|
|
||||||
)
|
|
||||||
lines.append(header)
|
|
||||||
lines.append("─" * width)
|
|
||||||
|
|
||||||
# Draw oscilloscope trace
|
|
||||||
waveform_fn = osc.WAVEFORMS[osc.waveform]
|
|
||||||
time_offset = phase * frequency * 0.3
|
|
||||||
|
|
||||||
for row in range(height - 3):
|
|
||||||
row_pos = 1.0 - (row / (height - 4))
|
|
||||||
line_chars = []
|
|
||||||
for col in range(width):
|
|
||||||
col_fraction = col / width
|
|
||||||
time_pos = time_offset + col_fraction
|
|
||||||
sample = waveform_fn(time_pos * frequency * 2)
|
|
||||||
tolerance = 1.0 / (height - 4)
|
|
||||||
if abs(sample - row_pos) < tolerance:
|
|
||||||
line_chars.append("█")
|
|
||||||
else:
|
|
||||||
line_chars.append(" ")
|
|
||||||
lines.append("".join(line_chars))
|
|
||||||
|
|
||||||
# Footer
|
|
||||||
footer = f"Value: {current_value:.3f} | Frame: {frame} | Phase: {phase:.2f}"
|
|
||||||
lines.append(footer)
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def demo_oscilloscope_mod(
|
|
||||||
waveform: str = "sine",
|
|
||||||
base_freq: float = 1.0,
|
|
||||||
modulate: bool = False,
|
|
||||||
mod_waveform: str = "sine",
|
|
||||||
mod_freq: float = 0.5,
|
|
||||||
mod_depth: float = 0.5,
|
|
||||||
frames: int = 0,
|
|
||||||
):
|
|
||||||
"""Run enhanced oscilloscope demo with modulation support."""
|
|
||||||
# Frame timing for smooth 15 FPS
|
|
||||||
frame_interval = 1.0 / 15.0 # 66.67ms per frame
|
|
||||||
|
|
||||||
print("Enhanced Oscilloscope Demo")
|
|
||||||
print("Frame rate: 15 FPS (66ms per frame)")
|
|
||||||
if modulate:
|
|
||||||
print(
|
|
||||||
f"Modulation: {mod_waveform} @ {mod_freq}Hz -> {waveform} @ {base_freq}Hz"
|
|
||||||
)
|
|
||||||
print(f"Modulation depth: {mod_depth}")
|
|
||||||
else:
|
|
||||||
print(f"Waveform: {waveform} @ {base_freq}Hz")
|
|
||||||
if frames > 0:
|
|
||||||
print(f"Running for {frames} frames")
|
|
||||||
else:
|
|
||||||
print("Press Ctrl+C to stop")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Create oscillators
|
|
||||||
if modulate:
|
|
||||||
# Create modulation chain: modulator -> modulated
|
|
||||||
modulator = OscillatorSensor(
|
|
||||||
name="modulator", waveform=mod_waveform, frequency=mod_freq
|
|
||||||
)
|
|
||||||
modulator.start()
|
|
||||||
|
|
||||||
modulated = ModulatedOscillator(
|
|
||||||
name="modulated",
|
|
||||||
waveform=waveform,
|
|
||||||
base_frequency=base_freq,
|
|
||||||
modulator=modulator,
|
|
||||||
modulation_depth=mod_depth,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Single oscillator
|
|
||||||
register_oscillator_sensor(
|
|
||||||
name="oscilloscope", waveform=waveform, frequency=base_freq
|
|
||||||
)
|
|
||||||
osc = OscillatorSensor(
|
|
||||||
name="oscilloscope", waveform=waveform, frequency=base_freq
|
|
||||||
)
|
|
||||||
osc.start()
|
|
||||||
|
|
||||||
# Run demo loop with consistent timing
|
|
||||||
try:
|
|
||||||
frame = 0
|
|
||||||
last_time = time.time()
|
|
||||||
|
|
||||||
while frames == 0 or frame < frames:
|
|
||||||
# Render based on mode
|
|
||||||
if modulate:
|
|
||||||
visualization = render_dual_waveform(
|
|
||||||
80, 30, modulator, modulated, frame
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
visualization = render_single_waveform(80, 22, osc, frame)
|
|
||||||
|
|
||||||
# Use cursor positioning instead of full clear to reduce flicker
|
|
||||||
print("\033[H" + visualization)
|
|
||||||
|
|
||||||
# Calculate sleep time for consistent 15 FPS
|
|
||||||
elapsed = time.time() - last_time
|
|
||||||
sleep_time = max(0, frame_interval - elapsed)
|
|
||||||
time.sleep(sleep_time)
|
|
||||||
last_time = time.time()
|
|
||||||
|
|
||||||
frame += 1
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\nDemo stopped by user")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if modulate:
|
|
||||||
modulator.stop()
|
|
||||||
modulated.stop()
|
|
||||||
else:
|
|
||||||
osc.stop()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Enhanced oscilloscope with LFO modulation chain"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--waveform",
|
|
||||||
choices=["sine", "square", "sawtooth", "triangle", "noise"],
|
|
||||||
default="sine",
|
|
||||||
help="Main waveform type",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--frequency",
|
|
||||||
type=float,
|
|
||||||
default=1.0,
|
|
||||||
help="Main oscillator frequency (LFO range: 0.1-20Hz)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--lfo",
|
|
||||||
action="store_true",
|
|
||||||
help="Use slow LFO frequency (0.5Hz) for main oscillator",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--modulate",
|
|
||||||
action="store_true",
|
|
||||||
help="Enable LFO modulation chain (modulator modulates main oscillator)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mod-waveform",
|
|
||||||
choices=["sine", "square", "sawtooth", "triangle", "noise"],
|
|
||||||
default="sine",
|
|
||||||
help="Modulator waveform type",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mod-freq",
|
|
||||||
type=float,
|
|
||||||
default=0.5,
|
|
||||||
help="Modulator frequency in Hz",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mod-depth",
|
|
||||||
type=float,
|
|
||||||
default=0.5,
|
|
||||||
help="Modulation depth (0.0-1.0, higher = more frequency variation)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--frames",
|
|
||||||
type=int,
|
|
||||||
default=0,
|
|
||||||
help="Number of frames to render (0 = infinite until Ctrl+C)",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# Set frequency based on LFO flag
|
|
||||||
base_freq = args.frequency
|
|
||||||
if args.lfo:
|
|
||||||
base_freq = 0.5
|
|
||||||
|
|
||||||
demo_oscilloscope_mod(
|
|
||||||
waveform=args.waveform,
|
|
||||||
base_freq=base_freq,
|
|
||||||
modulate=args.modulate,
|
|
||||||
mod_waveform=args.mod_waveform,
|
|
||||||
mod_freq=args.mod_freq,
|
|
||||||
mod_depth=args.mod_depth,
|
|
||||||
frames=args.frames,
|
|
||||||
)
|
|
||||||
@@ -1,411 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Enhanced Oscilloscope with Pipeline Switching
|
|
||||||
|
|
||||||
This demo features:
|
|
||||||
1. Text-based oscilloscope (first 15 seconds)
|
|
||||||
2. Pygame renderer with PIL to ANSI conversion (next 15 seconds)
|
|
||||||
3. Continuous looping between the two modes
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
uv run python scripts/demo_oscilloscope_pipeline.py --lfo --modulate
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Add mainline to path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from engine.sensors.oscillator import OscillatorSensor, register_oscillator_sensor
|
|
||||||
|
|
||||||
|
|
||||||
class ModulatedOscillator:
|
|
||||||
"""Oscillator with frequency modulation from another oscillator."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
waveform: str = "sine",
|
|
||||||
base_frequency: float = 1.0,
|
|
||||||
modulator: "OscillatorSensor | None" = None,
|
|
||||||
modulation_depth: float = 0.5,
|
|
||||||
):
|
|
||||||
self.name = name
|
|
||||||
self.waveform = waveform
|
|
||||||
self.base_frequency = base_frequency
|
|
||||||
self.modulator = modulator
|
|
||||||
self.modulation_depth = modulation_depth
|
|
||||||
|
|
||||||
register_oscillator_sensor(
|
|
||||||
name=name, waveform=waveform, frequency=base_frequency
|
|
||||||
)
|
|
||||||
self.osc = OscillatorSensor(
|
|
||||||
name=name, waveform=waveform, frequency=base_frequency
|
|
||||||
)
|
|
||||||
self.osc.start()
|
|
||||||
|
|
||||||
def read(self):
|
|
||||||
"""Read current value, applying modulation if present."""
|
|
||||||
if self.modulator:
|
|
||||||
mod_reading = self.modulator.read()
|
|
||||||
if mod_reading:
|
|
||||||
mod_offset = (mod_reading.value - 0.5) * 2 * self.modulation_depth
|
|
||||||
effective_freq = self.base_frequency + mod_offset
|
|
||||||
effective_freq = max(0.1, min(effective_freq, 20.0))
|
|
||||||
self.osc._frequency = effective_freq
|
|
||||||
return self.osc.read()
|
|
||||||
|
|
||||||
def get_phase(self):
|
|
||||||
return self.osc._phase
|
|
||||||
|
|
||||||
def get_effective_frequency(self):
|
|
||||||
if self.modulator:
|
|
||||||
mod_reading = self.modulator.read()
|
|
||||||
if mod_reading:
|
|
||||||
mod_offset = (mod_reading.value - 0.5) * 2 * self.modulation_depth
|
|
||||||
return max(0.1, min(self.base_frequency + mod_offset, 20.0))
|
|
||||||
return self.base_frequency
|
|
||||||
|
|
||||||
def stop(self):
|
|
||||||
self.osc.stop()
|
|
||||||
|
|
||||||
|
|
||||||
def render_text_mode(
|
|
||||||
width: int,
|
|
||||||
height: int,
|
|
||||||
modulator: OscillatorSensor,
|
|
||||||
modulated: ModulatedOscillator,
|
|
||||||
frame: int,
|
|
||||||
) -> str:
|
|
||||||
"""Render dual waveforms in text mode."""
|
|
||||||
mod_reading = modulator.read()
|
|
||||||
mod_val = mod_reading.value if mod_reading else 0.5
|
|
||||||
modulated_reading = modulated.read()
|
|
||||||
modulated_val = modulated_reading.value if modulated_reading else 0.5
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
header1 = (
|
|
||||||
f"TEXT MODE | MODULATOR: {modulator.waveform} @ {modulator.frequency:.2f}Hz"
|
|
||||||
)
|
|
||||||
header2 = (
|
|
||||||
f"MODULATED: {modulated.waveform} @ {modulated.get_effective_frequency():.2f}Hz"
|
|
||||||
)
|
|
||||||
lines.append(header1)
|
|
||||||
lines.append(header2)
|
|
||||||
lines.append("─" * width)
|
|
||||||
|
|
||||||
# Modulator waveform (top half)
|
|
||||||
top_height = (height - 5) // 2
|
|
||||||
waveform_fn = modulator.WAVEFORMS[modulator.waveform]
|
|
||||||
mod_time_offset = modulator._phase * modulator.frequency * 0.3
|
|
||||||
|
|
||||||
for row in range(top_height):
|
|
||||||
row_pos = 1.0 - (row / (top_height - 1))
|
|
||||||
line_chars = []
|
|
||||||
for col in range(width):
|
|
||||||
col_fraction = col / width
|
|
||||||
time_pos = mod_time_offset + col_fraction
|
|
||||||
sample = waveform_fn(time_pos * modulator.frequency * 2)
|
|
||||||
tolerance = 1.0 / (top_height - 1)
|
|
||||||
if abs(sample - row_pos) < tolerance:
|
|
||||||
line_chars.append("█")
|
|
||||||
else:
|
|
||||||
line_chars.append(" ")
|
|
||||||
lines.append("".join(line_chars))
|
|
||||||
|
|
||||||
lines.append(
|
|
||||||
f"─ MODULATION: depth={modulated.modulation_depth:.2f} | mod_value={mod_val:.2f} ─"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Modulated waveform (bottom half)
|
|
||||||
bottom_height = height - top_height - 5
|
|
||||||
waveform_fn = modulated.osc.WAVEFORMS[modulated.waveform]
|
|
||||||
modulated_time_offset = (
|
|
||||||
modulated.get_phase() * modulated.get_effective_frequency() * 0.3
|
|
||||||
)
|
|
||||||
|
|
||||||
for row in range(bottom_height):
|
|
||||||
row_pos = 1.0 - (row / (bottom_height - 1))
|
|
||||||
line_chars = []
|
|
||||||
for col in range(width):
|
|
||||||
col_fraction = col / width
|
|
||||||
time_pos = modulated_time_offset + col_fraction
|
|
||||||
sample = waveform_fn(time_pos * modulated.get_effective_frequency() * 2)
|
|
||||||
tolerance = 1.0 / (bottom_height - 1)
|
|
||||||
if abs(sample - row_pos) < tolerance:
|
|
||||||
line_chars.append("█")
|
|
||||||
else:
|
|
||||||
line_chars.append(" ")
|
|
||||||
lines.append("".join(line_chars))
|
|
||||||
|
|
||||||
footer = (
|
|
||||||
f"Mod Value: {mod_val:.3f} | Modulated: {modulated_val:.3f} | Frame: {frame}"
|
|
||||||
)
|
|
||||||
lines.append(footer)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def render_pygame_to_ansi(
|
|
||||||
width: int,
|
|
||||||
height: int,
|
|
||||||
modulator: OscillatorSensor,
|
|
||||||
modulated: ModulatedOscillator,
|
|
||||||
frame: int,
|
|
||||||
font_path: str | None,
|
|
||||||
) -> str:
|
|
||||||
"""Render waveforms using Pygame, convert to ANSI with PIL."""
|
|
||||||
try:
|
|
||||||
import pygame
|
|
||||||
from PIL import Image
|
|
||||||
except ImportError:
|
|
||||||
return "Pygame or PIL not available\n\n" + render_text_mode(
|
|
||||||
width, height, modulator, modulated, frame
|
|
||||||
)
|
|
||||||
|
|
||||||
# Initialize Pygame surface (smaller for ANSI conversion)
|
|
||||||
pygame_width = width * 2 # Double for better quality
|
|
||||||
pygame_height = height * 4
|
|
||||||
surface = pygame.Surface((pygame_width, pygame_height))
|
|
||||||
surface.fill((10, 10, 20)) # Dark background
|
|
||||||
|
|
||||||
# Get readings
|
|
||||||
mod_reading = modulator.read()
|
|
||||||
mod_val = mod_reading.value if mod_reading else 0.5
|
|
||||||
modulated_reading = modulated.read()
|
|
||||||
modulated_val = modulated_reading.value if modulated_reading else 0.5
|
|
||||||
|
|
||||||
# Draw modulator waveform (top half)
|
|
||||||
top_height = pygame_height // 2
|
|
||||||
waveform_fn = modulator.WAVEFORMS[modulator.waveform]
|
|
||||||
mod_time_offset = modulator._phase * modulator.frequency * 0.3
|
|
||||||
|
|
||||||
prev_x, prev_y = 0, 0
|
|
||||||
for x in range(pygame_width):
|
|
||||||
col_fraction = x / pygame_width
|
|
||||||
time_pos = mod_time_offset + col_fraction
|
|
||||||
sample = waveform_fn(time_pos * modulator.frequency * 2)
|
|
||||||
y = int(top_height - (sample * (top_height - 20)) - 10)
|
|
||||||
if x > 0:
|
|
||||||
pygame.draw.line(surface, (100, 200, 255), (prev_x, prev_y), (x, y), 2)
|
|
||||||
prev_x, prev_y = x, y
|
|
||||||
|
|
||||||
# Draw separator
|
|
||||||
pygame.draw.line(
|
|
||||||
surface, (80, 80, 100), (0, top_height), (pygame_width, top_height), 1
|
|
||||||
)
|
|
||||||
|
|
||||||
# Draw modulated waveform (bottom half)
|
|
||||||
bottom_start = top_height + 10
|
|
||||||
bottom_height = pygame_height - bottom_start - 20
|
|
||||||
waveform_fn = modulated.osc.WAVEFORMS[modulated.waveform]
|
|
||||||
modulated_time_offset = (
|
|
||||||
modulated.get_phase() * modulated.get_effective_frequency() * 0.3
|
|
||||||
)
|
|
||||||
|
|
||||||
prev_x, prev_y = 0, 0
|
|
||||||
for x in range(pygame_width):
|
|
||||||
col_fraction = x / pygame_width
|
|
||||||
time_pos = modulated_time_offset + col_fraction
|
|
||||||
sample = waveform_fn(time_pos * modulated.get_effective_frequency() * 2)
|
|
||||||
y = int(bottom_start + (bottom_height - (sample * (bottom_height - 20))) - 10)
|
|
||||||
if x > 0:
|
|
||||||
pygame.draw.line(surface, (255, 150, 100), (prev_x, prev_y), (x, y), 2)
|
|
||||||
prev_x, prev_y = x, y
|
|
||||||
|
|
||||||
# Draw info text on pygame surface
|
|
||||||
try:
|
|
||||||
if font_path:
|
|
||||||
font = pygame.font.Font(font_path, 16)
|
|
||||||
info_text = f"PYGAME MODE | Mod: {mod_val:.2f} | Out: {modulated_val:.2f} | Frame: {frame}"
|
|
||||||
text_surface = font.render(info_text, True, (200, 200, 200))
|
|
||||||
surface.blit(text_surface, (10, 10))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Convert Pygame surface to PIL Image
|
|
||||||
img_str = pygame.image.tostring(surface, "RGB")
|
|
||||||
pil_image = Image.frombytes("RGB", (pygame_width, pygame_height), img_str)
|
|
||||||
|
|
||||||
# Convert to ANSI
|
|
||||||
return pil_to_ansi(pil_image)
|
|
||||||
|
|
||||||
|
|
||||||
def pil_to_ansi(image) -> str:
|
|
||||||
"""Convert PIL image to ANSI escape codes."""
|
|
||||||
# Resize for terminal display
|
|
||||||
terminal_width = 80
|
|
||||||
terminal_height = 30
|
|
||||||
image = image.resize((terminal_width * 2, terminal_height * 2))
|
|
||||||
|
|
||||||
# Convert to grayscale
|
|
||||||
image = image.convert("L")
|
|
||||||
|
|
||||||
# ANSI character ramp (dark to light)
|
|
||||||
chars = " .:-=+*#%@"
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
for y in range(0, image.height, 2): # Sample every 2nd row for aspect ratio
|
|
||||||
line = ""
|
|
||||||
for x in range(0, image.width, 2):
|
|
||||||
pixel = image.getpixel((x, y))
|
|
||||||
char_index = int((pixel / 255) * (len(chars) - 1))
|
|
||||||
line += chars[char_index]
|
|
||||||
lines.append(line)
|
|
||||||
|
|
||||||
# Add header info
|
|
||||||
header = "PYGAME → ANSI RENDER MODE"
|
|
||||||
header_line = "─" * terminal_width
|
|
||||||
return f"{header}\n{header_line}\n" + "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def demo_with_pipeline_switching(
|
|
||||||
waveform: str = "sine",
|
|
||||||
base_freq: float = 0.5,
|
|
||||||
modulate: bool = False,
|
|
||||||
mod_waveform: str = "sine",
|
|
||||||
mod_freq: float = 0.5,
|
|
||||||
mod_depth: float = 0.5,
|
|
||||||
frames: int = 0,
|
|
||||||
):
|
|
||||||
"""Run demo with pipeline switching every 15 seconds."""
|
|
||||||
frame_interval = 1.0 / 15.0 # 15 FPS
|
|
||||||
mode_duration = 15.0 # 15 seconds per mode
|
|
||||||
|
|
||||||
print("Enhanced Oscilloscope with Pipeline Switching")
|
|
||||||
print(f"Mode duration: {mode_duration} seconds")
|
|
||||||
print("Frame rate: 15 FPS")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# Create oscillators
|
|
||||||
modulator = OscillatorSensor(
|
|
||||||
name="modulator", waveform=mod_waveform, frequency=mod_freq
|
|
||||||
)
|
|
||||||
modulator.start()
|
|
||||||
|
|
||||||
modulated = ModulatedOscillator(
|
|
||||||
name="modulated",
|
|
||||||
waveform=waveform,
|
|
||||||
base_frequency=base_freq,
|
|
||||||
modulator=modulator if modulate else None,
|
|
||||||
modulation_depth=mod_depth,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Find font path
|
|
||||||
font_path = Path("fonts/Pixel_Sparta.otf")
|
|
||||||
if not font_path.exists():
|
|
||||||
font_path = Path("fonts/Pixel Sparta.otf")
|
|
||||||
font_path = str(font_path) if font_path.exists() else None
|
|
||||||
|
|
||||||
# Run demo loop
|
|
||||||
try:
|
|
||||||
frame = 0
|
|
||||||
mode_start_time = time.time()
|
|
||||||
mode_index = 0 # 0 = text, 1 = pygame
|
|
||||||
|
|
||||||
while frames == 0 or frame < frames:
|
|
||||||
elapsed = time.time() - mode_start_time
|
|
||||||
|
|
||||||
# Switch mode every 15 seconds
|
|
||||||
if elapsed >= mode_duration:
|
|
||||||
mode_index = (mode_index + 1) % 2
|
|
||||||
mode_start_time = time.time()
|
|
||||||
print(f"\n{'=' * 60}")
|
|
||||||
print(
|
|
||||||
f"SWITCHING TO {'PYGAME+ANSI' if mode_index == 1 else 'TEXT'} MODE"
|
|
||||||
)
|
|
||||||
print(f"{'=' * 60}\n")
|
|
||||||
time.sleep(1.0) # Brief pause to show mode switch
|
|
||||||
|
|
||||||
# Render based on mode
|
|
||||||
if mode_index == 0:
|
|
||||||
# Text mode
|
|
||||||
visualization = render_text_mode(80, 30, modulator, modulated, frame)
|
|
||||||
else:
|
|
||||||
# Pygame + PIL to ANSI mode
|
|
||||||
visualization = render_pygame_to_ansi(
|
|
||||||
80, 30, modulator, modulated, frame, font_path
|
|
||||||
)
|
|
||||||
|
|
||||||
# Display with cursor positioning
|
|
||||||
print("\033[H" + visualization)
|
|
||||||
|
|
||||||
# Frame timing
|
|
||||||
time.sleep(frame_interval)
|
|
||||||
frame += 1
|
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n\nDemo stopped by user")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
modulator.stop()
|
|
||||||
modulated.stop()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Enhanced oscilloscope with pipeline switching"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--waveform",
|
|
||||||
choices=["sine", "square", "sawtooth", "triangle", "noise"],
|
|
||||||
default="sine",
|
|
||||||
help="Main waveform type",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--frequency",
|
|
||||||
type=float,
|
|
||||||
default=0.5,
|
|
||||||
help="Main oscillator frequency (LFO range)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--lfo",
|
|
||||||
action="store_true",
|
|
||||||
help="Use slow LFO frequency (0.5Hz)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--modulate",
|
|
||||||
action="store_true",
|
|
||||||
help="Enable LFO modulation chain",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mod-waveform",
|
|
||||||
choices=["sine", "square", "sawtooth", "triangle", "noise"],
|
|
||||||
default="sine",
|
|
||||||
help="Modulator waveform type",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mod-freq",
|
|
||||||
type=float,
|
|
||||||
default=0.5,
|
|
||||||
help="Modulator frequency in Hz",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mod-depth",
|
|
||||||
type=float,
|
|
||||||
default=0.5,
|
|
||||||
help="Modulation depth",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--frames",
|
|
||||||
type=int,
|
|
||||||
default=0,
|
|
||||||
help="Number of frames to render (0 = infinite)",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
base_freq = args.frequency
|
|
||||||
if args.lfo:
|
|
||||||
base_freq = 0.5
|
|
||||||
|
|
||||||
demo_with_pipeline_switching(
|
|
||||||
waveform=args.waveform,
|
|
||||||
base_freq=base_freq,
|
|
||||||
modulate=args.modulate,
|
|
||||||
mod_waveform=args.mod_waveform,
|
|
||||||
mod_freq=args.mod_freq,
|
|
||||||
mod_depth=args.mod_depth,
|
|
||||||
)
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Oscillator Data Export
|
|
||||||
|
|
||||||
Exports oscillator sensor data in JSON format for external use.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
uv run python scripts/oscillator_data_export.py --waveform sine --frequency 1.0 --duration 5.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# Add mainline to path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from engine.sensors.oscillator import OscillatorSensor, register_oscillator_sensor
|
|
||||||
|
|
||||||
|
|
||||||
def export_oscillator_data(
|
|
||||||
waveform: str = "sine",
|
|
||||||
frequency: float = 1.0,
|
|
||||||
duration: float = 5.0,
|
|
||||||
sample_rate: float = 60.0,
|
|
||||||
output_file: str | None = None,
|
|
||||||
):
|
|
||||||
"""Export oscillator data to JSON."""
|
|
||||||
print(f"Exporting oscillator data: {waveform} wave at {frequency}Hz")
|
|
||||||
print(f"Duration: {duration}s, Sample rate: {sample_rate}Hz")
|
|
||||||
|
|
||||||
# Create oscillator sensor
|
|
||||||
register_oscillator_sensor(
|
|
||||||
name="export_osc", waveform=waveform, frequency=frequency
|
|
||||||
)
|
|
||||||
osc = OscillatorSensor(name="export_osc", waveform=waveform, frequency=frequency)
|
|
||||||
osc.start()
|
|
||||||
|
|
||||||
# Collect data
|
|
||||||
data = {
|
|
||||||
"waveform": waveform,
|
|
||||||
"frequency": frequency,
|
|
||||||
"duration": duration,
|
|
||||||
"sample_rate": sample_rate,
|
|
||||||
"timestamp": datetime.now().isoformat(),
|
|
||||||
"samples": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
sample_interval = 1.0 / sample_rate
|
|
||||||
num_samples = int(duration * sample_rate)
|
|
||||||
|
|
||||||
print(f"Collecting {num_samples} samples...")
|
|
||||||
|
|
||||||
for i in range(num_samples):
|
|
||||||
reading = osc.read()
|
|
||||||
if reading:
|
|
||||||
data["samples"].append(
|
|
||||||
{
|
|
||||||
"index": i,
|
|
||||||
"timestamp": reading.timestamp,
|
|
||||||
"value": reading.value,
|
|
||||||
"phase": osc._phase,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
time.sleep(sample_interval)
|
|
||||||
|
|
||||||
osc.stop()
|
|
||||||
|
|
||||||
# Export to JSON
|
|
||||||
if output_file:
|
|
||||||
with open(output_file, "w") as f:
|
|
||||||
json.dump(data, f, indent=2)
|
|
||||||
print(f"Data exported to {output_file}")
|
|
||||||
else:
|
|
||||||
print(json.dumps(data, indent=2))
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(description="Export oscillator sensor data")
|
|
||||||
parser.add_argument(
|
|
||||||
"--waveform",
|
|
||||||
choices=["sine", "square", "sawtooth", "triangle", "noise"],
|
|
||||||
default="sine",
|
|
||||||
help="Waveform type",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--frequency", type=float, default=1.0, help="Oscillator frequency in Hz"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--duration", type=float, default=5.0, help="Duration to record in seconds"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--sample-rate", type=float, default=60.0, help="Sample rate in Hz"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--output", "-o", type=str, help="Output JSON file (default: print to stdout)"
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
export_oscillator_data(
|
|
||||||
waveform=args.waveform,
|
|
||||||
frequency=args.frequency,
|
|
||||||
duration=args.duration,
|
|
||||||
sample_rate=args.sample_rate,
|
|
||||||
output_file=args.output,
|
|
||||||
)
|
|
||||||
@@ -1,509 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Pipeline Demo Orchestrator
|
|
||||||
|
|
||||||
Demonstrates all effects and camera modes with gentle oscillation.
|
|
||||||
Runs a comprehensive test of the Mainline pipeline system with proper
|
|
||||||
frame rate control and extended duration for visibility.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import math
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from engine.camera import Camera
|
|
||||||
from engine.data_sources.checkerboard import CheckerboardDataSource
|
|
||||||
from engine.data_sources.sources import SourceItem
|
|
||||||
from engine.display import DisplayRegistry, NullDisplay
|
|
||||||
from engine.effects.plugins import discover_plugins
|
|
||||||
from engine.effects import get_registry
|
|
||||||
from engine.effects.types import EffectConfig
|
|
||||||
from engine.frame import FrameTimer
|
|
||||||
from engine.pipeline import Pipeline, PipelineConfig, PipelineContext
|
|
||||||
from engine.pipeline.adapters import (
|
|
||||||
CameraClockStage,
|
|
||||||
CameraStage,
|
|
||||||
DataSourceStage,
|
|
||||||
DisplayStage,
|
|
||||||
EffectPluginStage,
|
|
||||||
SourceItemsToBufferStage,
|
|
||||||
)
|
|
||||||
from engine.pipeline.stages.framebuffer import FrameBufferStage
|
|
||||||
|
|
||||||
|
|
||||||
class GentleOscillator:
|
|
||||||
"""Produces smooth, gentle sinusoidal values."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, speed: float = 60.0, amplitude: float = 1.0, offset: float = 0.0
|
|
||||||
):
|
|
||||||
self.speed = speed # Period length in frames
|
|
||||||
self.amplitude = amplitude # Amplitude
|
|
||||||
self.offset = offset # Base offset
|
|
||||||
|
|
||||||
def value(self, frame: int) -> float:
|
|
||||||
"""Get oscillated value for given frame."""
|
|
||||||
return self.offset + self.amplitude * 0.5 * (1 + math.sin(frame / self.speed))
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineDemoOrchestrator:
|
|
||||||
"""Orchestrates comprehensive pipeline demonstrations."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
use_terminal: bool = True,
|
|
||||||
target_fps: float = 30.0,
|
|
||||||
effect_duration: float = 8.0,
|
|
||||||
mode_duration: float = 3.0,
|
|
||||||
enable_fps_switch: bool = False,
|
|
||||||
loop: bool = False,
|
|
||||||
verbose: bool = False,
|
|
||||||
):
|
|
||||||
self.use_terminal = use_terminal
|
|
||||||
self.target_fps = target_fps
|
|
||||||
self.effect_duration = effect_duration
|
|
||||||
self.mode_duration = mode_duration
|
|
||||||
self.enable_fps_switch = enable_fps_switch
|
|
||||||
self.loop = loop
|
|
||||||
self.verbose = verbose
|
|
||||||
self.frame_count = 0
|
|
||||||
self.pipeline = None
|
|
||||||
self.context = None
|
|
||||||
self.framebuffer = None
|
|
||||||
self.camera = None
|
|
||||||
self.timer = None
|
|
||||||
|
|
||||||
def log(self, message: str, verbose: bool = False):
|
|
||||||
"""Print with timestamp if verbose or always-important."""
|
|
||||||
if self.verbose or not verbose:
|
|
||||||
print(f"[{time.strftime('%H:%M:%S')}] {message}")
|
|
||||||
|
|
||||||
def build_base_pipeline(
|
|
||||||
self, camera_type: str = "scroll", camera_speed: float = 0.5
|
|
||||||
):
|
|
||||||
"""Build a base pipeline with all required components."""
|
|
||||||
self.log(f"Building base pipeline: camera={camera_type}, speed={camera_speed}")
|
|
||||||
|
|
||||||
# Camera
|
|
||||||
camera = Camera.scroll(speed=camera_speed)
|
|
||||||
camera.set_canvas_size(200, 200)
|
|
||||||
|
|
||||||
# Context
|
|
||||||
ctx = PipelineContext()
|
|
||||||
|
|
||||||
# Pipeline config
|
|
||||||
config = PipelineConfig(
|
|
||||||
source="empty",
|
|
||||||
display="terminal" if self.use_terminal else "null",
|
|
||||||
camera=camera_type,
|
|
||||||
effects=[],
|
|
||||||
enable_metrics=True,
|
|
||||||
)
|
|
||||||
pipeline = Pipeline(config=config, context=ctx)
|
|
||||||
|
|
||||||
# Use a large checkerboard pattern for visible motion effects
|
|
||||||
source = CheckerboardDataSource(width=200, height=200, square_size=10)
|
|
||||||
pipeline.add_stage("source", DataSourceStage(source, name="checkerboard"))
|
|
||||||
|
|
||||||
# Add camera clock (must run every frame)
|
|
||||||
pipeline.add_stage(
|
|
||||||
"camera_update", CameraClockStage(camera, name="camera-clock")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add render
|
|
||||||
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
|
||||||
|
|
||||||
# Add camera stage
|
|
||||||
pipeline.add_stage("camera", CameraStage(camera, name="camera"))
|
|
||||||
|
|
||||||
# Add framebuffer (optional for effects that use it)
|
|
||||||
self.framebuffer = FrameBufferStage(name="default", history_depth=5)
|
|
||||||
pipeline.add_stage("framebuffer", self.framebuffer)
|
|
||||||
|
|
||||||
# Add display
|
|
||||||
display_backend = "terminal" if self.use_terminal else "null"
|
|
||||||
display = DisplayRegistry.create(display_backend)
|
|
||||||
if display:
|
|
||||||
pipeline.add_stage("display", DisplayStage(display, name=display_backend))
|
|
||||||
|
|
||||||
# Build and initialize
|
|
||||||
pipeline.build(auto_inject=False)
|
|
||||||
pipeline.initialize()
|
|
||||||
|
|
||||||
self.pipeline = pipeline
|
|
||||||
self.context = ctx
|
|
||||||
self.camera = camera
|
|
||||||
|
|
||||||
self.log("Base pipeline built successfully")
|
|
||||||
return pipeline
|
|
||||||
|
|
||||||
def test_effects_oscillation(self):
|
|
||||||
"""Test each effect with gentle intensity oscillation."""
|
|
||||||
self.log("\n=== EFFECTS OSCILLATION TEST ===")
|
|
||||||
self.log(
|
|
||||||
f"Duration: {self.effect_duration}s per effect at {self.target_fps} FPS"
|
|
||||||
)
|
|
||||||
|
|
||||||
discover_plugins() # Ensure all plugins are registered
|
|
||||||
registry = get_registry()
|
|
||||||
all_effects = registry.list_all()
|
|
||||||
effect_names = [
|
|
||||||
name
|
|
||||||
for name in all_effects.keys()
|
|
||||||
if name not in ("motionblur", "afterimage")
|
|
||||||
]
|
|
||||||
|
|
||||||
# Calculate frames based on duration and FPS
|
|
||||||
frames_per_effect = int(self.effect_duration * self.target_fps)
|
|
||||||
oscillator = GentleOscillator(speed=90, amplitude=0.7, offset=0.3)
|
|
||||||
|
|
||||||
total_effects = len(effect_names) + 2 # +2 for motionblur and afterimage
|
|
||||||
estimated_total = total_effects * self.effect_duration
|
|
||||||
|
|
||||||
self.log(f"Testing {len(effect_names)} regular effects + 2 framebuffer effects")
|
|
||||||
self.log(f"Estimated time: {estimated_total:.0f}s")
|
|
||||||
|
|
||||||
for idx, effect_name in enumerate(sorted(effect_names), 1):
|
|
||||||
try:
|
|
||||||
self.log(f"[{idx}/{len(effect_names)}] Testing effect: {effect_name}")
|
|
||||||
|
|
||||||
effect = registry.get(effect_name)
|
|
||||||
if not effect:
|
|
||||||
self.log(f" Skipped: plugin not found")
|
|
||||||
continue
|
|
||||||
|
|
||||||
stage = EffectPluginStage(effect, name=effect_name)
|
|
||||||
self.pipeline.add_stage(f"effect_{effect_name}", stage)
|
|
||||||
self.pipeline.build(auto_inject=False)
|
|
||||||
|
|
||||||
self._run_frames(
|
|
||||||
frames_per_effect, oscillator=oscillator, effect=effect
|
|
||||||
)
|
|
||||||
|
|
||||||
self.pipeline.remove_stage(f"effect_{effect_name}")
|
|
||||||
self.pipeline.build(auto_inject=False)
|
|
||||||
|
|
||||||
self.log(f" ✓ {effect_name} completed successfully")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f" ✗ {effect_name} failed: {e}")
|
|
||||||
|
|
||||||
# Test motionblur and afterimage separately with framebuffer
|
|
||||||
for effect_name in ["motionblur", "afterimage"]:
|
|
||||||
try:
|
|
||||||
self.log(
|
|
||||||
f"[{len(effect_names) + 1}/{total_effects}] Testing effect: {effect_name} (with framebuffer)"
|
|
||||||
)
|
|
||||||
|
|
||||||
effect = registry.get(effect_name)
|
|
||||||
if not effect:
|
|
||||||
self.log(f" Skipped: plugin not found")
|
|
||||||
continue
|
|
||||||
|
|
||||||
stage = EffectPluginStage(
|
|
||||||
effect,
|
|
||||||
name=effect_name,
|
|
||||||
dependencies={"framebuffer.history.default"},
|
|
||||||
)
|
|
||||||
self.pipeline.add_stage(f"effect_{effect_name}", stage)
|
|
||||||
self.pipeline.build(auto_inject=False)
|
|
||||||
|
|
||||||
self._run_frames(
|
|
||||||
frames_per_effect, oscillator=oscillator, effect=effect
|
|
||||||
)
|
|
||||||
|
|
||||||
self.pipeline.remove_stage(f"effect_{effect_name}")
|
|
||||||
self.pipeline.build(auto_inject=False)
|
|
||||||
self.log(f" ✓ {effect_name} completed successfully")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f" ✗ {effect_name} failed: {e}")
|
|
||||||
|
|
||||||
def _run_frames(self, num_frames: int, oscillator=None, effect=None):
|
|
||||||
"""Run a specified number of frames with proper timing."""
|
|
||||||
for frame in range(num_frames):
|
|
||||||
self.frame_count += 1
|
|
||||||
self.context.set("frame_number", frame)
|
|
||||||
|
|
||||||
if oscillator and effect:
|
|
||||||
intensity = oscillator.value(frame)
|
|
||||||
effect.configure(EffectConfig(intensity=intensity))
|
|
||||||
|
|
||||||
dt = self.timer.sleep_until_next_frame()
|
|
||||||
self.camera.update(dt)
|
|
||||||
self.pipeline.execute([])
|
|
||||||
|
|
||||||
def test_framebuffer(self):
|
|
||||||
"""Test framebuffer functionality."""
|
|
||||||
self.log("\n=== FRAMEBUFFER TEST ===")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Run frames using FrameTimer for consistent pacing
|
|
||||||
self._run_frames(10)
|
|
||||||
|
|
||||||
# Check framebuffer history
|
|
||||||
history = self.context.get("framebuffer.default.history")
|
|
||||||
assert history is not None, "No framebuffer history found"
|
|
||||||
assert len(history) > 0, "Framebuffer history is empty"
|
|
||||||
|
|
||||||
self.log(f"History frames: {len(history)}")
|
|
||||||
self.log(f"Configured depth: {self.framebuffer.config.history_depth}")
|
|
||||||
|
|
||||||
# Check intensity computation
|
|
||||||
intensity = self.context.get("framebuffer.default.current_intensity")
|
|
||||||
assert intensity is not None, "No intensity map found"
|
|
||||||
self.log(f"Intensity map length: {len(intensity)}")
|
|
||||||
|
|
||||||
# Check that frames are being stored correctly
|
|
||||||
recent_frame = self.framebuffer.get_frame(0, self.context)
|
|
||||||
assert recent_frame is not None, "Cannot retrieve recent frame"
|
|
||||||
self.log(f"Recent frame rows: {len(recent_frame)}")
|
|
||||||
|
|
||||||
self.log("✓ Framebuffer test passed")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f"✗ Framebuffer test failed: {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
def test_camera_modes(self):
|
|
||||||
"""Test each camera mode."""
|
|
||||||
self.log("\n=== CAMERA MODES TEST ===")
|
|
||||||
self.log(f"Duration: {self.mode_duration}s per mode at {self.target_fps} FPS")
|
|
||||||
|
|
||||||
camera_modes = [
|
|
||||||
("feed", 0.1),
|
|
||||||
("scroll", 0.5),
|
|
||||||
("horizontal", 0.3),
|
|
||||||
("omni", 0.3),
|
|
||||||
("floating", 0.5),
|
|
||||||
("bounce", 0.5),
|
|
||||||
("radial", 0.3),
|
|
||||||
]
|
|
||||||
|
|
||||||
frames_per_mode = int(self.mode_duration * self.target_fps)
|
|
||||||
self.log(f"Testing {len(camera_modes)} camera modes")
|
|
||||||
self.log(f"Estimated time: {len(camera_modes) * self.mode_duration:.0f}s")
|
|
||||||
|
|
||||||
for idx, (camera_type, speed) in enumerate(camera_modes, 1):
|
|
||||||
try:
|
|
||||||
self.log(f"[{idx}/{len(camera_modes)}] Testing camera: {camera_type}")
|
|
||||||
|
|
||||||
# Rebuild camera
|
|
||||||
self.camera.reset()
|
|
||||||
cam_class = getattr(Camera, camera_type, Camera.scroll)
|
|
||||||
new_camera = cam_class(speed=speed)
|
|
||||||
new_camera.set_canvas_size(200, 200)
|
|
||||||
|
|
||||||
# Update camera stages
|
|
||||||
clock_stage = CameraClockStage(new_camera, name="camera-clock")
|
|
||||||
self.pipeline.replace_stage("camera_update", clock_stage)
|
|
||||||
|
|
||||||
camera_stage = CameraStage(new_camera, name="camera")
|
|
||||||
self.pipeline.replace_stage("camera", camera_stage)
|
|
||||||
|
|
||||||
self.camera = new_camera
|
|
||||||
|
|
||||||
# Run frames with proper timing
|
|
||||||
self._run_frames(frames_per_mode)
|
|
||||||
|
|
||||||
# Verify camera moved (check final position)
|
|
||||||
x, y = self.camera.x, self.camera.y
|
|
||||||
self.log(f" Final position: ({x:.1f}, {y:.1f})")
|
|
||||||
|
|
||||||
if camera_type == "feed":
|
|
||||||
assert x == 0 and y == 0, "Feed camera should not move"
|
|
||||||
elif camera_type in ("scroll", "horizontal"):
|
|
||||||
assert abs(x) > 0 or abs(y) > 0, "Camera should have moved"
|
|
||||||
else:
|
|
||||||
self.log(f" Position check skipped (mode={camera_type})")
|
|
||||||
|
|
||||||
self.log(f" ✓ {camera_type} completed successfully")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f" ✗ {camera_type} failed: {e}")
|
|
||||||
|
|
||||||
def test_fps_switch_demo(self):
|
|
||||||
"""Demonstrate the effect of different frame rates on animation smoothness."""
|
|
||||||
if not self.enable_fps_switch:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.log("\n=== FPS SWITCH DEMONSTRATION ===")
|
|
||||||
|
|
||||||
fps_sequence = [
|
|
||||||
(30.0, 5.0), # 30 FPS for 5 seconds
|
|
||||||
(60.0, 5.0), # 60 FPS for 5 seconds
|
|
||||||
(30.0, 5.0), # Back to 30 FPS for 5 seconds
|
|
||||||
(20.0, 3.0), # 20 FPS for 3 seconds
|
|
||||||
(60.0, 3.0), # 60 FPS for 3 seconds
|
|
||||||
]
|
|
||||||
|
|
||||||
original_fps = self.target_fps
|
|
||||||
|
|
||||||
for fps, duration in fps_sequence:
|
|
||||||
self.log(f"\n--- Switching to {fps} FPS for {duration}s ---")
|
|
||||||
self.target_fps = fps
|
|
||||||
self.timer.target_frame_dt = 1.0 / fps
|
|
||||||
|
|
||||||
# Update display FPS if supported
|
|
||||||
display = (
|
|
||||||
self.pipeline.get_stage("display").stage
|
|
||||||
if self.pipeline.get_stage("display")
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
if display and hasattr(display, "target_fps"):
|
|
||||||
display.target_fps = fps
|
|
||||||
display._frame_period = 1.0 / fps if fps > 0 else 0
|
|
||||||
|
|
||||||
frames = int(duration * fps)
|
|
||||||
camera_type = "radial" # Use radial for smooth rotation that's visible at different FPS
|
|
||||||
speed = 0.3
|
|
||||||
|
|
||||||
# Rebuild camera if needed
|
|
||||||
self.camera.reset()
|
|
||||||
new_camera = Camera.radial(speed=speed)
|
|
||||||
new_camera.set_canvas_size(200, 200)
|
|
||||||
clock_stage = CameraClockStage(new_camera, name="camera-clock")
|
|
||||||
self.pipeline.replace_stage("camera_update", clock_stage)
|
|
||||||
camera_stage = CameraStage(new_camera, name="camera")
|
|
||||||
self.pipeline.replace_stage("camera", camera_stage)
|
|
||||||
self.camera = new_camera
|
|
||||||
|
|
||||||
for frame in range(frames):
|
|
||||||
self.context.set("frame_number", frame)
|
|
||||||
dt = self.timer.sleep_until_next_frame()
|
|
||||||
self.camera.update(dt)
|
|
||||||
result = self.pipeline.execute([])
|
|
||||||
|
|
||||||
self.log(f" Completed {frames} frames at {fps} FPS")
|
|
||||||
|
|
||||||
# Restore original FPS
|
|
||||||
self.target_fps = original_fps
|
|
||||||
self.timer.target_frame_dt = 1.0 / original_fps
|
|
||||||
self.log("✓ FPS switch demo completed")
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
"""Run the complete demo."""
|
|
||||||
start_time = time.time()
|
|
||||||
self.log("Starting Pipeline Demo Orchestrator")
|
|
||||||
self.log("=" * 50)
|
|
||||||
|
|
||||||
# Initialize frame timer
|
|
||||||
self.timer = FrameTimer(target_frame_dt=1.0 / self.target_fps)
|
|
||||||
|
|
||||||
# Build pipeline
|
|
||||||
self.build_base_pipeline()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Test framebuffer first (needed for motion blur effects)
|
|
||||||
self.test_framebuffer()
|
|
||||||
|
|
||||||
# Test effects
|
|
||||||
self.test_effects_oscillation()
|
|
||||||
|
|
||||||
# Test camera modes
|
|
||||||
self.test_camera_modes()
|
|
||||||
|
|
||||||
# Optional FPS switch demonstration
|
|
||||||
if self.enable_fps_switch:
|
|
||||||
self.test_fps_switch_demo()
|
|
||||||
else:
|
|
||||||
self.log("\n=== FPS SWITCH DEMO ===")
|
|
||||||
self.log("Skipped (enable with --switch-fps)")
|
|
||||||
|
|
||||||
elapsed = time.time() - start_time
|
|
||||||
self.log("\n" + "=" * 50)
|
|
||||||
self.log("Demo completed successfully!")
|
|
||||||
self.log(f"Total frames processed: {self.frame_count}")
|
|
||||||
self.log(f"Total elapsed time: {elapsed:.1f}s")
|
|
||||||
self.log(f"Average FPS: {self.frame_count / elapsed:.1f}")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Always cleanup properly
|
|
||||||
self._cleanup()
|
|
||||||
|
|
||||||
def _cleanup(self):
|
|
||||||
"""Clean up pipeline resources."""
|
|
||||||
self.log("Cleaning up...", verbose=True)
|
|
||||||
if self.pipeline:
|
|
||||||
try:
|
|
||||||
self.pipeline.cleanup()
|
|
||||||
if self.verbose:
|
|
||||||
self.log("Pipeline cleaned up successfully", verbose=True)
|
|
||||||
except Exception as e:
|
|
||||||
self.log(f"Error during pipeline cleanup: {e}", verbose=True)
|
|
||||||
|
|
||||||
# If not looping, clear references
|
|
||||||
if not self.loop:
|
|
||||||
self.pipeline = None
|
|
||||||
self.context = None
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Pipeline Demo Orchestrator - comprehensive demo of Mainline pipeline"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--null",
|
|
||||||
action="store_true",
|
|
||||||
help="Use null display (no visual output)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--fps",
|
|
||||||
type=float,
|
|
||||||
default=30.0,
|
|
||||||
help="Target frame rate (default: 30)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--effect-duration",
|
|
||||||
type=float,
|
|
||||||
default=8.0,
|
|
||||||
help="Duration per effect in seconds (default: 8)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--mode-duration",
|
|
||||||
type=float,
|
|
||||||
default=3.0,
|
|
||||||
help="Duration per camera mode in seconds (default: 3)",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--switch-fps",
|
|
||||||
action="store_true",
|
|
||||||
help="Include FPS switching demonstration",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--loop",
|
|
||||||
action="store_true",
|
|
||||||
help="Run demo in an infinite loop",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--verbose",
|
|
||||||
action="store_true",
|
|
||||||
help="Enable verbose output",
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
orchestrator = PipelineDemoOrchestrator(
|
|
||||||
use_terminal=not args.null,
|
|
||||||
target_fps=args.fps,
|
|
||||||
effect_duration=args.effect_duration,
|
|
||||||
mode_duration=args.mode_duration,
|
|
||||||
enable_fps_switch=args.switch_fps,
|
|
||||||
loop=args.loop,
|
|
||||||
verbose=args.verbose,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
orchestrator.run()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\nInterrupted by user")
|
|
||||||
sys.exit(0)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"\nDemo failed: {e}")
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
traceback.print_exc()
|
|
||||||
sys.exit(1)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""
|
|
||||||
Simple test for UIPanel integration.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from engine.pipeline.ui import UIPanel, UIConfig, StageControl
|
|
||||||
|
|
||||||
# Create panel
|
|
||||||
panel = UIPanel(UIConfig(panel_width=24))
|
|
||||||
|
|
||||||
# Add some mock stages
|
|
||||||
panel.register_stage(
|
|
||||||
type(
|
|
||||||
"Stage", (), {"name": "noise", "category": "effect", "is_enabled": lambda: True}
|
|
||||||
),
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
panel.register_stage(
|
|
||||||
type(
|
|
||||||
"Stage", (), {"name": "fade", "category": "effect", "is_enabled": lambda: True}
|
|
||||||
),
|
|
||||||
enabled=False,
|
|
||||||
)
|
|
||||||
panel.register_stage(
|
|
||||||
type(
|
|
||||||
"Stage",
|
|
||||||
(),
|
|
||||||
{"name": "glitch", "category": "effect", "is_enabled": lambda: True},
|
|
||||||
),
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
panel.register_stage(
|
|
||||||
type(
|
|
||||||
"Stage",
|
|
||||||
(),
|
|
||||||
{"name": "font", "category": "transform", "is_enabled": lambda: True},
|
|
||||||
),
|
|
||||||
enabled=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Select first stage
|
|
||||||
panel.select_stage("noise")
|
|
||||||
|
|
||||||
# Render at 80x24
|
|
||||||
lines = panel.render(80, 24)
|
|
||||||
print("\n".join(lines))
|
|
||||||
|
|
||||||
print("\nStage list:")
|
|
||||||
for name, ctrl in panel.stages.items():
|
|
||||||
print(f" {name}: enabled={ctrl.enabled}, selected={ctrl.selected}")
|
|
||||||
|
|
||||||
print("\nToggle 'fade' and re-render:")
|
|
||||||
panel.toggle_stage("fade")
|
|
||||||
lines = panel.render(80, 24)
|
|
||||||
print("\n".join(lines))
|
|
||||||
|
|
||||||
print("\nEnabled stages:", panel.get_enabled_stages())
|
|
||||||
@@ -1,473 +0,0 @@
|
|||||||
"""
|
|
||||||
HTML Acceptance Test Report Generator
|
|
||||||
|
|
||||||
Generates HTML reports showing frame buffers from acceptance tests.
|
|
||||||
Uses NullDisplay to capture frames and renders them with monospace font.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import html
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
ANSI_256_TO_RGB = {
|
|
||||||
0: (0, 0, 0),
|
|
||||||
1: (128, 0, 0),
|
|
||||||
2: (0, 128, 0),
|
|
||||||
3: (128, 128, 0),
|
|
||||||
4: (0, 0, 128),
|
|
||||||
5: (128, 0, 128),
|
|
||||||
6: (0, 128, 128),
|
|
||||||
7: (192, 192, 192),
|
|
||||||
8: (128, 128, 128),
|
|
||||||
9: (255, 0, 0),
|
|
||||||
10: (0, 255, 0),
|
|
||||||
11: (255, 255, 0),
|
|
||||||
12: (0, 0, 255),
|
|
||||||
13: (255, 0, 255),
|
|
||||||
14: (0, 255, 255),
|
|
||||||
15: (255, 255, 255),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def ansi_to_rgb(color_code: int) -> tuple[int, int, int]:
|
|
||||||
"""Convert ANSI 256-color code to RGB tuple."""
|
|
||||||
if 0 <= color_code <= 15:
|
|
||||||
return ANSI_256_TO_RGB.get(color_code, (255, 255, 255))
|
|
||||||
elif 16 <= color_code <= 231:
|
|
||||||
color_code -= 16
|
|
||||||
r = (color_code // 36) * 51
|
|
||||||
g = ((color_code % 36) // 6) * 51
|
|
||||||
b = (color_code % 6) * 51
|
|
||||||
return (r, g, b)
|
|
||||||
elif 232 <= color_code <= 255:
|
|
||||||
gray = (color_code - 232) * 10 + 8
|
|
||||||
return (gray, gray, gray)
|
|
||||||
return (255, 255, 255)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_ansi_line(line: str) -> list[dict[str, Any]]:
|
|
||||||
"""Parse a single line with ANSI escape codes into styled segments.
|
|
||||||
|
|
||||||
Returns list of dicts with 'text', 'fg', 'bg', 'bold' keys.
|
|
||||||
"""
|
|
||||||
import re
|
|
||||||
|
|
||||||
segments = []
|
|
||||||
current_fg = None
|
|
||||||
current_bg = None
|
|
||||||
current_bold = False
|
|
||||||
pos = 0
|
|
||||||
|
|
||||||
# Find all ANSI escape sequences
|
|
||||||
escape_pattern = re.compile(r"\x1b\[([0-9;]*)m")
|
|
||||||
|
|
||||||
while pos < len(line):
|
|
||||||
match = escape_pattern.search(line, pos)
|
|
||||||
if not match:
|
|
||||||
# Remaining text with current styling
|
|
||||||
if pos < len(line):
|
|
||||||
text = line[pos:]
|
|
||||||
if text:
|
|
||||||
segments.append(
|
|
||||||
{
|
|
||||||
"text": text,
|
|
||||||
"fg": current_fg,
|
|
||||||
"bg": current_bg,
|
|
||||||
"bold": current_bold,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
# Add text before escape sequence
|
|
||||||
if match.start() > pos:
|
|
||||||
text = line[pos : match.start()]
|
|
||||||
if text:
|
|
||||||
segments.append(
|
|
||||||
{
|
|
||||||
"text": text,
|
|
||||||
"fg": current_fg,
|
|
||||||
"bg": current_bg,
|
|
||||||
"bold": current_bold,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse escape sequence
|
|
||||||
codes = match.group(1).split(";") if match.group(1) else ["0"]
|
|
||||||
for code in codes:
|
|
||||||
code = code.strip()
|
|
||||||
if not code or code == "0":
|
|
||||||
current_fg = None
|
|
||||||
current_bg = None
|
|
||||||
current_bold = False
|
|
||||||
elif code == "1":
|
|
||||||
current_bold = True
|
|
||||||
elif code.isdigit():
|
|
||||||
code_int = int(code)
|
|
||||||
if 30 <= code_int <= 37:
|
|
||||||
current_fg = ansi_to_rgb(code_int - 30 + 8)
|
|
||||||
elif 90 <= code_int <= 97:
|
|
||||||
current_fg = ansi_to_rgb(code_int - 90)
|
|
||||||
elif code_int == 38:
|
|
||||||
current_fg = (255, 255, 255)
|
|
||||||
elif code_int == 39:
|
|
||||||
current_fg = None
|
|
||||||
|
|
||||||
pos = match.end()
|
|
||||||
|
|
||||||
return segments
|
|
||||||
|
|
||||||
|
|
||||||
def render_line_to_html(line: str) -> str:
|
|
||||||
"""Render a single terminal line to HTML with styling."""
|
|
||||||
import re
|
|
||||||
|
|
||||||
result = ""
|
|
||||||
pos = 0
|
|
||||||
current_fg = None
|
|
||||||
current_bg = None
|
|
||||||
current_bold = False
|
|
||||||
|
|
||||||
escape_pattern = re.compile(r"(\x1b\[[0-9;]*m)|(\x1b\[([0-9]+);([0-9]+)H)")
|
|
||||||
|
|
||||||
while pos < len(line):
|
|
||||||
match = escape_pattern.search(line, pos)
|
|
||||||
if not match:
|
|
||||||
# Remaining text
|
|
||||||
if pos < len(line):
|
|
||||||
text = html.escape(line[pos:])
|
|
||||||
if text:
|
|
||||||
style = _build_style(current_fg, current_bg, current_bold)
|
|
||||||
result += f"<span{style}>{text}</span>"
|
|
||||||
break
|
|
||||||
|
|
||||||
# Handle cursor positioning - just skip it for rendering
|
|
||||||
if match.group(2): # Cursor positioning \x1b[row;colH
|
|
||||||
pos = match.end()
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Handle style codes
|
|
||||||
if match.group(1):
|
|
||||||
codes = match.group(1)[2:-1].split(";") if match.group(1) else ["0"]
|
|
||||||
for code in codes:
|
|
||||||
code = code.strip()
|
|
||||||
if not code or code == "0":
|
|
||||||
current_fg = None
|
|
||||||
current_bg = None
|
|
||||||
current_bold = False
|
|
||||||
elif code == "1":
|
|
||||||
current_bold = True
|
|
||||||
elif code.isdigit():
|
|
||||||
code_int = int(code)
|
|
||||||
if 30 <= code_int <= 37:
|
|
||||||
current_fg = ansi_to_rgb(code_int - 30 + 8)
|
|
||||||
elif 90 <= code_int <= 97:
|
|
||||||
current_fg = ansi_to_rgb(code_int - 90)
|
|
||||||
|
|
||||||
pos = match.end()
|
|
||||||
continue
|
|
||||||
|
|
||||||
pos = match.end()
|
|
||||||
|
|
||||||
# Handle remaining text without escape codes
|
|
||||||
if pos < len(line):
|
|
||||||
text = html.escape(line[pos:])
|
|
||||||
if text:
|
|
||||||
style = _build_style(current_fg, current_bg, current_bold)
|
|
||||||
result += f"<span{style}>{text}</span>"
|
|
||||||
|
|
||||||
return result or html.escape(line)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_style(
|
|
||||||
fg: tuple[int, int, int] | None, bg: tuple[int, int, int] | None, bold: bool
|
|
||||||
) -> str:
|
|
||||||
"""Build CSS style string from color values."""
|
|
||||||
styles = []
|
|
||||||
if fg:
|
|
||||||
styles.append(f"color: rgb({fg[0]},{fg[1]},{fg[2]})")
|
|
||||||
if bg:
|
|
||||||
styles.append(f"background-color: rgb({bg[0]},{bg[1]},{bg[2]})")
|
|
||||||
if bold:
|
|
||||||
styles.append("font-weight: bold")
|
|
||||||
if not styles:
|
|
||||||
return ""
|
|
||||||
return f' style="{"; ".join(styles)}"'
|
|
||||||
|
|
||||||
|
|
||||||
def render_frame_to_html(frame: list[str], frame_number: int = 0) -> str:
|
|
||||||
"""Render a complete frame (list of lines) to HTML."""
|
|
||||||
html_lines = []
|
|
||||||
for i, line in enumerate(frame):
|
|
||||||
# Strip ANSI cursor positioning but preserve colors
|
|
||||||
clean_line = (
|
|
||||||
line.replace("\x1b[1;1H", "")
|
|
||||||
.replace("\x1b[2;1H", "")
|
|
||||||
.replace("\x1b[3;1H", "")
|
|
||||||
)
|
|
||||||
rendered = render_line_to_html(clean_line)
|
|
||||||
html_lines.append(f'<div class="frame-line" data-line="{i}">{rendered}</div>')
|
|
||||||
|
|
||||||
return f"""<div class="frame" id="frame-{frame_number}">
|
|
||||||
<div class="frame-header">Frame {frame_number} ({len(frame)} lines)</div>
|
|
||||||
<div class="frame-content">
|
|
||||||
{"".join(html_lines)}
|
|
||||||
</div>
|
|
||||||
</div>"""
|
|
||||||
|
|
||||||
|
|
||||||
def generate_test_report(
|
|
||||||
test_name: str,
|
|
||||||
frames: list[list[str]],
|
|
||||||
status: str = "PASS",
|
|
||||||
duration_ms: float = 0.0,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Generate HTML report for a single test."""
|
|
||||||
frames_html = ""
|
|
||||||
for i, frame in enumerate(frames):
|
|
||||||
frames_html += render_frame_to_html(frame, i)
|
|
||||||
|
|
||||||
metadata_html = ""
|
|
||||||
if metadata:
|
|
||||||
metadata_html = '<div class="metadata">'
|
|
||||||
for key, value in metadata.items():
|
|
||||||
metadata_html += f'<div class="meta-row"><span class="meta-key">{key}:</span> <span class="meta-value">{value}</span></div>'
|
|
||||||
metadata_html += "</div>"
|
|
||||||
|
|
||||||
status_class = "pass" if status == "PASS" else "fail"
|
|
||||||
|
|
||||||
return f"""<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>{test_name} - Acceptance Test Report</title>
|
|
||||||
<style>
|
|
||||||
body {{
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
background: #1a1a2e;
|
|
||||||
color: #eee;
|
|
||||||
margin: 0;
|
|
||||||
padding: 20px;
|
|
||||||
}}
|
|
||||||
.test-report {{
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}}
|
|
||||||
.test-header {{
|
|
||||||
background: #16213e;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}}
|
|
||||||
.test-name {{
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #fff;
|
|
||||||
}}
|
|
||||||
.status {{
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: bold;
|
|
||||||
}}
|
|
||||||
.status.pass {{
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
}}
|
|
||||||
.status.fail {{
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}}
|
|
||||||
.frame {{
|
|
||||||
background: #0f0f1a;
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
overflow: hidden;
|
|
||||||
}}
|
|
||||||
.frame-header {{
|
|
||||||
background: #16213e;
|
|
||||||
padding: 10px 15px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #888;
|
|
||||||
border-bottom: 1px solid #333;
|
|
||||||
}}
|
|
||||||
.frame-content {{
|
|
||||||
padding: 15px;
|
|
||||||
font-family: 'Fira Code', 'Consolas', 'Monaco', monospace;
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.4;
|
|
||||||
white-space: pre;
|
|
||||||
overflow-x: auto;
|
|
||||||
}}
|
|
||||||
.frame-line {{
|
|
||||||
min-height: 1.4em;
|
|
||||||
}}
|
|
||||||
.metadata {{
|
|
||||||
background: #16213e;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}}
|
|
||||||
.meta-row {{
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
}}
|
|
||||||
.meta-key {{
|
|
||||||
color: #888;
|
|
||||||
}}
|
|
||||||
.meta-value {{
|
|
||||||
color: #fff;
|
|
||||||
}}
|
|
||||||
.footer {{
|
|
||||||
text-align: center;
|
|
||||||
color: #666;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 40px;
|
|
||||||
}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="test-report">
|
|
||||||
<div class="test-header">
|
|
||||||
<div class="test-name">{test_name}</div>
|
|
||||||
<div class="status {status_class}">{status}</div>
|
|
||||||
</div>
|
|
||||||
{metadata_html}
|
|
||||||
{frames_html}
|
|
||||||
<div class="footer">
|
|
||||||
Generated: {datetime.now().isoformat()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
|
|
||||||
def save_report(
|
|
||||||
test_name: str,
|
|
||||||
frames: list[list[str]],
|
|
||||||
output_dir: str = "test-reports",
|
|
||||||
status: str = "PASS",
|
|
||||||
duration_ms: float = 0.0,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Save HTML report to disk and return the file path."""
|
|
||||||
output_path = Path(output_dir)
|
|
||||||
output_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Sanitize test name for filename
|
|
||||||
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in test_name)
|
|
||||||
filename = f"{safe_name}.html"
|
|
||||||
filepath = output_path / filename
|
|
||||||
|
|
||||||
html_content = generate_test_report(
|
|
||||||
test_name, frames, status, duration_ms, metadata
|
|
||||||
)
|
|
||||||
filepath.write_text(html_content)
|
|
||||||
|
|
||||||
return str(filepath)
|
|
||||||
|
|
||||||
|
|
||||||
def save_index_report(
|
|
||||||
reports: list[dict[str, Any]],
|
|
||||||
output_dir: str = "test-reports",
|
|
||||||
) -> str:
|
|
||||||
"""Generate an index HTML page linking to all test reports."""
|
|
||||||
output_path = Path(output_dir)
|
|
||||||
output_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
rows = ""
|
|
||||||
for report in reports:
|
|
||||||
safe_name = "".join(
|
|
||||||
c if c.isalnum() or c in "-_" else "_" for c in report["test_name"]
|
|
||||||
)
|
|
||||||
filename = f"{safe_name}.html"
|
|
||||||
status_class = "pass" if report["status"] == "PASS" else "fail"
|
|
||||||
rows += f"""
|
|
||||||
<tr>
|
|
||||||
<td><a href="{filename}">{report["test_name"]}</a></td>
|
|
||||||
<td class="status {status_class}">{report["status"]}</td>
|
|
||||||
<td>{report.get("duration_ms", 0):.1f}ms</td>
|
|
||||||
<td>{report.get("frame_count", 0)}</td>
|
|
||||||
</tr>
|
|
||||||
"""
|
|
||||||
|
|
||||||
html = f"""<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Acceptance Test Reports</title>
|
|
||||||
<style>
|
|
||||||
body {{
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
background: #1a1a2e;
|
|
||||||
color: #eee;
|
|
||||||
margin: 0;
|
|
||||||
padding: 40px;
|
|
||||||
}}
|
|
||||||
h1 {{
|
|
||||||
color: #fff;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}}
|
|
||||||
table {{
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}}
|
|
||||||
th, td {{
|
|
||||||
padding: 12px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid #333;
|
|
||||||
}}
|
|
||||||
th {{
|
|
||||||
background: #16213e;
|
|
||||||
color: #888;
|
|
||||||
font-weight: normal;
|
|
||||||
}}
|
|
||||||
a {{
|
|
||||||
color: #4dabf7;
|
|
||||||
text-decoration: none;
|
|
||||||
}}
|
|
||||||
a:hover {{
|
|
||||||
text-decoration: underline;
|
|
||||||
}}
|
|
||||||
.status {{
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: bold;
|
|
||||||
}}
|
|
||||||
.status.pass {{
|
|
||||||
background: #28a745;
|
|
||||||
color: white;
|
|
||||||
}}
|
|
||||||
.status.fail {{
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Acceptance Test Reports</h1>
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Test</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Duration</th>
|
|
||||||
<th>Frames</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>"""
|
|
||||||
|
|
||||||
index_path = output_path / "index.html"
|
|
||||||
index_path.write_text(html)
|
|
||||||
return str(index_path)
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
"""
|
|
||||||
Acceptance tests for HUD visibility and positioning.
|
|
||||||
|
|
||||||
These tests verify that HUD appears in the final output frame.
|
|
||||||
Frames are captured and saved as HTML reports for visual verification.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from engine.data_sources.sources import ListDataSource, SourceItem
|
|
||||||
from engine.effects.plugins.hud import HudEffect
|
|
||||||
from engine.pipeline import Pipeline, PipelineConfig
|
|
||||||
from engine.pipeline.adapters import (
|
|
||||||
DataSourceStage,
|
|
||||||
DisplayStage,
|
|
||||||
EffectPluginStage,
|
|
||||||
SourceItemsToBufferStage,
|
|
||||||
)
|
|
||||||
from engine.pipeline.core import PipelineContext
|
|
||||||
from engine.pipeline.params import PipelineParams
|
|
||||||
from tests.acceptance_report import save_report
|
|
||||||
|
|
||||||
|
|
||||||
class FrameCaptureDisplay:
|
|
||||||
"""Display that captures frames for HTML report generation."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.frames: queue.Queue[list[str]] = queue.Queue()
|
|
||||||
self.width = 80
|
|
||||||
self.height = 24
|
|
||||||
self._recorded_frames: list[list[str]] = []
|
|
||||||
|
|
||||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
|
|
||||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
|
||||||
self._recorded_frames.append(list(buffer))
|
|
||||||
self.frames.put(list(buffer))
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_dimensions(self) -> tuple[int, int]:
|
|
||||||
return (self.width, self.height)
|
|
||||||
|
|
||||||
def get_recorded_frames(self) -> list[list[str]]:
|
|
||||||
return self._recorded_frames
|
|
||||||
|
|
||||||
|
|
||||||
def _build_pipeline_with_hud(
|
|
||||||
items: list[SourceItem],
|
|
||||||
) -> tuple[Pipeline, FrameCaptureDisplay, PipelineContext]:
|
|
||||||
"""Build a pipeline with HUD effect."""
|
|
||||||
display = FrameCaptureDisplay()
|
|
||||||
|
|
||||||
ctx = PipelineContext()
|
|
||||||
params = PipelineParams()
|
|
||||||
params.viewport_width = display.width
|
|
||||||
params.viewport_height = display.height
|
|
||||||
params.frame_number = 0
|
|
||||||
params.effect_order = ["noise", "hud"]
|
|
||||||
params.effect_enabled = {"noise": False}
|
|
||||||
ctx.params = params
|
|
||||||
|
|
||||||
pipeline = Pipeline(
|
|
||||||
config=PipelineConfig(
|
|
||||||
source="list",
|
|
||||||
display="terminal",
|
|
||||||
effects=["hud"],
|
|
||||||
enable_metrics=True,
|
|
||||||
),
|
|
||||||
context=ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
source = ListDataSource(items, name="test-source")
|
|
||||||
pipeline.add_stage("source", DataSourceStage(source, name="test-source"))
|
|
||||||
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
|
||||||
|
|
||||||
hud_effect = HudEffect()
|
|
||||||
pipeline.add_stage("hud", EffectPluginStage(hud_effect, name="hud"))
|
|
||||||
|
|
||||||
pipeline.add_stage("display", DisplayStage(display, name="terminal"))
|
|
||||||
|
|
||||||
pipeline.build()
|
|
||||||
pipeline.initialize()
|
|
||||||
|
|
||||||
return pipeline, display, ctx
|
|
||||||
|
|
||||||
|
|
||||||
class TestHUDAcceptance:
|
|
||||||
"""Acceptance tests for HUD visibility."""
|
|
||||||
|
|
||||||
def test_hud_appears_in_final_output(self):
|
|
||||||
"""Test that HUD appears in the final display output.
|
|
||||||
|
|
||||||
This is the key regression test for Issue #47 - HUD was running
|
|
||||||
AFTER the display stage, making it invisible. Now it should appear
|
|
||||||
in the frame captured by the display.
|
|
||||||
"""
|
|
||||||
items = [SourceItem(content="Test content line", source="test", timestamp="0")]
|
|
||||||
pipeline, display, ctx = _build_pipeline_with_hud(items)
|
|
||||||
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
assert result.success, f"Pipeline execution failed: {result.error}"
|
|
||||||
|
|
||||||
frame = display.frames.get(timeout=1)
|
|
||||||
frame_text = "\n".join(frame)
|
|
||||||
|
|
||||||
assert "MAINLINE" in frame_text, "HUD header not found in final output"
|
|
||||||
assert "EFFECT:" in frame_text, "EFFECT line not found in final output"
|
|
||||||
assert "PIPELINE:" in frame_text, "PIPELINE line not found in final output"
|
|
||||||
|
|
||||||
save_report(
|
|
||||||
test_name="test_hud_appears_in_final_output",
|
|
||||||
frames=display.get_recorded_frames(),
|
|
||||||
status="PASS",
|
|
||||||
metadata={
|
|
||||||
"description": "Verifies HUD appears in final display output (Issue #47 fix)",
|
|
||||||
"frame_lines": len(frame),
|
|
||||||
"has_mainline": "MAINLINE" in frame_text,
|
|
||||||
"has_effect": "EFFECT:" in frame_text,
|
|
||||||
"has_pipeline": "PIPELINE:" in frame_text,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_hud_cursor_positioning(self):
|
|
||||||
"""Test that HUD uses correct cursor positioning."""
|
|
||||||
items = [SourceItem(content="Sample content", source="test", timestamp="0")]
|
|
||||||
pipeline, display, ctx = _build_pipeline_with_hud(items)
|
|
||||||
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
assert result.success
|
|
||||||
|
|
||||||
frame = display.frames.get(timeout=1)
|
|
||||||
has_cursor_pos = any("\x1b[" in line and "H" in line for line in frame)
|
|
||||||
|
|
||||||
save_report(
|
|
||||||
test_name="test_hud_cursor_positioning",
|
|
||||||
frames=display.get_recorded_frames(),
|
|
||||||
status="PASS",
|
|
||||||
metadata={
|
|
||||||
"description": "Verifies HUD uses cursor positioning",
|
|
||||||
"has_cursor_positioning": has_cursor_pos,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCameraSpeedAcceptance:
|
|
||||||
"""Acceptance tests for camera speed modulation."""
|
|
||||||
|
|
||||||
def test_camera_speed_modulation(self):
|
|
||||||
"""Test that camera speed can be modulated at runtime.
|
|
||||||
|
|
||||||
This verifies the camera speed modulation feature added in Phase 1.
|
|
||||||
"""
|
|
||||||
from engine.camera import Camera
|
|
||||||
from engine.pipeline.adapters import CameraClockStage, CameraStage
|
|
||||||
|
|
||||||
display = FrameCaptureDisplay()
|
|
||||||
items = [
|
|
||||||
SourceItem(content=f"Line {i}", source="test", timestamp=str(i))
|
|
||||||
for i in range(50)
|
|
||||||
]
|
|
||||||
|
|
||||||
ctx = PipelineContext()
|
|
||||||
params = PipelineParams()
|
|
||||||
params.viewport_width = display.width
|
|
||||||
params.viewport_height = display.height
|
|
||||||
params.frame_number = 0
|
|
||||||
params.camera_speed = 1.0
|
|
||||||
ctx.params = params
|
|
||||||
|
|
||||||
pipeline = Pipeline(
|
|
||||||
config=PipelineConfig(
|
|
||||||
source="list",
|
|
||||||
display="terminal",
|
|
||||||
camera="scroll",
|
|
||||||
enable_metrics=False,
|
|
||||||
),
|
|
||||||
context=ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
source = ListDataSource(items, name="test")
|
|
||||||
pipeline.add_stage("source", DataSourceStage(source, name="test"))
|
|
||||||
pipeline.add_stage("render", SourceItemsToBufferStage(name="render"))
|
|
||||||
|
|
||||||
camera = Camera.scroll(speed=0.5)
|
|
||||||
pipeline.add_stage(
|
|
||||||
"camera_update", CameraClockStage(camera, name="camera-clock")
|
|
||||||
)
|
|
||||||
pipeline.add_stage("camera", CameraStage(camera, name="camera"))
|
|
||||||
pipeline.add_stage("display", DisplayStage(display, name="terminal"))
|
|
||||||
|
|
||||||
pipeline.build()
|
|
||||||
pipeline.initialize()
|
|
||||||
|
|
||||||
initial_camera_speed = camera.speed
|
|
||||||
|
|
||||||
for _ in range(3):
|
|
||||||
pipeline.execute(items)
|
|
||||||
|
|
||||||
speed_after_first_run = camera.speed
|
|
||||||
|
|
||||||
params.camera_speed = 5.0
|
|
||||||
ctx.params = params
|
|
||||||
|
|
||||||
for _ in range(3):
|
|
||||||
pipeline.execute(items)
|
|
||||||
|
|
||||||
speed_after_increase = camera.speed
|
|
||||||
|
|
||||||
assert speed_after_increase == 5.0, (
|
|
||||||
f"Camera speed should be modulated to 5.0, got {speed_after_increase}"
|
|
||||||
)
|
|
||||||
|
|
||||||
params.camera_speed = 0.0
|
|
||||||
ctx.params = params
|
|
||||||
|
|
||||||
for _ in range(3):
|
|
||||||
pipeline.execute(items)
|
|
||||||
|
|
||||||
speed_after_stop = camera.speed
|
|
||||||
assert speed_after_stop == 0.0, (
|
|
||||||
f"Camera speed should be 0.0, got {speed_after_stop}"
|
|
||||||
)
|
|
||||||
|
|
||||||
save_report(
|
|
||||||
test_name="test_camera_speed_modulation",
|
|
||||||
frames=display.get_recorded_frames()[:5],
|
|
||||||
status="PASS",
|
|
||||||
metadata={
|
|
||||||
"description": "Verifies camera speed can be modulated at runtime",
|
|
||||||
"initial_camera_speed": initial_camera_speed,
|
|
||||||
"speed_after_first_run": speed_after_first_run,
|
|
||||||
"speed_after_increase": speed_after_increase,
|
|
||||||
"speed_after_stop": speed_after_stop,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEmptyLinesAcceptance:
|
|
||||||
"""Acceptance tests for empty line handling."""
|
|
||||||
|
|
||||||
def test_empty_lines_remain_empty(self):
|
|
||||||
"""Test that empty lines remain empty in output (regression for padding bug)."""
|
|
||||||
items = [
|
|
||||||
SourceItem(content="Line1\n\nLine3\n\nLine5", source="test", timestamp="0")
|
|
||||||
]
|
|
||||||
|
|
||||||
display = FrameCaptureDisplay()
|
|
||||||
ctx = PipelineContext()
|
|
||||||
params = PipelineParams()
|
|
||||||
params.viewport_width = display.width
|
|
||||||
params.viewport_height = display.height
|
|
||||||
ctx.params = params
|
|
||||||
|
|
||||||
pipeline = Pipeline(
|
|
||||||
config=PipelineConfig(enable_metrics=False),
|
|
||||||
context=ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
source = ListDataSource(items, name="test")
|
|
||||||
pipeline.add_stage("source", DataSourceStage(source, name="test"))
|
|
||||||
pipeline.add_stage("render", SourceItemsToBufferStage(name="render"))
|
|
||||||
pipeline.add_stage("display", DisplayStage(display, name="terminal"))
|
|
||||||
|
|
||||||
pipeline.build()
|
|
||||||
pipeline.initialize()
|
|
||||||
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
assert result.success
|
|
||||||
|
|
||||||
frame = display.frames.get(timeout=1)
|
|
||||||
has_truly_empty = any(not line for line in frame)
|
|
||||||
|
|
||||||
save_report(
|
|
||||||
test_name="test_empty_lines_remain_empty",
|
|
||||||
frames=display.get_recorded_frames(),
|
|
||||||
status="PASS",
|
|
||||||
metadata={
|
|
||||||
"description": "Verifies empty lines remain empty (not padded)",
|
|
||||||
"has_truly_empty_lines": has_truly_empty,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert has_truly_empty, f"Expected at least one empty line, got: {frame[1]!r}"
|
|
||||||
@@ -18,7 +18,7 @@ class TestMain:
|
|||||||
|
|
||||||
def test_main_calls_run_pipeline_mode_with_default_preset(self):
|
def test_main_calls_run_pipeline_mode_with_default_preset(self):
|
||||||
"""main() runs default preset (demo) when no args provided."""
|
"""main() runs default preset (demo) when no args provided."""
|
||||||
with patch("engine.app.main.run_pipeline_mode") as mock_run:
|
with patch("engine.app.run_pipeline_mode") as mock_run:
|
||||||
sys.argv = ["mainline.py"]
|
sys.argv = ["mainline.py"]
|
||||||
main()
|
main()
|
||||||
mock_run.assert_called_once_with("demo")
|
mock_run.assert_called_once_with("demo")
|
||||||
@@ -26,23 +26,25 @@ class TestMain:
|
|||||||
def test_main_calls_run_pipeline_mode_with_config_preset(self):
|
def test_main_calls_run_pipeline_mode_with_config_preset(self):
|
||||||
"""main() uses PRESET from config if set."""
|
"""main() uses PRESET from config if set."""
|
||||||
with (
|
with (
|
||||||
patch("engine.config.PIPELINE_DIAGRAM", False),
|
patch("engine.app.config") as mock_config,
|
||||||
patch("engine.config.PRESET", "demo"),
|
patch("engine.app.run_pipeline_mode") as mock_run,
|
||||||
patch("engine.config.PIPELINE_MODE", False),
|
|
||||||
patch("engine.app.main.run_pipeline_mode") as mock_run,
|
|
||||||
):
|
):
|
||||||
|
mock_config.PIPELINE_DIAGRAM = False
|
||||||
|
mock_config.PRESET = "gallery-sources"
|
||||||
|
mock_config.PIPELINE_MODE = False
|
||||||
sys.argv = ["mainline.py"]
|
sys.argv = ["mainline.py"]
|
||||||
main()
|
main()
|
||||||
mock_run.assert_called_once_with("demo")
|
mock_run.assert_called_once_with("gallery-sources")
|
||||||
|
|
||||||
def test_main_exits_on_unknown_preset(self):
|
def test_main_exits_on_unknown_preset(self):
|
||||||
"""main() exits with error for unknown preset."""
|
"""main() exits with error for unknown preset."""
|
||||||
with (
|
with (
|
||||||
patch("engine.config.PIPELINE_DIAGRAM", False),
|
patch("engine.app.config") as mock_config,
|
||||||
patch("engine.config.PRESET", "nonexistent"),
|
patch("engine.app.list_presets", return_value=["demo", "poetry"]),
|
||||||
patch("engine.config.PIPELINE_MODE", False),
|
|
||||||
patch("engine.pipeline.list_presets", return_value=["demo", "poetry"]),
|
|
||||||
):
|
):
|
||||||
|
mock_config.PIPELINE_DIAGRAM = False
|
||||||
|
mock_config.PRESET = "nonexistent"
|
||||||
|
mock_config.PIPELINE_MODE = False
|
||||||
sys.argv = ["mainline.py"]
|
sys.argv = ["mainline.py"]
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
main()
|
main()
|
||||||
@@ -68,13 +70,9 @@ class TestRunPipelineMode:
|
|||||||
def test_run_pipeline_mode_exits_when_no_content_available(self):
|
def test_run_pipeline_mode_exits_when_no_content_available(self):
|
||||||
"""run_pipeline_mode() exits if no content can be fetched."""
|
"""run_pipeline_mode() exits if no content can be fetched."""
|
||||||
with (
|
with (
|
||||||
patch("engine.app.pipeline_runner.load_cache", return_value=None),
|
patch("engine.app.load_cache", return_value=None),
|
||||||
patch("engine.app.pipeline_runner.fetch_all_fast", return_value=[]),
|
patch("engine.app.fetch_all", return_value=([], None, None)),
|
||||||
patch(
|
patch("engine.app.effects_plugins"),
|
||||||
"engine.app.pipeline_runner.fetch_all", return_value=([], None, None)
|
|
||||||
), # Mock background thread
|
|
||||||
patch("engine.app.pipeline_runner.save_cache"), # Prevent disk I/O
|
|
||||||
patch("engine.effects.plugins.discover_plugins"),
|
|
||||||
pytest.raises(SystemExit) as exc_info,
|
pytest.raises(SystemExit) as exc_info,
|
||||||
):
|
):
|
||||||
run_pipeline_mode("demo")
|
run_pipeline_mode("demo")
|
||||||
@@ -84,12 +82,9 @@ class TestRunPipelineMode:
|
|||||||
"""run_pipeline_mode() uses cached content if available."""
|
"""run_pipeline_mode() uses cached content if available."""
|
||||||
cached = ["cached_item"]
|
cached = ["cached_item"]
|
||||||
with (
|
with (
|
||||||
patch(
|
patch("engine.app.load_cache", return_value=cached) as mock_load,
|
||||||
"engine.app.pipeline_runner.load_cache", return_value=cached
|
patch("engine.app.fetch_all") as mock_fetch,
|
||||||
) as mock_load,
|
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||||
patch("engine.app.pipeline_runner.fetch_all") as mock_fetch,
|
|
||||||
patch("engine.app.pipeline_runner.fetch_all_fast"),
|
|
||||||
patch("engine.app.pipeline_runner.DisplayRegistry.create") as mock_create,
|
|
||||||
):
|
):
|
||||||
mock_display = Mock()
|
mock_display = Mock()
|
||||||
mock_display.init = Mock()
|
mock_display.init = Mock()
|
||||||
@@ -112,8 +107,7 @@ class TestRunPipelineMode:
|
|||||||
def test_run_pipeline_mode_creates_display(self):
|
def test_run_pipeline_mode_creates_display(self):
|
||||||
"""run_pipeline_mode() creates a display backend."""
|
"""run_pipeline_mode() creates a display backend."""
|
||||||
with (
|
with (
|
||||||
patch("engine.app.pipeline_runner.load_cache", return_value=["item"]),
|
patch("engine.app.load_cache", return_value=["item"]),
|
||||||
patch("engine.app.pipeline_runner.fetch_all_fast", return_value=[]),
|
|
||||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||||
):
|
):
|
||||||
mock_display = Mock()
|
mock_display = Mock()
|
||||||
@@ -126,7 +120,7 @@ class TestRunPipelineMode:
|
|||||||
mock_create.return_value = mock_display
|
mock_create.return_value = mock_display
|
||||||
|
|
||||||
try:
|
try:
|
||||||
run_pipeline_mode("demo-base")
|
run_pipeline_mode("gallery-display-terminal")
|
||||||
except (KeyboardInterrupt, SystemExit):
|
except (KeyboardInterrupt, SystemExit):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -138,8 +132,7 @@ class TestRunPipelineMode:
|
|||||||
sys.argv = ["mainline.py", "--display", "websocket"]
|
sys.argv = ["mainline.py", "--display", "websocket"]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("engine.app.pipeline_runner.load_cache", return_value=["item"]),
|
patch("engine.app.load_cache", return_value=["item"]),
|
||||||
patch("engine.app.pipeline_runner.fetch_all_fast", return_value=[]),
|
|
||||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||||
):
|
):
|
||||||
mock_display = Mock()
|
mock_display = Mock()
|
||||||
@@ -162,14 +155,12 @@ class TestRunPipelineMode:
|
|||||||
def test_run_pipeline_mode_fetches_poetry_for_poetry_source(self):
|
def test_run_pipeline_mode_fetches_poetry_for_poetry_source(self):
|
||||||
"""run_pipeline_mode() fetches poetry for poetry preset."""
|
"""run_pipeline_mode() fetches poetry for poetry preset."""
|
||||||
with (
|
with (
|
||||||
patch("engine.app.pipeline_runner.load_cache", return_value=None),
|
patch("engine.app.load_cache", return_value=None),
|
||||||
patch(
|
patch(
|
||||||
"engine.app.pipeline_runner.fetch_poetry",
|
"engine.app.fetch_poetry", return_value=(["poem"], None, None)
|
||||||
return_value=(["poem"], None, None),
|
|
||||||
) as mock_fetch_poetry,
|
) as mock_fetch_poetry,
|
||||||
patch("engine.app.pipeline_runner.fetch_all") as mock_fetch_all,
|
patch("engine.app.fetch_all") as mock_fetch_all,
|
||||||
patch("engine.app.pipeline_runner.fetch_all_fast", return_value=[]),
|
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||||
patch("engine.app.pipeline_runner.DisplayRegistry.create") as mock_create,
|
|
||||||
):
|
):
|
||||||
mock_display = Mock()
|
mock_display = Mock()
|
||||||
mock_display.init = Mock()
|
mock_display.init = Mock()
|
||||||
@@ -192,10 +183,9 @@ class TestRunPipelineMode:
|
|||||||
def test_run_pipeline_mode_discovers_effect_plugins(self):
|
def test_run_pipeline_mode_discovers_effect_plugins(self):
|
||||||
"""run_pipeline_mode() discovers available effect plugins."""
|
"""run_pipeline_mode() discovers available effect plugins."""
|
||||||
with (
|
with (
|
||||||
patch("engine.app.pipeline_runner.load_cache", return_value=["item"]),
|
patch("engine.app.load_cache", return_value=["item"]),
|
||||||
patch("engine.app.pipeline_runner.fetch_all_fast", return_value=[]),
|
patch("engine.app.effects_plugins") as mock_effects,
|
||||||
patch("engine.effects.plugins.discover_plugins") as mock_discover,
|
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||||
patch("engine.app.pipeline_runner.DisplayRegistry.create") as mock_create,
|
|
||||||
):
|
):
|
||||||
mock_display = Mock()
|
mock_display = Mock()
|
||||||
mock_display.init = Mock()
|
mock_display.init = Mock()
|
||||||
@@ -212,4 +202,4 @@ class TestRunPipelineMode:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Verify effects_plugins.discover_plugins was called
|
# Verify effects_plugins.discover_plugins was called
|
||||||
mock_discover.assert_called_once()
|
mock_effects.discover_plugins.assert_called_once()
|
||||||
|
|||||||
@@ -2,52 +2,11 @@
|
|||||||
Tests for engine.benchmark module - performance regression tests.
|
Tests for engine.benchmark module - performance regression tests.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from engine.display import MultiDisplay, NullDisplay, TerminalDisplay
|
from engine.display import NullDisplay
|
||||||
from engine.effects import EffectContext, get_registry
|
|
||||||
from engine.effects.plugins import discover_plugins
|
|
||||||
|
|
||||||
|
|
||||||
def _is_coverage_active():
|
|
||||||
"""Check if coverage is active."""
|
|
||||||
# Check if coverage module is loaded
|
|
||||||
import sys
|
|
||||||
|
|
||||||
return "coverage" in sys.modules or "cov" in sys.modules
|
|
||||||
|
|
||||||
|
|
||||||
def _get_min_fps_threshold(base_threshold: int) -> int:
|
|
||||||
"""
|
|
||||||
Get minimum FPS threshold adjusted for coverage mode.
|
|
||||||
|
|
||||||
Coverage instrumentation typically slows execution by 2-5x.
|
|
||||||
We adjust thresholds accordingly to avoid false positives.
|
|
||||||
"""
|
|
||||||
if _is_coverage_active():
|
|
||||||
# Coverage typically slows execution by 2-5x
|
|
||||||
# Use a more conservative threshold (25% of original to account for higher overhead)
|
|
||||||
return max(500, int(base_threshold * 0.25))
|
|
||||||
return base_threshold
|
|
||||||
|
|
||||||
|
|
||||||
def _get_iterations() -> int:
|
|
||||||
"""Get number of iterations for benchmarks."""
|
|
||||||
# Check for environment variable override
|
|
||||||
env_iterations = os.environ.get("BENCHMARK_ITERATIONS")
|
|
||||||
if env_iterations:
|
|
||||||
try:
|
|
||||||
return int(env_iterations)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Default based on coverage mode
|
|
||||||
if _is_coverage_active():
|
|
||||||
return 100 # Fewer iterations when coverage is active
|
|
||||||
return 500 # Default iterations
|
|
||||||
|
|
||||||
|
|
||||||
class TestBenchmarkNullDisplay:
|
class TestBenchmarkNullDisplay:
|
||||||
@@ -62,14 +21,14 @@ class TestBenchmarkNullDisplay:
|
|||||||
display.init(80, 24)
|
display.init(80, 24)
|
||||||
buffer = ["x" * 80 for _ in range(24)]
|
buffer = ["x" * 80 for _ in range(24)]
|
||||||
|
|
||||||
iterations = _get_iterations()
|
iterations = 1000
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
for _ in range(iterations):
|
for _ in range(iterations):
|
||||||
display.show(buffer)
|
display.show(buffer)
|
||||||
elapsed = time.perf_counter() - start
|
elapsed = time.perf_counter() - start
|
||||||
|
|
||||||
fps = iterations / elapsed
|
fps = iterations / elapsed
|
||||||
min_fps = _get_min_fps_threshold(20000)
|
min_fps = 20000
|
||||||
|
|
||||||
assert fps >= min_fps, f"NullDisplay FPS {fps:.0f} below minimum {min_fps}"
|
assert fps >= min_fps, f"NullDisplay FPS {fps:.0f} below minimum {min_fps}"
|
||||||
|
|
||||||
@@ -98,14 +57,14 @@ class TestBenchmarkNullDisplay:
|
|||||||
has_message=False,
|
has_message=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
iterations = _get_iterations()
|
iterations = 500
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
for _ in range(iterations):
|
for _ in range(iterations):
|
||||||
effect.process(buffer, ctx)
|
effect.process(buffer, ctx)
|
||||||
elapsed = time.perf_counter() - start
|
elapsed = time.perf_counter() - start
|
||||||
|
|
||||||
fps = iterations / elapsed
|
fps = iterations / elapsed
|
||||||
min_fps = _get_min_fps_threshold(10000)
|
min_fps = 10000
|
||||||
|
|
||||||
assert fps >= min_fps, (
|
assert fps >= min_fps, (
|
||||||
f"Effect processing FPS {fps:.0f} below minimum {min_fps}"
|
f"Effect processing FPS {fps:.0f} below minimum {min_fps}"
|
||||||
@@ -127,254 +86,15 @@ class TestBenchmarkWebSocketDisplay:
|
|||||||
display.init(80, 24)
|
display.init(80, 24)
|
||||||
buffer = ["x" * 80 for _ in range(24)]
|
buffer = ["x" * 80 for _ in range(24)]
|
||||||
|
|
||||||
iterations = _get_iterations()
|
iterations = 500
|
||||||
start = time.perf_counter()
|
start = time.perf_counter()
|
||||||
for _ in range(iterations):
|
for _ in range(iterations):
|
||||||
display.show(buffer)
|
display.show(buffer)
|
||||||
elapsed = time.perf_counter() - start
|
elapsed = time.perf_counter() - start
|
||||||
|
|
||||||
fps = iterations / elapsed
|
fps = iterations / elapsed
|
||||||
min_fps = _get_min_fps_threshold(10000)
|
min_fps = 10000
|
||||||
|
|
||||||
assert fps >= min_fps, (
|
assert fps >= min_fps, (
|
||||||
f"WebSocketDisplay FPS {fps:.0f} below minimum {min_fps}"
|
f"WebSocketDisplay FPS {fps:.0f} below minimum {min_fps}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestBenchmarkTerminalDisplay:
|
|
||||||
"""Performance tests for TerminalDisplay."""
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
|
||||||
def test_terminal_display_minimum_fps(self):
|
|
||||||
"""TerminalDisplay should meet minimum performance threshold."""
|
|
||||||
import time
|
|
||||||
|
|
||||||
display = TerminalDisplay()
|
|
||||||
display.init(80, 24)
|
|
||||||
buffer = ["x" * 80 for _ in range(24)]
|
|
||||||
|
|
||||||
iterations = _get_iterations()
|
|
||||||
start = time.perf_counter()
|
|
||||||
for _ in range(iterations):
|
|
||||||
display.show(buffer)
|
|
||||||
elapsed = time.perf_counter() - start
|
|
||||||
|
|
||||||
fps = iterations / elapsed
|
|
||||||
min_fps = _get_min_fps_threshold(10000)
|
|
||||||
|
|
||||||
assert fps >= min_fps, f"TerminalDisplay FPS {fps:.0f} below minimum {min_fps}"
|
|
||||||
|
|
||||||
|
|
||||||
class TestBenchmarkMultiDisplay:
|
|
||||||
"""Performance tests for MultiDisplay."""
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
|
||||||
def test_multi_display_minimum_fps(self):
|
|
||||||
"""MultiDisplay should meet minimum performance threshold."""
|
|
||||||
import time
|
|
||||||
|
|
||||||
with patch("engine.display.backends.websocket.websockets", None):
|
|
||||||
from engine.display import WebSocketDisplay
|
|
||||||
|
|
||||||
null_display = NullDisplay()
|
|
||||||
null_display.init(80, 24)
|
|
||||||
ws_display = WebSocketDisplay()
|
|
||||||
ws_display.init(80, 24)
|
|
||||||
|
|
||||||
display = MultiDisplay([null_display, ws_display])
|
|
||||||
display.init(80, 24)
|
|
||||||
buffer = ["x" * 80 for _ in range(24)]
|
|
||||||
|
|
||||||
iterations = _get_iterations()
|
|
||||||
start = time.perf_counter()
|
|
||||||
for _ in range(iterations):
|
|
||||||
display.show(buffer)
|
|
||||||
elapsed = time.perf_counter() - start
|
|
||||||
|
|
||||||
fps = iterations / elapsed
|
|
||||||
min_fps = _get_min_fps_threshold(5000)
|
|
||||||
|
|
||||||
assert fps >= min_fps, f"MultiDisplay FPS {fps:.0f} below minimum {min_fps}"
|
|
||||||
|
|
||||||
|
|
||||||
class TestBenchmarkEffects:
|
|
||||||
"""Performance tests for various effects."""
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
|
||||||
def test_fade_effect_minimum_fps(self):
|
|
||||||
"""Fade effect should meet minimum performance threshold."""
|
|
||||||
import time
|
|
||||||
|
|
||||||
discover_plugins()
|
|
||||||
registry = get_registry()
|
|
||||||
effect = registry.get("fade")
|
|
||||||
assert effect is not None, "Fade effect should be registered"
|
|
||||||
|
|
||||||
buffer = ["x" * 80 for _ in range(24)]
|
|
||||||
ctx = EffectContext(
|
|
||||||
terminal_width=80,
|
|
||||||
terminal_height=24,
|
|
||||||
scroll_cam=0,
|
|
||||||
ticker_height=20,
|
|
||||||
mic_excess=0.0,
|
|
||||||
grad_offset=0.0,
|
|
||||||
frame_number=0,
|
|
||||||
has_message=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
iterations = _get_iterations()
|
|
||||||
start = time.perf_counter()
|
|
||||||
for _ in range(iterations):
|
|
||||||
effect.process(buffer, ctx)
|
|
||||||
elapsed = time.perf_counter() - start
|
|
||||||
|
|
||||||
fps = iterations / elapsed
|
|
||||||
min_fps = _get_min_fps_threshold(7000)
|
|
||||||
|
|
||||||
assert fps >= min_fps, f"Fade effect FPS {fps:.0f} below minimum {min_fps}"
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
|
||||||
def test_glitch_effect_minimum_fps(self):
|
|
||||||
"""Glitch effect should meet minimum performance threshold."""
|
|
||||||
import time
|
|
||||||
|
|
||||||
discover_plugins()
|
|
||||||
registry = get_registry()
|
|
||||||
effect = registry.get("glitch")
|
|
||||||
assert effect is not None, "Glitch effect should be registered"
|
|
||||||
|
|
||||||
buffer = ["x" * 80 for _ in range(24)]
|
|
||||||
ctx = EffectContext(
|
|
||||||
terminal_width=80,
|
|
||||||
terminal_height=24,
|
|
||||||
scroll_cam=0,
|
|
||||||
ticker_height=20,
|
|
||||||
mic_excess=0.0,
|
|
||||||
grad_offset=0.0,
|
|
||||||
frame_number=0,
|
|
||||||
has_message=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
iterations = _get_iterations()
|
|
||||||
start = time.perf_counter()
|
|
||||||
for _ in range(iterations):
|
|
||||||
effect.process(buffer, ctx)
|
|
||||||
elapsed = time.perf_counter() - start
|
|
||||||
|
|
||||||
fps = iterations / elapsed
|
|
||||||
min_fps = _get_min_fps_threshold(5000)
|
|
||||||
|
|
||||||
assert fps >= min_fps, f"Glitch effect FPS {fps:.0f} below minimum {min_fps}"
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
|
||||||
def test_border_effect_minimum_fps(self):
|
|
||||||
"""Border effect should meet minimum performance threshold."""
|
|
||||||
import time
|
|
||||||
|
|
||||||
discover_plugins()
|
|
||||||
registry = get_registry()
|
|
||||||
effect = registry.get("border")
|
|
||||||
assert effect is not None, "Border effect should be registered"
|
|
||||||
|
|
||||||
buffer = ["x" * 80 for _ in range(24)]
|
|
||||||
ctx = EffectContext(
|
|
||||||
terminal_width=80,
|
|
||||||
terminal_height=24,
|
|
||||||
scroll_cam=0,
|
|
||||||
ticker_height=20,
|
|
||||||
mic_excess=0.0,
|
|
||||||
grad_offset=0.0,
|
|
||||||
frame_number=0,
|
|
||||||
has_message=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
iterations = _get_iterations()
|
|
||||||
start = time.perf_counter()
|
|
||||||
for _ in range(iterations):
|
|
||||||
effect.process(buffer, ctx)
|
|
||||||
elapsed = time.perf_counter() - start
|
|
||||||
|
|
||||||
fps = iterations / elapsed
|
|
||||||
min_fps = _get_min_fps_threshold(5000)
|
|
||||||
|
|
||||||
assert fps >= min_fps, f"Border effect FPS {fps:.0f} below minimum {min_fps}"
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
|
||||||
def test_tint_effect_minimum_fps(self):
|
|
||||||
"""Tint effect should meet minimum performance threshold."""
|
|
||||||
import time
|
|
||||||
|
|
||||||
discover_plugins()
|
|
||||||
registry = get_registry()
|
|
||||||
effect = registry.get("tint")
|
|
||||||
assert effect is not None, "Tint effect should be registered"
|
|
||||||
|
|
||||||
buffer = ["x" * 80 for _ in range(24)]
|
|
||||||
ctx = EffectContext(
|
|
||||||
terminal_width=80,
|
|
||||||
terminal_height=24,
|
|
||||||
scroll_cam=0,
|
|
||||||
ticker_height=20,
|
|
||||||
mic_excess=0.0,
|
|
||||||
grad_offset=0.0,
|
|
||||||
frame_number=0,
|
|
||||||
has_message=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
iterations = _get_iterations()
|
|
||||||
start = time.perf_counter()
|
|
||||||
for _ in range(iterations):
|
|
||||||
effect.process(buffer, ctx)
|
|
||||||
elapsed = time.perf_counter() - start
|
|
||||||
|
|
||||||
fps = iterations / elapsed
|
|
||||||
min_fps = _get_min_fps_threshold(8000)
|
|
||||||
|
|
||||||
assert fps >= min_fps, f"Tint effect FPS {fps:.0f} below minimum {min_fps}"
|
|
||||||
|
|
||||||
|
|
||||||
class TestBenchmarkPipeline:
|
|
||||||
"""Performance tests for pipeline execution."""
|
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
|
||||||
def test_pipeline_execution_minimum_fps(self):
|
|
||||||
"""Pipeline execution should meet minimum performance threshold."""
|
|
||||||
import time
|
|
||||||
|
|
||||||
from engine.data_sources.sources import EmptyDataSource
|
|
||||||
from engine.pipeline import Pipeline, StageRegistry, discover_stages
|
|
||||||
from engine.pipeline.adapters import DataSourceStage, SourceItemsToBufferStage
|
|
||||||
|
|
||||||
discover_stages()
|
|
||||||
|
|
||||||
# Create a minimal pipeline with empty source to avoid network calls
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
# Create empty source directly (not registered in stage registry)
|
|
||||||
empty_source = EmptyDataSource(width=80, height=24)
|
|
||||||
source_stage = DataSourceStage(empty_source, name="empty")
|
|
||||||
|
|
||||||
# Add render stage to convert items to text buffer
|
|
||||||
render_stage = SourceItemsToBufferStage(name="items-to-buffer")
|
|
||||||
|
|
||||||
# Get null display from registry
|
|
||||||
null_display = StageRegistry.create("display", "null")
|
|
||||||
assert null_display is not None, "null display should be registered"
|
|
||||||
|
|
||||||
pipeline.add_stage("source", source_stage)
|
|
||||||
pipeline.add_stage("render", render_stage)
|
|
||||||
pipeline.add_stage("display", null_display)
|
|
||||||
pipeline.build()
|
|
||||||
|
|
||||||
iterations = _get_iterations()
|
|
||||||
start = time.perf_counter()
|
|
||||||
for _ in range(iterations):
|
|
||||||
pipeline.execute()
|
|
||||||
elapsed = time.perf_counter() - start
|
|
||||||
|
|
||||||
fps = iterations / elapsed
|
|
||||||
min_fps = _get_min_fps_threshold(1000)
|
|
||||||
|
|
||||||
assert fps >= min_fps, (
|
|
||||||
f"Pipeline execution FPS {fps:.0f} below minimum {min_fps}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,826 +0,0 @@
|
|||||||
"""
|
|
||||||
Camera acceptance tests using NullDisplay frame recording and ReplayDisplay.
|
|
||||||
|
|
||||||
Tests all camera modes by:
|
|
||||||
1. Creating deterministic source data (numbered lines)
|
|
||||||
2. Running pipeline with small viewport (40x15)
|
|
||||||
3. Recording frames with NullDisplay
|
|
||||||
4. Asserting expected viewport content for each mode
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
pytest tests/test_camera_acceptance.py -v
|
|
||||||
pytest tests/test_camera_acceptance.py --show-frames -v
|
|
||||||
|
|
||||||
The --show-frames flag displays recorded frames for visual verification.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import math
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from engine.camera import Camera, CameraMode
|
|
||||||
from engine.display import DisplayRegistry
|
|
||||||
from engine.effects import get_registry
|
|
||||||
from engine.pipeline import Pipeline, PipelineConfig, PipelineContext
|
|
||||||
from engine.pipeline.adapters import (
|
|
||||||
CameraClockStage,
|
|
||||||
CameraStage,
|
|
||||||
FontStage,
|
|
||||||
ViewportFilterStage,
|
|
||||||
create_stage_from_display,
|
|
||||||
create_stage_from_effect,
|
|
||||||
)
|
|
||||||
from engine.pipeline.params import PipelineParams
|
|
||||||
|
|
||||||
|
|
||||||
def get_camera_position(pipeline, camera):
|
|
||||||
"""Helper to get camera position directly from the camera object.
|
|
||||||
|
|
||||||
The pipeline context's camera_y/camera_x values may be transformed by
|
|
||||||
ViewportFilterStage (filtered relative position). This helper gets the
|
|
||||||
true camera position from the camera object itself.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
pipeline: The pipeline instance
|
|
||||||
camera: The camera object
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple (x, y) of the camera's absolute position
|
|
||||||
"""
|
|
||||||
return (camera.x, camera.y)
|
|
||||||
|
|
||||||
|
|
||||||
# Register custom CLI option for showing frames
|
|
||||||
def pytest_addoption(parser):
|
|
||||||
parser.addoption(
|
|
||||||
"--show-frames",
|
|
||||||
action="store_true",
|
|
||||||
default=False,
|
|
||||||
help="Display recorded frames for visual verification",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def show_frames(request):
|
|
||||||
"""Get the --show-frames flag value."""
|
|
||||||
try:
|
|
||||||
return request.config.getoption("--show-frames")
|
|
||||||
except ValueError:
|
|
||||||
# Option not registered, default to False
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def viewport_dims():
|
|
||||||
"""Small viewport dimensions for testing."""
|
|
||||||
return (40, 15)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def items():
|
|
||||||
"""Create deterministic test data - numbered lines for easy verification."""
|
|
||||||
# Create 100 numbered lines: LINE 000, LINE 001, etc.
|
|
||||||
return [{"text": f"LINE {i:03d} - This is line number {i}"} for i in range(100)]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def null_display(viewport_dims):
|
|
||||||
"""Create a NullDisplay for testing."""
|
|
||||||
display = DisplayRegistry.create("null")
|
|
||||||
display.init(viewport_dims[0], viewport_dims[1])
|
|
||||||
return display
|
|
||||||
|
|
||||||
|
|
||||||
def create_pipeline_with_camera(
|
|
||||||
camera, items, null_display, viewport_dims, effects=None
|
|
||||||
):
|
|
||||||
"""Helper to create a pipeline with a specific camera."""
|
|
||||||
effects = effects or []
|
|
||||||
width, height = viewport_dims
|
|
||||||
|
|
||||||
params = PipelineParams()
|
|
||||||
params.viewport_width = width
|
|
||||||
params.viewport_height = height
|
|
||||||
|
|
||||||
config = PipelineConfig(
|
|
||||||
source="fixture",
|
|
||||||
display="null",
|
|
||||||
camera="scroll",
|
|
||||||
effects=effects,
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline = Pipeline(config=config, context=PipelineContext())
|
|
||||||
|
|
||||||
from engine.data_sources.sources import ListDataSource
|
|
||||||
from engine.pipeline.adapters import DataSourceStage
|
|
||||||
|
|
||||||
list_source = ListDataSource(items, name="fixture")
|
|
||||||
pipeline.add_stage("source", DataSourceStage(list_source, name="fixture"))
|
|
||||||
|
|
||||||
# Add camera update stage to ensure camera_y is available for viewport filter
|
|
||||||
pipeline.add_stage("camera_update", CameraClockStage(camera, name="camera-clock"))
|
|
||||||
|
|
||||||
# Note: camera should come after font/viewport_filter, before effects
|
|
||||||
pipeline.add_stage("viewport_filter", ViewportFilterStage(name="viewport-filter"))
|
|
||||||
pipeline.add_stage("font", FontStage(name="font"))
|
|
||||||
pipeline.add_stage(
|
|
||||||
"camera",
|
|
||||||
CameraStage(
|
|
||||||
camera, name="radial" if camera.mode == CameraMode.RADIAL else "vertical"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if effects:
|
|
||||||
effect_registry = get_registry()
|
|
||||||
for effect_name in 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(null_display, "null"))
|
|
||||||
pipeline.build()
|
|
||||||
|
|
||||||
if not pipeline.initialize():
|
|
||||||
return None
|
|
||||||
|
|
||||||
ctx = pipeline.context
|
|
||||||
ctx.params = params
|
|
||||||
ctx.set("display", null_display)
|
|
||||||
ctx.set("items", items)
|
|
||||||
ctx.set("pipeline", pipeline)
|
|
||||||
ctx.set("pipeline_order", pipeline.execution_order)
|
|
||||||
|
|
||||||
return pipeline
|
|
||||||
|
|
||||||
|
|
||||||
class DisplayHelper:
|
|
||||||
"""Helper to display frames for visual verification."""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def show_frame(buffer, title, viewport_dims, marker_line=None):
|
|
||||||
"""Display a single frame with visual markers."""
|
|
||||||
width, height = viewport_dims
|
|
||||||
print(f"\n{'=' * (width + 20)}")
|
|
||||||
print(f" {title}")
|
|
||||||
print(f"{'=' * (width + 20)}")
|
|
||||||
|
|
||||||
for i, line in enumerate(buffer[:height]):
|
|
||||||
# Add marker if this line should be highlighted
|
|
||||||
marker = ">>>" if marker_line == i else " "
|
|
||||||
print(f"{marker} [{i:2}] {line[:width]}")
|
|
||||||
|
|
||||||
print(f"{'=' * (width + 20)}\n")
|
|
||||||
|
|
||||||
|
|
||||||
class TestFeedCamera:
|
|
||||||
"""Test FEED mode: rapid single-item scrolling (1 row/frame at speed=1.0)."""
|
|
||||||
|
|
||||||
def test_feed_camera_scrolls_down(
|
|
||||||
self, items, null_display, viewport_dims, show_frames
|
|
||||||
):
|
|
||||||
"""FEED camera should move content down (y increases) at 1 row/frame."""
|
|
||||||
camera = Camera.feed(speed=1.0)
|
|
||||||
camera.set_canvas_size(200, 100)
|
|
||||||
|
|
||||||
pipeline = create_pipeline_with_camera(
|
|
||||||
camera, items, null_display, viewport_dims
|
|
||||||
)
|
|
||||||
assert pipeline is not None, "Pipeline creation failed"
|
|
||||||
|
|
||||||
null_display.start_recording()
|
|
||||||
|
|
||||||
# Run for 10 frames with small delay between frames
|
|
||||||
# to ensure camera has time to move (dt calculation relies on time.perf_counter())
|
|
||||||
import time
|
|
||||||
|
|
||||||
for frame in range(10):
|
|
||||||
pipeline.context.set("frame_number", frame)
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
assert result.success, f"Frame {frame} execution failed"
|
|
||||||
if frame < 9: # No need to sleep after last frame
|
|
||||||
time.sleep(0.02) # Wait 20ms so dt~0.02, camera moves ~1.2 rows
|
|
||||||
|
|
||||||
null_display.stop_recording()
|
|
||||||
frames = null_display.get_frames()
|
|
||||||
|
|
||||||
if show_frames:
|
|
||||||
DisplayHelper.show_frame(frames[0], "FEED Camera - Frame 0", viewport_dims)
|
|
||||||
DisplayHelper.show_frame(frames[5], "FEED Camera - Frame 5", viewport_dims)
|
|
||||||
DisplayHelper.show_frame(frames[9], "FEED Camera - Frame 9", viewport_dims)
|
|
||||||
|
|
||||||
# FEED mode: each frame y increases by speed*dt*60
|
|
||||||
# At dt=1.0, speed=1.0: y increases by 60 per frame
|
|
||||||
# But clamp to canvas bounds (200)
|
|
||||||
# Frame 0: y=0, should show LINE 000
|
|
||||||
# Frame 1: y=60, should show LINE 060
|
|
||||||
|
|
||||||
# Verify frame 0 contains ASCII art content (rendered from LINE 000)
|
|
||||||
# The text is converted to block characters, so check for non-empty frames
|
|
||||||
assert len(frames[0]) > 0, "Frame 0 should not be empty"
|
|
||||||
assert frames[0][0].strip() != "", "Frame 0 should have visible content"
|
|
||||||
|
|
||||||
# Verify camera position changed between frames
|
|
||||||
# Feed mode moves 1 row per frame at speed=1.0 with dt~0.02
|
|
||||||
# After 5 frames, camera should have moved down
|
|
||||||
assert camera.y > 0, f"Camera should have moved down, y={camera.y}"
|
|
||||||
|
|
||||||
# Verify different frames show different content (camera is scrolling)
|
|
||||||
# Check that frame 0 and frame 5 are different
|
|
||||||
frame_0_str = "\n".join(frames[0])
|
|
||||||
frame_5_str = "\n".join(frames[5])
|
|
||||||
assert frame_0_str != frame_5_str, (
|
|
||||||
"Frame 0 and Frame 5 should show different content"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestScrollCamera:
|
|
||||||
"""Test SCROLL mode: smooth vertical scrolling with float accumulation."""
|
|
||||||
|
|
||||||
def test_scroll_camera_smooth_movement(
|
|
||||||
self, items, null_display, viewport_dims, show_frames
|
|
||||||
):
|
|
||||||
"""SCROLL camera should move content smoothly with sub-integer precision."""
|
|
||||||
camera = Camera.scroll(speed=0.5)
|
|
||||||
camera.set_canvas_size(0, 200) # Match viewport width for text wrapping
|
|
||||||
|
|
||||||
pipeline = create_pipeline_with_camera(
|
|
||||||
camera, items, null_display, viewport_dims
|
|
||||||
)
|
|
||||||
assert pipeline is not None, "Pipeline creation failed"
|
|
||||||
|
|
||||||
null_display.start_recording()
|
|
||||||
|
|
||||||
# Run for 20 frames
|
|
||||||
for frame in range(20):
|
|
||||||
pipeline.context.set("frame_number", frame)
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
assert result.success, f"Frame {frame} execution failed"
|
|
||||||
|
|
||||||
null_display.stop_recording()
|
|
||||||
frames = null_display.get_frames()
|
|
||||||
|
|
||||||
if show_frames:
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[0], "SCROLL Camera - Frame 0", viewport_dims
|
|
||||||
)
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[10], "SCROLL Camera - Frame 10", viewport_dims
|
|
||||||
)
|
|
||||||
|
|
||||||
# SCROLL mode uses float accumulation for smooth scrolling
|
|
||||||
# At speed=0.5, dt=1.0: y increases by 0.5 * 60 = 30 pixels per frame
|
|
||||||
# Verify camera_y is increasing (which causes the scroll)
|
|
||||||
camera_y_values = []
|
|
||||||
for frame in range(5):
|
|
||||||
# Get camera.y directly (not filtered context value)
|
|
||||||
pipeline.context.set("frame_number", frame)
|
|
||||||
pipeline.execute(items)
|
|
||||||
camera_y_values.append(camera.y)
|
|
||||||
|
|
||||||
print(f"\nSCROLL test - camera_y positions: {camera_y_values}")
|
|
||||||
|
|
||||||
# Verify camera_y is non-zero (camera is moving)
|
|
||||||
assert camera_y_values[-1] > 0, (
|
|
||||||
"Camera should have scrolled down (camera_y > 0)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify camera_y is increasing
|
|
||||||
for i in range(len(camera_y_values) - 1):
|
|
||||||
assert camera_y_values[i + 1] >= camera_y_values[i], (
|
|
||||||
f"Camera_y should be non-decreasing: {camera_y_values}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestHorizontalCamera:
|
|
||||||
"""Test HORIZONTAL mode: left/right scrolling."""
|
|
||||||
|
|
||||||
def test_horizontal_camera_scrolls_right(
|
|
||||||
self, items, null_display, viewport_dims, show_frames
|
|
||||||
):
|
|
||||||
"""HORIZONTAL camera should move content right (x increases)."""
|
|
||||||
camera = Camera.horizontal(speed=1.0)
|
|
||||||
camera.set_canvas_size(200, 200)
|
|
||||||
|
|
||||||
pipeline = create_pipeline_with_camera(
|
|
||||||
camera, items, null_display, viewport_dims
|
|
||||||
)
|
|
||||||
assert pipeline is not None, "Pipeline creation failed"
|
|
||||||
|
|
||||||
null_display.start_recording()
|
|
||||||
|
|
||||||
for frame in range(10):
|
|
||||||
pipeline.context.set("frame_number", frame)
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
assert result.success, f"Frame {frame} execution failed"
|
|
||||||
|
|
||||||
null_display.stop_recording()
|
|
||||||
frames = null_display.get_frames()
|
|
||||||
|
|
||||||
if show_frames:
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[0], "HORIZONTAL Camera - Frame 0", viewport_dims
|
|
||||||
)
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[5], "HORIZONTAL Camera - Frame 5", viewport_dims
|
|
||||||
)
|
|
||||||
|
|
||||||
# HORIZONTAL mode: x increases by speed*dt*60
|
|
||||||
# At dt=1.0, speed=1.0: x increases by 60 per frame
|
|
||||||
# Frame 0: x=0
|
|
||||||
# Frame 5: x=300 (clamped to canvas_width-viewport_width)
|
|
||||||
|
|
||||||
# Verify frame 0 contains content (ASCII art of LINE 000)
|
|
||||||
assert len(frames[0]) > 0, "Frame 0 should not be empty"
|
|
||||||
assert frames[0][0].strip() != "", "Frame 0 should have visible content"
|
|
||||||
|
|
||||||
# Verify camera x is increasing
|
|
||||||
print("\nHORIZONTAL test - camera positions:")
|
|
||||||
for i in range(10):
|
|
||||||
print(f" Frame {i}: x={camera.x}, y={camera.y}")
|
|
||||||
camera.update(1.0)
|
|
||||||
|
|
||||||
# Verify camera moved
|
|
||||||
assert camera.x > 0, f"Camera should have moved right, x={camera.x}"
|
|
||||||
|
|
||||||
|
|
||||||
class TestOmniCamera:
|
|
||||||
"""Test OMNI mode: diagonal scrolling (x and y increase together)."""
|
|
||||||
|
|
||||||
def test_omni_camera_diagonal_movement(
|
|
||||||
self, items, null_display, viewport_dims, show_frames
|
|
||||||
):
|
|
||||||
"""OMNI camera should move content diagonally (both x and y increase)."""
|
|
||||||
camera = Camera.omni(speed=1.0)
|
|
||||||
camera.set_canvas_size(200, 200)
|
|
||||||
|
|
||||||
pipeline = create_pipeline_with_camera(
|
|
||||||
camera, items, null_display, viewport_dims
|
|
||||||
)
|
|
||||||
assert pipeline is not None, "Pipeline creation failed"
|
|
||||||
|
|
||||||
null_display.start_recording()
|
|
||||||
|
|
||||||
for frame in range(10):
|
|
||||||
pipeline.context.set("frame_number", frame)
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
assert result.success, f"Frame {frame} execution failed"
|
|
||||||
|
|
||||||
null_display.stop_recording()
|
|
||||||
frames = null_display.get_frames()
|
|
||||||
|
|
||||||
if show_frames:
|
|
||||||
DisplayHelper.show_frame(frames[0], "OMNI Camera - Frame 0", viewport_dims)
|
|
||||||
DisplayHelper.show_frame(frames[5], "OMNI Camera - Frame 5", viewport_dims)
|
|
||||||
|
|
||||||
# OMNI mode: y increases by speed*dt*60, x increases by speed*dt*60*0.5
|
|
||||||
# At dt=1.0, speed=1.0: y += 60, x += 30
|
|
||||||
|
|
||||||
# Verify frame 0 contains content (ASCII art)
|
|
||||||
assert len(frames[0]) > 0, "Frame 0 should not be empty"
|
|
||||||
assert frames[0][0].strip() != "", "Frame 0 should have visible content"
|
|
||||||
|
|
||||||
print("\nOMNI test - camera positions:")
|
|
||||||
camera.reset()
|
|
||||||
for frame in range(5):
|
|
||||||
print(f" Frame {frame}: x={camera.x}, y={camera.y}")
|
|
||||||
camera.update(1.0)
|
|
||||||
|
|
||||||
# Verify camera moved
|
|
||||||
assert camera.y > 0, f"Camera should have moved down, y={camera.y}"
|
|
||||||
|
|
||||||
|
|
||||||
class TestFloatingCamera:
|
|
||||||
"""Test FLOATING mode: sinusoidal bobbing motion."""
|
|
||||||
|
|
||||||
def test_floating_camera_bobbing(
|
|
||||||
self, items, null_display, viewport_dims, show_frames
|
|
||||||
):
|
|
||||||
"""FLOATING camera should move content in a sinusoidal pattern."""
|
|
||||||
camera = Camera.floating(speed=1.0)
|
|
||||||
camera.set_canvas_size(200, 200)
|
|
||||||
|
|
||||||
pipeline = create_pipeline_with_camera(
|
|
||||||
camera, items, null_display, viewport_dims
|
|
||||||
)
|
|
||||||
assert pipeline is not None, "Pipeline creation failed"
|
|
||||||
|
|
||||||
null_display.start_recording()
|
|
||||||
|
|
||||||
for frame in range(32):
|
|
||||||
pipeline.context.set("frame_number", frame)
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
assert result.success, f"Frame {frame} execution failed"
|
|
||||||
|
|
||||||
null_display.stop_recording()
|
|
||||||
frames = null_display.get_frames()
|
|
||||||
|
|
||||||
if show_frames:
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[0], "FLOATING Camera - Frame 0", viewport_dims
|
|
||||||
)
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[8], "FLOATING Camera - Frame 8 (quarter cycle)", viewport_dims
|
|
||||||
)
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[16], "FLOATING Camera - Frame 16 (half cycle)", viewport_dims
|
|
||||||
)
|
|
||||||
|
|
||||||
# FLOATING mode: y = sin(time*2) * speed * 30
|
|
||||||
# Period: 2π / 2 = π ≈ 3.14 seconds (or ~3.14 frames at dt=1.0)
|
|
||||||
# Full cycle ~32 frames
|
|
||||||
|
|
||||||
print("\nFLOATING test - sinusoidal motion:")
|
|
||||||
camera.reset()
|
|
||||||
for frame in range(16):
|
|
||||||
print(f" Frame {frame}: y={camera.y}, x={camera.x}")
|
|
||||||
camera.update(1.0)
|
|
||||||
|
|
||||||
# Verify y oscillates around 0
|
|
||||||
camera.reset()
|
|
||||||
camera.update(1.0) # Frame 1
|
|
||||||
y1 = camera.y
|
|
||||||
camera.update(1.0) # Frame 2
|
|
||||||
y2 = camera.y
|
|
||||||
camera.update(1.0) # Frame 3
|
|
||||||
y3 = camera.y
|
|
||||||
|
|
||||||
# After a few frames, y should oscillate (not monotonic)
|
|
||||||
assert y1 != y2 or y2 != y3, "FLOATING camera should oscillate"
|
|
||||||
|
|
||||||
|
|
||||||
class TestBounceCamera:
|
|
||||||
"""Test BOUNCE mode: bouncing DVD-style motion."""
|
|
||||||
|
|
||||||
def test_bounce_camera_reverses_at_edges(
|
|
||||||
self, items, null_display, viewport_dims, show_frames
|
|
||||||
):
|
|
||||||
"""BOUNCE camera should reverse direction when hitting canvas edges."""
|
|
||||||
camera = Camera.bounce(speed=5.0) # Faster for quicker test
|
|
||||||
# Set zoom > 1.0 so viewport is smaller than canvas, allowing movement
|
|
||||||
camera.set_zoom(2.0) # Zoom out 2x, viewport is half the canvas size
|
|
||||||
camera.set_canvas_size(400, 400)
|
|
||||||
|
|
||||||
pipeline = create_pipeline_with_camera(
|
|
||||||
camera, items, null_display, viewport_dims
|
|
||||||
)
|
|
||||||
assert pipeline is not None, "Pipeline creation failed"
|
|
||||||
|
|
||||||
null_display.start_recording()
|
|
||||||
|
|
||||||
for frame in range(50):
|
|
||||||
pipeline.context.set("frame_number", frame)
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
assert result.success, f"Frame {frame} execution failed"
|
|
||||||
|
|
||||||
null_display.stop_recording()
|
|
||||||
frames = null_display.get_frames()
|
|
||||||
|
|
||||||
if show_frames:
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[0], "BOUNCE Camera - Frame 0", viewport_dims
|
|
||||||
)
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[25], "BOUNCE Camera - Frame 25", viewport_dims
|
|
||||||
)
|
|
||||||
|
|
||||||
# BOUNCE mode: moves until it hits edge, then reverses
|
|
||||||
# Verify the camera moves and changes direction
|
|
||||||
|
|
||||||
print("\nBOUNCE test - bouncing motion:")
|
|
||||||
camera.reset()
|
|
||||||
camera.set_zoom(2.0) # Reset also resets zoom, so set it again
|
|
||||||
for frame in range(20):
|
|
||||||
print(f" Frame {frame}: x={camera.x}, y={camera.y}")
|
|
||||||
camera.update(1.0)
|
|
||||||
|
|
||||||
# Check that camera hits bounds and reverses
|
|
||||||
camera.reset()
|
|
||||||
camera.set_zoom(2.0) # Reset also resets zoom, so set it again
|
|
||||||
for _ in range(51): # Odd number ensures ending at opposite corner
|
|
||||||
camera.update(1.0)
|
|
||||||
|
|
||||||
# Camera should have hit an edge and reversed direction
|
|
||||||
# With 400x400 canvas, viewport 200x200 (zoom=2), max_x = 200, max_y = 200
|
|
||||||
# Starting at (0,0), after 51 updates it should be at (200, 200)
|
|
||||||
max_x = max(0, camera.canvas_width - camera.viewport_width)
|
|
||||||
print(f"BOUNCE camera final position: x={camera.x}, y={camera.y}")
|
|
||||||
assert camera.x == max_x, (
|
|
||||||
f"Camera should be at max_x ({max_x}), got x={camera.x}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check bounds are respected
|
|
||||||
vw = camera.viewport_width
|
|
||||||
vh = camera.viewport_height
|
|
||||||
assert camera.x >= 0 and camera.x <= camera.canvas_width - vw
|
|
||||||
assert camera.y >= 0 and camera.y <= camera.canvas_height - vh
|
|
||||||
|
|
||||||
|
|
||||||
class TestRadialCamera:
|
|
||||||
"""Test RADIAL mode: polar coordinate scanning (rotation around center)."""
|
|
||||||
|
|
||||||
def test_radial_camera_rotates_around_center(
|
|
||||||
self, items, null_display, viewport_dims, show_frames
|
|
||||||
):
|
|
||||||
"""RADIAL camera should rotate around the center of the canvas."""
|
|
||||||
camera = Camera.radial(speed=0.5)
|
|
||||||
camera.set_canvas_size(200, 200)
|
|
||||||
|
|
||||||
pipeline = create_pipeline_with_camera(
|
|
||||||
camera, items, null_display, viewport_dims
|
|
||||||
)
|
|
||||||
assert pipeline is not None, "Pipeline creation failed"
|
|
||||||
|
|
||||||
null_display.start_recording()
|
|
||||||
|
|
||||||
for frame in range(32): # 32 frames = 2π at ~0.2 rad/frame
|
|
||||||
pipeline.context.set("frame_number", frame)
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
assert result.success, f"Frame {frame} execution failed"
|
|
||||||
|
|
||||||
null_display.stop_recording()
|
|
||||||
frames = null_display.get_frames()
|
|
||||||
|
|
||||||
if show_frames:
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[0], "RADIAL Camera - Frame 0", viewport_dims
|
|
||||||
)
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[8], "RADIAL Camera - Frame 8 (quarter turn)", viewport_dims
|
|
||||||
)
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[16], "RADIAL Camera - Frame 16 (half turn)", viewport_dims
|
|
||||||
)
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[24], "RADIAL Camera - Frame 24 (3/4 turn)", viewport_dims
|
|
||||||
)
|
|
||||||
|
|
||||||
# RADIAL mode: rotates around center with smooth angular motion
|
|
||||||
# At speed=0.5: theta increases by ~0.2 rad/frame (0.5 * dt * 1.0)
|
|
||||||
|
|
||||||
print("\nRADIAL test - rotational motion:")
|
|
||||||
camera.reset()
|
|
||||||
for frame in range(32):
|
|
||||||
theta_deg = (camera._theta_float * 180 / math.pi) % 360
|
|
||||||
print(
|
|
||||||
f" Frame {frame}: theta={theta_deg:.1f}°, x={camera.x}, y={camera.y}"
|
|
||||||
)
|
|
||||||
camera.update(1.0)
|
|
||||||
|
|
||||||
# Verify rotation occurs (angle should change)
|
|
||||||
camera.reset()
|
|
||||||
theta_start = camera._theta_float
|
|
||||||
camera.update(1.0) # Frame 1
|
|
||||||
theta_mid = camera._theta_float
|
|
||||||
camera.update(1.0) # Frame 2
|
|
||||||
theta_end = camera._theta_float
|
|
||||||
|
|
||||||
assert theta_mid > theta_start, "Theta should increase (rotation)"
|
|
||||||
assert theta_end > theta_mid, "Theta should continue increasing"
|
|
||||||
|
|
||||||
def test_radial_camera_with_sensor_integration(
|
|
||||||
self, items, null_display, viewport_dims, show_frames
|
|
||||||
):
|
|
||||||
"""RADIAL camera can be driven by external sensor (OSC integration test)."""
|
|
||||||
from engine.sensors.oscillator import (
|
|
||||||
OscillatorSensor,
|
|
||||||
register_oscillator_sensor,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create an oscillator sensor for testing
|
|
||||||
register_oscillator_sensor(name="test_osc", waveform="sine", frequency=0.5)
|
|
||||||
osc = OscillatorSensor(name="test_osc", waveform="sine", frequency=0.5)
|
|
||||||
|
|
||||||
camera = Camera.radial(speed=0.3)
|
|
||||||
camera.set_canvas_size(200, 200)
|
|
||||||
|
|
||||||
pipeline = create_pipeline_with_camera(
|
|
||||||
camera, items, null_display, viewport_dims
|
|
||||||
)
|
|
||||||
assert pipeline is not None, "Pipeline creation failed"
|
|
||||||
|
|
||||||
null_display.start_recording()
|
|
||||||
|
|
||||||
# Run frames while modulating camera with oscillator
|
|
||||||
for frame in range(32):
|
|
||||||
# Read oscillator value and set as radial input
|
|
||||||
osc_value = osc.read()
|
|
||||||
if osc_value:
|
|
||||||
camera.set_radial_input(osc_value.value)
|
|
||||||
|
|
||||||
pipeline.context.set("frame_number", frame)
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
assert result.success, f"Frame {frame} execution failed"
|
|
||||||
|
|
||||||
null_display.stop_recording()
|
|
||||||
frames = null_display.get_frames()
|
|
||||||
|
|
||||||
if show_frames:
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[0], "RADIAL+OSC Camera - Frame 0", viewport_dims
|
|
||||||
)
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[8], "RADIAL+OSC Camera - Frame 8", viewport_dims
|
|
||||||
)
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[16], "RADIAL+OSC Camera - Frame 16", viewport_dims
|
|
||||||
)
|
|
||||||
|
|
||||||
print("\nRADIAL+OSC test - sensor-driven rotation:")
|
|
||||||
osc.start()
|
|
||||||
camera.reset()
|
|
||||||
for frame in range(16):
|
|
||||||
osc_value = osc.read()
|
|
||||||
if osc_value:
|
|
||||||
camera.set_radial_input(osc_value.value)
|
|
||||||
camera.update(1.0)
|
|
||||||
theta_deg = (camera._theta_float * 180 / math.pi) % 360
|
|
||||||
print(
|
|
||||||
f" Frame {frame}: osc={osc_value.value if osc_value else 0:.3f}, theta={theta_deg:.1f}°"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify camera position changes when driven by sensor
|
|
||||||
camera.reset()
|
|
||||||
x_start = camera.x
|
|
||||||
camera.update(1.0)
|
|
||||||
x_mid = camera.x
|
|
||||||
assert x_start != x_mid, "Camera should move when driven by oscillator"
|
|
||||||
|
|
||||||
osc.stop()
|
|
||||||
|
|
||||||
def test_radial_camera_with_direct_angle_setting(
|
|
||||||
self, items, null_display, viewport_dims, show_frames
|
|
||||||
):
|
|
||||||
"""RADIAL camera can have angle set directly for OSC integration."""
|
|
||||||
camera = Camera.radial(speed=0.0) # No auto-rotation
|
|
||||||
camera.set_canvas_size(200, 200)
|
|
||||||
camera._r_float = 80.0 # Set initial radius to see movement
|
|
||||||
|
|
||||||
pipeline = create_pipeline_with_camera(
|
|
||||||
camera, items, null_display, viewport_dims
|
|
||||||
)
|
|
||||||
assert pipeline is not None, "Pipeline creation failed"
|
|
||||||
|
|
||||||
null_display.start_recording()
|
|
||||||
|
|
||||||
# Set angle directly to sweep through full rotation
|
|
||||||
for frame in range(32):
|
|
||||||
angle = (frame / 32) * 2 * math.pi # 0 to 2π over 32 frames
|
|
||||||
camera.set_radial_angle(angle)
|
|
||||||
camera.update(1.0) # Must update to convert polar to Cartesian
|
|
||||||
|
|
||||||
pipeline.context.set("frame_number", frame)
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
assert result.success, f"Frame {frame} execution failed"
|
|
||||||
|
|
||||||
null_display.stop_recording()
|
|
||||||
frames = null_display.get_frames()
|
|
||||||
|
|
||||||
if show_frames:
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[0], "RADIAL Direct Angle - Frame 0", viewport_dims
|
|
||||||
)
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[8], "RADIAL Direct Angle - Frame 8", viewport_dims
|
|
||||||
)
|
|
||||||
DisplayHelper.show_frame(
|
|
||||||
frames[16], "RADIAL Direct Angle - Frame 16", viewport_dims
|
|
||||||
)
|
|
||||||
|
|
||||||
print("\nRADIAL Direct Angle test - sweeping rotation:")
|
|
||||||
for frame in range(32):
|
|
||||||
angle = (frame / 32) * 2 * math.pi
|
|
||||||
camera.set_radial_angle(angle)
|
|
||||||
camera.update(1.0) # Update converts angle to x,y position
|
|
||||||
theta_deg = angle * 180 / math.pi
|
|
||||||
print(
|
|
||||||
f" Frame {frame}: set_angle={theta_deg:.1f}°, actual_x={camera.x}, actual_y={camera.y}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify camera position changes as angle sweeps
|
|
||||||
camera.reset()
|
|
||||||
camera._r_float = 80.0 # Set radius for testing
|
|
||||||
camera.set_radial_angle(0)
|
|
||||||
camera.update(1.0)
|
|
||||||
x0 = camera.x
|
|
||||||
camera.set_radial_angle(math.pi / 2)
|
|
||||||
camera.update(1.0)
|
|
||||||
x90 = camera.x
|
|
||||||
assert x0 != x90, (
|
|
||||||
f"Camera position should change with angle (x0={x0}, x90={x90})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCameraModeEnum:
|
|
||||||
"""Test CameraMode enum integrity."""
|
|
||||||
|
|
||||||
def test_all_modes_exist(self):
|
|
||||||
"""Verify all camera modes are defined."""
|
|
||||||
modes = [m.name for m in CameraMode]
|
|
||||||
expected = [
|
|
||||||
"FEED",
|
|
||||||
"SCROLL",
|
|
||||||
"HORIZONTAL",
|
|
||||||
"OMNI",
|
|
||||||
"FLOATING",
|
|
||||||
"BOUNCE",
|
|
||||||
"RADIAL",
|
|
||||||
]
|
|
||||||
|
|
||||||
for mode in expected:
|
|
||||||
assert mode in modes, f"CameraMode.{mode} should exist"
|
|
||||||
|
|
||||||
def test_radial_mode_exists(self):
|
|
||||||
"""Verify RADIAL mode is properly defined."""
|
|
||||||
assert CameraMode.RADIAL is not None
|
|
||||||
assert isinstance(CameraMode.RADIAL, CameraMode)
|
|
||||||
assert CameraMode.RADIAL.name == "RADIAL"
|
|
||||||
|
|
||||||
|
|
||||||
class TestCameraFactoryMethods:
|
|
||||||
"""Test camera factory methods create proper camera instances."""
|
|
||||||
|
|
||||||
def test_radial_factory(self):
|
|
||||||
"""RADIAL factory should create a camera with correct mode."""
|
|
||||||
camera = Camera.radial(speed=2.0)
|
|
||||||
assert camera.mode == CameraMode.RADIAL
|
|
||||||
assert camera.speed == 2.0
|
|
||||||
assert hasattr(camera, "_r_float")
|
|
||||||
assert hasattr(camera, "_theta_float")
|
|
||||||
|
|
||||||
def test_radial_factory_initializes_state(self):
|
|
||||||
"""RADIAL factory should initialize radial state."""
|
|
||||||
camera = Camera.radial()
|
|
||||||
assert camera._r_float == 0.0
|
|
||||||
assert camera._theta_float == 0.0
|
|
||||||
|
|
||||||
|
|
||||||
class TestCameraStateSaveRestore:
|
|
||||||
"""Test camera state can be saved and restored (for hot-rebuild)."""
|
|
||||||
|
|
||||||
def test_radial_camera_state_save(self):
|
|
||||||
"""RADIAL camera should save polar coordinate state."""
|
|
||||||
camera = Camera.radial()
|
|
||||||
camera._theta_float = math.pi / 4
|
|
||||||
camera._r_float = 50.0
|
|
||||||
|
|
||||||
# Save state via CameraStage adapter
|
|
||||||
from engine.pipeline.adapters.camera import CameraStage
|
|
||||||
|
|
||||||
stage = CameraStage(camera)
|
|
||||||
|
|
||||||
state = stage.save_state()
|
|
||||||
assert "_theta_float" in state
|
|
||||||
assert "_r_float" in state
|
|
||||||
assert state["_theta_float"] == math.pi / 4
|
|
||||||
assert state["_r_float"] == 50.0
|
|
||||||
|
|
||||||
def test_radial_camera_state_restore(self):
|
|
||||||
"""RADIAL camera should restore polar coordinate state."""
|
|
||||||
camera1 = Camera.radial()
|
|
||||||
camera1._theta_float = math.pi / 3
|
|
||||||
camera1._r_float = 75.0
|
|
||||||
|
|
||||||
from engine.pipeline.adapters.camera import CameraStage
|
|
||||||
|
|
||||||
stage1 = CameraStage(camera1)
|
|
||||||
state = stage1.save_state()
|
|
||||||
|
|
||||||
# Create new camera and restore
|
|
||||||
camera2 = Camera.radial()
|
|
||||||
stage2 = CameraStage(camera2)
|
|
||||||
stage2.restore_state(state)
|
|
||||||
|
|
||||||
assert abs(camera2._theta_float - math.pi / 3) < 0.001
|
|
||||||
assert abs(camera2._r_float - 75.0) < 0.001
|
|
||||||
|
|
||||||
|
|
||||||
class TestCameraViewportApplication:
|
|
||||||
"""Test camera.apply() properly slices buffers."""
|
|
||||||
|
|
||||||
def test_radial_camera_viewport_slicing(self):
|
|
||||||
"""RADIAL camera should properly slice buffer based on position."""
|
|
||||||
camera = Camera.radial(speed=0.5)
|
|
||||||
camera.set_canvas_size(200, 200)
|
|
||||||
|
|
||||||
# Update to move camera
|
|
||||||
camera.update(1.0)
|
|
||||||
|
|
||||||
# Create test buffer with 200 lines
|
|
||||||
buffer = [f"LINE {i:03d}" for i in range(200)]
|
|
||||||
|
|
||||||
# Apply camera viewport (15 lines high)
|
|
||||||
result = camera.apply(buffer, viewport_width=40, viewport_height=15)
|
|
||||||
|
|
||||||
# Result should be exactly 15 lines
|
|
||||||
assert len(result) == 15
|
|
||||||
|
|
||||||
# Each line should be 40 characters (padded or truncated)
|
|
||||||
for line in result:
|
|
||||||
assert len(line) <= 40
|
|
||||||
@@ -77,11 +77,11 @@ class TestDisplayRegistry:
|
|||||||
DisplayRegistry.initialize()
|
DisplayRegistry.initialize()
|
||||||
assert DisplayRegistry.get("terminal") == TerminalDisplay
|
assert DisplayRegistry.get("terminal") == TerminalDisplay
|
||||||
assert DisplayRegistry.get("null") == NullDisplay
|
assert DisplayRegistry.get("null") == NullDisplay
|
||||||
from engine.display.backends.pygame import PygameDisplay
|
from engine.display.backends.sixel import SixelDisplay
|
||||||
from engine.display.backends.websocket import WebSocketDisplay
|
from engine.display.backends.websocket import WebSocketDisplay
|
||||||
|
|
||||||
assert DisplayRegistry.get("websocket") == WebSocketDisplay
|
assert DisplayRegistry.get("websocket") == WebSocketDisplay
|
||||||
assert DisplayRegistry.get("pygame") == PygameDisplay
|
assert DisplayRegistry.get("sixel") == SixelDisplay
|
||||||
|
|
||||||
def test_initialize_idempotent(self):
|
def test_initialize_idempotent(self):
|
||||||
"""initialize can be called multiple times safely."""
|
"""initialize can be called multiple times safely."""
|
||||||
@@ -120,16 +120,12 @@ class TestTerminalDisplay:
|
|||||||
|
|
||||||
def test_get_dimensions_returns_cached_value(self):
|
def test_get_dimensions_returns_cached_value(self):
|
||||||
"""get_dimensions returns cached dimensions for stability."""
|
"""get_dimensions returns cached dimensions for stability."""
|
||||||
import os
|
display = TerminalDisplay()
|
||||||
from unittest.mock import patch
|
display.init(80, 24)
|
||||||
|
|
||||||
# Mock terminal size to ensure deterministic dimensions
|
# First call should set cache
|
||||||
term_size = os.terminal_size((80, 24))
|
d1 = display.get_dimensions()
|
||||||
with patch("os.get_terminal_size", return_value=term_size):
|
assert d1 == (80, 24)
|
||||||
display = TerminalDisplay()
|
|
||||||
display.init(80, 24)
|
|
||||||
d1 = display.get_dimensions()
|
|
||||||
assert d1 == (80, 24)
|
|
||||||
|
|
||||||
def test_show_clears_screen_before_each_frame(self):
|
def test_show_clears_screen_before_each_frame(self):
|
||||||
"""show clears previous frame to prevent visual wobble.
|
"""show clears previous frame to prevent visual wobble.
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ class TestFetchFeed:
|
|||||||
|
|
||||||
@patch("engine.fetch.urllib.request.urlopen")
|
@patch("engine.fetch.urllib.request.urlopen")
|
||||||
def test_fetch_network_error(self, mock_urlopen):
|
def test_fetch_network_error(self, mock_urlopen):
|
||||||
"""Network error returns tuple with None feed."""
|
"""Network error returns None."""
|
||||||
mock_urlopen.side_effect = Exception("Network error")
|
mock_urlopen.side_effect = Exception("Network error")
|
||||||
|
|
||||||
url, feed = fetch_feed("http://example.com/feed")
|
result = fetch_feed("http://example.com/feed")
|
||||||
|
|
||||||
assert feed is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
class TestFetchAll:
|
class TestFetchAll:
|
||||||
@@ -54,7 +54,7 @@ class TestFetchAll:
|
|||||||
{"title": "Headline 1", "published_parsed": (2024, 1, 1, 12, 0, 0)},
|
{"title": "Headline 1", "published_parsed": (2024, 1, 1, 12, 0, 0)},
|
||||||
{"title": "Headline 2", "updated_parsed": (2024, 1, 2, 12, 0, 0)},
|
{"title": "Headline 2", "updated_parsed": (2024, 1, 2, 12, 0, 0)},
|
||||||
]
|
]
|
||||||
mock_fetch_feed.return_value = ("http://example.com", mock_feed)
|
mock_fetch_feed.return_value = mock_feed
|
||||||
mock_skip.return_value = False
|
mock_skip.return_value = False
|
||||||
mock_strip.side_effect = lambda x: x
|
mock_strip.side_effect = lambda x: x
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ class TestFetchAll:
|
|||||||
@patch("engine.fetch.boot_ln")
|
@patch("engine.fetch.boot_ln")
|
||||||
def test_fetch_all_feed_error(self, mock_boot, mock_fetch_feed):
|
def test_fetch_all_feed_error(self, mock_boot, mock_fetch_feed):
|
||||||
"""Feed error increments failed count."""
|
"""Feed error increments failed count."""
|
||||||
mock_fetch_feed.return_value = ("http://example.com", None)
|
mock_fetch_feed.return_value = None
|
||||||
|
|
||||||
items, linked, failed = fetch_all()
|
items, linked, failed = fetch_all()
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ class TestFetchAll:
|
|||||||
{"title": "Sports scores"},
|
{"title": "Sports scores"},
|
||||||
{"title": "Valid headline"},
|
{"title": "Valid headline"},
|
||||||
]
|
]
|
||||||
mock_fetch_feed.return_value = ("http://example.com", mock_feed)
|
mock_fetch_feed.return_value = mock_feed
|
||||||
mock_skip.side_effect = lambda x: x == "Sports scores"
|
mock_skip.side_effect = lambda x: x == "Sports scores"
|
||||||
mock_strip.side_effect = lambda x: x
|
mock_strip.side_effect = lambda x: x
|
||||||
|
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
"""Integration test: FrameBufferStage in the pipeline."""
|
|
||||||
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from engine.data_sources.sources import ListDataSource, SourceItem
|
|
||||||
from engine.effects.types import EffectConfig
|
|
||||||
from engine.pipeline import Pipeline, PipelineConfig
|
|
||||||
from engine.pipeline.adapters import (
|
|
||||||
DataSourceStage,
|
|
||||||
DisplayStage,
|
|
||||||
SourceItemsToBufferStage,
|
|
||||||
)
|
|
||||||
from engine.pipeline.core import PipelineContext
|
|
||||||
from engine.pipeline.stages.framebuffer import FrameBufferStage
|
|
||||||
|
|
||||||
|
|
||||||
class QueueDisplay:
|
|
||||||
"""Stub display that captures every frame into a queue."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.frames: queue.Queue[list[str]] = queue.Queue()
|
|
||||||
self.width = 80
|
|
||||||
self.height = 24
|
|
||||||
self._init_called = False
|
|
||||||
|
|
||||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self._init_called = True
|
|
||||||
|
|
||||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
|
||||||
self.frames.put(list(buffer))
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_dimensions(self) -> tuple[int, int]:
|
|
||||||
return (self.width, self.height)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_pipeline(
|
|
||||||
items: list[SourceItem],
|
|
||||||
history_depth: int = 5,
|
|
||||||
width: int = 80,
|
|
||||||
height: int = 24,
|
|
||||||
) -> tuple[Pipeline, QueueDisplay, PipelineContext]:
|
|
||||||
"""Build pipeline: source -> render -> framebuffer -> display."""
|
|
||||||
display = QueueDisplay()
|
|
||||||
|
|
||||||
ctx = PipelineContext()
|
|
||||||
ctx.set("items", items)
|
|
||||||
|
|
||||||
pipeline = Pipeline(
|
|
||||||
config=PipelineConfig(enable_metrics=True),
|
|
||||||
context=ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Source
|
|
||||||
source = ListDataSource(items, name="test-source")
|
|
||||||
pipeline.add_stage("source", DataSourceStage(source, name="test-source"))
|
|
||||||
|
|
||||||
# Render
|
|
||||||
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
|
||||||
|
|
||||||
# Framebuffer
|
|
||||||
framebuffer = FrameBufferStage(name="default", history_depth=history_depth)
|
|
||||||
pipeline.add_stage("framebuffer", framebuffer)
|
|
||||||
|
|
||||||
# Display
|
|
||||||
pipeline.add_stage("display", DisplayStage(display, name="queue"))
|
|
||||||
|
|
||||||
pipeline.build()
|
|
||||||
pipeline.initialize()
|
|
||||||
|
|
||||||
return pipeline, display, ctx
|
|
||||||
|
|
||||||
|
|
||||||
class TestFrameBufferAcceptance:
|
|
||||||
"""Test FrameBufferStage in a full pipeline."""
|
|
||||||
|
|
||||||
def test_framebuffer_populates_history(self):
|
|
||||||
"""After several frames, framebuffer should have history stored."""
|
|
||||||
items = [
|
|
||||||
SourceItem(content="Frame\nBuffer\nTest", source="test", timestamp="0")
|
|
||||||
]
|
|
||||||
pipeline, display, ctx = _build_pipeline(items, history_depth=5)
|
|
||||||
|
|
||||||
# Run 3 frames
|
|
||||||
for i in range(3):
|
|
||||||
result = pipeline.execute([])
|
|
||||||
assert result.success, f"Pipeline failed at frame {i}: {result.error}"
|
|
||||||
|
|
||||||
# Check framebuffer history in context
|
|
||||||
history = ctx.get("framebuffer.default.history")
|
|
||||||
assert history is not None, "Framebuffer history not found in context"
|
|
||||||
assert len(history) == 3, f"Expected 3 history frames, got {len(history)}"
|
|
||||||
|
|
||||||
def test_framebuffer_respects_depth(self):
|
|
||||||
"""Framebuffer should not exceed configured history depth."""
|
|
||||||
items = [SourceItem(content="Depth\nTest", source="test", timestamp="0")]
|
|
||||||
pipeline, display, ctx = _build_pipeline(items, history_depth=3)
|
|
||||||
|
|
||||||
# Run 5 frames
|
|
||||||
for i in range(5):
|
|
||||||
result = pipeline.execute([])
|
|
||||||
assert result.success
|
|
||||||
|
|
||||||
history = ctx.get("framebuffer.default.history")
|
|
||||||
assert history is not None
|
|
||||||
assert len(history) == 3, f"Expected depth 3, got {len(history)}"
|
|
||||||
|
|
||||||
def test_framebuffer_current_intensity(self):
|
|
||||||
"""Framebuffer should compute current intensity map."""
|
|
||||||
items = [SourceItem(content="Intensity\nMap", source="test", timestamp="0")]
|
|
||||||
pipeline, display, ctx = _build_pipeline(items, history_depth=5)
|
|
||||||
|
|
||||||
# Run at least one frame
|
|
||||||
result = pipeline.execute([])
|
|
||||||
assert result.success
|
|
||||||
|
|
||||||
intensity = ctx.get("framebuffer.default.current_intensity")
|
|
||||||
assert intensity is not None, "No intensity map in context"
|
|
||||||
# Intensity should be a list of one value per line? Actually it's a 2D array or list?
|
|
||||||
# Let's just check it's non-empty
|
|
||||||
assert len(intensity) > 0, "Intensity map is empty"
|
|
||||||
|
|
||||||
def test_framebuffer_get_frame(self):
|
|
||||||
"""Should be able to retrieve specific frames from history."""
|
|
||||||
items = [SourceItem(content="Retrieve\nFrame", source="test", timestamp="0")]
|
|
||||||
pipeline, display, ctx = _build_pipeline(items, history_depth=5)
|
|
||||||
|
|
||||||
# Run 2 frames
|
|
||||||
for i in range(2):
|
|
||||||
result = pipeline.execute([])
|
|
||||||
assert result.success
|
|
||||||
|
|
||||||
# Retrieve frame 0 (most recent)
|
|
||||||
recent = pipeline.get_stage("framebuffer").get_frame(0, ctx)
|
|
||||||
assert recent is not None, "Cannot retrieve recent frame"
|
|
||||||
assert len(recent) > 0, "Recent frame is empty"
|
|
||||||
|
|
||||||
# Retrieve frame 1 (previous)
|
|
||||||
previous = pipeline.get_stage("framebuffer").get_frame(1, ctx)
|
|
||||||
assert previous is not None, "Cannot retrieve previous frame"
|
|
||||||
|
|
||||||
def test_framebuffer_with_motionblur_effect(self):
|
|
||||||
"""MotionBlurEffect should work when depending on framebuffer."""
|
|
||||||
from engine.effects.plugins.motionblur import MotionBlurEffect
|
|
||||||
from engine.pipeline.adapters import EffectPluginStage
|
|
||||||
|
|
||||||
items = [SourceItem(content="Motion\nBlur", source="test", timestamp="0")]
|
|
||||||
display = QueueDisplay()
|
|
||||||
ctx = PipelineContext()
|
|
||||||
ctx.set("items", items)
|
|
||||||
|
|
||||||
pipeline = Pipeline(
|
|
||||||
config=PipelineConfig(enable_metrics=True),
|
|
||||||
context=ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
source = ListDataSource(items, name="test")
|
|
||||||
pipeline.add_stage("source", DataSourceStage(source, name="test"))
|
|
||||||
pipeline.add_stage("render", SourceItemsToBufferStage(name="render"))
|
|
||||||
|
|
||||||
framebuffer = FrameBufferStage(name="default", history_depth=3)
|
|
||||||
pipeline.add_stage("framebuffer", framebuffer)
|
|
||||||
|
|
||||||
motionblur = MotionBlurEffect()
|
|
||||||
motionblur.configure(EffectConfig(enabled=True, intensity=0.5))
|
|
||||||
pipeline.add_stage(
|
|
||||||
"motionblur",
|
|
||||||
EffectPluginStage(
|
|
||||||
motionblur,
|
|
||||||
name="motionblur",
|
|
||||||
dependencies={"framebuffer.history.default"},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline.add_stage("display", DisplayStage(display, name="queue"))
|
|
||||||
|
|
||||||
pipeline.build()
|
|
||||||
pipeline.initialize()
|
|
||||||
|
|
||||||
# Run a few frames
|
|
||||||
for i in range(5):
|
|
||||||
result = pipeline.execute([])
|
|
||||||
assert result.success, f"Motion blur pipeline failed at frame {i}"
|
|
||||||
|
|
||||||
# Check that history exists
|
|
||||||
history = ctx.get("framebuffer.default.history")
|
|
||||||
assert history is not None
|
|
||||||
assert len(history) > 0
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for FrameBufferStage.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from engine.pipeline.core import DataType, PipelineContext
|
|
||||||
from engine.pipeline.params import PipelineParams
|
|
||||||
from engine.pipeline.stages.framebuffer import FrameBufferConfig, FrameBufferStage
|
|
||||||
|
|
||||||
|
|
||||||
def make_ctx(width: int = 80, height: int = 24) -> PipelineContext:
|
|
||||||
"""Create a PipelineContext for testing."""
|
|
||||||
ctx = PipelineContext()
|
|
||||||
params = PipelineParams()
|
|
||||||
params.viewport_width = width
|
|
||||||
params.viewport_height = height
|
|
||||||
ctx.params = params
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
class TestFrameBufferStage:
|
|
||||||
"""Tests for FrameBufferStage."""
|
|
||||||
|
|
||||||
def test_init(self):
|
|
||||||
"""FrameBufferStage initializes with default config."""
|
|
||||||
stage = FrameBufferStage()
|
|
||||||
assert stage.name == "framebuffer"
|
|
||||||
assert stage.category == "effect"
|
|
||||||
assert stage.config.history_depth == 2
|
|
||||||
|
|
||||||
def test_capabilities(self):
|
|
||||||
"""Stage provides framebuffer.history.{name} capability."""
|
|
||||||
stage = FrameBufferStage()
|
|
||||||
assert "framebuffer.history.default" in stage.capabilities
|
|
||||||
|
|
||||||
def test_dependencies(self):
|
|
||||||
"""Stage depends on render.output."""
|
|
||||||
stage = FrameBufferStage()
|
|
||||||
assert "render.output" in stage.dependencies
|
|
||||||
|
|
||||||
def test_inlet_outlet_types(self):
|
|
||||||
"""Stage accepts and produces TEXT_BUFFER."""
|
|
||||||
stage = FrameBufferStage()
|
|
||||||
assert DataType.TEXT_BUFFER in stage.inlet_types
|
|
||||||
assert DataType.TEXT_BUFFER in stage.outlet_types
|
|
||||||
|
|
||||||
def test_init_context(self):
|
|
||||||
"""init initializes context state with prefixed keys."""
|
|
||||||
stage = FrameBufferStage()
|
|
||||||
ctx = make_ctx()
|
|
||||||
|
|
||||||
result = stage.init(ctx)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
assert ctx.get("framebuffer.default.history") == []
|
|
||||||
assert ctx.get("framebuffer.default.intensity_history") == []
|
|
||||||
|
|
||||||
def test_process_stores_buffer_in_history(self):
|
|
||||||
"""process stores buffer in history."""
|
|
||||||
stage = FrameBufferStage()
|
|
||||||
ctx = make_ctx()
|
|
||||||
stage.init(ctx)
|
|
||||||
|
|
||||||
buffer = ["line1", "line2", "line3"]
|
|
||||||
result = stage.process(buffer, ctx)
|
|
||||||
|
|
||||||
assert result == buffer # Pass-through
|
|
||||||
history = ctx.get("framebuffer.default.history")
|
|
||||||
assert len(history) == 1
|
|
||||||
assert history[0] == buffer
|
|
||||||
|
|
||||||
def test_process_computes_intensity(self):
|
|
||||||
"""process computes intensity map."""
|
|
||||||
stage = FrameBufferStage()
|
|
||||||
ctx = make_ctx()
|
|
||||||
stage.init(ctx)
|
|
||||||
|
|
||||||
buffer = ["hello world", "test line", ""]
|
|
||||||
stage.process(buffer, ctx)
|
|
||||||
|
|
||||||
intensity = ctx.get("framebuffer.default.current_intensity")
|
|
||||||
assert intensity is not None
|
|
||||||
assert len(intensity) == 3 # Three rows
|
|
||||||
# Non-empty lines should have intensity > 0
|
|
||||||
assert intensity[0] > 0
|
|
||||||
assert intensity[1] > 0
|
|
||||||
# Empty line should have intensity 0
|
|
||||||
assert intensity[2] == 0.0
|
|
||||||
|
|
||||||
def test_process_keeps_multiple_frames(self):
|
|
||||||
"""process keeps configured depth of frames."""
|
|
||||||
config = FrameBufferConfig(history_depth=3, name="test")
|
|
||||||
stage = FrameBufferStage(config)
|
|
||||||
ctx = make_ctx()
|
|
||||||
stage.init(ctx)
|
|
||||||
|
|
||||||
# Process several frames
|
|
||||||
for i in range(5):
|
|
||||||
buffer = [f"frame {i}"]
|
|
||||||
stage.process(buffer, ctx)
|
|
||||||
|
|
||||||
history = ctx.get("framebuffer.test.history")
|
|
||||||
assert len(history) == 3 # Only last 3 kept
|
|
||||||
# Should be in reverse chronological order (most recent first)
|
|
||||||
assert history[0] == ["frame 4"]
|
|
||||||
assert history[1] == ["frame 3"]
|
|
||||||
assert history[2] == ["frame 2"]
|
|
||||||
|
|
||||||
def test_process_keeps_intensity_sync(self):
|
|
||||||
"""process keeps intensity history in sync with frame history."""
|
|
||||||
config = FrameBufferConfig(history_depth=3, name="sync")
|
|
||||||
stage = FrameBufferStage(config)
|
|
||||||
ctx = make_ctx()
|
|
||||||
stage.init(ctx)
|
|
||||||
|
|
||||||
buffers = [
|
|
||||||
["a"],
|
|
||||||
["bb"],
|
|
||||||
["ccc"],
|
|
||||||
]
|
|
||||||
for buf in buffers:
|
|
||||||
stage.process(buf, ctx)
|
|
||||||
|
|
||||||
prefix = "framebuffer.sync"
|
|
||||||
frame_hist = ctx.get(f"{prefix}.history")
|
|
||||||
intensity_hist = ctx.get(f"{prefix}.intensity_history")
|
|
||||||
assert len(frame_hist) == len(intensity_hist) == 3
|
|
||||||
|
|
||||||
# Each frame's intensity should match
|
|
||||||
for i, frame in enumerate(frame_hist):
|
|
||||||
computed_intensity = stage._compute_buffer_intensity(frame, len(frame))
|
|
||||||
assert intensity_hist[i] == pytest.approx(computed_intensity)
|
|
||||||
|
|
||||||
def test_get_frame(self):
|
|
||||||
"""get_frame retrieves frames from history by index."""
|
|
||||||
config = FrameBufferConfig(history_depth=3)
|
|
||||||
stage = FrameBufferStage(config)
|
|
||||||
ctx = make_ctx()
|
|
||||||
stage.init(ctx)
|
|
||||||
|
|
||||||
buffers = [["f1"], ["f2"], ["f3"]]
|
|
||||||
for buf in buffers:
|
|
||||||
stage.process(buf, ctx)
|
|
||||||
|
|
||||||
assert stage.get_frame(0, ctx) == ["f3"] # Most recent
|
|
||||||
assert stage.get_frame(1, ctx) == ["f2"]
|
|
||||||
assert stage.get_frame(2, ctx) == ["f1"]
|
|
||||||
assert stage.get_frame(3, ctx) is None # Out of range
|
|
||||||
|
|
||||||
def test_get_intensity(self):
|
|
||||||
"""get_intensity retrieves intensity maps by index."""
|
|
||||||
stage = FrameBufferStage()
|
|
||||||
ctx = make_ctx()
|
|
||||||
stage.init(ctx)
|
|
||||||
|
|
||||||
buffers = [["line"], ["longer line"]]
|
|
||||||
for buf in buffers:
|
|
||||||
stage.process(buf, ctx)
|
|
||||||
|
|
||||||
intensity0 = stage.get_intensity(0, ctx)
|
|
||||||
intensity1 = stage.get_intensity(1, ctx)
|
|
||||||
assert intensity0 is not None
|
|
||||||
assert intensity1 is not None
|
|
||||||
# Longer line should have higher intensity (more non-space chars)
|
|
||||||
assert sum(intensity1) > sum(intensity0)
|
|
||||||
|
|
||||||
def test_compute_buffer_intensity_simple(self):
|
|
||||||
"""_compute_buffer_intensity computes simple density."""
|
|
||||||
stage = FrameBufferStage()
|
|
||||||
|
|
||||||
buf = ["abc", " ", "de"]
|
|
||||||
intensities = stage._compute_buffer_intensity(buf, max_rows=3)
|
|
||||||
|
|
||||||
assert len(intensities) == 3
|
|
||||||
# "abc" -> 3/3 = 1.0
|
|
||||||
assert pytest.approx(intensities[0]) == 1.0
|
|
||||||
# " " -> 0/2 = 0.0
|
|
||||||
assert pytest.approx(intensities[1]) == 0.0
|
|
||||||
# "de" -> 2/2 = 1.0
|
|
||||||
assert pytest.approx(intensities[2]) == 1.0
|
|
||||||
|
|
||||||
def test_compute_buffer_intensity_with_ansi(self):
|
|
||||||
"""_compute_buffer_intensity strips ANSI codes."""
|
|
||||||
stage = FrameBufferStage()
|
|
||||||
|
|
||||||
# Line with ANSI color codes
|
|
||||||
buf = ["\033[31mred\033[0m", "normal"]
|
|
||||||
intensities = stage._compute_buffer_intensity(buf, max_rows=2)
|
|
||||||
|
|
||||||
assert len(intensities) == 2
|
|
||||||
# Should treat "red" as 3 non-space chars
|
|
||||||
assert pytest.approx(intensities[0]) == 1.0 # "red" = 3/3
|
|
||||||
assert pytest.approx(intensities[1]) == 1.0 # "normal" = 6/6
|
|
||||||
|
|
||||||
def test_compute_buffer_intensity_padding(self):
|
|
||||||
"""_compute_buffer_intensity pads to max_rows."""
|
|
||||||
stage = FrameBufferStage()
|
|
||||||
|
|
||||||
buf = ["short"]
|
|
||||||
intensities = stage._compute_buffer_intensity(buf, max_rows=5)
|
|
||||||
|
|
||||||
assert len(intensities) == 5
|
|
||||||
assert intensities[0] > 0
|
|
||||||
assert all(i == 0.0 for i in intensities[1:])
|
|
||||||
|
|
||||||
def test_thread_safety(self):
|
|
||||||
"""process is thread-safe."""
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
stage = FrameBufferStage(name="threadtest")
|
|
||||||
ctx = make_ctx()
|
|
||||||
stage.init(ctx)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
|
|
||||||
def worker(idx):
|
|
||||||
buffer = [f"thread {idx}"]
|
|
||||||
stage.process(buffer, ctx)
|
|
||||||
results.append(len(ctx.get("framebuffer.threadtest.history", [])))
|
|
||||||
|
|
||||||
threads = [Thread(target=worker, args=(i,)) for i in range(10)]
|
|
||||||
for t in threads:
|
|
||||||
t.start()
|
|
||||||
for t in threads:
|
|
||||||
t.join()
|
|
||||||
|
|
||||||
# All threads should see consistent state
|
|
||||||
assert len(ctx.get("framebuffer.threadtest.history")) <= 2 # Depth limit
|
|
||||||
# All worker threads should have completed without errors
|
|
||||||
assert len(results) == 10
|
|
||||||
|
|
||||||
def test_cleanup(self):
|
|
||||||
"""cleanup does nothing but can be called."""
|
|
||||||
stage = FrameBufferStage()
|
|
||||||
# Should not raise
|
|
||||||
stage.cleanup()
|
|
||||||
@@ -11,7 +11,14 @@ import pytest
|
|||||||
from engine.data_sources.sources import SourceItem
|
from engine.data_sources.sources import SourceItem
|
||||||
from engine.pipeline.adapters import FontStage, ViewportFilterStage
|
from engine.pipeline.adapters import FontStage, ViewportFilterStage
|
||||||
from engine.pipeline.core import PipelineContext
|
from engine.pipeline.core import PipelineContext
|
||||||
from engine.pipeline.params import PipelineParams
|
|
||||||
|
|
||||||
|
class MockParams:
|
||||||
|
"""Mock parameters object for testing."""
|
||||||
|
|
||||||
|
def __init__(self, viewport_width: int = 80, viewport_height: int = 24):
|
||||||
|
self.viewport_width = viewport_width
|
||||||
|
self.viewport_height = viewport_height
|
||||||
|
|
||||||
|
|
||||||
class TestViewportFilterPerformance:
|
class TestViewportFilterPerformance:
|
||||||
@@ -31,12 +38,12 @@ class TestViewportFilterPerformance:
|
|||||||
|
|
||||||
stage = ViewportFilterStage()
|
stage = ViewportFilterStage()
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = PipelineParams(viewport_height=24)
|
ctx.params = MockParams(viewport_height=24)
|
||||||
|
|
||||||
result = benchmark(stage.process, test_items, ctx)
|
result = benchmark(stage.process, test_items, ctx)
|
||||||
|
|
||||||
# Verify result is correct - viewport filter takes first N items
|
# Verify result is correct
|
||||||
assert len(result) <= 24 # viewport height
|
assert len(result) <= 5
|
||||||
assert len(result) > 0
|
assert len(result) > 0
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
@@ -54,7 +61,7 @@ class TestViewportFilterPerformance:
|
|||||||
|
|
||||||
font_stage = FontStage()
|
font_stage = FontStage()
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = PipelineParams()
|
ctx.params = MockParams()
|
||||||
|
|
||||||
result = benchmark(font_stage.process, filtered_items, ctx)
|
result = benchmark(font_stage.process, filtered_items, ctx)
|
||||||
|
|
||||||
@@ -68,8 +75,8 @@ class TestViewportFilterPerformance:
|
|||||||
|
|
||||||
With 1438 items and 24-line viewport:
|
With 1438 items and 24-line viewport:
|
||||||
- Without filter: FontStage renders all 1438 items
|
- Without filter: FontStage renders all 1438 items
|
||||||
- With filter: FontStage renders ~4 items (height-based)
|
- With filter: FontStage renders ~3 items (layout-based)
|
||||||
- Expected improvement: 1438 / 4 ≈ 360x
|
- Expected improvement: 1438 / 3 ≈ 479x
|
||||||
"""
|
"""
|
||||||
test_items = [
|
test_items = [
|
||||||
SourceItem(f"Headline {i}", "source", str(i)) for i in range(1438)
|
SourceItem(f"Headline {i}", "source", str(i)) for i in range(1438)
|
||||||
@@ -77,15 +84,15 @@ class TestViewportFilterPerformance:
|
|||||||
|
|
||||||
stage = ViewportFilterStage()
|
stage = ViewportFilterStage()
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = PipelineParams(viewport_height=24)
|
ctx.params = MockParams(viewport_height=24)
|
||||||
|
|
||||||
filtered = stage.process(test_items, ctx)
|
filtered = stage.process(test_items, ctx)
|
||||||
improvement_factor = len(test_items) / len(filtered)
|
improvement_factor = len(test_items) / len(filtered)
|
||||||
|
|
||||||
# Verify we get significant improvement (height-based filtering)
|
# Verify we get expected ~479x improvement (better than old ~288x)
|
||||||
assert 300 < improvement_factor < 500
|
assert 400 < improvement_factor < 600
|
||||||
# Verify filtered count is ~4 (24 viewport / 6 rows per item)
|
# Verify filtered count is reasonable (layout-based is more precise)
|
||||||
assert len(filtered) == 4
|
assert 2 <= len(filtered) <= 5
|
||||||
|
|
||||||
|
|
||||||
class TestPipelinePerformanceWithRealData:
|
class TestPipelinePerformanceWithRealData:
|
||||||
@@ -102,7 +109,7 @@ class TestPipelinePerformanceWithRealData:
|
|||||||
font_stage = FontStage()
|
font_stage = FontStage()
|
||||||
|
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = PipelineParams(viewport_height=24)
|
ctx.params = MockParams(viewport_height=24)
|
||||||
|
|
||||||
# Filter should reduce items quickly
|
# Filter should reduce items quickly
|
||||||
filtered = filter_stage.process(large_items, ctx)
|
filtered = filter_stage.process(large_items, ctx)
|
||||||
@@ -122,14 +129,14 @@ class TestPipelinePerformanceWithRealData:
|
|||||||
|
|
||||||
# Test different viewport heights
|
# Test different viewport heights
|
||||||
test_cases = [
|
test_cases = [
|
||||||
(12, 12), # 12px height -> 12 items
|
(12, 3), # 12px height -> ~3 items
|
||||||
(24, 24), # 24px height -> 24 items
|
(24, 5), # 24px height -> ~5 items
|
||||||
(48, 48), # 48px height -> 48 items
|
(48, 9), # 48px height -> ~9 items
|
||||||
]
|
]
|
||||||
|
|
||||||
for viewport_height, expected_max_items in test_cases:
|
for viewport_height, expected_max_items in test_cases:
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = PipelineParams(viewport_height=viewport_height)
|
ctx.params = MockParams(viewport_height=viewport_height)
|
||||||
|
|
||||||
filtered = stage.process(large_items, ctx)
|
filtered = stage.process(large_items, ctx)
|
||||||
|
|
||||||
@@ -152,14 +159,14 @@ class TestPerformanceRegressions:
|
|||||||
|
|
||||||
stage = ViewportFilterStage()
|
stage = ViewportFilterStage()
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = PipelineParams()
|
ctx.params = MockParams()
|
||||||
|
|
||||||
filtered = stage.process(large_items, ctx)
|
filtered = stage.process(large_items, ctx)
|
||||||
|
|
||||||
# Should NOT have all items (regression detection)
|
# Should NOT have all items (regression detection)
|
||||||
assert len(filtered) != len(large_items)
|
assert len(filtered) != len(large_items)
|
||||||
# With height-based filtering, ~4 items fit in 24-row viewport (6 rows/item)
|
# Should have drastically fewer items
|
||||||
assert len(filtered) == 4
|
assert len(filtered) < 10
|
||||||
|
|
||||||
def test_font_stage_doesnt_hang_with_filter(self):
|
def test_font_stage_doesnt_hang_with_filter(self):
|
||||||
"""Regression test: FontStage shouldn't hang when receiving filtered data.
|
"""Regression test: FontStage shouldn't hang when receiving filtered data.
|
||||||
@@ -175,7 +182,7 @@ class TestPerformanceRegressions:
|
|||||||
|
|
||||||
font_stage = FontStage()
|
font_stage = FontStage()
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = PipelineParams()
|
ctx.params = MockParams()
|
||||||
|
|
||||||
# Should complete instantly (not hang)
|
# Should complete instantly (not hang)
|
||||||
result = font_stage.process(filtered_items, ctx)
|
result = font_stage.process(filtered_items, ctx)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class TestStageRegistry:
|
|||||||
assert "pygame" in displays
|
assert "pygame" in displays
|
||||||
assert "websocket" in displays
|
assert "websocket" in displays
|
||||||
assert "null" in displays
|
assert "null" in displays
|
||||||
|
assert "sixel" in displays
|
||||||
|
|
||||||
def test_create_source_stage(self):
|
def test_create_source_stage(self):
|
||||||
"""StageRegistry.create creates source stages."""
|
"""StageRegistry.create creates source stages."""
|
||||||
@@ -129,7 +130,7 @@ class TestPipeline:
|
|||||||
|
|
||||||
pipeline.add_stage("source", mock_source)
|
pipeline.add_stage("source", mock_source)
|
||||||
pipeline.add_stage("display", mock_display)
|
pipeline.add_stage("display", mock_display)
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
assert pipeline._initialized is True
|
assert pipeline._initialized is True
|
||||||
assert "source" in pipeline.execution_order
|
assert "source" in pipeline.execution_order
|
||||||
@@ -182,7 +183,7 @@ class TestPipeline:
|
|||||||
pipeline.add_stage("source", mock_source)
|
pipeline.add_stage("source", mock_source)
|
||||||
pipeline.add_stage("effect", mock_effect)
|
pipeline.add_stage("effect", mock_effect)
|
||||||
pipeline.add_stage("display", mock_display)
|
pipeline.add_stage("display", mock_display)
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
result = pipeline.execute(None)
|
result = pipeline.execute(None)
|
||||||
|
|
||||||
@@ -218,7 +219,7 @@ class TestPipeline:
|
|||||||
|
|
||||||
pipeline.add_stage("source", mock_source)
|
pipeline.add_stage("source", mock_source)
|
||||||
pipeline.add_stage("failing", mock_failing)
|
pipeline.add_stage("failing", mock_failing)
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
result = pipeline.execute(None)
|
result = pipeline.execute(None)
|
||||||
|
|
||||||
@@ -254,7 +255,7 @@ class TestPipeline:
|
|||||||
|
|
||||||
pipeline.add_stage("source", mock_source)
|
pipeline.add_stage("source", mock_source)
|
||||||
pipeline.add_stage("optional", mock_optional)
|
pipeline.add_stage("optional", mock_optional)
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
result = pipeline.execute(None)
|
result = pipeline.execute(None)
|
||||||
|
|
||||||
@@ -302,7 +303,7 @@ class TestCapabilityBasedDependencies:
|
|||||||
pipeline = Pipeline()
|
pipeline = Pipeline()
|
||||||
pipeline.add_stage("headlines", SourceStage())
|
pipeline.add_stage("headlines", SourceStage())
|
||||||
pipeline.add_stage("render", RenderStage())
|
pipeline.add_stage("render", RenderStage())
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
assert "headlines" in pipeline.execution_order
|
assert "headlines" in pipeline.execution_order
|
||||||
assert "render" in pipeline.execution_order
|
assert "render" in pipeline.execution_order
|
||||||
@@ -334,7 +335,7 @@ class TestCapabilityBasedDependencies:
|
|||||||
pipeline.add_stage("render", RenderStage())
|
pipeline.add_stage("render", RenderStage())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
raise AssertionError("Should have raised StageError")
|
raise AssertionError("Should have raised StageError")
|
||||||
except StageError as e:
|
except StageError as e:
|
||||||
assert "Missing capabilities" in e.message
|
assert "Missing capabilities" in e.message
|
||||||
@@ -394,7 +395,7 @@ class TestCapabilityBasedDependencies:
|
|||||||
pipeline.add_stage("headlines", SourceA())
|
pipeline.add_stage("headlines", SourceA())
|
||||||
pipeline.add_stage("poetry", SourceB())
|
pipeline.add_stage("poetry", SourceB())
|
||||||
pipeline.add_stage("display", DisplayStage())
|
pipeline.add_stage("display", DisplayStage())
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
assert pipeline.execution_order[0] == "headlines"
|
assert pipeline.execution_order[0] == "headlines"
|
||||||
|
|
||||||
@@ -545,7 +546,7 @@ class TestPipelinePresets:
|
|||||||
FIREHOSE_PRESET,
|
FIREHOSE_PRESET,
|
||||||
PIPELINE_VIZ_PRESET,
|
PIPELINE_VIZ_PRESET,
|
||||||
POETRY_PRESET,
|
POETRY_PRESET,
|
||||||
UI_PRESET,
|
SIXEL_PRESET,
|
||||||
WEBSOCKET_PRESET,
|
WEBSOCKET_PRESET,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -553,8 +554,8 @@ class TestPipelinePresets:
|
|||||||
assert POETRY_PRESET.name == "poetry"
|
assert POETRY_PRESET.name == "poetry"
|
||||||
assert FIREHOSE_PRESET.name == "firehose"
|
assert FIREHOSE_PRESET.name == "firehose"
|
||||||
assert PIPELINE_VIZ_PRESET.name == "pipeline"
|
assert PIPELINE_VIZ_PRESET.name == "pipeline"
|
||||||
|
assert SIXEL_PRESET.name == "sixel"
|
||||||
assert WEBSOCKET_PRESET.name == "websocket"
|
assert WEBSOCKET_PRESET.name == "websocket"
|
||||||
assert UI_PRESET.name == "ui"
|
|
||||||
|
|
||||||
def test_preset_to_params(self):
|
def test_preset_to_params(self):
|
||||||
"""Presets convert to PipelineParams correctly."""
|
"""Presets convert to PipelineParams correctly."""
|
||||||
@@ -791,7 +792,7 @@ class TestFullPipeline:
|
|||||||
pipeline.add_stage("b", StageB())
|
pipeline.add_stage("b", StageB())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
raise AssertionError("Should detect circular dependency")
|
raise AssertionError("Should detect circular dependency")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -815,7 +816,7 @@ class TestPipelineMetrics:
|
|||||||
config = PipelineConfig(enable_metrics=True)
|
config = PipelineConfig(enable_metrics=True)
|
||||||
pipeline = Pipeline(config=config)
|
pipeline = Pipeline(config=config)
|
||||||
pipeline.add_stage("dummy", DummyStage())
|
pipeline.add_stage("dummy", DummyStage())
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
pipeline.execute("test_data")
|
pipeline.execute("test_data")
|
||||||
|
|
||||||
@@ -838,7 +839,7 @@ class TestPipelineMetrics:
|
|||||||
config = PipelineConfig(enable_metrics=False)
|
config = PipelineConfig(enable_metrics=False)
|
||||||
pipeline = Pipeline(config=config)
|
pipeline = Pipeline(config=config)
|
||||||
pipeline.add_stage("dummy", DummyStage())
|
pipeline.add_stage("dummy", DummyStage())
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
pipeline.execute("test_data")
|
pipeline.execute("test_data")
|
||||||
|
|
||||||
@@ -860,7 +861,7 @@ class TestPipelineMetrics:
|
|||||||
config = PipelineConfig(enable_metrics=True)
|
config = PipelineConfig(enable_metrics=True)
|
||||||
pipeline = Pipeline(config=config)
|
pipeline = Pipeline(config=config)
|
||||||
pipeline.add_stage("dummy", DummyStage())
|
pipeline.add_stage("dummy", DummyStage())
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
pipeline.execute("test1")
|
pipeline.execute("test1")
|
||||||
pipeline.execute("test2")
|
pipeline.execute("test2")
|
||||||
@@ -964,7 +965,7 @@ class TestOverlayStages:
|
|||||||
pipeline.add_stage("overlay_a", OverlayStageA())
|
pipeline.add_stage("overlay_a", OverlayStageA())
|
||||||
pipeline.add_stage("overlay_b", OverlayStageB())
|
pipeline.add_stage("overlay_b", OverlayStageB())
|
||||||
pipeline.add_stage("regular", RegularStage())
|
pipeline.add_stage("regular", RegularStage())
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
overlays = pipeline.get_overlay_stages()
|
overlays = pipeline.get_overlay_stages()
|
||||||
assert len(overlays) == 2
|
assert len(overlays) == 2
|
||||||
@@ -1006,7 +1007,7 @@ class TestOverlayStages:
|
|||||||
pipeline = Pipeline()
|
pipeline = Pipeline()
|
||||||
pipeline.add_stage("regular", RegularStage())
|
pipeline.add_stage("regular", RegularStage())
|
||||||
pipeline.add_stage("overlay", OverlayStage())
|
pipeline.add_stage("overlay", OverlayStage())
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
pipeline.execute("data")
|
pipeline.execute("data")
|
||||||
|
|
||||||
@@ -1070,7 +1071,7 @@ class TestOverlayStages:
|
|||||||
|
|
||||||
pipeline = Pipeline()
|
pipeline = Pipeline()
|
||||||
pipeline.add_stage("test", TestStage())
|
pipeline.add_stage("test", TestStage())
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
assert pipeline.get_stage_type("test") == "overlay"
|
assert pipeline.get_stage_type("test") == "overlay"
|
||||||
|
|
||||||
@@ -1092,7 +1093,7 @@ class TestOverlayStages:
|
|||||||
|
|
||||||
pipeline = Pipeline()
|
pipeline = Pipeline()
|
||||||
pipeline.add_stage("test", TestStage())
|
pipeline.add_stage("test", TestStage())
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
assert pipeline.get_render_order("test") == 42
|
assert pipeline.get_render_order("test") == 42
|
||||||
|
|
||||||
@@ -1142,7 +1143,7 @@ class TestInletOutletTypeValidation:
|
|||||||
pipeline.add_stage("consumer", ConsumerStage())
|
pipeline.add_stage("consumer", ConsumerStage())
|
||||||
|
|
||||||
with pytest.raises(StageError) as exc_info:
|
with pytest.raises(StageError) as exc_info:
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
assert "Type mismatch" in str(exc_info.value)
|
assert "Type mismatch" in str(exc_info.value)
|
||||||
assert "TEXT_BUFFER" in str(exc_info.value)
|
assert "TEXT_BUFFER" in str(exc_info.value)
|
||||||
@@ -1190,7 +1191,7 @@ class TestInletOutletTypeValidation:
|
|||||||
pipeline.add_stage("consumer", ConsumerStage())
|
pipeline.add_stage("consumer", ConsumerStage())
|
||||||
|
|
||||||
# Should not raise
|
# Should not raise
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
def test_any_type_accepts_everything(self):
|
def test_any_type_accepts_everything(self):
|
||||||
"""DataType.ANY accepts any upstream type."""
|
"""DataType.ANY accepts any upstream type."""
|
||||||
@@ -1234,7 +1235,7 @@ class TestInletOutletTypeValidation:
|
|||||||
pipeline.add_stage("consumer", ConsumerStage())
|
pipeline.add_stage("consumer", ConsumerStage())
|
||||||
|
|
||||||
# Should not raise because consumer accepts ANY
|
# Should not raise because consumer accepts ANY
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
def test_multiple_compatible_types(self):
|
def test_multiple_compatible_types(self):
|
||||||
"""Stage can declare multiple inlet types."""
|
"""Stage can declare multiple inlet types."""
|
||||||
@@ -1278,7 +1279,7 @@ class TestInletOutletTypeValidation:
|
|||||||
pipeline.add_stage("consumer", ConsumerStage())
|
pipeline.add_stage("consumer", ConsumerStage())
|
||||||
|
|
||||||
# Should not raise because consumer accepts SOURCE_ITEMS
|
# Should not raise because consumer accepts SOURCE_ITEMS
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
def test_display_must_accept_text_buffer(self):
|
def test_display_must_accept_text_buffer(self):
|
||||||
"""Display stages must accept TEXT_BUFFER type."""
|
"""Display stages must accept TEXT_BUFFER type."""
|
||||||
@@ -1302,543 +1303,7 @@ class TestInletOutletTypeValidation:
|
|||||||
pipeline.add_stage("display", BadDisplayStage())
|
pipeline.add_stage("display", BadDisplayStage())
|
||||||
|
|
||||||
with pytest.raises(StageError) as exc_info:
|
with pytest.raises(StageError) as exc_info:
|
||||||
pipeline.build(auto_inject=False)
|
pipeline.build()
|
||||||
|
|
||||||
assert "display" in str(exc_info.value).lower()
|
assert "display" in str(exc_info.value).lower()
|
||||||
|
assert "TEXT_BUFFER" in str(exc_info.value)
|
||||||
|
|
||||||
class TestPipelineMutation:
|
|
||||||
"""Tests for Pipeline Mutation API - dynamic stage modification."""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Set up test fixtures."""
|
|
||||||
StageRegistry._discovered = False
|
|
||||||
StageRegistry._categories.clear()
|
|
||||||
StageRegistry._instances.clear()
|
|
||||||
discover_stages()
|
|
||||||
|
|
||||||
def _create_mock_stage(
|
|
||||||
self,
|
|
||||||
name: str = "test",
|
|
||||||
category: str = "test",
|
|
||||||
capabilities: set | None = None,
|
|
||||||
dependencies: set | None = None,
|
|
||||||
):
|
|
||||||
"""Helper to create a mock stage."""
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
mock = MagicMock(spec=Stage)
|
|
||||||
mock.name = name
|
|
||||||
mock.category = category
|
|
||||||
mock.stage_type = category
|
|
||||||
mock.render_order = 0
|
|
||||||
mock.is_overlay = False
|
|
||||||
mock.inlet_types = {DataType.ANY}
|
|
||||||
mock.outlet_types = {DataType.TEXT_BUFFER}
|
|
||||||
mock.capabilities = capabilities or {f"{category}.{name}"}
|
|
||||||
mock.dependencies = dependencies or set()
|
|
||||||
mock.process = lambda data, ctx: data
|
|
||||||
mock.init = MagicMock(return_value=True)
|
|
||||||
mock.cleanup = MagicMock()
|
|
||||||
mock.is_enabled = MagicMock(return_value=True)
|
|
||||||
mock.set_enabled = MagicMock()
|
|
||||||
mock._enabled = True
|
|
||||||
return mock
|
|
||||||
|
|
||||||
def test_add_stage_initializes_when_pipeline_initialized(self):
|
|
||||||
"""add_stage() initializes stage when pipeline already initialized."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
mock_stage = self._create_mock_stage("test")
|
|
||||||
pipeline.build(auto_inject=False)
|
|
||||||
pipeline._initialized = True
|
|
||||||
|
|
||||||
pipeline.add_stage("test", mock_stage, initialize=True)
|
|
||||||
|
|
||||||
mock_stage.init.assert_called_once()
|
|
||||||
|
|
||||||
def test_add_stage_skips_initialize_when_pipeline_not_initialized(self):
|
|
||||||
"""add_stage() skips initialization when pipeline not built."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
mock_stage = self._create_mock_stage("test")
|
|
||||||
|
|
||||||
pipeline.add_stage("test", mock_stage, initialize=False)
|
|
||||||
|
|
||||||
mock_stage.init.assert_not_called()
|
|
||||||
|
|
||||||
def test_remove_stage_returns_removed_stage(self):
|
|
||||||
"""remove_stage() returns the removed stage."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
mock_stage = self._create_mock_stage("test")
|
|
||||||
pipeline.add_stage("test", mock_stage, initialize=False)
|
|
||||||
|
|
||||||
removed = pipeline.remove_stage("test", cleanup=False)
|
|
||||||
|
|
||||||
assert removed is mock_stage
|
|
||||||
assert "test" not in pipeline.stages
|
|
||||||
|
|
||||||
def test_remove_stage_calls_cleanup_when_requested(self):
|
|
||||||
"""remove_stage() calls cleanup when cleanup=True."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
mock_stage = self._create_mock_stage("test")
|
|
||||||
pipeline.add_stage("test", mock_stage, initialize=False)
|
|
||||||
|
|
||||||
pipeline.remove_stage("test", cleanup=True)
|
|
||||||
|
|
||||||
mock_stage.cleanup.assert_called_once()
|
|
||||||
|
|
||||||
def test_remove_stage_skips_cleanup_when_requested(self):
|
|
||||||
"""remove_stage() skips cleanup when cleanup=False."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
mock_stage = self._create_mock_stage("test")
|
|
||||||
pipeline.add_stage("test", mock_stage, initialize=False)
|
|
||||||
|
|
||||||
pipeline.remove_stage("test", cleanup=False)
|
|
||||||
|
|
||||||
mock_stage.cleanup.assert_not_called()
|
|
||||||
|
|
||||||
def test_remove_nonexistent_stage_returns_none(self):
|
|
||||||
"""remove_stage() returns None for nonexistent stage."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
result = pipeline.remove_stage("nonexistent", cleanup=False)
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_replace_stage_preserves_state(self):
|
|
||||||
"""replace_stage() copies _enabled from old to new stage."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
old_stage = self._create_mock_stage("test")
|
|
||||||
old_stage._enabled = False
|
|
||||||
|
|
||||||
new_stage = self._create_mock_stage("test")
|
|
||||||
|
|
||||||
pipeline.add_stage("test", old_stage, initialize=False)
|
|
||||||
pipeline.replace_stage("test", new_stage, preserve_state=True)
|
|
||||||
|
|
||||||
assert new_stage._enabled is False
|
|
||||||
old_stage.cleanup.assert_called_once()
|
|
||||||
new_stage.init.assert_called_once()
|
|
||||||
|
|
||||||
def test_replace_stage_without_preserving_state(self):
|
|
||||||
"""replace_stage() without preserve_state doesn't copy state."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
old_stage = self._create_mock_stage("test")
|
|
||||||
old_stage._enabled = False
|
|
||||||
|
|
||||||
new_stage = self._create_mock_stage("test")
|
|
||||||
new_stage._enabled = True
|
|
||||||
|
|
||||||
pipeline.add_stage("test", old_stage, initialize=False)
|
|
||||||
pipeline.replace_stage("test", new_stage, preserve_state=False)
|
|
||||||
|
|
||||||
assert new_stage._enabled is True
|
|
||||||
|
|
||||||
def test_replace_nonexistent_stage_returns_none(self):
|
|
||||||
"""replace_stage() returns None for nonexistent stage."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
mock_stage = self._create_mock_stage("test")
|
|
||||||
|
|
||||||
result = pipeline.replace_stage("nonexistent", mock_stage)
|
|
||||||
|
|
||||||
assert result is None
|
|
||||||
|
|
||||||
def test_swap_stages_swaps_stages(self):
|
|
||||||
"""swap_stages() swaps two stages."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
stage_a = self._create_mock_stage("stage_a", "a")
|
|
||||||
stage_b = self._create_mock_stage("stage_b", "b")
|
|
||||||
|
|
||||||
pipeline.add_stage("a", stage_a, initialize=False)
|
|
||||||
pipeline.add_stage("b", stage_b, initialize=False)
|
|
||||||
|
|
||||||
result = pipeline.swap_stages("a", "b")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
assert pipeline.stages["a"].name == "stage_b"
|
|
||||||
assert pipeline.stages["b"].name == "stage_a"
|
|
||||||
|
|
||||||
def test_swap_stages_fails_for_nonexistent(self):
|
|
||||||
"""swap_stages() fails if either stage doesn't exist."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
stage = self._create_mock_stage("test")
|
|
||||||
|
|
||||||
pipeline.add_stage("test", stage, initialize=False)
|
|
||||||
|
|
||||||
result = pipeline.swap_stages("test", "nonexistent")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_move_stage_after(self):
|
|
||||||
"""move_stage() moves stage after another."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
stage_a = self._create_mock_stage("a")
|
|
||||||
stage_b = self._create_mock_stage("b")
|
|
||||||
stage_c = self._create_mock_stage("c")
|
|
||||||
|
|
||||||
pipeline.add_stage("a", stage_a, initialize=False)
|
|
||||||
pipeline.add_stage("b", stage_b, initialize=False)
|
|
||||||
pipeline.add_stage("c", stage_c, initialize=False)
|
|
||||||
pipeline.build(auto_inject=False)
|
|
||||||
|
|
||||||
result = pipeline.move_stage("a", after="c")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
idx_a = pipeline.execution_order.index("a")
|
|
||||||
idx_c = pipeline.execution_order.index("c")
|
|
||||||
assert idx_a > idx_c
|
|
||||||
|
|
||||||
def test_move_stage_before(self):
|
|
||||||
"""move_stage() moves stage before another."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
stage_a = self._create_mock_stage("a")
|
|
||||||
stage_b = self._create_mock_stage("b")
|
|
||||||
stage_c = self._create_mock_stage("c")
|
|
||||||
|
|
||||||
pipeline.add_stage("a", stage_a, initialize=False)
|
|
||||||
pipeline.add_stage("b", stage_b, initialize=False)
|
|
||||||
pipeline.add_stage("c", stage_c, initialize=False)
|
|
||||||
pipeline.build(auto_inject=False)
|
|
||||||
|
|
||||||
result = pipeline.move_stage("c", before="a")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
idx_a = pipeline.execution_order.index("a")
|
|
||||||
idx_c = pipeline.execution_order.index("c")
|
|
||||||
assert idx_c < idx_a
|
|
||||||
|
|
||||||
def test_move_stage_fails_for_nonexistent(self):
|
|
||||||
"""move_stage() fails if stage doesn't exist."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
stage = self._create_mock_stage("test")
|
|
||||||
|
|
||||||
pipeline.add_stage("test", stage, initialize=False)
|
|
||||||
pipeline.build(auto_inject=False)
|
|
||||||
|
|
||||||
result = pipeline.move_stage("nonexistent", after="test")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_move_stage_fails_when_not_initialized(self):
|
|
||||||
"""move_stage() fails if pipeline not built."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
stage = self._create_mock_stage("test")
|
|
||||||
|
|
||||||
pipeline.add_stage("test", stage, initialize=False)
|
|
||||||
|
|
||||||
result = pipeline.move_stage("test", after="other")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_enable_stage(self):
|
|
||||||
"""enable_stage() enables a stage."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
stage = self._create_mock_stage("test")
|
|
||||||
|
|
||||||
pipeline.add_stage("test", stage, initialize=False)
|
|
||||||
|
|
||||||
result = pipeline.enable_stage("test")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
stage.set_enabled.assert_called_with(True)
|
|
||||||
|
|
||||||
def test_enable_nonexistent_stage_returns_false(self):
|
|
||||||
"""enable_stage() returns False for nonexistent stage."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
result = pipeline.enable_stage("nonexistent")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_disable_stage(self):
|
|
||||||
"""disable_stage() disables a stage."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
stage = self._create_mock_stage("test")
|
|
||||||
|
|
||||||
pipeline.add_stage("test", stage, initialize=False)
|
|
||||||
|
|
||||||
result = pipeline.disable_stage("test")
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
stage.set_enabled.assert_called_with(False)
|
|
||||||
|
|
||||||
def test_disable_nonexistent_stage_returns_false(self):
|
|
||||||
"""disable_stage() returns False for nonexistent stage."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
result = pipeline.disable_stage("nonexistent")
|
|
||||||
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_get_stage_info_returns_correct_info(self):
|
|
||||||
"""get_stage_info() returns correct stage information."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
stage = self._create_mock_stage(
|
|
||||||
"test_stage",
|
|
||||||
"effect",
|
|
||||||
capabilities={"effect.test"},
|
|
||||||
dependencies={"source"},
|
|
||||||
)
|
|
||||||
stage.render_order = 5
|
|
||||||
stage.is_overlay = False
|
|
||||||
stage.optional = True
|
|
||||||
|
|
||||||
pipeline.add_stage("test", stage, initialize=False)
|
|
||||||
|
|
||||||
info = pipeline.get_stage_info("test")
|
|
||||||
|
|
||||||
assert info is not None
|
|
||||||
assert info["name"] == "test" # Dict key, not stage.name
|
|
||||||
assert info["category"] == "effect"
|
|
||||||
assert info["stage_type"] == "effect"
|
|
||||||
assert info["enabled"] is True
|
|
||||||
assert info["optional"] is True
|
|
||||||
assert info["capabilities"] == ["effect.test"]
|
|
||||||
assert info["dependencies"] == ["source"]
|
|
||||||
assert info["render_order"] == 5
|
|
||||||
assert info["is_overlay"] is False
|
|
||||||
|
|
||||||
def test_get_stage_info_returns_none_for_nonexistent(self):
|
|
||||||
"""get_stage_info() returns None for nonexistent stage."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
info = pipeline.get_stage_info("nonexistent")
|
|
||||||
|
|
||||||
assert info is None
|
|
||||||
|
|
||||||
def test_get_pipeline_info_returns_complete_info(self):
|
|
||||||
"""get_pipeline_info() returns complete pipeline state."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
stage1 = self._create_mock_stage("stage1")
|
|
||||||
stage2 = self._create_mock_stage("stage2")
|
|
||||||
|
|
||||||
pipeline.add_stage("s1", stage1, initialize=False)
|
|
||||||
pipeline.add_stage("s2", stage2, initialize=False)
|
|
||||||
pipeline.build(auto_inject=False)
|
|
||||||
|
|
||||||
info = pipeline.get_pipeline_info()
|
|
||||||
|
|
||||||
assert "stages" in info
|
|
||||||
assert "execution_order" in info
|
|
||||||
assert info["initialized"] is True
|
|
||||||
assert info["stage_count"] == 2
|
|
||||||
assert "s1" in info["stages"]
|
|
||||||
assert "s2" in info["stages"]
|
|
||||||
|
|
||||||
def test_rebuild_after_mutation(self):
|
|
||||||
"""_rebuild() updates execution order after mutation."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
source = self._create_mock_stage(
|
|
||||||
"source", "source", capabilities={"source"}, dependencies=set()
|
|
||||||
)
|
|
||||||
effect = self._create_mock_stage(
|
|
||||||
"effect", "effect", capabilities={"effect"}, dependencies={"source"}
|
|
||||||
)
|
|
||||||
display = self._create_mock_stage(
|
|
||||||
"display", "display", capabilities={"display"}, dependencies={"effect"}
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline.add_stage("source", source, initialize=False)
|
|
||||||
pipeline.add_stage("effect", effect, initialize=False)
|
|
||||||
pipeline.add_stage("display", display, initialize=False)
|
|
||||||
pipeline.build(auto_inject=False)
|
|
||||||
|
|
||||||
assert pipeline.execution_order == ["source", "effect", "display"]
|
|
||||||
|
|
||||||
pipeline.remove_stage("effect", cleanup=False)
|
|
||||||
|
|
||||||
pipeline._rebuild()
|
|
||||||
|
|
||||||
assert "effect" not in pipeline.execution_order
|
|
||||||
assert "source" in pipeline.execution_order
|
|
||||||
assert "display" in pipeline.execution_order
|
|
||||||
|
|
||||||
def test_add_stage_after_build(self):
|
|
||||||
"""add_stage() can add stage after build with initialization."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
source = self._create_mock_stage(
|
|
||||||
"source", "source", capabilities={"source"}, dependencies=set()
|
|
||||||
)
|
|
||||||
display = self._create_mock_stage(
|
|
||||||
"display", "display", capabilities={"display"}, dependencies={"source"}
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline.add_stage("source", source, initialize=False)
|
|
||||||
pipeline.add_stage("display", display, initialize=False)
|
|
||||||
pipeline.build(auto_inject=False)
|
|
||||||
|
|
||||||
new_stage = self._create_mock_stage(
|
|
||||||
"effect", "effect", capabilities={"effect"}, dependencies={"source"}
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline.add_stage("effect", new_stage, initialize=True)
|
|
||||||
|
|
||||||
assert "effect" in pipeline.stages
|
|
||||||
new_stage.init.assert_called_once()
|
|
||||||
|
|
||||||
def test_mutation_preserves_execution_for_remaining_stages(self):
|
|
||||||
"""Removing a stage doesn't break execution of remaining stages."""
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
call_log = []
|
|
||||||
|
|
||||||
class TestSource(Stage):
|
|
||||||
name = "source"
|
|
||||||
category = "source"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self):
|
|
||||||
return {DataType.NONE}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self):
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self):
|
|
||||||
return {"source"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self):
|
|
||||||
return set()
|
|
||||||
|
|
||||||
def process(self, data, ctx):
|
|
||||||
call_log.append("source")
|
|
||||||
return ["item"]
|
|
||||||
|
|
||||||
class TestEffect(Stage):
|
|
||||||
name = "effect"
|
|
||||||
category = "effect"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self):
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self):
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self):
|
|
||||||
return {"effect"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self):
|
|
||||||
return {"source"}
|
|
||||||
|
|
||||||
def process(self, data, ctx):
|
|
||||||
call_log.append("effect")
|
|
||||||
return data
|
|
||||||
|
|
||||||
class TestDisplay(Stage):
|
|
||||||
name = "display"
|
|
||||||
category = "display"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self):
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self):
|
|
||||||
return {DataType.NONE}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self):
|
|
||||||
return {"display"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self):
|
|
||||||
return {"effect"}
|
|
||||||
|
|
||||||
def process(self, data, ctx):
|
|
||||||
call_log.append("display")
|
|
||||||
return data
|
|
||||||
|
|
||||||
pipeline = Pipeline()
|
|
||||||
pipeline.add_stage("source", TestSource(), initialize=False)
|
|
||||||
pipeline.add_stage("effect", TestEffect(), initialize=False)
|
|
||||||
pipeline.add_stage("display", TestDisplay(), initialize=False)
|
|
||||||
pipeline.build(auto_inject=False)
|
|
||||||
pipeline.initialize()
|
|
||||||
|
|
||||||
result = pipeline.execute(None)
|
|
||||||
assert result.success
|
|
||||||
assert call_log == ["source", "effect", "display"]
|
|
||||||
|
|
||||||
call_log.clear()
|
|
||||||
pipeline.remove_stage("effect", cleanup=True)
|
|
||||||
|
|
||||||
pipeline._rebuild()
|
|
||||||
|
|
||||||
result = pipeline.execute(None)
|
|
||||||
assert result.success
|
|
||||||
assert call_log == ["source", "display"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestAutoInjection:
|
|
||||||
"""Tests for auto-injection of minimum capabilities."""
|
|
||||||
|
|
||||||
def setup_method(self):
|
|
||||||
"""Reset registry before each test."""
|
|
||||||
StageRegistry._discovered = False
|
|
||||||
StageRegistry._categories.clear()
|
|
||||||
StageRegistry._instances.clear()
|
|
||||||
discover_stages()
|
|
||||||
|
|
||||||
def test_auto_injection_provides_minimum_capabilities(self):
|
|
||||||
"""Pipeline with no stages gets minimum capabilities auto-injected."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
# Don't add any stages
|
|
||||||
pipeline.build(auto_inject=True)
|
|
||||||
|
|
||||||
# Should have stages for source, render, camera, display
|
|
||||||
assert len(pipeline.stages) > 0
|
|
||||||
assert "source" in pipeline.stages
|
|
||||||
assert "display" in pipeline.stages
|
|
||||||
|
|
||||||
def test_auto_injection_rebuilds_execution_order(self):
|
|
||||||
"""Auto-injection rebuilds execution order correctly."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
pipeline.build(auto_inject=True)
|
|
||||||
|
|
||||||
# Execution order should be valid
|
|
||||||
assert len(pipeline.execution_order) > 0
|
|
||||||
# Source should come before display
|
|
||||||
source_idx = pipeline.execution_order.index("source")
|
|
||||||
display_idx = pipeline.execution_order.index("display")
|
|
||||||
assert source_idx < display_idx
|
|
||||||
|
|
||||||
def test_validation_error_after_auto_injection(self):
|
|
||||||
"""Pipeline raises error if auto-injection fails to provide capabilities."""
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
# Mock ensure_minimum_capabilities to return empty list (injection failed)
|
|
||||||
with (
|
|
||||||
patch.object(pipeline, "ensure_minimum_capabilities", return_value=[]),
|
|
||||||
patch.object(
|
|
||||||
pipeline,
|
|
||||||
"validate_minimum_capabilities",
|
|
||||||
return_value=(False, ["source"]),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
# Even though injection "ran", it didn't provide the capability
|
|
||||||
# build() should raise StageError
|
|
||||||
with pytest.raises(StageError) as exc_info:
|
|
||||||
pipeline.build(auto_inject=True)
|
|
||||||
|
|
||||||
assert "Auto-injection failed" in str(exc_info.value)
|
|
||||||
|
|
||||||
def test_minimum_capability_removal_recovery(self):
|
|
||||||
"""Pipeline re-injects minimum capability if removed."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
pipeline.build(auto_inject=True)
|
|
||||||
|
|
||||||
# Remove the display stage
|
|
||||||
pipeline.remove_stage("display", cleanup=True)
|
|
||||||
|
|
||||||
# Rebuild with auto-injection
|
|
||||||
pipeline.build(auto_inject=True)
|
|
||||||
|
|
||||||
# Display should be back
|
|
||||||
assert "display" in pipeline.stages
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from engine.pipeline.adapters import (
|
|||||||
EffectPluginStage,
|
EffectPluginStage,
|
||||||
FontStage,
|
FontStage,
|
||||||
SourceItemsToBufferStage,
|
SourceItemsToBufferStage,
|
||||||
ViewportFilterStage,
|
|
||||||
)
|
)
|
||||||
from engine.pipeline.core import PipelineContext
|
from engine.pipeline.core import PipelineContext
|
||||||
from engine.pipeline.params import PipelineParams
|
from engine.pipeline.params import PipelineParams
|
||||||
@@ -130,28 +129,7 @@ def _build_pipeline(
|
|||||||
|
|
||||||
# Render stage
|
# Render stage
|
||||||
if use_font_stage:
|
if use_font_stage:
|
||||||
# FontStage requires viewport_filter stage which requires camera state
|
|
||||||
from engine.camera import Camera
|
|
||||||
from engine.pipeline.adapters import CameraClockStage, CameraStage
|
|
||||||
|
|
||||||
camera = Camera.scroll(speed=0.0)
|
|
||||||
camera.set_canvas_size(200, 200)
|
|
||||||
|
|
||||||
# CameraClockStage updates camera state, must come before viewport_filter
|
|
||||||
pipeline.add_stage(
|
|
||||||
"camera_update", CameraClockStage(camera, name="camera-clock")
|
|
||||||
)
|
|
||||||
|
|
||||||
# ViewportFilterStage requires camera.state
|
|
||||||
pipeline.add_stage(
|
|
||||||
"viewport_filter", ViewportFilterStage(name="viewport-filter")
|
|
||||||
)
|
|
||||||
|
|
||||||
# FontStage converts items to buffer
|
|
||||||
pipeline.add_stage("render", FontStage(name="font"))
|
pipeline.add_stage("render", FontStage(name="font"))
|
||||||
|
|
||||||
# CameraStage applies viewport transformation to rendered buffer
|
|
||||||
pipeline.add_stage("camera", CameraStage(camera, name="static"))
|
|
||||||
else:
|
else:
|
||||||
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
||||||
|
|
||||||
@@ -218,10 +196,9 @@ class TestPipelineE2EHappyPath:
|
|||||||
|
|
||||||
assert result.success
|
assert result.success
|
||||||
frame = display.frames.get(timeout=1)
|
frame = display.frames.get(timeout=1)
|
||||||
# Camera stage pads lines to viewport width, so check for substring match
|
assert "Line A" in frame
|
||||||
assert any("Line A" in line for line in frame)
|
assert "Line B" in frame
|
||||||
assert any("Line B" in line for line in frame)
|
assert "Line C" in frame
|
||||||
assert any("Line C" in line for line in frame)
|
|
||||||
|
|
||||||
def test_empty_source_produces_empty_buffer(self):
|
def test_empty_source_produces_empty_buffer(self):
|
||||||
"""An empty source should produce an empty (or blank) frame."""
|
"""An empty source should produce an empty (or blank) frame."""
|
||||||
@@ -264,10 +241,7 @@ class TestPipelineE2EEffects:
|
|||||||
|
|
||||||
assert result.success
|
assert result.success
|
||||||
frame = display.frames.get(timeout=1)
|
frame = display.frames.get(timeout=1)
|
||||||
# Camera stage pads lines to viewport width, so check for substring match
|
assert "[FX1]" in frame, f"Marker not found in frame: {frame}"
|
||||||
assert any("[FX1]" in line for line in frame), (
|
|
||||||
f"Marker not found in frame: {frame}"
|
|
||||||
)
|
|
||||||
assert "Original" in "\n".join(frame)
|
assert "Original" in "\n".join(frame)
|
||||||
|
|
||||||
def test_effect_chain_ordering(self):
|
def test_effect_chain_ordering(self):
|
||||||
@@ -391,7 +365,7 @@ class TestPipelineE2EStageOrder:
|
|||||||
# All regular (non-overlay) stages should have metrics
|
# All regular (non-overlay) stages should have metrics
|
||||||
assert "source" in stage_names
|
assert "source" in stage_names
|
||||||
assert "render" in stage_names
|
assert "render" in stage_names
|
||||||
assert "queue" in stage_names # Display stage is named "queue" in the test
|
assert "display" in stage_names
|
||||||
assert "effect_m" in stage_names
|
assert "effect_m" in stage_names
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
"""
|
|
||||||
Integration tests for pipeline mutation commands via WebSocket/UI panel.
|
|
||||||
|
|
||||||
Tests the mutation API through the command interface.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from unittest.mock import Mock
|
|
||||||
|
|
||||||
from engine.app.pipeline_runner import _handle_pipeline_mutation
|
|
||||||
from engine.pipeline import Pipeline
|
|
||||||
from engine.pipeline.ui import UIConfig, UIPanel
|
|
||||||
|
|
||||||
|
|
||||||
class TestPipelineMutationCommands:
|
|
||||||
"""Test pipeline mutation commands through the mutation API."""
|
|
||||||
|
|
||||||
def test_can_hot_swap_existing_stage(self):
|
|
||||||
"""Test can_hot_swap returns True for existing, non-critical stage."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
# Add a test stage
|
|
||||||
mock_stage = Mock()
|
|
||||||
mock_stage.capabilities = {"test_capability"}
|
|
||||||
pipeline.add_stage("test_stage", mock_stage)
|
|
||||||
pipeline._capability_map = {"test_capability": ["test_stage"]}
|
|
||||||
|
|
||||||
# Test that we can check hot-swap capability
|
|
||||||
result = pipeline.can_hot_swap("test_stage")
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_can_hot_swap_nonexistent_stage(self):
|
|
||||||
"""Test can_hot_swap returns False for non-existent stage."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
result = pipeline.can_hot_swap("nonexistent_stage")
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_can_hot_swap_minimum_capability(self):
|
|
||||||
"""Test can_hot_swap with minimum capability stage."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
# Add a source stage (minimum capability)
|
|
||||||
mock_stage = Mock()
|
|
||||||
mock_stage.capabilities = {"source"}
|
|
||||||
pipeline.add_stage("source", mock_stage)
|
|
||||||
pipeline._capability_map = {"source": ["source"]}
|
|
||||||
|
|
||||||
# Initialize pipeline to trigger capability validation
|
|
||||||
pipeline._initialized = True
|
|
||||||
|
|
||||||
# Source is the only provider of minimum capability
|
|
||||||
result = pipeline.can_hot_swap("source")
|
|
||||||
# Should be False because it's the sole provider of a minimum capability
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
def test_cleanup_stage(self):
|
|
||||||
"""Test cleanup_stage calls cleanup on specific stage."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
# Add a stage with a mock cleanup method
|
|
||||||
mock_stage = Mock()
|
|
||||||
pipeline.add_stage("test_stage", mock_stage)
|
|
||||||
|
|
||||||
# Cleanup the specific stage
|
|
||||||
pipeline.cleanup_stage("test_stage")
|
|
||||||
|
|
||||||
# Verify cleanup was called
|
|
||||||
mock_stage.cleanup.assert_called_once()
|
|
||||||
|
|
||||||
def test_cleanup_stage_nonexistent(self):
|
|
||||||
"""Test cleanup_stage on non-existent stage doesn't crash."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
pipeline.cleanup_stage("nonexistent_stage")
|
|
||||||
# Should not raise an exception
|
|
||||||
|
|
||||||
def test_remove_stage_rebuilds_execution_order(self):
|
|
||||||
"""Test that remove_stage rebuilds execution order."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
# Add two independent stages
|
|
||||||
stage1 = Mock()
|
|
||||||
stage1.capabilities = {"source"}
|
|
||||||
stage1.dependencies = set()
|
|
||||||
stage1.stage_dependencies = [] # Add empty list for stage dependencies
|
|
||||||
|
|
||||||
stage2 = Mock()
|
|
||||||
stage2.capabilities = {"render.output"}
|
|
||||||
stage2.dependencies = set() # No dependencies
|
|
||||||
stage2.stage_dependencies = [] # No stage dependencies
|
|
||||||
|
|
||||||
pipeline.add_stage("stage1", stage1)
|
|
||||||
pipeline.add_stage("stage2", stage2)
|
|
||||||
|
|
||||||
# Build pipeline to establish execution order
|
|
||||||
pipeline._initialized = True
|
|
||||||
pipeline._capability_map = {"source": ["stage1"], "render.output": ["stage2"]}
|
|
||||||
pipeline._execution_order = ["stage1", "stage2"]
|
|
||||||
|
|
||||||
# Remove stage1
|
|
||||||
pipeline.remove_stage("stage1")
|
|
||||||
|
|
||||||
# Verify execution order was rebuilt
|
|
||||||
assert "stage1" not in pipeline._execution_order
|
|
||||||
assert "stage2" in pipeline._execution_order
|
|
||||||
|
|
||||||
def test_handle_pipeline_mutation_remove_stage(self):
|
|
||||||
"""Test _handle_pipeline_mutation with remove_stage command."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
# Add a mock stage
|
|
||||||
mock_stage = Mock()
|
|
||||||
pipeline.add_stage("test_stage", mock_stage)
|
|
||||||
|
|
||||||
# Create remove command
|
|
||||||
command = {"action": "remove_stage", "stage": "test_stage"}
|
|
||||||
|
|
||||||
# Handle the mutation
|
|
||||||
result = _handle_pipeline_mutation(pipeline, command)
|
|
||||||
|
|
||||||
# Verify it was handled and stage was removed
|
|
||||||
assert result is True
|
|
||||||
assert "test_stage" not in pipeline._stages
|
|
||||||
|
|
||||||
def test_handle_pipeline_mutation_swap_stages(self):
|
|
||||||
"""Test _handle_pipeline_mutation with swap_stages command."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
# Add two mock stages
|
|
||||||
stage1 = Mock()
|
|
||||||
stage2 = Mock()
|
|
||||||
pipeline.add_stage("stage1", stage1)
|
|
||||||
pipeline.add_stage("stage2", stage2)
|
|
||||||
|
|
||||||
# Create swap command
|
|
||||||
command = {"action": "swap_stages", "stage1": "stage1", "stage2": "stage2"}
|
|
||||||
|
|
||||||
# Handle the mutation
|
|
||||||
result = _handle_pipeline_mutation(pipeline, command)
|
|
||||||
|
|
||||||
# Verify it was handled
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_handle_pipeline_mutation_enable_stage(self):
|
|
||||||
"""Test _handle_pipeline_mutation with enable_stage command."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
# Add a mock stage with set_enabled method
|
|
||||||
mock_stage = Mock()
|
|
||||||
mock_stage.set_enabled = Mock()
|
|
||||||
pipeline.add_stage("test_stage", mock_stage)
|
|
||||||
|
|
||||||
# Create enable command
|
|
||||||
command = {"action": "enable_stage", "stage": "test_stage"}
|
|
||||||
|
|
||||||
# Handle the mutation
|
|
||||||
result = _handle_pipeline_mutation(pipeline, command)
|
|
||||||
|
|
||||||
# Verify it was handled
|
|
||||||
assert result is True
|
|
||||||
mock_stage.set_enabled.assert_called_once_with(True)
|
|
||||||
|
|
||||||
def test_handle_pipeline_mutation_disable_stage(self):
|
|
||||||
"""Test _handle_pipeline_mutation with disable_stage command."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
# Add a mock stage with set_enabled method
|
|
||||||
mock_stage = Mock()
|
|
||||||
mock_stage.set_enabled = Mock()
|
|
||||||
pipeline.add_stage("test_stage", mock_stage)
|
|
||||||
|
|
||||||
# Create disable command
|
|
||||||
command = {"action": "disable_stage", "stage": "test_stage"}
|
|
||||||
|
|
||||||
# Handle the mutation
|
|
||||||
result = _handle_pipeline_mutation(pipeline, command)
|
|
||||||
|
|
||||||
# Verify it was handled
|
|
||||||
assert result is True
|
|
||||||
mock_stage.set_enabled.assert_called_once_with(False)
|
|
||||||
|
|
||||||
def test_handle_pipeline_mutation_cleanup_stage(self):
|
|
||||||
"""Test _handle_pipeline_mutation with cleanup_stage command."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
# Add a mock stage
|
|
||||||
mock_stage = Mock()
|
|
||||||
pipeline.add_stage("test_stage", mock_stage)
|
|
||||||
|
|
||||||
# Create cleanup command
|
|
||||||
command = {"action": "cleanup_stage", "stage": "test_stage"}
|
|
||||||
|
|
||||||
# Handle the mutation
|
|
||||||
result = _handle_pipeline_mutation(pipeline, command)
|
|
||||||
|
|
||||||
# Verify it was handled and cleanup was called
|
|
||||||
assert result is True
|
|
||||||
mock_stage.cleanup.assert_called_once()
|
|
||||||
|
|
||||||
def test_handle_pipeline_mutation_can_hot_swap(self):
|
|
||||||
"""Test _handle_pipeline_mutation with can_hot_swap command."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
# Add a mock stage
|
|
||||||
mock_stage = Mock()
|
|
||||||
mock_stage.capabilities = {"test"}
|
|
||||||
pipeline.add_stage("test_stage", mock_stage)
|
|
||||||
pipeline._capability_map = {"test": ["test_stage"]}
|
|
||||||
|
|
||||||
# Create can_hot_swap command
|
|
||||||
command = {"action": "can_hot_swap", "stage": "test_stage"}
|
|
||||||
|
|
||||||
# Handle the mutation
|
|
||||||
result = _handle_pipeline_mutation(pipeline, command)
|
|
||||||
|
|
||||||
# Verify it was handled
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_handle_pipeline_mutation_move_stage(self):
|
|
||||||
"""Test _handle_pipeline_mutation with move_stage command."""
|
|
||||||
pipeline = Pipeline()
|
|
||||||
|
|
||||||
# Add two mock stages
|
|
||||||
stage1 = Mock()
|
|
||||||
stage2 = Mock()
|
|
||||||
pipeline.add_stage("stage1", stage1)
|
|
||||||
pipeline.add_stage("stage2", stage2)
|
|
||||||
|
|
||||||
# Initialize execution order
|
|
||||||
pipeline._execution_order = ["stage1", "stage2"]
|
|
||||||
|
|
||||||
# Create move command to move stage1 after stage2
|
|
||||||
command = {"action": "move_stage", "stage": "stage1", "after": "stage2"}
|
|
||||||
|
|
||||||
# Handle the mutation
|
|
||||||
result = _handle_pipeline_mutation(pipeline, command)
|
|
||||||
|
|
||||||
# Verify it was handled (result might be True or False depending on validation)
|
|
||||||
# The key is that the command was processed
|
|
||||||
assert result in (True, False)
|
|
||||||
|
|
||||||
def test_ui_panel_execute_command_mutation_actions(self):
|
|
||||||
"""Test UI panel execute_command with mutation actions."""
|
|
||||||
ui_panel = UIPanel(UIConfig())
|
|
||||||
|
|
||||||
# Test that mutation actions return False (not handled by UI panel)
|
|
||||||
# These should be handled by the WebSocket command handler instead
|
|
||||||
mutation_actions = [
|
|
||||||
{"action": "remove_stage", "stage": "test"},
|
|
||||||
{"action": "swap_stages", "stage1": "a", "stage2": "b"},
|
|
||||||
{"action": "enable_stage", "stage": "test"},
|
|
||||||
{"action": "disable_stage", "stage": "test"},
|
|
||||||
{"action": "cleanup_stage", "stage": "test"},
|
|
||||||
{"action": "can_hot_swap", "stage": "test"},
|
|
||||||
]
|
|
||||||
|
|
||||||
for command in mutation_actions:
|
|
||||||
result = ui_panel.execute_command(command)
|
|
||||||
assert result is False, (
|
|
||||||
f"Mutation action {command['action']} should not be handled by UI panel"
|
|
||||||
)
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
"""
|
|
||||||
Integration tests for pipeline hot-rebuild and state preservation.
|
|
||||||
|
|
||||||
Tests:
|
|
||||||
1. Viewport size control via --viewport flag
|
|
||||||
2. NullDisplay recording and save/load functionality
|
|
||||||
3. Pipeline state preservation during hot-rebuild
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
from engine.display import DisplayRegistry
|
|
||||||
from engine.display.backends.null import NullDisplay
|
|
||||||
from engine.display.backends.replay import ReplayDisplay
|
|
||||||
from engine.effects import get_registry
|
|
||||||
from engine.fetch import load_cache
|
|
||||||
from engine.pipeline import Pipeline, PipelineConfig, PipelineContext
|
|
||||||
from engine.pipeline.adapters import (
|
|
||||||
EffectPluginStage,
|
|
||||||
FontStage,
|
|
||||||
ViewportFilterStage,
|
|
||||||
create_stage_from_display,
|
|
||||||
create_stage_from_effect,
|
|
||||||
)
|
|
||||||
from engine.pipeline.params import PipelineParams
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def viewport_dims():
|
|
||||||
"""Small viewport dimensions for testing."""
|
|
||||||
return (40, 15)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def items():
|
|
||||||
"""Load cached source items."""
|
|
||||||
items = load_cache()
|
|
||||||
if not items:
|
|
||||||
pytest.skip("No fixture cache available")
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def null_display(viewport_dims):
|
|
||||||
"""Create a NullDisplay for testing."""
|
|
||||||
display = DisplayRegistry.create("null")
|
|
||||||
display.init(viewport_dims[0], viewport_dims[1])
|
|
||||||
return display
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def pipeline_with_null_display(items, null_display):
|
|
||||||
"""Create a pipeline with NullDisplay for testing."""
|
|
||||||
import engine.effects.plugins as effects_plugins
|
|
||||||
|
|
||||||
effects_plugins.discover_plugins()
|
|
||||||
|
|
||||||
width, height = null_display.width, null_display.height
|
|
||||||
params = PipelineParams()
|
|
||||||
params.viewport_width = width
|
|
||||||
params.viewport_height = height
|
|
||||||
|
|
||||||
config = PipelineConfig(
|
|
||||||
source="fixture",
|
|
||||||
display="null",
|
|
||||||
camera="scroll",
|
|
||||||
effects=["noise", "fade"],
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline = Pipeline(config=config, context=PipelineContext())
|
|
||||||
|
|
||||||
from engine.camera import Camera
|
|
||||||
from engine.data_sources.sources import ListDataSource
|
|
||||||
from engine.pipeline.adapters import CameraClockStage, CameraStage, DataSourceStage
|
|
||||||
|
|
||||||
list_source = ListDataSource(items, name="fixture")
|
|
||||||
pipeline.add_stage("source", DataSourceStage(list_source, name="fixture"))
|
|
||||||
|
|
||||||
# Add camera stages (required by ViewportFilterStage)
|
|
||||||
camera = Camera.scroll(speed=0.3)
|
|
||||||
camera.set_canvas_size(200, 200)
|
|
||||||
pipeline.add_stage("camera_update", CameraClockStage(camera, name="camera-clock"))
|
|
||||||
pipeline.add_stage("camera", CameraStage(camera, name="scroll"))
|
|
||||||
|
|
||||||
pipeline.add_stage("viewport_filter", ViewportFilterStage(name="viewport-filter"))
|
|
||||||
pipeline.add_stage("font", FontStage(name="font"))
|
|
||||||
|
|
||||||
effect_registry = get_registry()
|
|
||||||
for effect_name in config.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(null_display, "null"))
|
|
||||||
pipeline.build()
|
|
||||||
|
|
||||||
if not pipeline.initialize():
|
|
||||||
pytest.fail("Failed to initialize pipeline")
|
|
||||||
|
|
||||||
ctx = pipeline.context
|
|
||||||
ctx.params = params
|
|
||||||
ctx.set("display", null_display)
|
|
||||||
ctx.set("items", items)
|
|
||||||
ctx.set("pipeline", pipeline)
|
|
||||||
ctx.set("pipeline_order", pipeline.execution_order)
|
|
||||||
ctx.set("camera_y", 0)
|
|
||||||
|
|
||||||
yield pipeline, params, null_display
|
|
||||||
|
|
||||||
pipeline.cleanup()
|
|
||||||
null_display.cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
class TestNullDisplayRecording:
|
|
||||||
"""Tests for NullDisplay recording functionality."""
|
|
||||||
|
|
||||||
def test_null_display_initialization(self, viewport_dims):
|
|
||||||
"""NullDisplay initializes with correct dimensions."""
|
|
||||||
display = NullDisplay()
|
|
||||||
display.init(viewport_dims[0], viewport_dims[1])
|
|
||||||
assert display.width == viewport_dims[0]
|
|
||||||
assert display.height == viewport_dims[1]
|
|
||||||
|
|
||||||
def test_start_stop_recording(self, null_display):
|
|
||||||
"""NullDisplay can start and stop recording."""
|
|
||||||
assert not null_display._is_recording
|
|
||||||
|
|
||||||
null_display.start_recording()
|
|
||||||
assert null_display._is_recording is True
|
|
||||||
|
|
||||||
null_display.stop_recording()
|
|
||||||
assert null_display._is_recording is False
|
|
||||||
|
|
||||||
def test_record_frames(self, null_display, pipeline_with_null_display):
|
|
||||||
"""NullDisplay records frames when recording is enabled."""
|
|
||||||
pipeline, params, display = pipeline_with_null_display
|
|
||||||
|
|
||||||
display.start_recording()
|
|
||||||
assert len(display._recorded_frames) == 0
|
|
||||||
|
|
||||||
for frame in range(5):
|
|
||||||
params.frame_number = frame
|
|
||||||
pipeline.context.params = params
|
|
||||||
pipeline.execute([])
|
|
||||||
|
|
||||||
assert len(display._recorded_frames) == 5
|
|
||||||
|
|
||||||
def test_get_frames(self, null_display, pipeline_with_null_display):
|
|
||||||
"""NullDisplay.get_frames() returns recorded buffers."""
|
|
||||||
pipeline, params, display = pipeline_with_null_display
|
|
||||||
|
|
||||||
display.start_recording()
|
|
||||||
|
|
||||||
for frame in range(3):
|
|
||||||
params.frame_number = frame
|
|
||||||
pipeline.context.params = params
|
|
||||||
pipeline.execute([])
|
|
||||||
|
|
||||||
frames = display.get_frames()
|
|
||||||
assert len(frames) == 3
|
|
||||||
assert all(isinstance(f, list) for f in frames)
|
|
||||||
|
|
||||||
def test_clear_recording(self, null_display, pipeline_with_null_display):
|
|
||||||
"""NullDisplay.clear_recording() clears recorded frames."""
|
|
||||||
pipeline, params, display = pipeline_with_null_display
|
|
||||||
|
|
||||||
display.start_recording()
|
|
||||||
for frame in range(3):
|
|
||||||
params.frame_number = frame
|
|
||||||
pipeline.context.params = params
|
|
||||||
pipeline.execute([])
|
|
||||||
|
|
||||||
assert len(display._recorded_frames) == 3
|
|
||||||
|
|
||||||
display.clear_recording()
|
|
||||||
assert len(display._recorded_frames) == 0
|
|
||||||
|
|
||||||
def test_save_load_recording(self, null_display, pipeline_with_null_display):
|
|
||||||
"""NullDisplay can save and load recordings."""
|
|
||||||
pipeline, params, display = pipeline_with_null_display
|
|
||||||
|
|
||||||
display.start_recording()
|
|
||||||
for frame in range(3):
|
|
||||||
params.frame_number = frame
|
|
||||||
pipeline.context.params = params
|
|
||||||
pipeline.execute([])
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
||||||
temp_path = f.name
|
|
||||||
|
|
||||||
try:
|
|
||||||
display.save_recording(temp_path)
|
|
||||||
|
|
||||||
with open(temp_path) as f:
|
|
||||||
data = json.load(f)
|
|
||||||
|
|
||||||
assert data["version"] == 1
|
|
||||||
assert data["display"] == "null"
|
|
||||||
assert data["frame_count"] == 3
|
|
||||||
assert len(data["frames"]) == 3
|
|
||||||
|
|
||||||
display2 = NullDisplay()
|
|
||||||
display2.load_recording(temp_path)
|
|
||||||
assert len(display2._recorded_frames) == 3
|
|
||||||
|
|
||||||
finally:
|
|
||||||
Path(temp_path).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
class TestReplayDisplay:
|
|
||||||
"""Tests for ReplayDisplay functionality."""
|
|
||||||
|
|
||||||
def test_replay_display_initialization(self, viewport_dims):
|
|
||||||
"""ReplayDisplay initializes correctly."""
|
|
||||||
display = ReplayDisplay()
|
|
||||||
display.init(viewport_dims[0], viewport_dims[1])
|
|
||||||
assert display.width == viewport_dims[0]
|
|
||||||
assert display.height == viewport_dims[1]
|
|
||||||
|
|
||||||
def test_set_and_get_frames(self):
|
|
||||||
"""ReplayDisplay can set and retrieve frames."""
|
|
||||||
display = ReplayDisplay()
|
|
||||||
frames = [
|
|
||||||
{"buffer": ["line1", "line2"], "width": 40, "height": 15},
|
|
||||||
{"buffer": ["line3", "line4"], "width": 40, "height": 15},
|
|
||||||
]
|
|
||||||
display.set_frames(frames)
|
|
||||||
|
|
||||||
frame = display.get_next_frame()
|
|
||||||
assert frame == ["line1", "line2"]
|
|
||||||
|
|
||||||
frame = display.get_next_frame()
|
|
||||||
assert frame == ["line3", "line4"]
|
|
||||||
|
|
||||||
frame = display.get_next_frame()
|
|
||||||
assert frame is None
|
|
||||||
|
|
||||||
def test_replay_loop_mode(self):
|
|
||||||
"""ReplayDisplay can loop playback."""
|
|
||||||
display = ReplayDisplay()
|
|
||||||
display.set_loop(True)
|
|
||||||
frames = [
|
|
||||||
{"buffer": ["frame1"], "width": 40, "height": 15},
|
|
||||||
{"buffer": ["frame2"], "width": 40, "height": 15},
|
|
||||||
]
|
|
||||||
display.set_frames(frames)
|
|
||||||
|
|
||||||
assert display.get_next_frame() == ["frame1"]
|
|
||||||
assert display.get_next_frame() == ["frame2"]
|
|
||||||
assert display.get_next_frame() == ["frame1"]
|
|
||||||
assert display.get_next_frame() == ["frame2"]
|
|
||||||
|
|
||||||
def test_replay_seek_and_reset(self):
|
|
||||||
"""ReplayDisplay supports seek and reset."""
|
|
||||||
display = ReplayDisplay()
|
|
||||||
frames = [
|
|
||||||
{"buffer": [f"frame{i}"], "width": 40, "height": 15} for i in range(5)
|
|
||||||
]
|
|
||||||
display.set_frames(frames)
|
|
||||||
|
|
||||||
display.seek(3)
|
|
||||||
assert display.get_next_frame() == ["frame3"]
|
|
||||||
|
|
||||||
display.reset()
|
|
||||||
assert display.get_next_frame() == ["frame0"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPipelineHotRebuild:
|
|
||||||
"""Tests for pipeline hot-rebuild and state preservation."""
|
|
||||||
|
|
||||||
def test_pipeline_runs_with_null_display(self, pipeline_with_null_display):
|
|
||||||
"""Pipeline executes successfully with NullDisplay."""
|
|
||||||
pipeline, params, display = pipeline_with_null_display
|
|
||||||
|
|
||||||
for frame in range(5):
|
|
||||||
params.frame_number = frame
|
|
||||||
pipeline.context.params = params
|
|
||||||
result = pipeline.execute([])
|
|
||||||
|
|
||||||
assert result.success
|
|
||||||
assert display._last_buffer is not None
|
|
||||||
|
|
||||||
def test_effect_toggle_during_execution(self, pipeline_with_null_display):
|
|
||||||
"""Effects can be toggled during pipeline execution."""
|
|
||||||
pipeline, params, display = pipeline_with_null_display
|
|
||||||
|
|
||||||
params.frame_number = 0
|
|
||||||
pipeline.context.params = params
|
|
||||||
pipeline.execute([])
|
|
||||||
buffer1 = display._last_buffer
|
|
||||||
|
|
||||||
fade_stage = pipeline.get_stage("effect_fade")
|
|
||||||
assert fade_stage is not None
|
|
||||||
assert isinstance(fade_stage, EffectPluginStage)
|
|
||||||
|
|
||||||
fade_stage._enabled = False
|
|
||||||
fade_stage._effect.config.enabled = False
|
|
||||||
|
|
||||||
params.frame_number = 1
|
|
||||||
pipeline.context.params = params
|
|
||||||
pipeline.execute([])
|
|
||||||
buffer2 = display._last_buffer
|
|
||||||
|
|
||||||
assert buffer1 != buffer2
|
|
||||||
|
|
||||||
def test_state_preservation_across_rebuild(self, pipeline_with_null_display):
|
|
||||||
"""Pipeline state is preserved across hot-rebuild events."""
|
|
||||||
pipeline, params, display = pipeline_with_null_display
|
|
||||||
|
|
||||||
for frame in range(5):
|
|
||||||
params.frame_number = frame
|
|
||||||
pipeline.context.params = params
|
|
||||||
pipeline.execute([])
|
|
||||||
|
|
||||||
camera_y_before = pipeline.context.get("camera_y")
|
|
||||||
|
|
||||||
fade_stage = pipeline.get_stage("effect_fade")
|
|
||||||
if fade_stage and isinstance(fade_stage, EffectPluginStage):
|
|
||||||
fade_stage.set_enabled(not fade_stage.is_enabled())
|
|
||||||
fade_stage._effect.config.enabled = fade_stage.is_enabled()
|
|
||||||
|
|
||||||
params.frame_number = 5
|
|
||||||
pipeline.context.params = params
|
|
||||||
pipeline.execute([])
|
|
||||||
|
|
||||||
pipeline.context.get("camera_y")
|
|
||||||
|
|
||||||
assert camera_y_before is not None
|
|
||||||
|
|
||||||
|
|
||||||
class TestViewportControl:
|
|
||||||
"""Tests for viewport size control."""
|
|
||||||
|
|
||||||
def test_viewport_dimensions_applied(self, items):
|
|
||||||
"""Viewport dimensions are correctly applied to pipeline."""
|
|
||||||
width, height = 40, 15
|
|
||||||
|
|
||||||
display = DisplayRegistry.create("null")
|
|
||||||
display.init(width, height)
|
|
||||||
|
|
||||||
params = PipelineParams()
|
|
||||||
params.viewport_width = width
|
|
||||||
params.viewport_height = height
|
|
||||||
|
|
||||||
config = PipelineConfig(
|
|
||||||
source="fixture",
|
|
||||||
display="null",
|
|
||||||
camera="scroll",
|
|
||||||
effects=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
pipeline = Pipeline(config=config, context=PipelineContext())
|
|
||||||
|
|
||||||
from engine.camera import Camera
|
|
||||||
from engine.data_sources.sources import ListDataSource
|
|
||||||
from engine.pipeline.adapters import (
|
|
||||||
CameraClockStage,
|
|
||||||
CameraStage,
|
|
||||||
DataSourceStage,
|
|
||||||
)
|
|
||||||
|
|
||||||
list_source = ListDataSource(items, name="fixture")
|
|
||||||
pipeline.add_stage("source", DataSourceStage(list_source, name="fixture"))
|
|
||||||
|
|
||||||
# Add camera stages (required by ViewportFilterStage)
|
|
||||||
camera = Camera.scroll(speed=0.3)
|
|
||||||
camera.set_canvas_size(200, 200)
|
|
||||||
pipeline.add_stage(
|
|
||||||
"camera_update", CameraClockStage(camera, name="camera-clock")
|
|
||||||
)
|
|
||||||
pipeline.add_stage("camera", CameraStage(camera, name="scroll"))
|
|
||||||
|
|
||||||
pipeline.add_stage(
|
|
||||||
"viewport_filter", ViewportFilterStage(name="viewport-filter")
|
|
||||||
)
|
|
||||||
pipeline.add_stage("font", FontStage(name="font"))
|
|
||||||
pipeline.add_stage("display", create_stage_from_display(display, "null"))
|
|
||||||
pipeline.build()
|
|
||||||
|
|
||||||
assert pipeline.initialize()
|
|
||||||
|
|
||||||
ctx = pipeline.context
|
|
||||||
ctx.params = params
|
|
||||||
ctx.set("display", display)
|
|
||||||
ctx.set("items", items)
|
|
||||||
ctx.set("pipeline", pipeline)
|
|
||||||
ctx.set("camera_y", 0)
|
|
||||||
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
|
|
||||||
assert result.success
|
|
||||||
assert display._last_buffer is not None
|
|
||||||
|
|
||||||
pipeline.cleanup()
|
|
||||||
display.cleanup()
|
|
||||||
128
tests/test_sixel.py
Normal file
128
tests/test_sixel.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
Tests for engine.display.backends.sixel module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
class TestSixelDisplay:
|
||||||
|
"""Tests for SixelDisplay class."""
|
||||||
|
|
||||||
|
def test_init_stores_dimensions(self):
|
||||||
|
"""init stores dimensions."""
|
||||||
|
from engine.display.backends.sixel import SixelDisplay
|
||||||
|
|
||||||
|
display = SixelDisplay()
|
||||||
|
display.init(80, 24)
|
||||||
|
assert display.width == 80
|
||||||
|
assert display.height == 24
|
||||||
|
|
||||||
|
def test_init_custom_cell_size(self):
|
||||||
|
"""init accepts custom cell size."""
|
||||||
|
from engine.display.backends.sixel import SixelDisplay
|
||||||
|
|
||||||
|
display = SixelDisplay(cell_width=12, cell_height=18)
|
||||||
|
assert display.cell_width == 12
|
||||||
|
assert display.cell_height == 18
|
||||||
|
|
||||||
|
def test_show_handles_empty_buffer(self):
|
||||||
|
"""show handles empty buffer gracefully."""
|
||||||
|
from engine.display.backends.sixel import SixelDisplay
|
||||||
|
|
||||||
|
display = SixelDisplay()
|
||||||
|
display.init(80, 24)
|
||||||
|
|
||||||
|
with patch("engine.display.backends.sixel._encode_sixel") as mock_encode:
|
||||||
|
mock_encode.return_value = ""
|
||||||
|
display.show([])
|
||||||
|
|
||||||
|
def test_show_handles_pil_import_error(self):
|
||||||
|
"""show gracefully handles missing PIL."""
|
||||||
|
from engine.display.backends.sixel import SixelDisplay
|
||||||
|
|
||||||
|
display = SixelDisplay()
|
||||||
|
display.init(80, 24)
|
||||||
|
|
||||||
|
with patch.dict("sys.modules", {"PIL": None}):
|
||||||
|
display.show(["test line"])
|
||||||
|
|
||||||
|
def test_clear_sends_escape_sequence(self):
|
||||||
|
"""clear sends clear screen escape sequence."""
|
||||||
|
from engine.display.backends.sixel import SixelDisplay
|
||||||
|
|
||||||
|
display = SixelDisplay()
|
||||||
|
|
||||||
|
with patch("sys.stdout") as mock_stdout:
|
||||||
|
display.clear()
|
||||||
|
mock_stdout.buffer.write.assert_called()
|
||||||
|
|
||||||
|
def test_cleanup_does_nothing(self):
|
||||||
|
"""cleanup does nothing."""
|
||||||
|
from engine.display.backends.sixel import SixelDisplay
|
||||||
|
|
||||||
|
display = SixelDisplay()
|
||||||
|
display.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSixelAnsiParsing:
|
||||||
|
"""Tests for ANSI parsing in SixelDisplay."""
|
||||||
|
|
||||||
|
def test_parse_empty_string(self):
|
||||||
|
"""handles empty string."""
|
||||||
|
from engine.display.renderer import parse_ansi
|
||||||
|
|
||||||
|
result = parse_ansi("")
|
||||||
|
assert len(result) > 0
|
||||||
|
|
||||||
|
def test_parse_plain_text(self):
|
||||||
|
"""parses plain text without ANSI codes."""
|
||||||
|
from engine.display.renderer import parse_ansi
|
||||||
|
|
||||||
|
result = parse_ansi("hello world")
|
||||||
|
assert len(result) == 1
|
||||||
|
text, fg, bg, bold = result[0]
|
||||||
|
assert text == "hello world"
|
||||||
|
|
||||||
|
def test_parse_with_color_codes(self):
|
||||||
|
"""parses ANSI color codes."""
|
||||||
|
from engine.display.renderer import parse_ansi
|
||||||
|
|
||||||
|
result = parse_ansi("\033[31mred\033[0m")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0][0] == "red"
|
||||||
|
assert result[0][1] == (205, 49, 49)
|
||||||
|
|
||||||
|
def test_parse_with_bold(self):
|
||||||
|
"""parses bold codes."""
|
||||||
|
from engine.display.renderer import parse_ansi
|
||||||
|
|
||||||
|
result = parse_ansi("\033[1mbold\033[0m")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0][0] == "bold"
|
||||||
|
assert result[0][3] is True
|
||||||
|
|
||||||
|
def test_parse_256_color(self):
|
||||||
|
"""parses 256 color codes."""
|
||||||
|
from engine.display.renderer import parse_ansi
|
||||||
|
|
||||||
|
result = parse_ansi("\033[38;5;196mred\033[0m")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0][0] == "red"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSixelEncoding:
|
||||||
|
"""Tests for Sixel encoding."""
|
||||||
|
|
||||||
|
def test_encode_empty_image(self):
|
||||||
|
"""handles empty image."""
|
||||||
|
from engine.display.backends.sixel import _encode_sixel
|
||||||
|
|
||||||
|
with patch("PIL.Image.Image") as mock_image:
|
||||||
|
mock_img_instance = MagicMock()
|
||||||
|
mock_img_instance.convert.return_value = mock_img_instance
|
||||||
|
mock_img_instance.size = (0, 0)
|
||||||
|
mock_img_instance.load.return_value = {}
|
||||||
|
mock_image.return_value = mock_img_instance
|
||||||
|
|
||||||
|
result = _encode_sixel(mock_img_instance)
|
||||||
|
assert result == ""
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for streaming protocol utilities.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from engine.display.streaming import (
|
|
||||||
FrameDiff,
|
|
||||||
MessageType,
|
|
||||||
apply_diff,
|
|
||||||
compress_frame,
|
|
||||||
compute_diff,
|
|
||||||
decode_binary_message,
|
|
||||||
decode_diff_message,
|
|
||||||
decode_rle,
|
|
||||||
decompress_frame,
|
|
||||||
encode_binary_message,
|
|
||||||
encode_diff_message,
|
|
||||||
encode_rle,
|
|
||||||
should_use_diff,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestFrameDiff:
|
|
||||||
"""Tests for FrameDiff computation."""
|
|
||||||
|
|
||||||
def test_compute_diff_all_changed(self):
|
|
||||||
"""compute_diff detects all changed lines."""
|
|
||||||
old = ["a", "b", "c"]
|
|
||||||
new = ["x", "y", "z"]
|
|
||||||
|
|
||||||
diff = compute_diff(old, new)
|
|
||||||
|
|
||||||
assert len(diff.changed_lines) == 3
|
|
||||||
assert diff.width == 1
|
|
||||||
assert diff.height == 3
|
|
||||||
|
|
||||||
def test_compute_diff_no_changes(self):
|
|
||||||
"""compute_diff returns empty for identical buffers."""
|
|
||||||
old = ["a", "b", "c"]
|
|
||||||
new = ["a", "b", "c"]
|
|
||||||
|
|
||||||
diff = compute_diff(old, new)
|
|
||||||
|
|
||||||
assert len(diff.changed_lines) == 0
|
|
||||||
|
|
||||||
def test_compute_diff_partial_changes(self):
|
|
||||||
"""compute_diff detects partial changes."""
|
|
||||||
old = ["a", "b", "c"]
|
|
||||||
new = ["a", "x", "c"]
|
|
||||||
|
|
||||||
diff = compute_diff(old, new)
|
|
||||||
|
|
||||||
assert len(diff.changed_lines) == 1
|
|
||||||
assert diff.changed_lines[0] == (1, "x")
|
|
||||||
|
|
||||||
def test_compute_diff_new_lines(self):
|
|
||||||
"""compute_diff detects new lines added."""
|
|
||||||
old = ["a", "b"]
|
|
||||||
new = ["a", "b", "c"]
|
|
||||||
|
|
||||||
diff = compute_diff(old, new)
|
|
||||||
|
|
||||||
assert len(diff.changed_lines) == 1
|
|
||||||
assert diff.changed_lines[0] == (2, "c")
|
|
||||||
|
|
||||||
def test_compute_diff_empty_old(self):
|
|
||||||
"""compute_diff handles empty old buffer."""
|
|
||||||
old = []
|
|
||||||
new = ["a", "b", "c"]
|
|
||||||
|
|
||||||
diff = compute_diff(old, new)
|
|
||||||
|
|
||||||
assert len(diff.changed_lines) == 3
|
|
||||||
|
|
||||||
|
|
||||||
class TestRLE:
|
|
||||||
"""Tests for run-length encoding."""
|
|
||||||
|
|
||||||
def test_encode_rle_no_repeats(self):
|
|
||||||
"""encode_rle handles no repeated lines."""
|
|
||||||
lines = [(0, "a"), (1, "b"), (2, "c")]
|
|
||||||
|
|
||||||
encoded = encode_rle(lines)
|
|
||||||
|
|
||||||
assert len(encoded) == 3
|
|
||||||
assert encoded[0] == (0, "a", 1)
|
|
||||||
assert encoded[1] == (1, "b", 1)
|
|
||||||
assert encoded[2] == (2, "c", 1)
|
|
||||||
|
|
||||||
def test_encode_rle_with_repeats(self):
|
|
||||||
"""encode_rle compresses repeated lines."""
|
|
||||||
lines = [(0, "a"), (1, "a"), (2, "a"), (3, "b")]
|
|
||||||
|
|
||||||
encoded = encode_rle(lines)
|
|
||||||
|
|
||||||
assert len(encoded) == 2
|
|
||||||
assert encoded[0] == (0, "a", 3)
|
|
||||||
assert encoded[1] == (3, "b", 1)
|
|
||||||
|
|
||||||
def test_decode_rle(self):
|
|
||||||
"""decode_rle reconstructs original lines."""
|
|
||||||
encoded = [(0, "a", 3), (3, "b", 1)]
|
|
||||||
|
|
||||||
decoded = decode_rle(encoded)
|
|
||||||
|
|
||||||
assert decoded == [(0, "a"), (1, "a"), (2, "a"), (3, "b")]
|
|
||||||
|
|
||||||
def test_encode_decode_roundtrip(self):
|
|
||||||
"""encode/decode is lossless."""
|
|
||||||
original = [(i, f"line{i % 3}") for i in range(10)]
|
|
||||||
encoded = encode_rle(original)
|
|
||||||
decoded = decode_rle(encoded)
|
|
||||||
|
|
||||||
assert decoded == original
|
|
||||||
|
|
||||||
|
|
||||||
class TestCompression:
|
|
||||||
"""Tests for frame compression."""
|
|
||||||
|
|
||||||
def test_compress_decompress(self):
|
|
||||||
"""compress_frame is lossless."""
|
|
||||||
buffer = [f"Line {i:02d}" for i in range(24)]
|
|
||||||
|
|
||||||
compressed = compress_frame(buffer)
|
|
||||||
decompressed = decompress_frame(compressed, 24)
|
|
||||||
|
|
||||||
assert decompressed == buffer
|
|
||||||
|
|
||||||
def test_compress_empty(self):
|
|
||||||
"""compress_frame handles empty buffer."""
|
|
||||||
compressed = compress_frame([])
|
|
||||||
decompressed = decompress_frame(compressed, 0)
|
|
||||||
|
|
||||||
assert decompressed == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestBinaryProtocol:
|
|
||||||
"""Tests for binary message encoding."""
|
|
||||||
|
|
||||||
def test_encode_decode_message(self):
|
|
||||||
"""encode_binary_message is lossless."""
|
|
||||||
payload = b"test payload"
|
|
||||||
|
|
||||||
encoded = encode_binary_message(MessageType.FULL_FRAME, 80, 24, payload)
|
|
||||||
msg_type, width, height, decoded_payload = decode_binary_message(encoded)
|
|
||||||
|
|
||||||
assert msg_type == MessageType.FULL_FRAME
|
|
||||||
assert width == 80
|
|
||||||
assert height == 24
|
|
||||||
assert decoded_payload == payload
|
|
||||||
|
|
||||||
def test_encode_decode_all_types(self):
|
|
||||||
"""All message types encode correctly."""
|
|
||||||
for msg_type in MessageType:
|
|
||||||
payload = b"test"
|
|
||||||
encoded = encode_binary_message(msg_type, 80, 24, payload)
|
|
||||||
decoded_type, _, _, _ = decode_binary_message(encoded)
|
|
||||||
assert decoded_type == msg_type
|
|
||||||
|
|
||||||
|
|
||||||
class TestDiffProtocol:
|
|
||||||
"""Tests for diff message encoding."""
|
|
||||||
|
|
||||||
def test_encode_decode_diff(self):
|
|
||||||
"""encode_diff_message is lossless."""
|
|
||||||
diff = FrameDiff(width=80, height=24, changed_lines=[(0, "a"), (5, "b")])
|
|
||||||
|
|
||||||
payload = encode_diff_message(diff)
|
|
||||||
decoded = decode_diff_message(payload)
|
|
||||||
|
|
||||||
assert decoded == diff.changed_lines
|
|
||||||
|
|
||||||
|
|
||||||
class TestApplyDiff:
|
|
||||||
"""Tests for applying diffs."""
|
|
||||||
|
|
||||||
def test_apply_diff(self):
|
|
||||||
"""apply_diff reconstructs new buffer."""
|
|
||||||
old_buffer = ["a", "b", "c", "d"]
|
|
||||||
diff = FrameDiff(width=1, height=4, changed_lines=[(1, "x"), (2, "y")])
|
|
||||||
|
|
||||||
new_buffer = apply_diff(old_buffer, diff)
|
|
||||||
|
|
||||||
assert new_buffer == ["a", "x", "y", "d"]
|
|
||||||
|
|
||||||
def test_apply_diff_new_lines(self):
|
|
||||||
"""apply_diff handles new lines."""
|
|
||||||
old_buffer = ["a", "b"]
|
|
||||||
diff = FrameDiff(width=1, height=4, changed_lines=[(2, "c"), (3, "d")])
|
|
||||||
|
|
||||||
new_buffer = apply_diff(old_buffer, diff)
|
|
||||||
|
|
||||||
assert new_buffer == ["a", "b", "c", "d"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestShouldUseDiff:
|
|
||||||
"""Tests for diff threshold decision."""
|
|
||||||
|
|
||||||
def test_uses_diff_when_small_changes(self):
|
|
||||||
"""should_use_diff returns True when few changes."""
|
|
||||||
old = ["a"] * 100
|
|
||||||
new = ["a"] * 95 + ["b"] * 5
|
|
||||||
|
|
||||||
assert should_use_diff(old, new, threshold=0.3) is True
|
|
||||||
|
|
||||||
def test_uses_full_when_many_changes(self):
|
|
||||||
"""should_use_diff returns False when many changes."""
|
|
||||||
old = ["a"] * 100
|
|
||||||
new = ["b"] * 100
|
|
||||||
|
|
||||||
assert should_use_diff(old, new, threshold=0.3) is False
|
|
||||||
|
|
||||||
def test_uses_diff_at_threshold(self):
|
|
||||||
"""should_use_diff handles threshold boundary."""
|
|
||||||
old = ["a"] * 100
|
|
||||||
new = ["a"] * 70 + ["b"] * 30
|
|
||||||
|
|
||||||
result = should_use_diff(old, new, threshold=0.3)
|
|
||||||
assert result is True or result is False # At boundary
|
|
||||||
|
|
||||||
def test_returns_false_for_empty(self):
|
|
||||||
"""should_use_diff returns False for empty buffers."""
|
|
||||||
assert should_use_diff([], ["a", "b"]) is False
|
|
||||||
assert should_use_diff(["a", "b"], []) is False
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
"""Integration test: TintEffect in the pipeline."""
|
|
||||||
|
|
||||||
import queue
|
|
||||||
|
|
||||||
from engine.data_sources.sources import ListDataSource, SourceItem
|
|
||||||
from engine.effects.plugins.tint import TintEffect
|
|
||||||
from engine.effects.types import EffectConfig
|
|
||||||
from engine.pipeline import Pipeline, PipelineConfig
|
|
||||||
from engine.pipeline.adapters import (
|
|
||||||
DataSourceStage,
|
|
||||||
DisplayStage,
|
|
||||||
EffectPluginStage,
|
|
||||||
SourceItemsToBufferStage,
|
|
||||||
)
|
|
||||||
from engine.pipeline.core import PipelineContext
|
|
||||||
from engine.pipeline.params import PipelineParams
|
|
||||||
|
|
||||||
|
|
||||||
class QueueDisplay:
|
|
||||||
"""Stub display that captures every frame into a queue."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.frames: queue.Queue[list[str]] = queue.Queue()
|
|
||||||
self.width = 80
|
|
||||||
self.height = 24
|
|
||||||
self._init_called = False
|
|
||||||
|
|
||||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self._init_called = True
|
|
||||||
|
|
||||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
|
||||||
self.frames.put(list(buffer))
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_dimensions(self) -> tuple[int, int]:
|
|
||||||
return (self.width, self.height)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_pipeline(
|
|
||||||
items: list[SourceItem],
|
|
||||||
tint_config: EffectConfig | None = None,
|
|
||||||
width: int = 80,
|
|
||||||
height: int = 24,
|
|
||||||
) -> tuple[Pipeline, QueueDisplay, PipelineContext]:
|
|
||||||
"""Build pipeline: source -> render -> tint effect -> display."""
|
|
||||||
display = QueueDisplay()
|
|
||||||
|
|
||||||
ctx = PipelineContext()
|
|
||||||
params = PipelineParams()
|
|
||||||
params.viewport_width = width
|
|
||||||
params.viewport_height = height
|
|
||||||
params.frame_number = 0
|
|
||||||
ctx.params = params
|
|
||||||
ctx.set("items", items)
|
|
||||||
|
|
||||||
pipeline = Pipeline(
|
|
||||||
config=PipelineConfig(enable_metrics=True),
|
|
||||||
context=ctx,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Source
|
|
||||||
source = ListDataSource(items, name="test-source")
|
|
||||||
pipeline.add_stage("source", DataSourceStage(source, name="test-source"))
|
|
||||||
|
|
||||||
# Render (simple)
|
|
||||||
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
|
||||||
|
|
||||||
# Tint effect
|
|
||||||
tint_effect = TintEffect()
|
|
||||||
if tint_config is not None:
|
|
||||||
tint_effect.configure(tint_config)
|
|
||||||
pipeline.add_stage("tint", EffectPluginStage(tint_effect, name="tint"))
|
|
||||||
|
|
||||||
# Display
|
|
||||||
pipeline.add_stage("display", DisplayStage(display, name="queue"))
|
|
||||||
|
|
||||||
pipeline.build()
|
|
||||||
pipeline.initialize()
|
|
||||||
|
|
||||||
return pipeline, display, ctx
|
|
||||||
|
|
||||||
|
|
||||||
class TestTintAcceptance:
|
|
||||||
"""Test TintEffect in a full pipeline."""
|
|
||||||
|
|
||||||
def test_tint_applies_default_color(self):
|
|
||||||
"""Default tint should apply ANSI color codes to output."""
|
|
||||||
items = [SourceItem(content="Hello World", source="test", timestamp="0")]
|
|
||||||
pipeline, display, ctx = _build_pipeline(items)
|
|
||||||
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
|
|
||||||
assert result.success, f"Pipeline failed: {result.error}"
|
|
||||||
frame = display.frames.get(timeout=1)
|
|
||||||
|
|
||||||
text = "\n".join(frame)
|
|
||||||
assert "\033[" in text, f"Expected ANSI codes in frame: {frame}"
|
|
||||||
assert "Hello World" in text
|
|
||||||
|
|
||||||
def test_tint_applies_red_color(self):
|
|
||||||
"""Configured red tint should produce red ANSI code (196-197)."""
|
|
||||||
items = [SourceItem(content="Red Text", source="test", timestamp="0")]
|
|
||||||
config = EffectConfig(
|
|
||||||
enabled=True,
|
|
||||||
intensity=1.0,
|
|
||||||
params={"r": 255, "g": 0, "b": 0, "a": 0.8},
|
|
||||||
)
|
|
||||||
pipeline, display, ctx = _build_pipeline(items, tint_config=config)
|
|
||||||
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
|
|
||||||
assert result.success
|
|
||||||
frame = display.frames.get(timeout=1)
|
|
||||||
line = frame[0]
|
|
||||||
|
|
||||||
# Should contain red ANSI code (196 or 197 in 256 color)
|
|
||||||
assert "\033[38;5;196m" in line or "\033[38;5;197m" in line, (
|
|
||||||
f"Missing red tint: {line}"
|
|
||||||
)
|
|
||||||
assert "Red Text" in line
|
|
||||||
|
|
||||||
def test_tint_disabled_does_nothing(self):
|
|
||||||
"""Disabled tint stage should pass through buffer unchanged."""
|
|
||||||
items = [SourceItem(content="Plain Text", source="test", timestamp="0")]
|
|
||||||
pipeline, display, ctx = _build_pipeline(items)
|
|
||||||
|
|
||||||
# Disable the tint stage
|
|
||||||
stage = pipeline.get_stage("tint")
|
|
||||||
stage.set_enabled(False)
|
|
||||||
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
|
|
||||||
assert result.success
|
|
||||||
frame = display.frames.get(timeout=1)
|
|
||||||
text = "\n".join(frame)
|
|
||||||
|
|
||||||
# Should contain Plain Text with NO ANSI color codes
|
|
||||||
assert "Plain Text" in text
|
|
||||||
assert "\033[" not in text, f"Unexpected ANSI codes in frame: {frame}"
|
|
||||||
|
|
||||||
def test_tint_zero_transparency(self):
|
|
||||||
"""Alpha=0 should pass through buffer unchanged (no tint)."""
|
|
||||||
items = [SourceItem(content="Transparent", source="test", timestamp="0")]
|
|
||||||
config = EffectConfig(
|
|
||||||
enabled=True,
|
|
||||||
intensity=1.0,
|
|
||||||
params={"r": 255, "g": 128, "b": 64, "a": 0.0},
|
|
||||||
)
|
|
||||||
pipeline, display, ctx = _build_pipeline(items, tint_config=config)
|
|
||||||
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
|
|
||||||
assert result.success
|
|
||||||
frame = display.frames.get(timeout=1)
|
|
||||||
text = "\n".join(frame)
|
|
||||||
|
|
||||||
assert "Transparent" in text
|
|
||||||
assert "\033[" not in text, f"Expected no ANSI codes with alpha=0: {frame}"
|
|
||||||
|
|
||||||
def test_tint_with_multiples_lines(self):
|
|
||||||
"""Tint should apply to all non-empty lines."""
|
|
||||||
items = [
|
|
||||||
SourceItem(content="Line1\nLine2\n\nLine4", source="test", timestamp="0")
|
|
||||||
]
|
|
||||||
config = EffectConfig(
|
|
||||||
enabled=True,
|
|
||||||
intensity=1.0,
|
|
||||||
params={"r": 0, "g": 255, "b": 0, "a": 0.7},
|
|
||||||
)
|
|
||||||
pipeline, display, ctx = _build_pipeline(items, tint_config=config)
|
|
||||||
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
|
|
||||||
assert result.success
|
|
||||||
frame = display.frames.get(timeout=1)
|
|
||||||
|
|
||||||
# All non-empty lines should have green ANSI codes
|
|
||||||
green_codes = ["\033[38;5;", "m"]
|
|
||||||
for line in frame:
|
|
||||||
if line.strip():
|
|
||||||
assert green_codes[0] in line and green_codes[1] in line, (
|
|
||||||
f"Missing green tint: {line}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
assert line == "", f"Empty lines should be exactly empty: {line}"
|
|
||||||
|
|
||||||
def test_tint_preserves_empty_lines(self):
|
|
||||||
"""Empty lines should remain empty (no ANSI codes)."""
|
|
||||||
items = [SourceItem(content="A\n\nB", source="test", timestamp="0")]
|
|
||||||
pipeline, display, ctx = _build_pipeline(items)
|
|
||||||
|
|
||||||
result = pipeline.execute(items)
|
|
||||||
|
|
||||||
assert result.success
|
|
||||||
frame = display.frames.get(timeout=1)
|
|
||||||
|
|
||||||
assert frame[0].strip() != ""
|
|
||||||
assert frame[1] == "" # Empty line unchanged
|
|
||||||
assert frame[2].strip() != ""
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
"""
|
|
||||||
Tests for UIPanel.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from engine.pipeline.ui import StageControl, UIConfig, UIPanel
|
|
||||||
|
|
||||||
|
|
||||||
class MockStage:
|
|
||||||
"""Mock stage for testing."""
|
|
||||||
|
|
||||||
def __init__(self, name, category="effect"):
|
|
||||||
self.name = name
|
|
||||||
self.category = category
|
|
||||||
self._enabled = True
|
|
||||||
|
|
||||||
def is_enabled(self):
|
|
||||||
return self._enabled
|
|
||||||
|
|
||||||
|
|
||||||
class TestUIPanel:
|
|
||||||
"""Tests for UIPanel."""
|
|
||||||
|
|
||||||
def test_init(self):
|
|
||||||
"""UIPanel initializes with default config."""
|
|
||||||
panel = UIPanel()
|
|
||||||
assert panel.config.panel_width == 24
|
|
||||||
assert panel.config.stage_list_height == 12
|
|
||||||
assert panel.scroll_offset == 0
|
|
||||||
assert panel.selected_stage is None
|
|
||||||
|
|
||||||
def test_register_stage(self):
|
|
||||||
"""register_stage adds a stage control."""
|
|
||||||
panel = UIPanel()
|
|
||||||
stage = MockStage("noise")
|
|
||||||
panel.register_stage(stage, enabled=True)
|
|
||||||
assert "noise" in panel.stages
|
|
||||||
ctrl = panel.stages["noise"]
|
|
||||||
assert ctrl.name == "noise"
|
|
||||||
assert ctrl.enabled is True
|
|
||||||
assert ctrl.selected is False
|
|
||||||
|
|
||||||
def test_select_stage(self):
|
|
||||||
"""select_stage sets selection."""
|
|
||||||
panel = UIPanel()
|
|
||||||
stage1 = MockStage("noise")
|
|
||||||
stage2 = MockStage("fade")
|
|
||||||
panel.register_stage(stage1)
|
|
||||||
panel.register_stage(stage2)
|
|
||||||
panel.select_stage("fade")
|
|
||||||
assert panel.selected_stage == "fade"
|
|
||||||
assert panel.stages["fade"].selected is True
|
|
||||||
assert panel.stages["noise"].selected is False
|
|
||||||
|
|
||||||
def test_toggle_stage(self):
|
|
||||||
"""toggle_stage flips enabled state."""
|
|
||||||
panel = UIPanel()
|
|
||||||
stage = MockStage("glitch")
|
|
||||||
panel.register_stage(stage, enabled=True)
|
|
||||||
result = panel.toggle_stage("glitch")
|
|
||||||
assert result is False
|
|
||||||
assert panel.stages["glitch"].enabled is False
|
|
||||||
result = panel.toggle_stage("glitch")
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
def test_get_enabled_stages(self):
|
|
||||||
"""get_enabled_stages returns only enabled stage names."""
|
|
||||||
panel = UIPanel()
|
|
||||||
panel.register_stage(MockStage("noise"), enabled=True)
|
|
||||||
panel.register_stage(MockStage("fade"), enabled=False)
|
|
||||||
panel.register_stage(MockStage("glitch"), enabled=True)
|
|
||||||
enabled = panel.get_enabled_stages()
|
|
||||||
assert set(enabled) == {"noise", "glitch"}
|
|
||||||
|
|
||||||
def test_scroll_stages(self):
|
|
||||||
"""scroll_stages moves the view."""
|
|
||||||
panel = UIPanel(UIConfig(stage_list_height=3))
|
|
||||||
for i in range(10):
|
|
||||||
panel.register_stage(MockStage(f"stage{i}"))
|
|
||||||
assert panel.scroll_offset == 0
|
|
||||||
panel.scroll_stages(1)
|
|
||||||
assert panel.scroll_offset == 1
|
|
||||||
panel.scroll_stages(-1)
|
|
||||||
assert panel.scroll_offset == 0
|
|
||||||
# Clamp at max
|
|
||||||
panel.scroll_stages(100)
|
|
||||||
assert panel.scroll_offset == 7 # 10 - 3 = 7
|
|
||||||
|
|
||||||
def test_render_produces_lines(self):
|
|
||||||
"""render produces list of strings of correct width."""
|
|
||||||
panel = UIPanel(UIConfig(panel_width=20))
|
|
||||||
panel.register_stage(MockStage("noise"), enabled=True)
|
|
||||||
panel.register_stage(MockStage("fade"), enabled=False)
|
|
||||||
panel.select_stage("noise")
|
|
||||||
lines = panel.render(80, 24)
|
|
||||||
# All lines should be exactly panel_width chars (20)
|
|
||||||
for line in lines:
|
|
||||||
assert len(line) == 20
|
|
||||||
# Should have header, stage rows, separator, params area, footer
|
|
||||||
assert len(lines) >= 5
|
|
||||||
|
|
||||||
def test_process_key_event_space_toggles_stage(self):
|
|
||||||
"""process_key_event with space toggles UI panel visibility."""
|
|
||||||
panel = UIPanel()
|
|
||||||
stage = MockStage("glitch")
|
|
||||||
panel.register_stage(stage, enabled=True)
|
|
||||||
panel.select_stage("glitch")
|
|
||||||
# Space should now toggle UI panel visibility, not stage
|
|
||||||
assert panel._show_panel is True
|
|
||||||
handled = panel.process_key_event(" ")
|
|
||||||
assert handled is True
|
|
||||||
assert panel._show_panel is False
|
|
||||||
# Pressing space again should show panel
|
|
||||||
handled = panel.process_key_event(" ")
|
|
||||||
assert panel._show_panel is True
|
|
||||||
|
|
||||||
def test_process_key_event_space_does_not_toggle_in_picker(self):
|
|
||||||
"""Space should not toggle UI panel when preset picker is active."""
|
|
||||||
panel = UIPanel()
|
|
||||||
panel._show_panel = True
|
|
||||||
panel._show_preset_picker = True
|
|
||||||
handled = panel.process_key_event(" ")
|
|
||||||
assert handled is False # Not handled when picker active
|
|
||||||
assert panel._show_panel is True # Unchanged
|
|
||||||
|
|
||||||
def test_process_key_event_s_selects_next(self):
|
|
||||||
"""process_key_event with s cycles selection."""
|
|
||||||
panel = UIPanel()
|
|
||||||
panel.register_stage(MockStage("noise"))
|
|
||||||
panel.register_stage(MockStage("fade"))
|
|
||||||
panel.register_stage(MockStage("glitch"))
|
|
||||||
panel.select_stage("noise")
|
|
||||||
handled = panel.process_key_event("s")
|
|
||||||
assert handled is True
|
|
||||||
assert panel.selected_stage == "fade"
|
|
||||||
|
|
||||||
def test_process_key_event_hjkl_navigation(self):
|
|
||||||
"""process_key_event with HJKL keys."""
|
|
||||||
panel = UIPanel()
|
|
||||||
stage = MockStage("noise")
|
|
||||||
panel.register_stage(stage)
|
|
||||||
panel.select_stage("noise")
|
|
||||||
|
|
||||||
# J or Down should scroll or adjust param
|
|
||||||
assert panel.scroll_stages(1) is None # Just test it doesn't error
|
|
||||||
# H or Left should adjust param (when param selected)
|
|
||||||
panel.selected_stage = "noise"
|
|
||||||
panel._focused_param = "intensity"
|
|
||||||
panel.stages["noise"].params["intensity"] = 0.5
|
|
||||||
|
|
||||||
# Left/H should decrease
|
|
||||||
handled = panel.process_key_event("h")
|
|
||||||
assert handled is True
|
|
||||||
# L or Right should increase
|
|
||||||
handled = panel.process_key_event("l")
|
|
||||||
assert handled is True
|
|
||||||
|
|
||||||
# K should scroll up
|
|
||||||
panel.selected_stage = None
|
|
||||||
handled = panel.process_key_event("k")
|
|
||||||
assert handled is True
|
|
||||||
|
|
||||||
def test_set_event_callback(self):
|
|
||||||
"""set_event_callback registers callback."""
|
|
||||||
panel = UIPanel()
|
|
||||||
called = []
|
|
||||||
|
|
||||||
def callback(stage_name, enabled):
|
|
||||||
called.append((stage_name, enabled))
|
|
||||||
|
|
||||||
panel.set_event_callback("stage_toggled", callback)
|
|
||||||
panel.toggle_stage("test") # No stage, won't trigger
|
|
||||||
# Simulate toggle through event
|
|
||||||
panel._emit_event("stage_toggled", stage_name="noise", enabled=False)
|
|
||||||
assert called == [("noise", False)]
|
|
||||||
|
|
||||||
def test_register_stage_returns_control(self):
|
|
||||||
"""register_stage should return the StageControl instance."""
|
|
||||||
panel = UIPanel()
|
|
||||||
stage = MockStage("noise_effect")
|
|
||||||
control = panel.register_stage(stage, enabled=True)
|
|
||||||
assert control is not None
|
|
||||||
assert isinstance(control, StageControl)
|
|
||||||
assert control.name == "noise_effect"
|
|
||||||
assert control.enabled is True
|
|
||||||
@@ -110,9 +110,10 @@ class TestViewportFilterStage:
|
|||||||
filtered = stage.process(test_items, ctx)
|
filtered = stage.process(test_items, ctx)
|
||||||
improvement_factor = len(test_items) / len(filtered)
|
improvement_factor = len(test_items) / len(filtered)
|
||||||
|
|
||||||
# Verify we get significant improvement (360x with 4 items vs 1438)
|
# Verify we get at least 400x improvement (better than old ~288x)
|
||||||
assert improvement_factor > 300
|
assert improvement_factor > 400
|
||||||
assert 300 < improvement_factor < 500
|
# Verify we get the expected ~479x improvement
|
||||||
|
assert 400 < improvement_factor < 600
|
||||||
|
|
||||||
|
|
||||||
class TestViewportFilterIntegration:
|
class TestViewportFilterIntegration:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
from engine.effects.legacy import vis_offset, vis_trunc
|
from engine.effects.legacy import vis_offset, vis_trunc
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -160,236 +160,3 @@ class TestWebSocketDisplayUnavailable:
|
|||||||
"""show does nothing when websockets unavailable."""
|
"""show does nothing when websockets unavailable."""
|
||||||
display = WebSocketDisplay()
|
display = WebSocketDisplay()
|
||||||
display.show(["line1", "line2"])
|
display.show(["line1", "line2"])
|
||||||
|
|
||||||
|
|
||||||
class TestWebSocketUIPanelIntegration:
|
|
||||||
"""Tests for WebSocket-UIPanel integration for remote control."""
|
|
||||||
|
|
||||||
def test_set_controller_stores_controller(self):
|
|
||||||
"""set_controller stores the controller reference."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
mock_controller = MagicMock()
|
|
||||||
display.set_controller(mock_controller)
|
|
||||||
assert display._controller is mock_controller
|
|
||||||
|
|
||||||
def test_set_command_callback_stores_callback(self):
|
|
||||||
"""set_command_callback stores the callback."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
callback = MagicMock()
|
|
||||||
display.set_command_callback(callback)
|
|
||||||
assert display._command_callback is callback
|
|
||||||
|
|
||||||
def test_get_state_snapshot_returns_none_without_controller(self):
|
|
||||||
"""_get_state_snapshot returns None when no controller is set."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
assert display._get_state_snapshot() is None
|
|
||||||
|
|
||||||
def test_get_state_snapshot_returns_controller_state(self):
|
|
||||||
"""_get_state_snapshot returns state from controller."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
|
|
||||||
# Create mock controller with expected attributes
|
|
||||||
mock_controller = MagicMock()
|
|
||||||
mock_controller.stages = {
|
|
||||||
"test_stage": MagicMock(
|
|
||||||
enabled=True, params={"intensity": 0.5}, selected=False
|
|
||||||
)
|
|
||||||
}
|
|
||||||
mock_controller._current_preset = "demo"
|
|
||||||
mock_controller._presets = ["demo", "test"]
|
|
||||||
mock_controller.selected_stage = "test_stage"
|
|
||||||
|
|
||||||
display.set_controller(mock_controller)
|
|
||||||
state = display._get_state_snapshot()
|
|
||||||
|
|
||||||
assert state is not None
|
|
||||||
assert "stages" in state
|
|
||||||
assert "test_stage" in state["stages"]
|
|
||||||
assert state["stages"]["test_stage"]["enabled"] is True
|
|
||||||
assert state["stages"]["test_stage"]["params"] == {"intensity": 0.5}
|
|
||||||
assert state["preset"] == "demo"
|
|
||||||
assert state["presets"] == ["demo", "test"]
|
|
||||||
assert state["selected_stage"] == "test_stage"
|
|
||||||
|
|
||||||
def test_get_state_snapshot_handles_missing_attributes(self):
|
|
||||||
"""_get_state_snapshot handles controller without all attributes."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
|
|
||||||
# Create mock controller without stages attribute using spec
|
|
||||||
# This prevents MagicMock from auto-creating the attribute
|
|
||||||
mock_controller = MagicMock(spec=[]) # Empty spec means no attributes
|
|
||||||
|
|
||||||
display.set_controller(mock_controller)
|
|
||||||
state = display._get_state_snapshot()
|
|
||||||
|
|
||||||
assert state == {}
|
|
||||||
|
|
||||||
def test_broadcast_state_sends_to_clients(self):
|
|
||||||
"""broadcast_state sends state update to all connected clients."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
|
|
||||||
# Mock client with send method
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.send = MagicMock()
|
|
||||||
display._clients.add(mock_client)
|
|
||||||
|
|
||||||
test_state = {"test": "state"}
|
|
||||||
display.broadcast_state(test_state)
|
|
||||||
|
|
||||||
# Verify send was called with JSON containing state
|
|
||||||
mock_client.send.assert_called_once()
|
|
||||||
call_args = mock_client.send.call_args[0][0]
|
|
||||||
assert '"type": "state"' in call_args
|
|
||||||
assert '"test"' in call_args
|
|
||||||
|
|
||||||
def test_broadcast_state_noop_when_no_clients(self):
|
|
||||||
"""broadcast_state does nothing when no clients connected."""
|
|
||||||
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
|
||||||
display = WebSocketDisplay()
|
|
||||||
display._clients.clear()
|
|
||||||
|
|
||||||
# Should not raise error
|
|
||||||
display.broadcast_state({"test": "state"})
|
|
||||||
|
|
||||||
|
|
||||||
class TestWebSocketHTTPServerPath:
|
|
||||||
"""Tests for WebSocket HTTP server client directory path calculation."""
|
|
||||||
|
|
||||||
def test_client_dir_path_calculation(self):
|
|
||||||
"""Client directory path is correctly calculated from websocket.py location."""
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Use the actual websocket.py file location, not the test file
|
|
||||||
websocket_module = __import__(
|
|
||||||
"engine.display.backends.websocket", fromlist=["WebSocketDisplay"]
|
|
||||||
)
|
|
||||||
websocket_file = websocket_module.__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 calculation (shouldn't happen in normal test runs)
|
|
||||||
client_dir = os.path.join(
|
|
||||||
os.path.dirname(
|
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(websocket_file)))
|
|
||||||
),
|
|
||||||
"client",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify the client directory exists and contains expected files
|
|
||||||
assert os.path.exists(client_dir), f"Client directory not found: {client_dir}"
|
|
||||||
assert "index.html" in os.listdir(client_dir), (
|
|
||||||
"index.html not found in client directory"
|
|
||||||
)
|
|
||||||
assert "editor.html" in os.listdir(client_dir), (
|
|
||||||
"editor.html not found in client directory"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify the path is correct (should be .../Mainline/client)
|
|
||||||
assert client_dir.endswith("client"), (
|
|
||||||
f"Client dir should end with 'client': {client_dir}"
|
|
||||||
)
|
|
||||||
assert "Mainline" in client_dir, (
|
|
||||||
f"Client dir should contain 'Mainline': {client_dir}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_http_server_directory_serves_client_files(self):
|
|
||||||
"""HTTP server directory correctly serves client files."""
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Use the actual websocket.py file location, not the test file
|
|
||||||
websocket_module = __import__(
|
|
||||||
"engine.display.backends.websocket", fromlist=["WebSocketDisplay"]
|
|
||||||
)
|
|
||||||
websocket_file = websocket_module.__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:
|
|
||||||
client_dir = os.path.join(
|
|
||||||
os.path.dirname(
|
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(websocket_file)))
|
|
||||||
),
|
|
||||||
"client",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify the handler would be able to serve files from this directory
|
|
||||||
# We can't actually instantiate the handler without a valid request,
|
|
||||||
# but we can verify the directory is accessible
|
|
||||||
assert os.access(client_dir, os.R_OK), (
|
|
||||||
f"Client directory not readable: {client_dir}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify key files exist
|
|
||||||
index_path = os.path.join(client_dir, "index.html")
|
|
||||||
editor_path = os.path.join(client_dir, "editor.html")
|
|
||||||
|
|
||||||
assert os.path.exists(index_path), f"index.html not found at: {index_path}"
|
|
||||||
assert os.path.exists(editor_path), f"editor.html not found at: {editor_path}"
|
|
||||||
|
|
||||||
# Verify files are readable
|
|
||||||
assert os.access(index_path, os.R_OK), "index.html not readable"
|
|
||||||
assert os.access(editor_path, os.R_OK), "editor.html not readable"
|
|
||||||
|
|
||||||
def test_old_buggy_path_does_not_find_client_directory(self):
|
|
||||||
"""The old buggy path (3 dirname calls) should NOT find the client directory.
|
|
||||||
|
|
||||||
This test verifies that the old buggy behavior would have failed.
|
|
||||||
The old code used:
|
|
||||||
client_dir = os.path.join(
|
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "client"
|
|
||||||
)
|
|
||||||
|
|
||||||
This would resolve to: .../engine/client (which doesn't exist)
|
|
||||||
Instead of: .../Mainline/client (which does exist)
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Use the actual websocket.py file location
|
|
||||||
websocket_module = __import__(
|
|
||||||
"engine.display.backends.websocket", fromlist=["WebSocketDisplay"]
|
|
||||||
)
|
|
||||||
websocket_file = websocket_module.__file__
|
|
||||||
|
|
||||||
# OLD BUGGY CODE: 3 dirname calls
|
|
||||||
old_buggy_client_dir = os.path.join(
|
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(websocket_file))), "client"
|
|
||||||
)
|
|
||||||
|
|
||||||
# This path should NOT exist (it's the buggy path)
|
|
||||||
assert not os.path.exists(old_buggy_client_dir), (
|
|
||||||
f"Old buggy path should not exist: {old_buggy_client_dir}\n"
|
|
||||||
f"If this assertion fails, the bug may have been fixed elsewhere or "
|
|
||||||
f"the test needs updating."
|
|
||||||
)
|
|
||||||
|
|
||||||
# The buggy path should be .../engine/client, not .../Mainline/client
|
|
||||||
assert old_buggy_client_dir.endswith("engine/client"), (
|
|
||||||
f"Old buggy path should end with 'engine/client': {old_buggy_client_dir}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify that going up one more level (4 dirname calls) finds the correct path
|
|
||||||
correct_client_dir = os.path.join(
|
|
||||||
os.path.dirname(
|
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(websocket_file)))
|
|
||||||
),
|
|
||||||
"client",
|
|
||||||
)
|
|
||||||
assert os.path.exists(correct_client_dir), (
|
|
||||||
f"Correct path should exist: {correct_client_dir}"
|
|
||||||
)
|
|
||||||
assert "index.html" in os.listdir(correct_client_dir), (
|
|
||||||
f"index.html should exist in correct path: {correct_client_dir}"
|
|
||||||
)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user