forked from genewildish/Mainline
Compare commits
45 Commits
60ae4f7dfb
...
integratio
| Author | SHA1 | Date | |
|---|---|---|---|
| ef98add0c5 | |||
| 42aa6f16cc | |||
| a25b80d4a6 | |||
| 3a1aa975d1 | |||
| d5e5f39404 | |||
| 2bfd3a01da | |||
| 4cf316c280 | |||
| 79d271c42b | |||
| 525af4bc46 | |||
| 085f150cb0 | |||
| 0b6e2fae74 | |||
| 6864ad84c6 | |||
| acb42ea140 | |||
| 7014a9d5cd | |||
| 2cc8dbfc02 | |||
| f1d5162488 | |||
| 9f61226779 | |||
| 9415e18679 | |||
| 0819f8d160 | |||
| edd1416407 | |||
| ac9b47f668 | |||
| b149825bcb | |||
| 1b29e91f9d | |||
| 001158214c | |||
| 31f5d9f171 | |||
| bc20a35ea9 | |||
| d4d0344a12 | |||
| 84cb16d463 | |||
| d67423fe4c | |||
| ebe7b04ba5 | |||
| abc4483859 | |||
| d9422b1fec | |||
| 6daea90b0a | |||
| 9d9172ef0d | |||
| 667bef2685 | |||
| f085042dee | |||
| 8b696c96ce | |||
| 72d21459ca | |||
| 58dbbbdba7 | |||
| 7ff78c66ed | |||
| 2229ccdea4 | |||
| f13e89f823 | |||
| 4228400c43 | |||
| 05cc475858 | |||
| cfd7e8931e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,3 +12,4 @@ htmlcov/
|
||||
coverage.xml
|
||||
*.dot
|
||||
*.png
|
||||
test-reports/
|
||||
|
||||
@@ -29,17 +29,28 @@ class Stage(ABC):
|
||||
return set()
|
||||
|
||||
@property
|
||||
def dependencies(self) -> list[str]:
|
||||
"""What this stage needs (e.g., ['source'])"""
|
||||
return []
|
||||
def dependencies(self) -> set[str]:
|
||||
"""What this stage needs (e.g., {'source'})"""
|
||||
return set()
|
||||
```
|
||||
|
||||
### Capability-Based Dependencies
|
||||
|
||||
The Pipeline resolves dependencies using **prefix matching**:
|
||||
- `"source"` matches `"source.headlines"`, `"source.poetry"`, etc.
|
||||
- `"camera.state"` matches the camera state capability
|
||||
- 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
|
||||
|
||||
PureData-style data types for inlet/outlet validation:
|
||||
@@ -76,3 +87,11 @@ Canvas tracks dirty regions automatically when content is written via `put_regio
|
||||
- Use adapters (engine/pipeline/adapters.py) to wrap existing components as stages
|
||||
- Set `optional=True` for stages that can fail gracefully
|
||||
- Use `stage_type` and `render_order` for execution ordering
|
||||
- Clock stages update state independently of data flow
|
||||
|
||||
## Sources
|
||||
|
||||
- engine/pipeline/core.py - Stage base class
|
||||
- engine/pipeline/controller.py - Pipeline implementation
|
||||
- engine/pipeline/adapters/ - Stage adapters
|
||||
- docs/PIPELINE.md - Pipeline documentation
|
||||
|
||||
@@ -19,7 +19,14 @@ All backends implement a common Display protocol (in `engine/display/__init__.py
|
||||
|
||||
```python
|
||||
class Display(Protocol):
|
||||
def show(self, buf: list[str]) -> None:
|
||||
width: int
|
||||
height: int
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize the display"""
|
||||
...
|
||||
|
||||
def show(self, buf: list[str], border: bool = False) -> None:
|
||||
"""Display the buffer"""
|
||||
...
|
||||
|
||||
@@ -27,7 +34,11 @@ class Display(Protocol):
|
||||
"""Clear the display"""
|
||||
...
|
||||
|
||||
def size(self) -> tuple[int, int]:
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources"""
|
||||
...
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Return (width, height)"""
|
||||
...
|
||||
```
|
||||
@@ -37,8 +48,8 @@ class Display(Protocol):
|
||||
Discovers and manages backends:
|
||||
|
||||
```python
|
||||
from engine.display import get_monitor
|
||||
display = get_monitor("terminal") # or "websocket", "sixel", "null", "multi"
|
||||
from engine.display import DisplayRegistry
|
||||
display = DisplayRegistry.create("terminal") # or "websocket", "null", "multi"
|
||||
```
|
||||
|
||||
### Available Backends
|
||||
@@ -47,9 +58,9 @@ display = get_monitor("terminal") # or "websocket", "sixel", "null", "multi"
|
||||
|---------|------|-------------|
|
||||
| terminal | backends/terminal.py | ANSI terminal output |
|
||||
| websocket | backends/websocket.py | Web browser via WebSocket |
|
||||
| sixel | backends/sixel.py | Sixel graphics (pure Python) |
|
||||
| null | backends/null.py | Headless for testing |
|
||||
| multi | backends/multi.py | Forwards to multiple displays |
|
||||
| moderngl | backends/moderngl.py | GPU-accelerated OpenGL rendering (optional) |
|
||||
|
||||
### WebSocket Backend
|
||||
|
||||
@@ -68,9 +79,11 @@ Forwards to multiple displays simultaneously - useful for `terminal + websocket`
|
||||
3. Register in `engine/display/__init__.py`'s `DisplayRegistry`
|
||||
|
||||
Required methods:
|
||||
- `show(buf: list[str])` - Display buffer
|
||||
- `init(width: int, height: int, reuse: bool = False)` - Initialize display
|
||||
- `show(buf: list[str], border: bool = False)` - Display buffer
|
||||
- `clear()` - Clear screen
|
||||
- `size() -> tuple[int, int]` - Terminal dimensions
|
||||
- `cleanup()` - Clean up resources
|
||||
- `get_dimensions() -> tuple[int, int]` - Get terminal dimensions
|
||||
|
||||
Optional methods:
|
||||
- `title(text: str)` - Set window title
|
||||
@@ -81,6 +94,70 @@ Optional methods:
|
||||
```bash
|
||||
python mainline.py --display terminal # default
|
||||
python mainline.py --display websocket
|
||||
python mainline.py --display sixel
|
||||
python mainline.py --display both # terminal + websocket
|
||||
python mainline.py --display moderngl # GPU-accelerated (requires moderngl)
|
||||
```
|
||||
|
||||
## 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
|
||||
- `websocket` - Web browser
|
||||
- `sixel` - Sixel graphics
|
||||
- `null` - Headless
|
||||
- `moderngl` - GPU-accelerated (optional)
|
||||
|
||||
## Available Effects
|
||||
|
||||
|
||||
110
AGENTS.md
110
AGENTS.md
@@ -12,7 +12,7 @@ This project uses:
|
||||
|
||||
```bash
|
||||
mise run install # Install dependencies
|
||||
# Or: uv sync --all-extras # includes mic, websocket, sixel support
|
||||
# Or: uv sync --all-extras # includes mic, websocket support
|
||||
```
|
||||
|
||||
### Available Commands
|
||||
@@ -206,20 +206,6 @@ class TestEventBusSubscribe:
|
||||
|
||||
**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
|
||||
|
||||
Tests live in `tests/` and follow the pattern `test_*.py`.
|
||||
@@ -281,15 +267,45 @@ The new Stage-based pipeline architecture provides capability-based dependency r
|
||||
|
||||
- **Stage** (`engine/pipeline/core.py`): Base class for pipeline stages
|
||||
- **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
|
||||
- **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
|
||||
|
||||
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.
|
||||
- `"camera.state"` matches the camera state capability
|
||||
- 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** (`engine/sensors/__init__.py`): Base class for real-time input sensors
|
||||
@@ -336,9 +352,9 @@ Functions:
|
||||
- **Display abstraction** (`engine/display/`): swap display backends via the Display protocol
|
||||
- `display/backends/terminal.py` - ANSI terminal output
|
||||
- `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/multi.py` - forwards to multiple displays simultaneously
|
||||
- `display/backends/moderngl.py` - GPU-accelerated OpenGL rendering (optional)
|
||||
- `display/__init__.py` - DisplayRegistry for backend discovery
|
||||
|
||||
- **WebSocket display** (`engine/display/backends/websocket.py`): real-time frame broadcasting to web browsers
|
||||
@@ -349,8 +365,7 @@ Functions:
|
||||
- **Display modes** (`--display` flag):
|
||||
- `terminal` - Default ANSI terminal output
|
||||
- `websocket` - Web browser display (requires websockets package)
|
||||
- `sixel` - Sixel graphics in supported terminals (iTerm2, mintty, etc.)
|
||||
- `both` - Terminal + WebSocket simultaneously
|
||||
- `moderngl` - GPU-accelerated rendering (requires moderngl package)
|
||||
|
||||
### Effect Plugin System
|
||||
|
||||
@@ -377,6 +392,43 @@ The rendering pipeline is documented in `docs/PIPELINE.md` using Mermaid diagram
|
||||
2. If adding new SVG diagrams, render them manually using an external tool (e.g., Mermaid Live Editor)
|
||||
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
|
||||
|
||||
A skills library MCP server (`skills`) is available for capturing and tracking learned knowledge. Skills are stored in `~/.skills/`.
|
||||
@@ -384,23 +436,23 @@ A skills library MCP server (`skills`) is available for capturing and tracking l
|
||||
### Workflow
|
||||
|
||||
**Before starting work:**
|
||||
1. Run `skills_list_skills` to see available skills
|
||||
2. Use `skills_peek_skill({name: "skill-name"})` to preview relevant skills
|
||||
3. Use `skills_skill_slice({name: "skill-name", query: "your question"})` to get relevant sections
|
||||
1. Run `local_skills_list_skills` to see available skills
|
||||
2. Use `local_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
|
||||
|
||||
**While working:**
|
||||
- If a skill was wrong or incomplete: `skills_update_skill` → `skills_record_assessment` → `skills_report_outcome({quality: 1})`
|
||||
- If a skill worked correctly: `skills_report_outcome({quality: 4})` (normal) or `quality: 5` (perfect)
|
||||
- If a skill was wrong or incomplete: `local_skills_update_skill` → `local_skills_record_assessment` → `local_skills_report_outcome({quality: 1})`
|
||||
- If a skill worked correctly: `local_skills_report_outcome({quality: 4})` (normal) or `quality: 5` (perfect)
|
||||
|
||||
**End of session:**
|
||||
- Run `skills_reflect_on_session({context_summary: "what you did"})` to identify new skills to capture
|
||||
- Use `skills_create_skill` to add new skills
|
||||
- Use `skills_record_assessment` to score them
|
||||
- Run `local_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 `local_skills_record_assessment` to score them
|
||||
|
||||
### Useful Tools
|
||||
- `skills_review_stale_skills()` - Skills due for review (negative days_until_due)
|
||||
- `skills_skills_report()` - Overview of entire collection
|
||||
- `skills_validate_skill({name: "skill-name"})` - Load skill for review with sources
|
||||
- `local_skills_review_stale_skills()` - Skills due for review (negative days_until_due)
|
||||
- `local_skills_skills_report()` - Overview of entire collection
|
||||
- `local_skills_validate_skill({name: "skill-name"})` - Load skill for review with sources
|
||||
|
||||
### Agent Skills
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -16,7 +16,6 @@ python3 mainline.py --poetry # literary consciousness mode
|
||||
python3 mainline.py -p # same
|
||||
python3 mainline.py --firehose # dense rapid-fire headline mode
|
||||
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 --font-file path.otf # use a specific font file
|
||||
python3 mainline.py --font-dir ~/fonts # scan a different font folder
|
||||
@@ -75,8 +74,7 @@ Mainline supports multiple display backends:
|
||||
|
||||
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
||||
- **WebSocket** (`--display websocket`): Stream to web browser clients
|
||||
- **Sixel** (`--display sixel`): Sixel graphics in supported terminals (iTerm2, mintty)
|
||||
- **Both** (`--display both`): Terminal + WebSocket simultaneously
|
||||
- **ModernGL** (`--display moderngl`): GPU-accelerated rendering (optional)
|
||||
|
||||
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
|
||||
|
||||
@@ -160,9 +158,9 @@ engine/
|
||||
backends/
|
||||
terminal.py ANSI terminal display
|
||||
websocket.py WebSocket server for browser clients
|
||||
sixel.py Sixel graphics (pure Python)
|
||||
null.py headless display for testing
|
||||
multi.py forwards to multiple displays
|
||||
moderngl.py GPU-accelerated OpenGL rendering
|
||||
benchmark.py performance benchmarking tool
|
||||
```
|
||||
|
||||
@@ -194,9 +192,7 @@ mise run format # ruff format
|
||||
|
||||
mise run run # terminal display
|
||||
mise run run-websocket # web display only
|
||||
mise run run-sixel # sixel graphics
|
||||
mise run run-both # terminal + web
|
||||
mise run run-client # both + open browser
|
||||
mise run run-client # terminal + web
|
||||
|
||||
mise run cmd # C&C command interface
|
||||
mise run cmd-stats # watch effects stats
|
||||
|
||||
27
TODO.md
Normal file
27
TODO.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 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
|
||||
313
client/editor.html
Normal file
313
client/editor.html
Normal file
@@ -0,0 +1,313 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mainline Pipeline Editor</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
background: #1a1a1a;
|
||||
color: #eee;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
#sidebar {
|
||||
width: 300px;
|
||||
background: #222;
|
||||
padding: 15px;
|
||||
border-right: 1px solid #333;
|
||||
overflow-y: auto;
|
||||
}
|
||||
#main {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
color: #888;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stage-list {
|
||||
list-style: none;
|
||||
}
|
||||
.stage-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
background: #333;
|
||||
margin-bottom: 2px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.stage-item:hover { background: #444; }
|
||||
.stage-item.selected { background: #0066cc; }
|
||||
.stage-item input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
.stage-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
}
|
||||
.param-group {
|
||||
background: #2a2a2a;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.param-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.param-name {
|
||||
width: 100px;
|
||||
color: #aaa;
|
||||
}
|
||||
.param-slider {
|
||||
flex: 1;
|
||||
margin: 0 10px;
|
||||
}
|
||||
.param-value {
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
color: #4f4;
|
||||
}
|
||||
.preset-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.preset-btn {
|
||||
background: #333;
|
||||
border: 1px solid #444;
|
||||
color: #ccc;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.preset-btn:hover { background: #444; }
|
||||
.preset-btn.active { background: #0066cc; border-color: #0077ff; color: #fff; }
|
||||
button.action-btn {
|
||||
background: #0066cc;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
button.action-btn:hover { background: #0077ee; }
|
||||
#status {
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
}
|
||||
#status.connected { color: #4f4; }
|
||||
#status.disconnected { color: #f44; }
|
||||
#pipeline-view {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.pipeline-node {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
margin: 2px;
|
||||
background: #333;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.pipeline-node.enabled { border-left: 3px solid #4f4; }
|
||||
.pipeline-node.disabled { border-left: 3px solid #666; opacity: 0.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sidebar">
|
||||
<div class="section">
|
||||
<h2>Preset</h2>
|
||||
<div id="preset-list" class="preset-list"></div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Stages</h2>
|
||||
<ul id="stage-list" class="stage-list"></ul>
|
||||
</div>
|
||||
<div class="section">
|
||||
<h2>Parameters</h2>
|
||||
<div id="param-editor" class="param-group"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="main">
|
||||
<h2>Pipeline</h2>
|
||||
<div id="pipeline-view"></div>
|
||||
<div style="margin-top: 20px;">
|
||||
<button class="action-btn" onclick="sendCommand({action: 'cycle_preset', direction: 1})">Next Preset</button>
|
||||
<button class="action-btn" onclick="sendCommand({action: 'cycle_preset', direction: -1})">Prev Preset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="status">Disconnected</div>
|
||||
|
||||
<script>
|
||||
const ws = new WebSocket(`ws://${location.hostname}:8765`);
|
||||
let state = { stages: {}, preset: '', presets: [], selected_stage: null };
|
||||
|
||||
function updateStatus(connected) {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = connected ? 'Connected' : 'Disconnected';
|
||||
status.className = connected ? 'connected' : 'disconnected';
|
||||
}
|
||||
|
||||
function connect() {
|
||||
ws.onopen = () => {
|
||||
updateStatus(true);
|
||||
// Request initial state
|
||||
ws.send(JSON.stringify({ type: 'state_request' }));
|
||||
};
|
||||
ws.onclose = () => {
|
||||
updateStatus(false);
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
ws.onerror = () => {
|
||||
updateStatus(false);
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'state') {
|
||||
state = data.state;
|
||||
render();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Parse error:', e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sendCommand(command) {
|
||||
ws.send(JSON.stringify({ type: 'command', command }));
|
||||
}
|
||||
|
||||
function render() {
|
||||
renderPresets();
|
||||
renderStageList();
|
||||
renderPipeline();
|
||||
renderParams();
|
||||
}
|
||||
|
||||
function renderPresets() {
|
||||
const container = document.getElementById('preset-list');
|
||||
container.innerHTML = '';
|
||||
(state.presets || []).forEach(preset => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'preset-btn' + (preset === state.preset ? ' active' : '');
|
||||
btn.textContent = preset;
|
||||
btn.onclick = () => sendCommand({ action: 'change_preset', preset });
|
||||
container.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function renderStageList() {
|
||||
const list = document.getElementById('stage-list');
|
||||
list.innerHTML = '';
|
||||
Object.entries(state.stages || {}).forEach(([name, info]) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'stage-item' + (name === state.selected_stage ? ' selected' : '');
|
||||
li.innerHTML = `
|
||||
<input type="checkbox" ${info.enabled ? 'checked' : ''}
|
||||
onchange="sendCommand({action: 'toggle_stage', stage: '${name}'})">
|
||||
<span class="stage-name">${name}</span>
|
||||
`;
|
||||
li.onclick = (e) => {
|
||||
if (e.target.type !== 'checkbox') {
|
||||
sendCommand({ action: 'select_stage', stage: name });
|
||||
}
|
||||
};
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPipeline() {
|
||||
const view = document.getElementById('pipeline-view');
|
||||
view.innerHTML = '';
|
||||
const stages = Object.entries(state.stages || {});
|
||||
if (stages.length === 0) {
|
||||
view.textContent = '(No stages)';
|
||||
return;
|
||||
}
|
||||
stages.forEach(([name, info]) => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'pipeline-node' + (info.enabled ? ' enabled' : ' disabled');
|
||||
span.textContent = name;
|
||||
view.appendChild(span);
|
||||
});
|
||||
}
|
||||
|
||||
function renderParams() {
|
||||
const container = document.getElementById('param-editor');
|
||||
container.innerHTML = '';
|
||||
const selected = state.selected_stage;
|
||||
if (!selected || !state.stages[selected]) {
|
||||
container.innerHTML = '<div style="color:#666;font-size:11px;">(select a stage)</div>';
|
||||
return;
|
||||
}
|
||||
const stage = state.stages[selected];
|
||||
if (!stage.params || Object.keys(stage.params).length === 0) {
|
||||
container.innerHTML = '<div style="color:#666;font-size:11px;">(no params)</div>';
|
||||
return;
|
||||
}
|
||||
Object.entries(stage.params).forEach(([key, value]) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'param-row';
|
||||
// Infer min/max/step from typical ranges
|
||||
let min = 0, max = 1, step = 0.1;
|
||||
if (typeof value === 'number') {
|
||||
if (value > 1) { max = value * 2; step = 1; }
|
||||
else { max = 1; step = 0.1; }
|
||||
}
|
||||
row.innerHTML = `
|
||||
<div class="param-name">${key}</div>
|
||||
<input type="range" class="param-slider" min="${min}" max="${max}" step="${step}"
|
||||
value="${value}"
|
||||
oninput="adjustParam('${key}', this.value)">
|
||||
<div class="param-value">${typeof value === 'number' ? Number(value).toFixed(2) : value}</div>
|
||||
`;
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function adjustParam(param, newValue) {
|
||||
const selected = state.selected_stage;
|
||||
if (!selected) return;
|
||||
// Update display immediately for responsiveness
|
||||
const num = parseFloat(newValue);
|
||||
if (!isNaN(num)) {
|
||||
// Show updated value
|
||||
document.querySelectorAll('.param-value').forEach(el => {
|
||||
if (el.parentElement.querySelector('.param-name').textContent === param) {
|
||||
el.textContent = num.toFixed(2);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Send command
|
||||
sendCommand({
|
||||
action: 'adjust_param',
|
||||
stage: selected,
|
||||
param: param,
|
||||
delta: num - (state.stages[selected].params[param] || 0)
|
||||
});
|
||||
}
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -277,6 +277,9 @@
|
||||
} else if (data.type === 'clear') {
|
||||
ctx.fillStyle = '#000';
|
||||
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) {
|
||||
console.error('Failed to parse message:', e);
|
||||
|
||||
@@ -54,7 +54,6 @@ classDiagram
|
||||
Display <|.. NullDisplay
|
||||
Display <|.. PygameDisplay
|
||||
Display <|.. WebSocketDisplay
|
||||
Display <|.. SixelDisplay
|
||||
|
||||
class Camera {
|
||||
+int viewport_width
|
||||
@@ -139,8 +138,6 @@ Display(Protocol)
|
||||
├── NullDisplay
|
||||
├── PygameDisplay
|
||||
├── WebSocketDisplay
|
||||
├── SixelDisplay
|
||||
├── KittyDisplay
|
||||
└── MultiDisplay
|
||||
```
|
||||
|
||||
|
||||
234
docs/PIPELINE.md
234
docs/PIPELINE.md
@@ -2,136 +2,160 @@
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The Mainline pipeline uses a **Stage-based architecture** with **capability-based dependency resolution**. Stages declare capabilities (what they provide) and dependencies (what they need), and the Pipeline resolves dependencies using prefix matching.
|
||||
|
||||
```
|
||||
Sources (static/dynamic) → Fetch → Prepare → Scroll → Effects → Render → Display
|
||||
↓
|
||||
NtfyPoller ← MicMonitor (async)
|
||||
Source Stage → Render Stage → Effect Stages → Display Stage
|
||||
↓
|
||||
Camera Stage (provides camera.state capability)
|
||||
```
|
||||
|
||||
### Data Source Abstraction (sources_v2.py)
|
||||
### Capability-Based Dependency Resolution
|
||||
|
||||
- **Static sources**: Data fetched once and cached (HeadlinesDataSource, PoetryDataSource)
|
||||
- **Dynamic sources**: Idempotent fetch for runtime updates (PipelineDataSource)
|
||||
- **SourceRegistry**: Discovery and management of data sources
|
||||
Stages declare capabilities and dependencies:
|
||||
- **Capabilities**: What the stage provides (e.g., `source`, `render.output`, `display.output`, `camera.state`)
|
||||
- **Dependencies**: What the stage needs (e.g., `source`, `render.output`, `camera.state`)
|
||||
|
||||
### Camera Modes
|
||||
The Pipeline resolves dependencies using **prefix matching**:
|
||||
- `"source"` matches `"source.headlines"`, `"source.poetry"`, etc.
|
||||
- `"camera.state"` matches the camera state capability provided by `CameraClockStage`
|
||||
- This allows flexible composition without hardcoding specific stage names
|
||||
|
||||
- **Vertical**: Scroll up (default)
|
||||
- **Horizontal**: Scroll left
|
||||
- **Omni**: Diagonal scroll
|
||||
- **Floating**: Sinusoidal bobbing
|
||||
- **Trace**: Follow network path node-by-node (for pipeline viz)
|
||||
### Minimum Capabilities
|
||||
|
||||
## Content to Display Rendering Pipeline
|
||||
The pipeline requires these minimum capabilities to function:
|
||||
- `"source"` - Data source capability (provides raw items)
|
||||
- `"render.output"` - Rendered content capability
|
||||
- `"display.output"` - Display output capability
|
||||
- `"camera.state"` - Camera state for viewport filtering
|
||||
|
||||
These are automatically injected if missing by the `ensure_minimum_capabilities()` method.
|
||||
|
||||
### Stage Registry
|
||||
|
||||
The `StageRegistry` discovers and registers stages automatically:
|
||||
- Scans `engine/stages/` for stage implementations
|
||||
- Registers stages by their declared capabilities
|
||||
- Enables runtime stage discovery and composition
|
||||
|
||||
## Stage-Based Pipeline Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Sources["Data Sources (v2)"]
|
||||
Headlines[HeadlinesDataSource]
|
||||
Poetry[PoetryDataSource]
|
||||
Pipeline[PipelineDataSource]
|
||||
Registry[SourceRegistry]
|
||||
end
|
||||
subgraph Stages["Stage Pipeline"]
|
||||
subgraph SourceStage["Source Stage (provides: source.*)"]
|
||||
Headlines[HeadlinesSource]
|
||||
Poetry[PoetrySource]
|
||||
Pipeline[PipelineSource]
|
||||
end
|
||||
|
||||
subgraph SourcesLegacy["Data Sources (legacy)"]
|
||||
RSS[("RSS Feeds")]
|
||||
PoetryFeed[("Poetry Feed")]
|
||||
Ntfy[("Ntfy Messages")]
|
||||
Mic[("Microphone")]
|
||||
end
|
||||
subgraph RenderStage["Render Stage (provides: render.*)"]
|
||||
Render[RenderStage]
|
||||
Canvas[Canvas]
|
||||
Camera[Camera]
|
||||
end
|
||||
|
||||
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"]
|
||||
subgraph EffectStages["Effect Stages (provides: effect.*)"]
|
||||
Noise[NoiseEffect]
|
||||
Fade[FadeEffect]
|
||||
Glitch[GlitchEffect]
|
||||
Firehose[FirehoseEffect]
|
||||
Hud[HudEffect]
|
||||
end
|
||||
EC[EffectChain]
|
||||
ER[EffectRegistry]
|
||||
|
||||
subgraph DisplayStage["Display Stage (provides: display.*)"]
|
||||
Terminal[TerminalDisplay]
|
||||
Pygame[PygameDisplay]
|
||||
WebSocket[WebSocketDisplay]
|
||||
Null[NullDisplay]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph Render["Render Layer"]
|
||||
BW[big_wrap]
|
||||
RL[render_line]
|
||||
subgraph Capabilities["Capability Map"]
|
||||
SourceCaps["source.headlines<br/>source.poetry<br/>source.pipeline"]
|
||||
RenderCaps["render.output<br/>render.canvas"]
|
||||
EffectCaps["effect.noise<br/>effect.fade<br/>effect.glitch"]
|
||||
DisplayCaps["display.output<br/>display.terminal"]
|
||||
end
|
||||
|
||||
subgraph Display["Display Backends"]
|
||||
TD[TerminalDisplay]
|
||||
PD[PygameDisplay]
|
||||
SD[SixelDisplay]
|
||||
KD[KittyDisplay]
|
||||
WSD[WebSocketDisplay]
|
||||
ND[NullDisplay]
|
||||
end
|
||||
SourceStage --> RenderStage
|
||||
RenderStage --> EffectStages
|
||||
EffectStages --> DisplayStage
|
||||
|
||||
subgraph Async["Async Sources"]
|
||||
NTFY[NtfyPoller]
|
||||
MIC[MicMonitor]
|
||||
end
|
||||
SourceStage --> SourceCaps
|
||||
RenderStage --> RenderCaps
|
||||
EffectStages --> EffectCaps
|
||||
DisplayStage --> DisplayCaps
|
||||
|
||||
subgraph Animation["Animation System"]
|
||||
AC[AnimationController]
|
||||
PR[Preset]
|
||||
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
|
||||
style SourceStage fill:#f9f,stroke:#333
|
||||
style RenderStage fill:#bbf,stroke:#333
|
||||
style EffectStages fill:#fbf,stroke:#333
|
||||
style DisplayStage 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
|
||||
|
||||
```mermaid
|
||||
@@ -161,7 +185,7 @@ flowchart LR
|
||||
Triggers --> Events
|
||||
```
|
||||
|
||||
## Camera Modes
|
||||
## Camera Modes State Diagram
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
|
||||
217
docs/proposals/adr-preset-scripting-language.md
Normal file
217
docs/proposals/adr-preset-scripting-language.md
Normal file
@@ -0,0 +1,217 @@
|
||||
# ADR: Preset Scripting Language for Mainline
|
||||
|
||||
## Status: Draft
|
||||
|
||||
## Context
|
||||
|
||||
We need to evaluate whether to add a scripting language for authoring presets in Mainline, replacing or augmenting the current TOML-based preset system. The goals are:
|
||||
|
||||
1. **Expressiveness**: More powerful than TOML for describing dynamic, procedural, or dataflow-based presets
|
||||
2. **Live coding**: Support hot-reloading of presets during runtime (like TidalCycles or Sonic Pi)
|
||||
3. **Testing**: Include assertion language to package tests alongside presets
|
||||
4. **Toolchain**: Consider packaging and build processes
|
||||
|
||||
### Current State
|
||||
|
||||
The current preset system uses TOML files (`presets.toml`) with a simple structure:
|
||||
|
||||
```toml
|
||||
[presets.demo-base]
|
||||
description = "Demo: Base preset for effect hot-swapping"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
camera = "feed"
|
||||
effects = [] # Demo script will add/remove effects dynamically
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
```
|
||||
|
||||
This is declarative and static. It cannot express:
|
||||
- Conditional logic based on runtime state
|
||||
- Dataflow between pipeline stages
|
||||
- Procedural generation of stage configurations
|
||||
- Assertions or validation of preset behavior
|
||||
|
||||
### Problems with TOML
|
||||
|
||||
- No way to express dependencies between effects or stages
|
||||
- Cannot describe temporal/animated behavior
|
||||
- No support for sensor bindings or parametric animations
|
||||
- Static configuration cannot adapt to runtime conditions
|
||||
- No built-in testing/assertion mechanism
|
||||
|
||||
## Approaches
|
||||
|
||||
### 1. Visual Dataflow Language (PureData-style)
|
||||
|
||||
Inspired by Pure Data (Pd), Max/MSP, and TouchDesigner:
|
||||
|
||||
**Pros:**
|
||||
- Intuitive for creative coding and live performance
|
||||
- Strong model for real-time parameter modulation
|
||||
- Matches the "patcher" paradigm already seen in pipeline architecture
|
||||
- Rich ecosystem of visual programming tools
|
||||
|
||||
**Cons:**
|
||||
- Complex to implement from scratch
|
||||
- Requires dedicated GUI editor
|
||||
- Harder to version control (binary/graph formats)
|
||||
- Mermaid diagrams alone aren't sufficient for this
|
||||
|
||||
**Tools to explore:**
|
||||
- libpd (Pure Data bindings for other languages)
|
||||
- Node-based frameworks (node-red, various DSP tools)
|
||||
- TouchDesigner-like approaches
|
||||
|
||||
### 2. Textual DSL (TidalCycles-style)
|
||||
|
||||
Domain-specific language focused on pattern transformation:
|
||||
|
||||
**Pros:**
|
||||
- Lightweight, fast iteration
|
||||
- Easy to version control (text files)
|
||||
- Can express complex patterns with minimal syntax
|
||||
- Proven in livecoding community
|
||||
|
||||
**Cons:**
|
||||
- Learning curve for non-programmers
|
||||
- Less visual than PureData approach
|
||||
|
||||
**Example (hypothetical):**
|
||||
```
|
||||
preset my-show {
|
||||
source: headlines
|
||||
|
||||
every 8s {
|
||||
effect noise: intensity = (0.5 <-> 1.0)
|
||||
}
|
||||
|
||||
on mic.level > 0.7 {
|
||||
effect glitch: intensity += 0.2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Embed Existing Language
|
||||
|
||||
Embed Lua, Python, or JavaScript:
|
||||
|
||||
**Pros:**
|
||||
- Full power of general-purpose language
|
||||
- Existing tooling, testing frameworks
|
||||
- Easy to integrate (many embeddable interpreters)
|
||||
|
||||
**Cons:**
|
||||
- Security concerns with running user code
|
||||
- May be overkill for simple presets
|
||||
- Testing/assertion system must be built on top
|
||||
|
||||
**Tools:**
|
||||
- Lua (lightweight, fast)
|
||||
- Python (rich ecosystem, but heavier)
|
||||
- QuickJS (small, embeddable JS)
|
||||
|
||||
### 4. Hybrid Approach
|
||||
|
||||
Visual editor generates textual DSL that compiles to Python:
|
||||
|
||||
**Pros:**
|
||||
- Best of both worlds
|
||||
- Can start with simple DSL and add editor later
|
||||
|
||||
**Cons:**
|
||||
- More complex initial implementation
|
||||
|
||||
## Requirements Analysis
|
||||
|
||||
### Must Have
|
||||
- [ ] Express pipeline stage configurations (source, effects, camera, display)
|
||||
- [ ] Support parameter bindings to sensors
|
||||
- [ ] Hot-reloading during runtime
|
||||
- [ ] Integration with existing Pipeline architecture
|
||||
|
||||
### Should Have
|
||||
- [ ] Basic assertion language for testing
|
||||
- [ ] Ability to define custom abstractions/modules
|
||||
- [ ] Version control friendly (text-based)
|
||||
|
||||
### Could Have
|
||||
- [ ] Visual node-based editor
|
||||
- [ ] Real-time visualization of dataflow
|
||||
- [ ] MIDI/OSC support for external controllers
|
||||
|
||||
## User Stories (Proposed)
|
||||
|
||||
### Spike Stories (Investigation)
|
||||
|
||||
**Story 1: Evaluate DSL Parsing Tools**
|
||||
> As a developer, I want to understand the available Python DSL parsing libraries (Lark, parsy, pyparsing) so that I can choose the right tool for implementing a preset DSL.
|
||||
>
|
||||
> **Acceptance**: Document pros/cons of 3+ parsing libraries with small proof-of-concept experiments
|
||||
|
||||
**Story 2: Research Livecoding Languages**
|
||||
> As a developer, I want to understand how TidalCycles, Sonic Pi, and PureData handle hot-reloading and pattern generation so that I can apply similar techniques to Mainline.
|
||||
>
|
||||
> **Acceptance**: Document key architectural patterns from 2+ livecoding systems
|
||||
|
||||
**Story 3: Prototype Textual DSL**
|
||||
> As a preset author, I want to write presets in a simple textual DSL that supports basic conditionals and sensor bindings.
|
||||
>
|
||||
> **Acceptance**: Create a prototype DSL that can parse a sample preset and convert to PipelineConfig
|
||||
|
||||
**Story 4: Investigate Assertion/Testing Approaches**
|
||||
> As a quality engineer, I want to include assertions with presets so that preset behavior can be validated automatically.
|
||||
>
|
||||
> **Acceptance**: Survey testing patterns in livecoding and propose assertion syntax
|
||||
|
||||
### Implementation Stories (Future)
|
||||
|
||||
**Story 5: Implement Core DSL Parser**
|
||||
> As a preset author, I want to write presets in a textual DSL that supports sensors, conditionals, and parameter bindings.
|
||||
>
|
||||
> **Acceptance**: DSL parser handles the core syntax, produces valid PipelineConfig
|
||||
|
||||
**Story 6: Hot-Reload System**
|
||||
> As a performer, I want to edit preset files and see changes reflected in real-time without restarting.
|
||||
>
|
||||
> **Acceptance**: File watcher + pipeline mutation API integration works
|
||||
|
||||
**Story 7: Assertion Language**
|
||||
> As a preset author, I want to include assertions that validate sensor values or pipeline state.
|
||||
>
|
||||
> **Acceptance**: Assertions can run as part of preset execution and report pass/fail
|
||||
|
||||
**Story 8: Toolchain/Packaging**
|
||||
> As a preset distributor, I want to package presets with dependencies for easy sharing.
|
||||
>
|
||||
> **Acceptance**: Can create, build, and install a preset package
|
||||
|
||||
## Decision
|
||||
|
||||
**Recommend: Start with textual DSL approach (Option 2/4)**
|
||||
|
||||
Rationale:
|
||||
- Lowest barrier to entry (text files, version control)
|
||||
- Can evolve to hybrid later if visual editor is needed
|
||||
- Strong precedents in livecoding community (TidalCycles, Sonic Pi)
|
||||
- Enables hot-reloading naturally
|
||||
- Assertion language can be part of the DSL syntax
|
||||
|
||||
**Not recommending Mermaid**: Mermaid is excellent for documentation and visualization, but it's a diagramming tool, not a programming language. It cannot express the logic, conditionals, and sensor bindings we need.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Execute Spike Stories 1-4 to reduce uncertainty
|
||||
2. Create minimal viable DSL syntax
|
||||
3. Prototype hot-reloading with existing preset system
|
||||
4. Evaluate whether visual editor adds sufficient value to warrant complexity
|
||||
|
||||
## References
|
||||
|
||||
- Pure Data: https://puredata.info/
|
||||
- TidalCycles: https://tidalcycles.org/
|
||||
- Sonic Pi: https://sonic-pi.net/
|
||||
- Lark parser: https://lark-parser.readthedocs.io/
|
||||
- Mainline Pipeline Architecture: `engine/pipeline/`
|
||||
- Current Presets: `presets.toml`
|
||||
@@ -1,145 +0,0 @@
|
||||
# README Update Design — 2026-03-15
|
||||
|
||||
## Goal
|
||||
|
||||
Restructure and expand `README.md` to:
|
||||
1. Align with the current codebase (Python 3.10+, uv/mise/pytest/ruff toolchain, 6 new fonts)
|
||||
2. Add extensibility-focused content (`Extending` section)
|
||||
3. Add developer workflow coverage (`Development` section)
|
||||
4. Improve navigability via top-level grouping (Approach C)
|
||||
|
||||
---
|
||||
|
||||
## Proposed Structure
|
||||
|
||||
```
|
||||
# MAINLINE
|
||||
> tagline + description
|
||||
|
||||
## Using
|
||||
### Run
|
||||
### Config
|
||||
### Feeds
|
||||
### Fonts
|
||||
### ntfy.sh
|
||||
|
||||
## Internals
|
||||
### How it works
|
||||
### Architecture
|
||||
|
||||
## Extending
|
||||
### NtfyPoller
|
||||
### MicMonitor
|
||||
### Render pipeline
|
||||
|
||||
## Development
|
||||
### Setup
|
||||
### Tasks
|
||||
### Testing
|
||||
### Linting
|
||||
|
||||
## Roadmap
|
||||
|
||||
---
|
||||
*footer*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Section-by-section design
|
||||
|
||||
### Using
|
||||
|
||||
All existing content preserved verbatim. Two changes:
|
||||
- **Run**: add `uv run mainline.py` as an alternative invocation; expand bootstrap note to mention `uv sync` / `uv sync --all-extras`
|
||||
- **ntfy.sh**: remove `NtfyPoller` reuse code example (moves to Extending); keep push instructions and topic config
|
||||
|
||||
Subsections moved into Using (currently standalone):
|
||||
- `Feeds` — it's configuration, not a concept
|
||||
- `ntfy.sh` (usage half)
|
||||
|
||||
### Internals
|
||||
|
||||
All existing content preserved verbatim. One change:
|
||||
- **Architecture**: append `tests/` directory listing to the module tree
|
||||
|
||||
### Extending
|
||||
|
||||
Entirely new section. Three subsections:
|
||||
|
||||
**NtfyPoller**
|
||||
- Minimal working import + usage example
|
||||
- Note: stdlib only dependencies
|
||||
|
||||
```python
|
||||
from engine.ntfy import NtfyPoller
|
||||
|
||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
||||
poller.start()
|
||||
|
||||
# in your render loop:
|
||||
msg = poller.get_active_message() # → (title, body, timestamp) or None
|
||||
if msg:
|
||||
title, body, ts = msg
|
||||
render_my_message(title, body) # visualizer-specific
|
||||
```
|
||||
|
||||
**MicMonitor**
|
||||
- Minimal working import + usage example
|
||||
- Note: sounddevice/numpy optional, degrades gracefully
|
||||
|
||||
```python
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
mic = MicMonitor(threshold_db=50)
|
||||
if mic.start(): # returns False if sounddevice unavailable
|
||||
excess = mic.excess # dB above threshold, clamped to 0
|
||||
db = mic.db # raw RMS dB level
|
||||
```
|
||||
|
||||
**Render pipeline**
|
||||
- Brief prose about `engine.render` as importable pipeline
|
||||
- Minimal sketch of serve.py / ESP32 usage pattern
|
||||
- Reference to `Mainline Renderer + ntfy Message Queue for ESP32.md`
|
||||
|
||||
### Development
|
||||
|
||||
Entirely new section. Four subsections:
|
||||
|
||||
**Setup**
|
||||
- Hard requirements: Python 3.10+, uv
|
||||
- `uv sync` / `uv sync --all-extras` / `uv sync --group dev`
|
||||
|
||||
**Tasks** (via mise)
|
||||
- `mise run test`, `test-cov`, `lint`, `lint-fix`, `format`, `run`, `run-poetry`, `run-firehose`
|
||||
|
||||
**Testing**
|
||||
- Tests in `tests/` covering config, filter, mic, ntfy, sources, terminal
|
||||
- `uv run pytest` and `uv run pytest --cov=engine --cov-report=term-missing`
|
||||
|
||||
**Linting**
|
||||
- `uv run ruff check` and `uv run ruff format`
|
||||
- Note: pre-commit hooks run lint via `hk`
|
||||
|
||||
### Roadmap
|
||||
|
||||
Existing `## Ideas / Future` content preserved verbatim. Only change: rename heading to `## Roadmap`.
|
||||
|
||||
### Footer
|
||||
|
||||
Update `Python 3.9+` → `Python 3.10+`.
|
||||
|
||||
---
|
||||
|
||||
## Files changed
|
||||
|
||||
- `README.md` — restructured and expanded as above
|
||||
- No other files
|
||||
|
||||
---
|
||||
|
||||
## What is not changing
|
||||
|
||||
- All existing prose, examples, and config table values — preserved verbatim where retained
|
||||
- The Ideas/Future content — kept intact under the new Roadmap heading
|
||||
- The cyberpunk voice and terse style of the existing README
|
||||
@@ -1 +1,10 @@
|
||||
# engine — modular internals for mainline
|
||||
|
||||
# Import submodules to make them accessible via engine.<name>
|
||||
# This is required for unittest.mock.patch to work with "engine.<module>.<function>"
|
||||
# strings and for direct attribute access on the engine package.
|
||||
import engine.config # noqa: F401
|
||||
import engine.fetch # noqa: F401
|
||||
import engine.filter # noqa: F401
|
||||
import engine.sources # noqa: F401
|
||||
import engine.terminal # noqa: F401
|
||||
|
||||
280
engine/app.py
280
engine/app.py
@@ -1,282 +1,14 @@
|
||||
"""
|
||||
Application orchestrator — pipeline mode entry point.
|
||||
|
||||
This module provides the main entry point for the application.
|
||||
The implementation has been refactored into the engine.app package.
|
||||
"""
|
||||
|
||||
import sys
|
||||
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")
|
||||
# Re-export from the new package structure
|
||||
from engine.app import main, run_pipeline_mode, run_pipeline_mode_direct
|
||||
|
||||
__all__ = ["main", "run_pipeline_mode", "run_pipeline_mode_direct"]
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
34
engine/app/__init__.py
Normal file
34
engine/app/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
Application orchestrator — pipeline mode entry point.
|
||||
|
||||
This package contains the main application logic for the pipeline mode,
|
||||
including pipeline construction, UI controller setup, and the main render loop.
|
||||
"""
|
||||
|
||||
# Re-export from engine for backward compatibility with tests
|
||||
# Re-export effects plugins for backward compatibility with tests
|
||||
import engine.effects.plugins as effects_plugins
|
||||
from engine import config
|
||||
|
||||
# Re-export display registry for backward compatibility with tests
|
||||
from engine.display import DisplayRegistry
|
||||
|
||||
# Re-export fetch functions for backward compatibility with tests
|
||||
from engine.fetch import fetch_all, fetch_poetry, load_cache
|
||||
from engine.pipeline import list_presets
|
||||
|
||||
from .main import main, run_pipeline_mode_direct
|
||||
from .pipeline_runner import run_pipeline_mode
|
||||
|
||||
__all__ = [
|
||||
"config",
|
||||
"list_presets",
|
||||
"main",
|
||||
"run_pipeline_mode",
|
||||
"run_pipeline_mode_direct",
|
||||
"fetch_all",
|
||||
"fetch_poetry",
|
||||
"load_cache",
|
||||
"DisplayRegistry",
|
||||
"effects_plugins",
|
||||
]
|
||||
457
engine/app/main.py
Normal file
457
engine/app/main.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
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")
|
||||
852
engine/app/pipeline_runner.py
Normal file
852
engine/app/pipeline_runner.py
Normal file
@@ -0,0 +1,852 @@
|
||||
"""
|
||||
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,6 +23,7 @@ class CameraMode(Enum):
|
||||
OMNI = auto()
|
||||
FLOATING = auto()
|
||||
BOUNCE = auto()
|
||||
RADIAL = auto() # Polar coordinates (r, theta) for radial scanning
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -71,6 +72,17 @@ class Camera:
|
||||
"""Shorthand for 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
|
||||
def h(self) -> int:
|
||||
"""Shorthand for viewport_height."""
|
||||
@@ -92,14 +104,17 @@ class Camera:
|
||||
"""
|
||||
return max(1, int(self.canvas_height / self.zoom))
|
||||
|
||||
def get_viewport(self) -> CameraViewport:
|
||||
def get_viewport(self, viewport_height: int | None = None) -> CameraViewport:
|
||||
"""Get the current viewport bounds.
|
||||
|
||||
Args:
|
||||
viewport_height: Optional viewport height to use instead of camera's viewport_height
|
||||
|
||||
Returns:
|
||||
CameraViewport with position and size (clamped to canvas bounds)
|
||||
"""
|
||||
vw = self.viewport_width
|
||||
vh = self.viewport_height
|
||||
vh = viewport_height if viewport_height is not None else self.viewport_height
|
||||
|
||||
clamped_x = max(0, min(self.x, self.canvas_width - vw))
|
||||
clamped_y = max(0, min(self.y, self.canvas_height - vh))
|
||||
@@ -111,6 +126,13 @@ class Camera:
|
||||
height=vh,
|
||||
)
|
||||
|
||||
return CameraViewport(
|
||||
x=clamped_x,
|
||||
y=clamped_y,
|
||||
width=vw,
|
||||
height=vh,
|
||||
)
|
||||
|
||||
def set_zoom(self, zoom: float) -> None:
|
||||
"""Set the zoom factor.
|
||||
|
||||
@@ -143,6 +165,8 @@ class Camera:
|
||||
self._update_floating(dt)
|
||||
elif self.mode == CameraMode.BOUNCE:
|
||||
self._update_bounce(dt)
|
||||
elif self.mode == CameraMode.RADIAL:
|
||||
self._update_radial(dt)
|
||||
|
||||
# Bounce mode handles its own bounds checking
|
||||
if self.mode != CameraMode.BOUNCE:
|
||||
@@ -223,12 +247,85 @@ class Camera:
|
||||
self.y = max_y
|
||||
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:
|
||||
"""Reset camera position."""
|
||||
"""Reset camera position and state."""
|
||||
self.x = 0
|
||||
self.y = 0
|
||||
self._time = 0.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:
|
||||
"""Set the canvas size and clamp position if needed.
|
||||
@@ -263,7 +360,7 @@ class Camera:
|
||||
return buffer
|
||||
|
||||
# Get current viewport bounds (clamped to canvas size)
|
||||
viewport = self.get_viewport()
|
||||
viewport = self.get_viewport(viewport_height)
|
||||
|
||||
# Use provided viewport_height if given, otherwise use camera's viewport
|
||||
vh = viewport_height if viewport_height is not None else viewport.height
|
||||
@@ -287,10 +384,11 @@ class Camera:
|
||||
truncated_line = vis_trunc(offset_line, viewport_width)
|
||||
|
||||
# Pad line to full viewport width to prevent ghosting when panning
|
||||
# Skip padding for empty lines to preserve intentional blank lines
|
||||
import re
|
||||
|
||||
visible_len = len(re.sub(r"\x1b\[[0-9;]*m", "", truncated_line))
|
||||
if visible_len < viewport_width:
|
||||
if visible_len < viewport_width and visible_len > 0:
|
||||
truncated_line += " " * (viewport_width - visible_len)
|
||||
|
||||
horizontal_slice.append(truncated_line)
|
||||
@@ -348,6 +446,27 @@ class Camera:
|
||||
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
|
||||
def custom(cls, update_fn: Callable[["Camera", float], None]) -> "Camera":
|
||||
"""Create a camera with custom update function."""
|
||||
|
||||
60
engine/data_sources/checkerboard.py
Normal file
60
engine/data_sources/checkerboard.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Checkerboard data source for visual pattern generation."""
|
||||
|
||||
from engine.data_sources.sources import DataSource, SourceItem
|
||||
|
||||
|
||||
class CheckerboardDataSource(DataSource):
|
||||
"""Data source that generates a checkerboard pattern.
|
||||
|
||||
Creates a grid of alternating characters, useful for testing motion effects
|
||||
and camera movement. The pattern is static; movement comes from camera panning.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
width: int = 200,
|
||||
height: int = 200,
|
||||
square_size: int = 10,
|
||||
char_a: str = "#",
|
||||
char_b: str = " ",
|
||||
):
|
||||
"""Initialize checkerboard data source.
|
||||
|
||||
Args:
|
||||
width: Total pattern width in characters
|
||||
height: Total pattern height in lines
|
||||
square_size: Size of each checker square in characters
|
||||
char_a: Character for "filled" squares (default: '#')
|
||||
char_b: Character for "empty" squares (default: ' ')
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.square_size = square_size
|
||||
self.char_a = char_a
|
||||
self.char_b = char_b
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "checkerboard"
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return False
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
"""Generate the checkerboard pattern as a single SourceItem."""
|
||||
lines = []
|
||||
for y in range(self.height):
|
||||
line_chars = []
|
||||
for x in range(self.width):
|
||||
# Determine which square this position belongs to
|
||||
square_x = x // self.square_size
|
||||
square_y = y // self.square_size
|
||||
# Alternate pattern based on parity of square coordinates
|
||||
if (square_x + square_y) % 2 == 0:
|
||||
line_chars.append(self.char_a)
|
||||
else:
|
||||
line_chars.append(self.char_b)
|
||||
lines.append("".join(line_chars))
|
||||
content = "\n".join(lines)
|
||||
return [SourceItem(content=content, source="checkerboard", timestamp="0")]
|
||||
@@ -5,102 +5,59 @@ Allows swapping output backends via the Display protocol.
|
||||
Supports auto-discovery of display backends.
|
||||
"""
|
||||
|
||||
from enum import Enum, auto
|
||||
from typing import Protocol
|
||||
|
||||
from engine.display.backends.kitty import KittyDisplay
|
||||
# Optional backend - requires moderngl package
|
||||
try:
|
||||
from engine.display.backends.moderngl import ModernGLDisplay
|
||||
|
||||
_MODERNGL_AVAILABLE = True
|
||||
except ImportError:
|
||||
ModernGLDisplay = None
|
||||
_MODERNGL_AVAILABLE = False
|
||||
|
||||
from engine.display.backends.multi import MultiDisplay
|
||||
from engine.display.backends.null import NullDisplay
|
||||
from engine.display.backends.pygame import PygameDisplay
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
from engine.display.backends.replay import ReplayDisplay
|
||||
from engine.display.backends.terminal import TerminalDisplay
|
||||
from engine.display.backends.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):
|
||||
"""Protocol for display backends.
|
||||
|
||||
All display backends must implement:
|
||||
- width, height: Terminal dimensions
|
||||
- init(width, height, reuse=False): Initialize the display
|
||||
- show(buffer): Render buffer to display
|
||||
- clear(): Clear the display
|
||||
- cleanup(): Shutdown the display
|
||||
Required attributes:
|
||||
- width: int
|
||||
- height: int
|
||||
|
||||
Optional methods for keyboard input:
|
||||
- is_quit_requested(): Returns True if user pressed Ctrl+C/Q or Escape
|
||||
- clear_quit_request(): Clears the quit request flag
|
||||
Required methods (duck typing - actual signatures may vary):
|
||||
- init(width, height, reuse=False)
|
||||
- show(buffer, border=False)
|
||||
- clear()
|
||||
- cleanup()
|
||||
- get_dimensions() -> (width, height)
|
||||
|
||||
The reuse flag allows attaching to an existing display instance
|
||||
rather than creating a new window/connection.
|
||||
Optional attributes (for UI mode):
|
||||
- ui_panel: UIPanel instance (set by app when border=UI)
|
||||
|
||||
Keyboard input support by backend:
|
||||
- terminal: No native input (relies on signal handler for Ctrl+C)
|
||||
- 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)
|
||||
Optional methods:
|
||||
- is_quit_requested() -> bool
|
||||
- clear_quit_request() -> None
|
||||
"""
|
||||
|
||||
width: 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:
|
||||
"""Registry for display backends with auto-discovery."""
|
||||
@@ -110,22 +67,18 @@ class DisplayRegistry:
|
||||
|
||||
@classmethod
|
||||
def register(cls, name: str, backend_class: type[Display]) -> None:
|
||||
"""Register a display backend."""
|
||||
cls._backends[name.lower()] = backend_class
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str) -> type[Display] | None:
|
||||
"""Get a display backend class by name."""
|
||||
return cls._backends.get(name.lower())
|
||||
|
||||
@classmethod
|
||||
def list_backends(cls) -> list[str]:
|
||||
"""List all available display backend names."""
|
||||
return list(cls._backends.keys())
|
||||
|
||||
@classmethod
|
||||
def create(cls, name: str, **kwargs) -> Display | None:
|
||||
"""Create a display instance by name."""
|
||||
cls.initialize()
|
||||
backend_class = cls.get(name)
|
||||
if backend_class:
|
||||
@@ -134,31 +87,19 @@ class DisplayRegistry:
|
||||
|
||||
@classmethod
|
||||
def initialize(cls) -> None:
|
||||
"""Initialize and register all built-in backends."""
|
||||
if cls._initialized:
|
||||
return
|
||||
|
||||
cls.register("terminal", TerminalDisplay)
|
||||
cls.register("null", NullDisplay)
|
||||
cls.register("replay", ReplayDisplay)
|
||||
cls.register("websocket", WebSocketDisplay)
|
||||
cls.register("sixel", SixelDisplay)
|
||||
cls.register("kitty", KittyDisplay)
|
||||
cls.register("pygame", PygameDisplay)
|
||||
|
||||
if _MODERNGL_AVAILABLE:
|
||||
cls.register("moderngl", ModernGLDisplay) # type: ignore[arg-type]
|
||||
cls._initialized = True
|
||||
|
||||
@classmethod
|
||||
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
|
||||
|
||||
def create_multi(cls, names: list[str]) -> MultiDisplay | None:
|
||||
displays = []
|
||||
for name in names:
|
||||
backend = cls.create(name)
|
||||
@@ -166,10 +107,8 @@ class DisplayRegistry:
|
||||
displays.append(backend)
|
||||
else:
|
||||
return None
|
||||
|
||||
if not displays:
|
||||
return None
|
||||
|
||||
return MultiDisplay(displays)
|
||||
|
||||
|
||||
@@ -190,44 +129,28 @@ def _strip_ansi(s: str) -> str:
|
||||
return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s)
|
||||
|
||||
|
||||
def render_border(
|
||||
def _render_simple_border(
|
||||
buf: list[str], width: int, height: int, fps: float = 0.0, frame_time: float = 0.0
|
||||
) -> list[str]:
|
||||
"""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
|
||||
"""
|
||||
"""Render a traditional border around the buffer."""
|
||||
if not buf or width < 3 or height < 3:
|
||||
return buf
|
||||
|
||||
inner_w = width - 2
|
||||
inner_h = height - 2
|
||||
|
||||
# Crop buffer to fit inside border
|
||||
cropped = []
|
||||
for i in range(min(inner_h, len(buf))):
|
||||
line = buf[i]
|
||||
# Calculate visible width (excluding ANSI codes)
|
||||
visible_len = len(_strip_ansi(line))
|
||||
if visible_len > inner_w:
|
||||
# Truncate carefully - this is approximate for ANSI text
|
||||
cropped.append(line[:inner_w])
|
||||
else:
|
||||
cropped.append(line + " " * (inner_w - visible_len))
|
||||
|
||||
# Pad with empty lines if needed
|
||||
while len(cropped) < inner_h:
|
||||
cropped.append(" " * inner_w)
|
||||
|
||||
# Build borders
|
||||
if fps > 0:
|
||||
fps_str = f" FPS:{fps:.0f}"
|
||||
if len(fps_str) < inner_w:
|
||||
@@ -248,10 +171,8 @@ def render_border(
|
||||
else:
|
||||
bottom_border = "└" + "─" * inner_w + "┘"
|
||||
|
||||
# Build result with left/right borders
|
||||
result = [top_border]
|
||||
for line in cropped:
|
||||
# Ensure exactly inner_w characters before adding right border
|
||||
if len(line) < inner_w:
|
||||
line = line + " " * (inner_w - len(line))
|
||||
elif len(line) > inner_w:
|
||||
@@ -262,14 +183,108 @@ def render_border(
|
||||
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__ = [
|
||||
"Display",
|
||||
"DisplayRegistry",
|
||||
"get_monitor",
|
||||
"render_border",
|
||||
"render_ui_panel",
|
||||
"BorderMode",
|
||||
"TerminalDisplay",
|
||||
"NullDisplay",
|
||||
"ReplayDisplay",
|
||||
"WebSocketDisplay",
|
||||
"SixelDisplay",
|
||||
"MultiDisplay",
|
||||
"PygameDisplay",
|
||||
]
|
||||
|
||||
if _MODERNGL_AVAILABLE:
|
||||
__all__.append("ModernGLDisplay")
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
"""
|
||||
Kitty graphics display backend - renders using kitty's native graphics protocol.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from engine.display.renderer import get_default_font_path, parse_ansi
|
||||
|
||||
|
||||
def _encode_kitty_graphic(image_data: bytes, width: int, height: int) -> bytes:
|
||||
"""Encode image data using kitty's graphics protocol."""
|
||||
import base64
|
||||
|
||||
encoded = base64.b64encode(image_data).decode("ascii")
|
||||
|
||||
chunks = []
|
||||
for i in range(0, len(encoded), 4096):
|
||||
chunk = encoded[i : i + 4096]
|
||||
if i == 0:
|
||||
chunks.append(f"\x1b_Gf=100,t=d,s={width},v={height},c=1,r=1;{chunk}\x1b\\")
|
||||
else:
|
||||
chunks.append(f"\x1b_Gm={height};{chunk}\x1b\\")
|
||||
|
||||
return "".join(chunks).encode("utf-8")
|
||||
|
||||
|
||||
class KittyDisplay:
|
||||
"""Kitty graphics display backend using kitty's native protocol."""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(self, cell_width: int = 9, cell_height: int = 16):
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self.cell_width = cell_width
|
||||
self.cell_height = cell_height
|
||||
self._initialized = False
|
||||
self._font_path = None
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: Ignored for KittyDisplay (protocol doesn't support reuse)
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._initialized = True
|
||||
|
||||
def _get_font_path(self) -> str | None:
|
||||
"""Get font path from env or detect common locations."""
|
||||
import os
|
||||
|
||||
if self._font_path:
|
||||
return self._font_path
|
||||
|
||||
env_font = os.environ.get("MAINLINE_KITTY_FONT")
|
||||
if env_font and os.path.exists(env_font):
|
||||
self._font_path = env_font
|
||||
return env_font
|
||||
|
||||
font_path = get_default_font_path()
|
||||
if font_path:
|
||||
self._font_path = font_path
|
||||
|
||||
return self._font_path
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
import sys
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# Get metrics for border display
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
from engine.display import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0
|
||||
frame_count = stats.get("frame_count", 0) if stats else 0
|
||||
if avg_ms and frame_count > 0:
|
||||
fps = 1000.0 / avg_ms
|
||||
frame_time = avg_ms
|
||||
|
||||
# Apply border if requested
|
||||
if border:
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
img_width = self.width * self.cell_width
|
||||
img_height = self.height * self.cell_height
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
font_path = self._get_font_path()
|
||||
font = None
|
||||
if font_path:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, self.cell_height - 2)
|
||||
except Exception:
|
||||
font = None
|
||||
|
||||
if font is None:
|
||||
try:
|
||||
font = ImageFont.load_default()
|
||||
except Exception:
|
||||
font = None
|
||||
|
||||
for row_idx, line in enumerate(buffer[: self.height]):
|
||||
if row_idx >= self.height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
y_pos = row_idx * self.cell_height
|
||||
|
||||
for text, fg, bg, bold in tokens:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if bg != (0, 0, 0):
|
||||
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
||||
draw.rectangle(bbox, fill=(*bg, 255))
|
||||
|
||||
if bold and font:
|
||||
draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font)
|
||||
|
||||
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
||||
|
||||
if font:
|
||||
x_pos += draw.textlength(text, font=font)
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
output = BytesIO()
|
||||
img.save(output, format="PNG")
|
||||
png_data = output.getvalue()
|
||||
|
||||
graphic = _encode_kitty_graphic(png_data, img_width, img_height)
|
||||
|
||||
sys.stdout.buffer.write(graphic)
|
||||
sys.stdout.flush()
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
from engine.display import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("kitty_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
import sys
|
||||
|
||||
sys.stdout.buffer.write(b"\x1b_Ga=d\x1b\\")
|
||||
sys.stdout.flush()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.clear()
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current dimensions.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
return (self.width, self.height)
|
||||
@@ -2,7 +2,10 @@
|
||||
Null/headless display backend.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NullDisplay:
|
||||
@@ -10,7 +13,8 @@ class NullDisplay:
|
||||
|
||||
This display does nothing - useful for headless benchmarking
|
||||
or when no display output is needed. Captures last buffer
|
||||
for testing purposes.
|
||||
for testing purposes. Supports frame recording for replay
|
||||
and file export/import.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
@@ -19,6 +23,9 @@ class NullDisplay:
|
||||
|
||||
def __init__(self):
|
||||
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:
|
||||
"""Initialize display with dimensions.
|
||||
@@ -33,9 +40,10 @@ class NullDisplay:
|
||||
self._last_buffer = None
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
import sys
|
||||
|
||||
from engine.display import get_monitor, render_border
|
||||
|
||||
# Get FPS for border (if available)
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
monitor = get_monitor()
|
||||
@@ -47,17 +55,111 @@ class NullDisplay:
|
||||
fps = 1000.0 / avg_ms
|
||||
frame_time = avg_ms
|
||||
|
||||
# Apply border if requested (same as terminal display)
|
||||
if border:
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
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:
|
||||
t0 = time.perf_counter()
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
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:
|
||||
pass
|
||||
|
||||
|
||||
@@ -99,10 +99,6 @@ class PygameDisplay:
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
import os
|
||||
|
||||
os.environ["SDL_VIDEODRIVER"] = "x11"
|
||||
|
||||
try:
|
||||
import pygame
|
||||
except ImportError:
|
||||
@@ -136,6 +132,21 @@ class PygameDisplay:
|
||||
else:
|
||||
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
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
@@ -184,14 +195,26 @@ class PygameDisplay:
|
||||
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)
|
||||
|
||||
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 = []
|
||||
|
||||
for row_idx, line in enumerate(buffer[: self.height]):
|
||||
@@ -199,7 +222,7 @@ class PygameDisplay:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
x_pos = content_offset_x
|
||||
|
||||
for text, fg, bg, _bold in tokens:
|
||||
if not text:
|
||||
@@ -219,10 +242,17 @@ class PygameDisplay:
|
||||
self._glyph_cache[cache_key] = self._font.render(text, True, fg)
|
||||
|
||||
surface = self._glyph_cache[cache_key]
|
||||
blit_list.append((surface, (x_pos, row_idx * self.cell_height)))
|
||||
blit_list.append(
|
||||
(surface, (x_pos, content_offset_y + row_idx * self.cell_height))
|
||||
)
|
||||
x_pos += self._font.size(text)[0]
|
||||
|
||||
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()
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
@@ -231,6 +261,56 @@ class PygameDisplay:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
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:
|
||||
if self._screen and self._pygame:
|
||||
self._screen.fill((0, 0, 0))
|
||||
|
||||
122
engine/display/backends/replay.py
Normal file
122
engine/display/backends/replay.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Replay display backend - plays back recorded frames.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ReplayDisplay:
|
||||
"""Replay display - plays back recorded frames.
|
||||
|
||||
This display reads frames from a recording (list of frame data)
|
||||
and yields them sequentially, useful for testing and demo purposes.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(self):
|
||||
self._frames: list[dict[str, Any]] = []
|
||||
self._current_frame = 0
|
||||
self._playback_index = 0
|
||||
self._loop = False
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: Ignored for ReplayDisplay
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
def set_frames(self, frames: list[dict[str, Any]]) -> None:
|
||||
"""Set frames to replay.
|
||||
|
||||
Args:
|
||||
frames: List of frame dicts with 'buffer', 'width', 'height'
|
||||
"""
|
||||
self._frames = frames
|
||||
self._current_frame = 0
|
||||
self._playback_index = 0
|
||||
|
||||
def set_loop(self, loop: bool) -> None:
|
||||
"""Set loop playback mode.
|
||||
|
||||
Args:
|
||||
loop: True to loop, False to stop at end
|
||||
"""
|
||||
self._loop = loop
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
"""Display a frame (ignored in replay mode).
|
||||
|
||||
Args:
|
||||
buffer: Buffer to display (ignored)
|
||||
border: Border flag (ignored)
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_next_frame(self) -> list[str] | None:
|
||||
"""Get the next frame in the recording.
|
||||
|
||||
Returns:
|
||||
Buffer list of strings, or None if playback is done
|
||||
"""
|
||||
if not self._frames:
|
||||
return None
|
||||
|
||||
if self._playback_index >= len(self._frames):
|
||||
if self._loop:
|
||||
self._playback_index = 0
|
||||
else:
|
||||
return None
|
||||
|
||||
frame = self._frames[self._playback_index]
|
||||
self._playback_index += 1
|
||||
return frame.get("buffer")
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset playback to the beginning."""
|
||||
self._playback_index = 0
|
||||
|
||||
def seek(self, index: int) -> None:
|
||||
"""Seek to a specific frame.
|
||||
|
||||
Args:
|
||||
index: Frame index to seek to
|
||||
"""
|
||||
if 0 <= index < len(self._frames):
|
||||
self._playback_index = index
|
||||
|
||||
def is_finished(self) -> bool:
|
||||
"""Check if playback is finished.
|
||||
|
||||
Returns:
|
||||
True if at end of frames and not looping
|
||||
"""
|
||||
return not self._loop and self._playback_index >= len(self._frames)
|
||||
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
pass
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current dimensions.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
return (self.width, self.height)
|
||||
|
||||
def is_quit_requested(self) -> bool:
|
||||
"""Check if quit was requested (optional protocol method)."""
|
||||
return False
|
||||
|
||||
def clear_quit_request(self) -> None:
|
||||
"""Clear quit request (optional protocol method)."""
|
||||
pass
|
||||
@@ -1,228 +0,0 @@
|
||||
"""
|
||||
Sixel graphics display backend - renders to sixel graphics in terminal.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from engine.display.renderer import get_default_font_path, parse_ansi
|
||||
|
||||
|
||||
def _encode_sixel(image) -> str:
|
||||
"""Encode a PIL Image to sixel format (pure Python)."""
|
||||
img = image.convert("RGBA")
|
||||
width, height = img.size
|
||||
pixels = img.load()
|
||||
|
||||
palette = []
|
||||
pixel_palette_idx = {}
|
||||
|
||||
def get_color_idx(r, g, b, a):
|
||||
if a < 128:
|
||||
return -1
|
||||
key = (r // 32, g // 32, b // 32)
|
||||
if key not in pixel_palette_idx:
|
||||
idx = len(palette)
|
||||
if idx < 256:
|
||||
palette.append((r, g, b))
|
||||
pixel_palette_idx[key] = idx
|
||||
return pixel_palette_idx.get(key, 0)
|
||||
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
r, g, b, a = pixels[x, y]
|
||||
get_color_idx(r, g, b, a)
|
||||
|
||||
if not palette:
|
||||
return ""
|
||||
|
||||
if len(palette) == 1:
|
||||
palette = [palette[0], (0, 0, 0)]
|
||||
|
||||
sixel_data = []
|
||||
sixel_data.append(
|
||||
f'"{"".join(f"#{i};2;{r};{g};{b}" for i, (r, g, b) in enumerate(palette))}'
|
||||
)
|
||||
|
||||
for x in range(width):
|
||||
col_data = []
|
||||
for y in range(0, height, 6):
|
||||
bits = 0
|
||||
color_idx = -1
|
||||
for dy in range(6):
|
||||
if y + dy < height:
|
||||
r, g, b, a = pixels[x, y + dy]
|
||||
if a >= 128:
|
||||
bits |= 1 << dy
|
||||
idx = get_color_idx(r, g, b, a)
|
||||
if color_idx == -1:
|
||||
color_idx = idx
|
||||
elif color_idx != idx:
|
||||
color_idx = -2
|
||||
|
||||
if color_idx >= 0:
|
||||
col_data.append(
|
||||
chr(63 + color_idx) + chr(63 + bits)
|
||||
if bits
|
||||
else chr(63 + color_idx) + "?"
|
||||
)
|
||||
elif color_idx == -2:
|
||||
pass
|
||||
|
||||
if col_data:
|
||||
sixel_data.append("".join(col_data) + "$")
|
||||
else:
|
||||
sixel_data.append("-" if x < width - 1 else "$")
|
||||
|
||||
sixel_data.append("\x1b\\")
|
||||
|
||||
return "\x1bPq" + "".join(sixel_data)
|
||||
|
||||
|
||||
class SixelDisplay:
|
||||
"""Sixel graphics display backend - renders to sixel graphics in terminal."""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(self, cell_width: int = 9, cell_height: int = 16):
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self.cell_width = cell_width
|
||||
self.cell_height = cell_height
|
||||
self._initialized = False
|
||||
self._font_path = None
|
||||
|
||||
def _get_font_path(self) -> str | None:
|
||||
"""Get font path from env or detect common locations."""
|
||||
import os
|
||||
|
||||
if self._font_path:
|
||||
return self._font_path
|
||||
|
||||
env_font = os.environ.get("MAINLINE_SIXEL_FONT")
|
||||
if env_font and os.path.exists(env_font):
|
||||
self._font_path = env_font
|
||||
return env_font
|
||||
|
||||
font_path = get_default_font_path()
|
||||
if font_path:
|
||||
self._font_path = font_path
|
||||
|
||||
return self._font_path
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: Ignored for SixelDisplay
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._initialized = True
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
import sys
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# Get metrics for border display
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
from engine.display import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
avg_ms = stats.get("pipeline", {}).get("avg_ms", 0) if stats else 0
|
||||
frame_count = stats.get("frame_count", 0) if stats else 0
|
||||
if avg_ms and frame_count > 0:
|
||||
fps = 1000.0 / avg_ms
|
||||
frame_time = avg_ms
|
||||
|
||||
# Apply border if requested
|
||||
if border:
|
||||
from engine.display import render_border
|
||||
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
img_width = self.width * self.cell_width
|
||||
img_height = self.height * self.cell_height
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
font_path = self._get_font_path()
|
||||
font = None
|
||||
if font_path:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, self.cell_height - 2)
|
||||
except Exception:
|
||||
font = None
|
||||
|
||||
if font is None:
|
||||
try:
|
||||
font = ImageFont.load_default()
|
||||
except Exception:
|
||||
font = None
|
||||
|
||||
for row_idx, line in enumerate(buffer[: self.height]):
|
||||
if row_idx >= self.height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
y_pos = row_idx * self.cell_height
|
||||
|
||||
for text, fg, bg, bold in tokens:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
if bg != (0, 0, 0):
|
||||
bbox = draw.textbbox((x_pos, y_pos), text, font=font)
|
||||
draw.rectangle(bbox, fill=(*bg, 255))
|
||||
|
||||
if bold and font:
|
||||
draw.text((x_pos - 1, y_pos - 1), text, fill=(*fg, 255), font=font)
|
||||
|
||||
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
||||
|
||||
if font:
|
||||
x_pos += draw.textlength(text, font=font)
|
||||
|
||||
sixel = _encode_sixel(img)
|
||||
|
||||
sys.stdout.buffer.write(sixel.encode("utf-8"))
|
||||
sys.stdout.flush()
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
from engine.display import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
monitor.record_effect("sixel_display", elapsed_ms, chars_in, chars_in)
|
||||
|
||||
def clear(self) -> None:
|
||||
import sys
|
||||
|
||||
sys.stdout.buffer.write(b"\x1b[2J\x1b[H")
|
||||
sys.stdout.flush()
|
||||
|
||||
def cleanup(self) -> None:
|
||||
pass
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current dimensions.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
return (self.width, self.height)
|
||||
@@ -3,7 +3,6 @@ ANSI terminal display backend.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
|
||||
class TerminalDisplay:
|
||||
@@ -89,16 +88,8 @@ class TerminalDisplay:
|
||||
|
||||
from engine.display import get_monitor, render_border
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# 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
|
||||
# Note: Frame rate limiting is handled by the caller (e.g., FrameTimer).
|
||||
# This display renders every frame it receives.
|
||||
|
||||
# Get metrics for border display
|
||||
fps = 0.0
|
||||
@@ -113,19 +104,15 @@ class TerminalDisplay:
|
||||
frame_time = avg_ms
|
||||
|
||||
# Apply border if requested
|
||||
if border:
|
||||
from engine.display import BorderMode
|
||||
|
||||
if border and border != BorderMode.OFF:
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
# 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)
|
||||
sys.stdout.buffer.write(output.encode())
|
||||
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:
|
||||
from engine.terminal import CLR
|
||||
|
||||
@@ -1,11 +1,44 @@
|
||||
"""
|
||||
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 base64
|
||||
import json
|
||||
import threading
|
||||
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:
|
||||
import websockets
|
||||
@@ -34,6 +67,7 @@ class WebSocketDisplay:
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8765,
|
||||
http_port: int = 8766,
|
||||
streaming_mode: StreamingMode = StreamingMode.JSON,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
@@ -49,7 +83,15 @@ class WebSocketDisplay:
|
||||
self._max_clients = 10
|
||||
self._client_connected_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._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:
|
||||
import websockets as _ws
|
||||
@@ -78,7 +120,7 @@ class WebSocketDisplay:
|
||||
self.start_http_server()
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
"""Broadcast buffer to all connected clients."""
|
||||
"""Broadcast buffer to all connected clients using streaming protocol."""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# Get metrics for border display
|
||||
@@ -99,33 +141,82 @@ class WebSocketDisplay:
|
||||
|
||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||
|
||||
if self._clients:
|
||||
frame_data = {
|
||||
"type": "frame",
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
"lines": buffer,
|
||||
}
|
||||
message = json.dumps(frame_data)
|
||||
if not self._clients:
|
||||
self._last_buffer = buffer
|
||||
return
|
||||
|
||||
disconnected = set()
|
||||
for client in list(self._clients):
|
||||
try:
|
||||
asyncio.run(client.send(message))
|
||||
except Exception:
|
||||
disconnected.add(client)
|
||||
# Send to each client based on their capabilities
|
||||
disconnected = set()
|
||||
for client in list(self._clients):
|
||||
try:
|
||||
client_id = id(client)
|
||||
client_mode = self._client_capabilities.get(
|
||||
client_id, StreamingMode.JSON
|
||||
)
|
||||
|
||||
for client in disconnected:
|
||||
self._clients.discard(client)
|
||||
if self._client_disconnected_callback:
|
||||
self._client_disconnected_callback(client)
|
||||
if client_mode & StreamingMode.DIFF:
|
||||
self._send_diff_frame(client, buffer)
|
||||
elif client_mode & StreamingMode.BINARY:
|
||||
self._send_binary_frame(client, buffer)
|
||||
else:
|
||||
self._send_json_frame(client, buffer)
|
||||
except Exception:
|
||||
disconnected.add(client)
|
||||
|
||||
for client in disconnected:
|
||||
self._clients.discard(client)
|
||||
if self._client_disconnected_callback:
|
||||
self._client_disconnected_callback(client)
|
||||
|
||||
self._last_buffer = buffer
|
||||
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
chars_in = sum(len(line) for line in buffer)
|
||||
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:
|
||||
"""Broadcast clear command to all clients."""
|
||||
if self._clients:
|
||||
@@ -156,9 +247,21 @@ class WebSocketDisplay:
|
||||
async for message in websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
if data.get("type") == "resize":
|
||||
msg_type = data.get("type")
|
||||
|
||||
if msg_type == "resize":
|
||||
self.width = data.get("width", 80)
|
||||
self.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:
|
||||
pass
|
||||
except Exception:
|
||||
@@ -170,6 +273,8 @@ class WebSocketDisplay:
|
||||
|
||||
async def _run_websocket_server(self):
|
||||
"""Run the WebSocket server."""
|
||||
if not websockets:
|
||||
return
|
||||
async with websockets.serve(self._websocket_handler, self.host, self.port):
|
||||
while self._server_running:
|
||||
await asyncio.sleep(0.1)
|
||||
@@ -179,9 +284,23 @@ class WebSocketDisplay:
|
||||
import os
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
|
||||
client_dir = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "client"
|
||||
)
|
||||
# Find the project root by locating 'engine' directory in the path
|
||||
websocket_file = os.path.abspath(__file__)
|
||||
parts = websocket_file.split(os.sep)
|
||||
if "engine" in parts:
|
||||
engine_idx = parts.index("engine")
|
||||
project_root = os.sep.join(parts[:engine_idx])
|
||||
client_dir = os.path.join(project_root, "client")
|
||||
else:
|
||||
# Fallback: go up 4 levels from websocket.py
|
||||
# websocket.py: .../engine/display/backends/websocket.py
|
||||
# We need: .../client
|
||||
client_dir = os.path.join(
|
||||
os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
),
|
||||
"client",
|
||||
)
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -191,8 +310,10 @@ class WebSocketDisplay:
|
||||
pass
|
||||
|
||||
httpd = HTTPServer((self.host, self.http_port), Handler)
|
||||
while self._http_running:
|
||||
httpd.handle_request()
|
||||
# Store reference for shutdown
|
||||
self._httpd = httpd
|
||||
# Serve requests continuously
|
||||
httpd.serve_forever()
|
||||
|
||||
def _run_async(self, coro):
|
||||
"""Run coroutine in background."""
|
||||
@@ -237,6 +358,8 @@ class WebSocketDisplay:
|
||||
def stop_http_server(self):
|
||||
"""Stop the HTTP server."""
|
||||
self._http_running = False
|
||||
if hasattr(self, "_httpd") and self._httpd:
|
||||
self._httpd.shutdown()
|
||||
self._http_thread = None
|
||||
|
||||
def client_count(self) -> int:
|
||||
@@ -267,6 +390,71 @@ class WebSocketDisplay:
|
||||
"""Set callback for client disconnections."""
|
||||
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]:
|
||||
"""Get current dimensions.
|
||||
|
||||
|
||||
268
engine/display/streaming.py
Normal file
268
engine/display/streaming.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
Streaming protocol utilities for efficient frame transmission.
|
||||
|
||||
Provides:
|
||||
- Frame differencing: Only send changed lines
|
||||
- Run-length encoding: Compress repeated lines
|
||||
- Binary encoding: Compact message format
|
||||
"""
|
||||
|
||||
import json
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class MessageType(IntEnum):
|
||||
"""Message types for streaming protocol."""
|
||||
|
||||
FULL_FRAME = 1
|
||||
DIFF_FRAME = 2
|
||||
STATE = 3
|
||||
CLEAR = 4
|
||||
PING = 5
|
||||
PONG = 6
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameDiff:
|
||||
"""Represents a diff between two frames."""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
changed_lines: list[tuple[int, str]] # (line_index, content)
|
||||
|
||||
|
||||
def compute_diff(old_buffer: list[str], new_buffer: list[str]) -> FrameDiff:
|
||||
"""Compute differences between old and new buffer.
|
||||
|
||||
Args:
|
||||
old_buffer: Previous frame buffer
|
||||
new_buffer: Current frame buffer
|
||||
|
||||
Returns:
|
||||
FrameDiff with only changed lines
|
||||
"""
|
||||
height = len(new_buffer)
|
||||
changed_lines = []
|
||||
|
||||
for i, line in enumerate(new_buffer):
|
||||
if i >= len(old_buffer) or line != old_buffer[i]:
|
||||
changed_lines.append((i, line))
|
||||
|
||||
return FrameDiff(
|
||||
width=len(new_buffer[0]) if new_buffer else 0,
|
||||
height=height,
|
||||
changed_lines=changed_lines,
|
||||
)
|
||||
|
||||
|
||||
def encode_rle(lines: list[tuple[int, str]]) -> list[tuple[int, str, int]]:
|
||||
"""Run-length encode consecutive identical lines.
|
||||
|
||||
Args:
|
||||
lines: List of (index, content) tuples (must be sorted by index)
|
||||
|
||||
Returns:
|
||||
List of (start_index, content, run_length) tuples
|
||||
"""
|
||||
if not lines:
|
||||
return []
|
||||
|
||||
encoded = []
|
||||
start_idx = lines[0][0]
|
||||
current_line = lines[0][1]
|
||||
current_rle = 1
|
||||
|
||||
for idx, line in lines[1:]:
|
||||
if line == current_line:
|
||||
current_rle += 1
|
||||
else:
|
||||
encoded.append((start_idx, current_line, current_rle))
|
||||
start_idx = idx
|
||||
current_line = line
|
||||
current_rle = 1
|
||||
|
||||
encoded.append((start_idx, current_line, current_rle))
|
||||
return encoded
|
||||
|
||||
|
||||
def decode_rle(encoded: list[tuple[int, str, int]]) -> list[tuple[int, str]]:
|
||||
"""Decode run-length encoded lines.
|
||||
|
||||
Args:
|
||||
encoded: List of (start_index, content, run_length) tuples
|
||||
|
||||
Returns:
|
||||
List of (index, content) tuples
|
||||
"""
|
||||
result = []
|
||||
for start_idx, line, rle in encoded:
|
||||
for i in range(rle):
|
||||
result.append((start_idx + i, line))
|
||||
return result
|
||||
|
||||
|
||||
def compress_frame(buffer: list[str], level: int = 6) -> bytes:
|
||||
"""Compress a frame buffer using zlib.
|
||||
|
||||
Args:
|
||||
buffer: Frame buffer (list of lines)
|
||||
level: Compression level (0-9)
|
||||
|
||||
Returns:
|
||||
Compressed bytes
|
||||
"""
|
||||
content = "\n".join(buffer)
|
||||
return zlib.compress(content.encode("utf-8"), level)
|
||||
|
||||
|
||||
def decompress_frame(data: bytes, height: int) -> list[str]:
|
||||
"""Decompress a frame buffer.
|
||||
|
||||
Args:
|
||||
data: Compressed bytes
|
||||
height: Number of lines in original buffer
|
||||
|
||||
Returns:
|
||||
Frame buffer (list of lines)
|
||||
"""
|
||||
content = zlib.decompress(data).decode("utf-8")
|
||||
lines = content.split("\n")
|
||||
if len(lines) > height:
|
||||
lines = lines[:height]
|
||||
while len(lines) < height:
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def encode_binary_message(
|
||||
msg_type: MessageType, width: int, height: int, payload: bytes
|
||||
) -> bytes:
|
||||
"""Encode a binary message.
|
||||
|
||||
Message format:
|
||||
- 1 byte: message type
|
||||
- 2 bytes: width (uint16)
|
||||
- 2 bytes: height (uint16)
|
||||
- 4 bytes: payload length (uint32)
|
||||
- N bytes: payload
|
||||
|
||||
Args:
|
||||
msg_type: Message type
|
||||
width: Frame width
|
||||
height: Frame height
|
||||
payload: Message payload
|
||||
|
||||
Returns:
|
||||
Encoded binary message
|
||||
"""
|
||||
import struct
|
||||
|
||||
header = struct.pack("!BHHI", msg_type.value, width, height, len(payload))
|
||||
return header + payload
|
||||
|
||||
|
||||
def decode_binary_message(data: bytes) -> tuple[MessageType, int, int, bytes]:
|
||||
"""Decode a binary message.
|
||||
|
||||
Args:
|
||||
data: Binary message data
|
||||
|
||||
Returns:
|
||||
Tuple of (msg_type, width, height, payload)
|
||||
"""
|
||||
import struct
|
||||
|
||||
msg_type_val, width, height, payload_len = struct.unpack("!BHHI", data[:9])
|
||||
payload = data[9 : 9 + payload_len]
|
||||
return MessageType(msg_type_val), width, height, payload
|
||||
|
||||
|
||||
def encode_diff_message(diff: FrameDiff, use_rle: bool = True) -> bytes:
|
||||
"""Encode a diff message for transmission.
|
||||
|
||||
Args:
|
||||
diff: Frame diff
|
||||
use_rle: Whether to use run-length encoding
|
||||
|
||||
Returns:
|
||||
Encoded diff payload
|
||||
"""
|
||||
|
||||
if use_rle:
|
||||
encoded_lines = encode_rle(diff.changed_lines)
|
||||
data = [[idx, line, rle] for idx, line, rle in encoded_lines]
|
||||
else:
|
||||
data = [[idx, line] for idx, line in diff.changed_lines]
|
||||
|
||||
payload = json.dumps(data).encode("utf-8")
|
||||
return payload
|
||||
|
||||
|
||||
def decode_diff_message(payload: bytes, use_rle: bool = True) -> list[tuple[int, str]]:
|
||||
"""Decode a diff message.
|
||||
|
||||
Args:
|
||||
payload: Encoded diff payload
|
||||
use_rle: Whether run-length encoding was used
|
||||
|
||||
Returns:
|
||||
List of (line_index, content) tuples
|
||||
"""
|
||||
|
||||
data = json.loads(payload.decode("utf-8"))
|
||||
|
||||
if use_rle:
|
||||
return decode_rle([(idx, line, rle) for idx, line, rle in data])
|
||||
else:
|
||||
return [(idx, line) for idx, line in data]
|
||||
|
||||
|
||||
def should_use_diff(
|
||||
old_buffer: list[str], new_buffer: list[str], threshold: float = 0.3
|
||||
) -> bool:
|
||||
"""Determine if diff or full frame is more efficient.
|
||||
|
||||
Args:
|
||||
old_buffer: Previous frame
|
||||
new_buffer: Current frame
|
||||
threshold: Max changed ratio to use diff (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
True if diff is more efficient
|
||||
"""
|
||||
if not old_buffer or not new_buffer:
|
||||
return False
|
||||
|
||||
diff = compute_diff(old_buffer, new_buffer)
|
||||
total_lines = len(new_buffer)
|
||||
changed_ratio = len(diff.changed_lines) / total_lines if total_lines > 0 else 1.0
|
||||
|
||||
return changed_ratio <= threshold
|
||||
|
||||
|
||||
def apply_diff(old_buffer: list[str], diff: FrameDiff) -> list[str]:
|
||||
"""Apply a diff to an old buffer to get the new buffer.
|
||||
|
||||
Args:
|
||||
old_buffer: Previous frame buffer
|
||||
diff: Frame diff to apply
|
||||
|
||||
Returns:
|
||||
New frame buffer
|
||||
"""
|
||||
new_buffer = list(old_buffer)
|
||||
|
||||
for line_idx, content in diff.changed_lines:
|
||||
if line_idx < len(new_buffer):
|
||||
new_buffer[line_idx] = content
|
||||
else:
|
||||
while len(new_buffer) < line_idx:
|
||||
new_buffer.append("")
|
||||
new_buffer.append(content)
|
||||
|
||||
while len(new_buffer) < diff.height:
|
||||
new_buffer.append("")
|
||||
|
||||
return new_buffer[: diff.height]
|
||||
122
engine/effects/plugins/afterimage.py
Normal file
122
engine/effects/plugins/afterimage.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""Afterimage effect using previous frame."""
|
||||
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class AfterimageEffect(EffectPlugin):
|
||||
"""Show a faint ghost of the previous frame.
|
||||
|
||||
This effect requires a FrameBufferStage to be present in the pipeline.
|
||||
It shows a dimmed version of the previous frame super-imposed on the
|
||||
current frame.
|
||||
|
||||
Attributes:
|
||||
name: "afterimage"
|
||||
config: EffectConfig with intensity parameter (0.0-1.0)
|
||||
param_bindings: Optional sensor bindings for intensity modulation
|
||||
|
||||
Example:
|
||||
>>> effect = AfterimageEffect()
|
||||
>>> effect.configure(EffectConfig(intensity=0.3))
|
||||
>>> result = effect.process(buffer, ctx)
|
||||
"""
|
||||
|
||||
name = "afterimage"
|
||||
config: EffectConfig = EffectConfig(enabled=True, intensity=0.3)
|
||||
param_bindings: dict[str, dict[str, str | float]] = {}
|
||||
supports_partial_updates = False
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
"""Apply afterimage effect using the previous frame.
|
||||
|
||||
Args:
|
||||
buf: Current text buffer (list of strings)
|
||||
ctx: Effect context with access to framebuffer history
|
||||
|
||||
Returns:
|
||||
Buffer with ghost of previous frame overlaid
|
||||
"""
|
||||
if not buf:
|
||||
return buf
|
||||
|
||||
# Get framebuffer history from context
|
||||
history = None
|
||||
|
||||
for key in ctx.state:
|
||||
if key.startswith("framebuffer.") and key.endswith(".history"):
|
||||
history = ctx.state[key]
|
||||
break
|
||||
|
||||
if not history or len(history) < 1:
|
||||
# No previous frame available
|
||||
return buf
|
||||
|
||||
# Get intensity from config
|
||||
intensity = self.config.params.get("intensity", self.config.intensity)
|
||||
intensity = max(0.0, min(1.0, intensity))
|
||||
|
||||
if intensity <= 0.0:
|
||||
return buf
|
||||
|
||||
# Get the previous frame (index 1, since index 0 is current)
|
||||
prev_frame = history[1] if len(history) > 1 else None
|
||||
if not prev_frame:
|
||||
return buf
|
||||
|
||||
# Blend current and previous frames
|
||||
viewport_height = ctx.terminal_height - ctx.ticker_height
|
||||
result = []
|
||||
|
||||
for row in range(len(buf)):
|
||||
if row >= viewport_height:
|
||||
result.append(buf[row])
|
||||
continue
|
||||
|
||||
current_line = buf[row]
|
||||
prev_line = prev_frame[row] if row < len(prev_frame) else ""
|
||||
|
||||
if not prev_line:
|
||||
result.append(current_line)
|
||||
continue
|
||||
|
||||
# Apply dimming effect by reducing ANSI color intensity or adding transparency
|
||||
# For a simple text version, we'll use a blend strategy
|
||||
blended = self._blend_lines(current_line, prev_line, intensity)
|
||||
result.append(blended)
|
||||
|
||||
return result
|
||||
|
||||
def _blend_lines(self, current: str, previous: str, intensity: float) -> str:
|
||||
"""Blend current and previous line with given intensity.
|
||||
|
||||
For text with ANSI codes, true blending is complex. This is a simplified
|
||||
version that uses color averaging when possible.
|
||||
|
||||
A more sophisticated implementation would:
|
||||
1. Parse ANSI color codes from both lines
|
||||
2. Blend RGB values based on intensity
|
||||
3. Reconstruct the line with blended colors
|
||||
|
||||
For now, we'll use a heuristic: if lines are similar, return current.
|
||||
If they differ, we alternate or use the previous as a faint overlay.
|
||||
"""
|
||||
if current == previous:
|
||||
return current
|
||||
|
||||
# Simple blending: intensity determines mix
|
||||
# intensity=1.0 => fully current
|
||||
# intensity=0.3 => 70% previous ghost, 30% current
|
||||
|
||||
if intensity > 0.7:
|
||||
return current
|
||||
elif intensity < 0.3:
|
||||
# Show previous but dimmed (simulate by adding faint color/gray)
|
||||
return previous # Would need to dim ANSI colors
|
||||
else:
|
||||
# For medium intensity, alternate based on character pattern
|
||||
# This is a placeholder for proper blending
|
||||
return current
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
"""Configure the effect."""
|
||||
self.config = config
|
||||
@@ -5,7 +5,7 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
class FadeEffect(EffectPlugin):
|
||||
name = "fade"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
config = EffectConfig(enabled=True, intensity=1.0, entropy=0.1)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
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):
|
||||
name = "firehose"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
config = EffectConfig(enabled=True, intensity=1.0, entropy=0.9)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
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):
|
||||
name = "glitch"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
config = EffectConfig(enabled=True, intensity=1.0, entropy=0.8)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not buf:
|
||||
|
||||
@@ -92,7 +92,7 @@ class HudEffect(EffectPlugin):
|
||||
|
||||
for i, line in enumerate(hud_lines):
|
||||
if i < len(result):
|
||||
result[i] = line + result[i][len(line) :]
|
||||
result[i] = line
|
||||
else:
|
||||
result.append(line)
|
||||
|
||||
|
||||
119
engine/effects/plugins/motionblur.py
Normal file
119
engine/effects/plugins/motionblur.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Motion blur effect using frame history."""
|
||||
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class MotionBlurEffect(EffectPlugin):
|
||||
"""Apply motion blur by blending current frame with previous frames.
|
||||
|
||||
This effect requires a FrameBufferStage to be present in the pipeline.
|
||||
The framebuffer provides frame history which is blended with the current
|
||||
frame based on intensity.
|
||||
|
||||
Attributes:
|
||||
name: "motionblur"
|
||||
config: EffectConfig with intensity parameter (0.0-1.0)
|
||||
param_bindings: Optional sensor bindings for intensity modulation
|
||||
|
||||
Example:
|
||||
>>> effect = MotionBlurEffect()
|
||||
>>> effect.configure(EffectConfig(intensity=0.5))
|
||||
>>> result = effect.process(buffer, ctx)
|
||||
"""
|
||||
|
||||
name = "motionblur"
|
||||
config: EffectConfig = EffectConfig(enabled=True, intensity=0.5)
|
||||
param_bindings: dict[str, dict[str, str | float]] = {}
|
||||
supports_partial_updates = False
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
"""Apply motion blur by blending with previous frames.
|
||||
|
||||
Args:
|
||||
buf: Current text buffer (list of strings)
|
||||
ctx: Effect context with access to framebuffer history
|
||||
|
||||
Returns:
|
||||
Blended buffer with motion blur effect applied
|
||||
"""
|
||||
if not buf:
|
||||
return buf
|
||||
|
||||
# Get framebuffer history from context
|
||||
# We'll look for the first available framebuffer history
|
||||
history = None
|
||||
|
||||
for key in ctx.state:
|
||||
if key.startswith("framebuffer.") and key.endswith(".history"):
|
||||
history = ctx.state[key]
|
||||
break
|
||||
|
||||
if not history:
|
||||
# No framebuffer available, return unchanged
|
||||
return buf
|
||||
|
||||
# Get intensity from config
|
||||
intensity = self.config.params.get("intensity", self.config.intensity)
|
||||
intensity = max(0.0, min(1.0, intensity))
|
||||
|
||||
if intensity <= 0.0:
|
||||
return buf
|
||||
|
||||
# Get decay factor (how quickly older frames fade)
|
||||
decay = self.config.params.get("decay", 0.7)
|
||||
|
||||
# Build output buffer
|
||||
result = []
|
||||
viewport_height = ctx.terminal_height - ctx.ticker_height
|
||||
|
||||
# Determine how many frames to blend (up to history depth)
|
||||
max_frames = min(len(history), 5) # Cap at 5 frames for performance
|
||||
|
||||
for row in range(len(buf)):
|
||||
if row >= viewport_height:
|
||||
# Beyond viewport, just copy
|
||||
result.append(buf[row])
|
||||
continue
|
||||
|
||||
# Start with current frame
|
||||
blended = buf[row]
|
||||
|
||||
# Blend with historical frames
|
||||
weight_sum = 1.0
|
||||
if max_frames > 0 and intensity > 0:
|
||||
for i in range(max_frames):
|
||||
frame_weight = intensity * (decay**i)
|
||||
if frame_weight < 0.01: # Skip negligible weights
|
||||
break
|
||||
|
||||
hist_row = history[i][row] if row < len(history[i]) else ""
|
||||
# Simple string blending: we'll concatenate with space
|
||||
# For a proper effect, we'd need to blend ANSI colors
|
||||
# This is a simplified version that just adds the frames
|
||||
blended = self._blend_strings(blended, hist_row, frame_weight)
|
||||
weight_sum += frame_weight
|
||||
|
||||
result.append(blended)
|
||||
|
||||
return result
|
||||
|
||||
def _blend_strings(self, current: str, historical: str, weight: float) -> str:
|
||||
"""Blend two strings with given weight.
|
||||
|
||||
This is a simplified blending that works with ANSI codes.
|
||||
For proper blending we'd need to parse colors, but for now
|
||||
we use a heuristic: if strings are identical, return one.
|
||||
If they differ, we alternate or concatenate based on weight.
|
||||
"""
|
||||
if current == historical:
|
||||
return current
|
||||
|
||||
# If weight is high, show current; if low, show historical
|
||||
if weight > 0.5:
|
||||
return current
|
||||
else:
|
||||
return historical
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
"""Configure the effect."""
|
||||
self.config = config
|
||||
@@ -7,7 +7,7 @@ from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||
|
||||
class NoiseEffect(EffectPlugin):
|
||||
name = "noise"
|
||||
config = EffectConfig(enabled=True, intensity=0.15)
|
||||
config = EffectConfig(enabled=True, intensity=0.15, entropy=0.4)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not ctx.ticker_height:
|
||||
|
||||
@@ -44,6 +44,11 @@ class PartialUpdate:
|
||||
|
||||
@dataclass
|
||||
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_height: int
|
||||
scroll_cam: int
|
||||
@@ -56,6 +61,26 @@ class EffectContext:
|
||||
items: list = field(default_factory=list)
|
||||
_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:
|
||||
"""Get a sensor value from context state.
|
||||
|
||||
@@ -75,11 +100,17 @@ class EffectContext:
|
||||
"""Get a state value from the context."""
|
||||
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
|
||||
class EffectConfig:
|
||||
enabled: bool = True
|
||||
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)
|
||||
|
||||
|
||||
|
||||
157
engine/fetch.py
157
engine/fetch.py
@@ -7,6 +7,7 @@ import json
|
||||
import pathlib
|
||||
import re
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -17,54 +18,98 @@ from engine.filter import skip, strip_tags
|
||||
from engine.sources import FEEDS, POETRY_SOURCES
|
||||
from engine.terminal import boot_ln
|
||||
|
||||
# Type alias for headline items
|
||||
HeadlineTuple = tuple[str, str, str]
|
||||
|
||||
DEFAULT_MAX_WORKERS = 10
|
||||
FAST_START_SOURCES = 5
|
||||
FAST_START_TIMEOUT = 3
|
||||
|
||||
# ─── SINGLE FEED ──────────────────────────────────────────
|
||||
def fetch_feed(url: str) -> Any | None:
|
||||
"""Fetch and parse a single RSS feed URL."""
|
||||
|
||||
def fetch_feed(url: str) -> tuple[str, Any] | tuple[None, None]:
|
||||
"""Fetch and parse a single RSS feed URL. Returns (url, feed) tuple."""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "mainline/0.1"})
|
||||
resp = urllib.request.urlopen(req, timeout=config.FEED_TIMEOUT)
|
||||
return feedparser.parse(resp.read())
|
||||
timeout = FAST_START_TIMEOUT if url in _fast_start_urls else config.FEED_TIMEOUT
|
||||
resp = urllib.request.urlopen(req, timeout=timeout)
|
||||
return (url, feedparser.parse(resp.read()))
|
||||
except Exception:
|
||||
return None
|
||||
return (url, None)
|
||||
|
||||
|
||||
def _parse_feed(feed: Any, src: str) -> list[HeadlineTuple]:
|
||||
"""Parse a feed and return list of headline tuples."""
|
||||
items = []
|
||||
if feed is None or (feed.bozo and not feed.entries):
|
||||
return items
|
||||
|
||||
for e in feed.entries:
|
||||
t = strip_tags(e.get("title", ""))
|
||||
if not t or skip(t):
|
||||
continue
|
||||
pub = e.get("published_parsed") or e.get("updated_parsed")
|
||||
try:
|
||||
ts = datetime(*pub[:6]).strftime("%H:%M") if pub else "——:——"
|
||||
except Exception:
|
||||
ts = "——:——"
|
||||
items.append((t, src, ts))
|
||||
return items
|
||||
|
||||
|
||||
def fetch_all_fast() -> list[HeadlineTuple]:
|
||||
"""Fetch only the first N sources for fast startup."""
|
||||
global _fast_start_urls
|
||||
_fast_start_urls = set(list(FEEDS.values())[:FAST_START_SOURCES])
|
||||
|
||||
items: list[HeadlineTuple] = []
|
||||
with ThreadPoolExecutor(max_workers=FAST_START_SOURCES) as executor:
|
||||
futures = {
|
||||
executor.submit(fetch_feed, url): src
|
||||
for src, url in list(FEEDS.items())[:FAST_START_SOURCES]
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
src = futures[future]
|
||||
url, feed = future.result()
|
||||
if feed is None or (feed.bozo and not feed.entries):
|
||||
boot_ln(src, "DARK", False)
|
||||
continue
|
||||
parsed = _parse_feed(feed, src)
|
||||
if parsed:
|
||||
items.extend(parsed)
|
||||
boot_ln(src, f"LINKED [{len(parsed)}]", True)
|
||||
else:
|
||||
boot_ln(src, "EMPTY", False)
|
||||
return items
|
||||
|
||||
|
||||
# ─── ALL RSS FEEDS ────────────────────────────────────────
|
||||
def fetch_all() -> tuple[list[HeadlineTuple], int, int]:
|
||||
"""Fetch all RSS feeds and return items, linked count, failed count."""
|
||||
"""Fetch all RSS feeds concurrently and return items, linked count, failed count."""
|
||||
global _fast_start_urls
|
||||
_fast_start_urls = set()
|
||||
|
||||
items: list[HeadlineTuple] = []
|
||||
linked = failed = 0
|
||||
for src, url in FEEDS.items():
|
||||
feed = fetch_feed(url)
|
||||
if feed is None or (feed.bozo and not feed.entries):
|
||||
boot_ln(src, "DARK", False)
|
||||
failed += 1
|
||||
continue
|
||||
n = 0
|
||||
for e in feed.entries:
|
||||
t = strip_tags(e.get("title", ""))
|
||||
if not t or skip(t):
|
||||
|
||||
with ThreadPoolExecutor(max_workers=DEFAULT_MAX_WORKERS) as executor:
|
||||
futures = {executor.submit(fetch_feed, url): src for src, url in FEEDS.items()}
|
||||
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)
|
||||
failed += 1
|
||||
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))
|
||||
n += 1
|
||||
if n:
|
||||
boot_ln(src, f"LINKED [{n}]", True)
|
||||
linked += 1
|
||||
else:
|
||||
boot_ln(src, "EMPTY", False)
|
||||
failed += 1
|
||||
parsed = _parse_feed(feed, src)
|
||||
if parsed:
|
||||
items.extend(parsed)
|
||||
boot_ln(src, f"LINKED [{len(parsed)}]", True)
|
||||
linked += 1
|
||||
else:
|
||||
boot_ln(src, "EMPTY", False)
|
||||
failed += 1
|
||||
|
||||
return items, linked, failed
|
||||
|
||||
|
||||
# ─── PROJECT GUTENBERG ────────────────────────────────────
|
||||
def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
||||
"""Download and parse stanzas/passages from a Project Gutenberg text."""
|
||||
try:
|
||||
@@ -76,23 +121,21 @@ def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
||||
.replace("\r\n", "\n")
|
||||
.replace("\r", "\n")
|
||||
)
|
||||
# Strip PG boilerplate
|
||||
m = re.search(r"\*\*\*\s*START OF[^\n]*\n", text)
|
||||
if m:
|
||||
text = text[m.end() :]
|
||||
m = re.search(r"\*\*\*\s*END OF", text)
|
||||
if m:
|
||||
text = text[: m.start()]
|
||||
# Split on blank lines into stanzas/passages
|
||||
blocks = re.split(r"\n{2,}", text.strip())
|
||||
items = []
|
||||
for blk in blocks:
|
||||
blk = " ".join(blk.split()) # flatten to one line
|
||||
blk = " ".join(blk.split())
|
||||
if len(blk) < 20 or len(blk) > 280:
|
||||
continue
|
||||
if blk.isupper(): # skip all-caps headers
|
||||
if blk.isupper():
|
||||
continue
|
||||
if re.match(r"^[IVXLCDM]+\.?\s*$", blk): # roman numerals
|
||||
if re.match(r"^[IVXLCDM]+\.?\s*$", blk):
|
||||
continue
|
||||
items.append((blk, label, ""))
|
||||
return items
|
||||
@@ -100,28 +143,35 @@ def _fetch_gutenberg(url: str, label: str) -> list[HeadlineTuple]:
|
||||
return []
|
||||
|
||||
|
||||
def fetch_poetry():
|
||||
"""Fetch all poetry/literature sources."""
|
||||
def fetch_poetry() -> tuple[list[HeadlineTuple], int, int]:
|
||||
"""Fetch all poetry/literature sources concurrently."""
|
||||
items = []
|
||||
linked = failed = 0
|
||||
for label, url in POETRY_SOURCES.items():
|
||||
stanzas = _fetch_gutenberg(url, label)
|
||||
if stanzas:
|
||||
boot_ln(label, f"LOADED [{len(stanzas)}]", True)
|
||||
items.extend(stanzas)
|
||||
linked += 1
|
||||
else:
|
||||
boot_ln(label, "DARK", False)
|
||||
failed += 1
|
||||
|
||||
with ThreadPoolExecutor(max_workers=DEFAULT_MAX_WORKERS) as executor:
|
||||
futures = {
|
||||
executor.submit(_fetch_gutenberg, url, label): label
|
||||
for label, url in POETRY_SOURCES.items()
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
label = futures[future]
|
||||
stanzas = future.result()
|
||||
if stanzas:
|
||||
boot_ln(label, f"LOADED [{len(stanzas)}]", True)
|
||||
items.extend(stanzas)
|
||||
linked += 1
|
||||
else:
|
||||
boot_ln(label, "DARK", False)
|
||||
failed += 1
|
||||
|
||||
return items, linked, failed
|
||||
|
||||
|
||||
# ─── CACHE ────────────────────────────────────────────────
|
||||
_CACHE_DIR = pathlib.Path(__file__).resolve().parent.parent
|
||||
_cache_dir = pathlib.Path(__file__).resolve().parent / "fixtures"
|
||||
|
||||
|
||||
def _cache_path():
|
||||
return _CACHE_DIR / f".mainline_cache_{config.MODE}.json"
|
||||
return _cache_dir / "headlines.json"
|
||||
|
||||
|
||||
def load_cache():
|
||||
@@ -143,3 +193,6 @@ def save_cache(items):
|
||||
_cache_path().write_text(json.dumps({"items": items}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_fast_start_urls: set = set()
|
||||
|
||||
1
engine/fixtures/headlines.json
Normal file
1
engine/fixtures/headlines.json
Normal file
@@ -0,0 +1 @@
|
||||
{"items": []}
|
||||
@@ -50,8 +50,7 @@ from engine.pipeline.presets import (
|
||||
FIREHOSE_PRESET,
|
||||
PIPELINE_VIZ_PRESET,
|
||||
POETRY_PRESET,
|
||||
PRESETS,
|
||||
SIXEL_PRESET,
|
||||
UI_PRESET,
|
||||
WEBSOCKET_PRESET,
|
||||
PipelinePreset,
|
||||
create_preset_from_params,
|
||||
@@ -92,8 +91,8 @@ __all__ = [
|
||||
"POETRY_PRESET",
|
||||
"PIPELINE_VIZ_PRESET",
|
||||
"WEBSOCKET_PRESET",
|
||||
"SIXEL_PRESET",
|
||||
"FIREHOSE_PRESET",
|
||||
"UI_PRESET",
|
||||
"get_preset",
|
||||
"list_presets",
|
||||
"create_preset_from_params",
|
||||
|
||||
@@ -3,843 +3,48 @@ Stage adapters - Bridge existing components to the Stage interface.
|
||||
|
||||
This module provides adapters that wrap existing components
|
||||
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
|
||||
|
||||
DEPRECATED: This file is now a compatibility wrapper.
|
||||
Use `engine.pipeline.adapters` package instead.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from engine.pipeline.core import PipelineContext, Stage
|
||||
|
||||
|
||||
class EffectPluginStage(Stage):
|
||||
"""Adapter wrapping EffectPlugin as a Stage."""
|
||||
|
||||
def __init__(self, effect_plugin, name: str = "effect"):
|
||||
self._effect = effect_plugin
|
||||
self.name = name
|
||||
self.category = "effect"
|
||||
self.optional = False
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
"""Return stage_type based on effect name.
|
||||
|
||||
HUD effects are overlays.
|
||||
"""
|
||||
if self.name == "hud":
|
||||
return "overlay"
|
||||
return self.category
|
||||
|
||||
@property
|
||||
def render_order(self) -> int:
|
||||
"""Return render_order based on effect type.
|
||||
|
||||
HUD effects have high render_order to appear on top.
|
||||
"""
|
||||
if self.name == "hud":
|
||||
return 100 # High order for overlays
|
||||
return 0
|
||||
|
||||
@property
|
||||
def is_overlay(self) -> bool:
|
||||
"""Return True for HUD effects.
|
||||
|
||||
HUD is an overlay - it composes on top of the buffer
|
||||
rather than transforming it for the next stage.
|
||||
"""
|
||||
return self.name == "hud"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"effect.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
@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
|
||||
# Re-export from the new package structure for backward compatibility
|
||||
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__ = [
|
||||
# Adapter classes
|
||||
"EffectPluginStage",
|
||||
"DisplayStage",
|
||||
"DataSourceStage",
|
||||
"PassthroughStage",
|
||||
"SourceItemsToBufferStage",
|
||||
"CameraStage",
|
||||
"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",
|
||||
]
|
||||
|
||||
44
engine/pipeline/adapters/__init__.py
Normal file
44
engine/pipeline/adapters/__init__.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""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",
|
||||
]
|
||||
219
engine/pipeline/adapters/camera.py
Normal file
219
engine/pipeline/adapters/camera.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Adapter for camera stage."""
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||
|
||||
|
||||
class CameraClockStage(Stage):
|
||||
"""Per-frame clock stage that updates camera state.
|
||||
|
||||
This stage runs once per frame and updates the camera's internal state
|
||||
(position, time). It makes camera_y/camera_x available to subsequent
|
||||
stages via the pipeline context.
|
||||
|
||||
Unlike other stages, this is a pure clock stage and doesn't process
|
||||
data - it just updates camera state and passes data through unchanged.
|
||||
"""
|
||||
|
||||
def __init__(self, camera, name: str = "camera-clock"):
|
||||
self._camera = camera
|
||||
self.name = name
|
||||
self.category = "camera"
|
||||
self.optional = False
|
||||
self._last_frame_time: float | None = None
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "camera"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
# Provides camera state info only
|
||||
# NOTE: Do NOT provide "source" as it conflicts with viewport_filter's "source.filtered"
|
||||
return {"camera.state"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
# Clock stage - no dependencies (updates every frame regardless of data flow)
|
||||
return set()
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
# Accept any data type - this is a pass-through stage
|
||||
return {DataType.ANY}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
# Pass through whatever was received
|
||||
return {DataType.ANY}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Update camera state and pass data through.
|
||||
|
||||
This stage updates the camera's internal state (position, time) and
|
||||
makes the updated camera_y/camera_x available to subsequent stages
|
||||
via the pipeline context.
|
||||
|
||||
The data is passed through unchanged - this stage only updates
|
||||
camera state, it doesn't transform the data.
|
||||
"""
|
||||
if data is None:
|
||||
return data
|
||||
|
||||
# Update camera speed from params if explicitly set (for dynamic modulation)
|
||||
# Only update if camera_speed in params differs from the default (1.0)
|
||||
# This preserves camera speed set during construction
|
||||
if (
|
||||
ctx.params
|
||||
and hasattr(ctx.params, "camera_speed")
|
||||
and ctx.params.camera_speed != 1.0
|
||||
):
|
||||
self._camera.set_speed(ctx.params.camera_speed)
|
||||
|
||||
current_time = time.perf_counter()
|
||||
dt = 0.0
|
||||
if self._last_frame_time is not None:
|
||||
dt = current_time - self._last_frame_time
|
||||
self._camera.update(dt)
|
||||
self._last_frame_time = current_time
|
||||
|
||||
# Update context with current camera position
|
||||
ctx.set_state("camera_y", self._camera.y)
|
||||
ctx.set_state("camera_x", self._camera.x)
|
||||
|
||||
# Pass data through unchanged
|
||||
return data
|
||||
|
||||
|
||||
class CameraStage(Stage):
|
||||
"""Adapter wrapping Camera as a Stage.
|
||||
|
||||
This stage applies camera viewport transformation to the rendered buffer.
|
||||
Camera state updates are handled by CameraClockStage.
|
||||
"""
|
||||
|
||||
def __init__(self, camera, name: str = "vertical"):
|
||||
self._camera = camera
|
||||
self.name = name
|
||||
self.category = "camera"
|
||||
self.optional = True
|
||||
self._last_frame_time: float | None = None
|
||||
|
||||
def save_state(self) -> dict[str, Any]:
|
||||
"""Save camera state for restoration after pipeline rebuild.
|
||||
|
||||
Returns:
|
||||
Dictionary containing camera state that can be restored
|
||||
"""
|
||||
state = {
|
||||
"x": self._camera.x,
|
||||
"y": self._camera.y,
|
||||
"mode": self._camera.mode.value
|
||||
if hasattr(self._camera.mode, "value")
|
||||
else self._camera.mode,
|
||||
"speed": self._camera.speed,
|
||||
"zoom": self._camera.zoom,
|
||||
"canvas_width": self._camera.canvas_width,
|
||||
"canvas_height": self._camera.canvas_height,
|
||||
"_x_float": getattr(self._camera, "_x_float", 0.0),
|
||||
"_y_float": getattr(self._camera, "_y_float", 0.0),
|
||||
"_time": getattr(self._camera, "_time", 0.0),
|
||||
}
|
||||
# Save radial camera state if present
|
||||
if hasattr(self._camera, "_r_float"):
|
||||
state["_r_float"] = self._camera._r_float
|
||||
if hasattr(self._camera, "_theta_float"):
|
||||
state["_theta_float"] = self._camera._theta_float
|
||||
if hasattr(self._camera, "_radial_input"):
|
||||
state["_radial_input"] = self._camera._radial_input
|
||||
return state
|
||||
|
||||
def restore_state(self, state: dict[str, Any]) -> None:
|
||||
"""Restore camera state from saved state.
|
||||
|
||||
Args:
|
||||
state: Dictionary containing camera state from save_state()
|
||||
"""
|
||||
from engine.camera import CameraMode
|
||||
|
||||
self._camera.x = state.get("x", 0)
|
||||
self._camera.y = state.get("y", 0)
|
||||
|
||||
# Restore mode - handle both enum value and direct enum
|
||||
mode_value = state.get("mode", 0)
|
||||
if isinstance(mode_value, int):
|
||||
self._camera.mode = CameraMode(mode_value)
|
||||
else:
|
||||
self._camera.mode = mode_value
|
||||
|
||||
self._camera.speed = state.get("speed", 1.0)
|
||||
self._camera.zoom = state.get("zoom", 1.0)
|
||||
self._camera.canvas_width = state.get("canvas_width", 200)
|
||||
self._camera.canvas_height = state.get("canvas_height", 200)
|
||||
|
||||
# Restore internal state
|
||||
if hasattr(self._camera, "_x_float"):
|
||||
self._camera._x_float = state.get("_x_float", 0.0)
|
||||
if hasattr(self._camera, "_y_float"):
|
||||
self._camera._y_float = state.get("_y_float", 0.0)
|
||||
if hasattr(self._camera, "_time"):
|
||||
self._camera._time = state.get("_time", 0.0)
|
||||
|
||||
# Restore radial camera state if present
|
||||
if hasattr(self._camera, "_r_float"):
|
||||
self._camera._r_float = state.get("_r_float", 0.0)
|
||||
if hasattr(self._camera, "_theta_float"):
|
||||
self._camera._theta_float = state.get("_theta_float", 0.0)
|
||||
if hasattr(self._camera, "_radial_input"):
|
||||
self._camera._radial_input = state.get("_radial_input", 0.0)
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "camera"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"camera"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Apply camera transformation to items."""
|
||||
if data is None:
|
||||
return data
|
||||
|
||||
# Camera state is updated by CameraClockStage
|
||||
# We only apply the viewport transformation here
|
||||
|
||||
if hasattr(self._camera, "apply"):
|
||||
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
||||
|
||||
# Use filtered camera position if available (from ViewportFilterStage)
|
||||
# This handles the case where the buffer has been filtered and starts at row 0
|
||||
filtered_camera_y = ctx.get("camera_y", self._camera.y)
|
||||
|
||||
# Temporarily adjust camera position for filtering
|
||||
original_y = self._camera.y
|
||||
self._camera.y = filtered_camera_y
|
||||
|
||||
try:
|
||||
result = self._camera.apply(data, viewport_width, viewport_height)
|
||||
finally:
|
||||
# Restore original camera position
|
||||
self._camera.y = original_y
|
||||
|
||||
return result
|
||||
return data
|
||||
143
engine/pipeline/adapters/data_source.py
Normal file
143
engine/pipeline/adapters/data_source.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Stage adapters - Bridge existing components to the Stage interface.
|
||||
|
||||
This module provides adapters that wrap existing components
|
||||
(DataSource) as Stage implementations.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from engine.data_sources import SourceItem
|
||||
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||
|
||||
|
||||
class DataSourceStage(Stage):
|
||||
"""Adapter wrapping DataSource as a Stage."""
|
||||
|
||||
def __init__(self, data_source, name: str = "headlines"):
|
||||
self._source = data_source
|
||||
self.name = name
|
||||
self.category = "source"
|
||||
self.optional = False
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"source.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
return {DataType.NONE} # Sources don't take input
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Fetch data from source."""
|
||||
if hasattr(self._source, "get_items"):
|
||||
return self._source.get_items()
|
||||
return data
|
||||
|
||||
|
||||
class PassthroughStage(Stage):
|
||||
"""Simple stage that passes data through unchanged.
|
||||
|
||||
Used for sources that already provide the data in the correct format
|
||||
(e.g., pipeline introspection that outputs text directly).
|
||||
"""
|
||||
|
||||
def __init__(self, name: str = "passthrough"):
|
||||
self.name = name
|
||||
self.category = "render"
|
||||
self.optional = True
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "render"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Pass data through unchanged."""
|
||||
return data
|
||||
|
||||
|
||||
class SourceItemsToBufferStage(Stage):
|
||||
"""Convert SourceItem objects to text buffer.
|
||||
|
||||
Takes a list of SourceItem objects and extracts their content,
|
||||
splitting on newlines to create a proper text buffer for display.
|
||||
"""
|
||||
|
||||
def __init__(self, name: str = "items-to-buffer"):
|
||||
self.name = name
|
||||
self.category = "render"
|
||||
self.optional = True
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "render"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Convert SourceItem list to text buffer."""
|
||||
if data is None:
|
||||
return []
|
||||
|
||||
# If already a list of strings, return as-is
|
||||
if isinstance(data, list) and data and isinstance(data[0], str):
|
||||
return data
|
||||
|
||||
# If it's a list of SourceItem, extract content
|
||||
if isinstance(data, list):
|
||||
result = []
|
||||
for item in data:
|
||||
if isinstance(item, SourceItem):
|
||||
# Split content by newline to get individual lines
|
||||
lines = item.content.split("\n")
|
||||
result.extend(lines)
|
||||
elif hasattr(item, "content"): # Has content attribute
|
||||
lines = str(item.content).split("\n")
|
||||
result.extend(lines)
|
||||
else:
|
||||
result.append(str(item))
|
||||
return result
|
||||
|
||||
# Single item
|
||||
if isinstance(data, SourceItem):
|
||||
return data.content.split("\n")
|
||||
|
||||
return [str(data)]
|
||||
93
engine/pipeline/adapters/display.py
Normal file
93
engine/pipeline/adapters/display.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""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()
|
||||
117
engine/pipeline/adapters/effect_plugin.py
Normal file
117
engine/pipeline/adapters/effect_plugin.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""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)
|
||||
38
engine/pipeline/adapters/factory.py
Normal file
38
engine/pipeline/adapters/factory.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Factory functions for creating stage instances."""
|
||||
|
||||
from engine.pipeline.adapters.camera import CameraStage
|
||||
from engine.pipeline.adapters.data_source import DataSourceStage
|
||||
from engine.pipeline.adapters.display import DisplayStage
|
||||
from engine.pipeline.adapters.effect_plugin import EffectPluginStage
|
||||
from engine.pipeline.adapters.transform import FontStage
|
||||
|
||||
|
||||
def create_stage_from_display(display, name: str = "terminal") -> DisplayStage:
|
||||
"""Create a DisplayStage from a display instance."""
|
||||
return DisplayStage(display, name=name)
|
||||
|
||||
|
||||
def create_stage_from_effect(effect_plugin, name: str) -> EffectPluginStage:
|
||||
"""Create an EffectPluginStage from an effect plugin."""
|
||||
return EffectPluginStage(effect_plugin, name=name)
|
||||
|
||||
|
||||
def create_stage_from_source(data_source, name: str = "headlines") -> DataSourceStage:
|
||||
"""Create a DataSourceStage from a data source."""
|
||||
return DataSourceStage(data_source, name=name)
|
||||
|
||||
|
||||
def create_stage_from_camera(camera, name: str = "vertical") -> CameraStage:
|
||||
"""Create a CameraStage from a camera instance."""
|
||||
return CameraStage(camera, name=name)
|
||||
|
||||
|
||||
def create_stage_from_font(
|
||||
font_path: str | None = None,
|
||||
font_size: int | None = None,
|
||||
font_ref: str | None = "default",
|
||||
name: str = "font",
|
||||
) -> FontStage:
|
||||
"""Create a FontStage with specified font configuration."""
|
||||
# FontStage currently doesn't use these parameters but keeps them for compatibility
|
||||
return FontStage(name=name)
|
||||
293
engine/pipeline/adapters/transform.py
Normal file
293
engine/pipeline/adapters/transform.py
Normal file
@@ -0,0 +1,293 @@
|
||||
"""Adapters for transform stages (viewport, font, image, canvas)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import engine.render
|
||||
from engine.data_sources import SourceItem
|
||||
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||
|
||||
|
||||
def estimate_simple_height(text: str, width: int) -> int:
|
||||
"""Estimate height in terminal rows using simple word wrap.
|
||||
|
||||
Uses conservative estimation suitable for headlines.
|
||||
Each wrapped line is approximately 6 terminal rows (big block rendering).
|
||||
"""
|
||||
words = text.split()
|
||||
if not words:
|
||||
return 6
|
||||
|
||||
lines = 1
|
||||
current_len = 0
|
||||
for word in words:
|
||||
word_len = len(word)
|
||||
if current_len + word_len + 1 > width - 4: # -4 for margins
|
||||
lines += 1
|
||||
current_len = word_len
|
||||
else:
|
||||
current_len += word_len + 1
|
||||
|
||||
return lines * 6 # 6 rows per line for big block rendering
|
||||
|
||||
|
||||
class ViewportFilterStage(Stage):
|
||||
"""Filter items to viewport height based on rendered height."""
|
||||
|
||||
def __init__(self, name: str = "viewport-filter"):
|
||||
self.name = name
|
||||
self.category = "render"
|
||||
self.optional = True
|
||||
self._layout: list[int] = []
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "render"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"source.filtered"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
# Always requires camera.state for viewport filtering
|
||||
# CameraUpdateStage provides this (auto-injected if missing)
|
||||
return {"source", "camera.state"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Filter items to viewport height based on rendered height."""
|
||||
if data is None:
|
||||
return data
|
||||
|
||||
if not isinstance(data, list):
|
||||
return data
|
||||
|
||||
if not data:
|
||||
return []
|
||||
|
||||
# Get viewport parameters from context
|
||||
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
||||
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||
camera_y = ctx.get("camera_y", 0)
|
||||
|
||||
# Estimate height for each item and cache layout
|
||||
self._layout = []
|
||||
cumulative_heights = []
|
||||
current_height = 0
|
||||
|
||||
for item in data:
|
||||
title = item.content if isinstance(item, SourceItem) else str(item)
|
||||
# Use simple height estimation (not PIL-based)
|
||||
estimated_height = estimate_simple_height(title, viewport_width)
|
||||
self._layout.append(estimated_height)
|
||||
current_height += estimated_height
|
||||
cumulative_heights.append(current_height)
|
||||
|
||||
# Find visible range based on camera_y and viewport_height
|
||||
# camera_y is the scroll offset (how many rows are scrolled up)
|
||||
start_y = camera_y
|
||||
end_y = camera_y + viewport_height
|
||||
|
||||
# Find start index (first item that intersects with visible range)
|
||||
start_idx = 0
|
||||
start_item_y = 0 # Y position where the first visible item starts
|
||||
for i, total_h in enumerate(cumulative_heights):
|
||||
if total_h > start_y:
|
||||
start_idx = i
|
||||
# Calculate the Y position of the start of this item
|
||||
if i > 0:
|
||||
start_item_y = cumulative_heights[i - 1]
|
||||
break
|
||||
|
||||
# Find end index (first item that extends beyond visible range)
|
||||
end_idx = len(data)
|
||||
for i, total_h in enumerate(cumulative_heights):
|
||||
if total_h >= end_y:
|
||||
end_idx = i + 1
|
||||
break
|
||||
|
||||
# Adjust camera_y for the filtered buffer
|
||||
# The filtered buffer starts at row 0, but the camera position
|
||||
# needs to be relative to where the first visible item starts
|
||||
filtered_camera_y = camera_y - start_item_y
|
||||
|
||||
# Update context with the filtered camera position
|
||||
# This ensures CameraStage can correctly slice the filtered buffer
|
||||
ctx.set_state("camera_y", filtered_camera_y)
|
||||
ctx.set_state("camera_x", ctx.get("camera_x", 0)) # Keep camera_x unchanged
|
||||
|
||||
# Return visible items
|
||||
return data[start_idx:end_idx]
|
||||
|
||||
|
||||
class FontStage(Stage):
|
||||
"""Render items using font."""
|
||||
|
||||
def __init__(self, name: str = "font"):
|
||||
self.name = name
|
||||
self.category = "render"
|
||||
self.optional = False
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "render"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def stage_dependencies(self) -> set[str]:
|
||||
# Must connect to viewport_filter stage to get filtered source
|
||||
return {"viewport_filter"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
# Depend on source.filtered (provided by viewport_filter)
|
||||
# This ensures we get the filtered/processed source, not raw source
|
||||
return {"source.filtered"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Render items to text buffer using font."""
|
||||
if data is None:
|
||||
return []
|
||||
|
||||
if not isinstance(data, list):
|
||||
return [str(data)]
|
||||
|
||||
import os
|
||||
|
||||
if os.environ.get("DEBUG_CAMERA"):
|
||||
print(f"FontStage: input items={len(data)}")
|
||||
|
||||
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||
|
||||
result = []
|
||||
for item in data:
|
||||
if isinstance(item, SourceItem):
|
||||
title = item.content
|
||||
src = item.source
|
||||
ts = item.timestamp
|
||||
content_lines, _, _ = engine.render.make_block(
|
||||
title, src, ts, viewport_width
|
||||
)
|
||||
result.extend(content_lines)
|
||||
elif hasattr(item, "content"):
|
||||
title = str(item.content)
|
||||
content_lines, _, _ = engine.render.make_block(
|
||||
title, "", "", viewport_width
|
||||
)
|
||||
result.extend(content_lines)
|
||||
else:
|
||||
result.append(str(item))
|
||||
return result
|
||||
|
||||
|
||||
class ImageToTextStage(Stage):
|
||||
"""Convert image items to text."""
|
||||
|
||||
def __init__(self, name: str = "image-to-text"):
|
||||
self.name = name
|
||||
self.category = "render"
|
||||
self.optional = True
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "render"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Convert image items to text representation."""
|
||||
if data is None:
|
||||
return []
|
||||
|
||||
if not isinstance(data, list):
|
||||
return [str(data)]
|
||||
|
||||
result = []
|
||||
for item in data:
|
||||
# Check if item is an image
|
||||
if hasattr(item, "image_path") or hasattr(item, "image_data"):
|
||||
# Placeholder: would normally render image to ASCII art
|
||||
result.append(f"[Image: {getattr(item, 'image_path', 'data')}]")
|
||||
elif isinstance(item, SourceItem):
|
||||
result.extend(item.content.split("\n"))
|
||||
else:
|
||||
result.append(str(item))
|
||||
return result
|
||||
|
||||
|
||||
class CanvasStage(Stage):
|
||||
"""Render items to canvas."""
|
||||
|
||||
def __init__(self, name: str = "canvas"):
|
||||
self.name = name
|
||||
self.category = "render"
|
||||
self.optional = False
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "render"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Render items to canvas."""
|
||||
if data is None:
|
||||
return []
|
||||
|
||||
if not isinstance(data, list):
|
||||
return [str(data)]
|
||||
|
||||
# Simple canvas rendering
|
||||
result = []
|
||||
for item in data:
|
||||
if isinstance(item, SourceItem):
|
||||
result.extend(item.content.split("\n"))
|
||||
else:
|
||||
result.append(str(item))
|
||||
return result
|
||||
@@ -49,6 +49,8 @@ class Pipeline:
|
||||
|
||||
Manages the execution of all stages in dependency order,
|
||||
handling initialization, processing, and cleanup.
|
||||
|
||||
Supports dynamic mutation during runtime via the mutation API.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -61,30 +63,460 @@ class Pipeline:
|
||||
self._stages: dict[str, Stage] = {}
|
||||
self._execution_order: list[str] = []
|
||||
self._initialized = False
|
||||
self._capability_map: dict[str, list[str]] = {}
|
||||
|
||||
self._metrics_enabled = self.config.enable_metrics
|
||||
self._frame_metrics: list[FrameMetrics] = []
|
||||
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
|
||||
|
||||
def add_stage(self, name: str, stage: Stage) -> "Pipeline":
|
||||
"""Add a stage to the pipeline."""
|
||||
def add_stage(self, name: str, stage: Stage, initialize: bool = True) -> "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
|
||||
if self._initialized and initialize:
|
||||
stage.init(self.context)
|
||||
return self
|
||||
|
||||
def remove_stage(self, name: str) -> None:
|
||||
"""Remove a stage from the pipeline."""
|
||||
if name in self._stages:
|
||||
del self._stages[name]
|
||||
def remove_stage(self, name: str, cleanup: bool = True) -> Stage | None:
|
||||
"""Remove a stage from the pipeline.
|
||||
|
||||
Args:
|
||||
name: Name of the stage to remove
|
||||
cleanup: If True, call cleanup() on the removed stage
|
||||
|
||||
Returns:
|
||||
The removed stage, or None if not found
|
||||
"""
|
||||
stage = self._stages.pop(name, None)
|
||||
if stage and cleanup:
|
||||
try:
|
||||
stage.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Rebuild execution order and capability map if stage was removed
|
||||
if stage and self._initialized:
|
||||
self._rebuild()
|
||||
|
||||
return stage
|
||||
|
||||
def remove_stage_safe(self, name: str, cleanup: bool = True) -> Stage | None:
|
||||
"""Remove a stage and rebuild execution order safely.
|
||||
|
||||
This is an alias for remove_stage() that explicitly rebuilds
|
||||
the execution order after removal.
|
||||
|
||||
Args:
|
||||
name: Name of the stage to remove
|
||||
cleanup: If True, call cleanup() on the removed stage
|
||||
|
||||
Returns:
|
||||
The removed stage, or None if not found
|
||||
"""
|
||||
return self.remove_stage(name, cleanup)
|
||||
|
||||
def cleanup_stage(self, name: str) -> None:
|
||||
"""Clean up a specific stage without removing it.
|
||||
|
||||
This is useful for stages that need to release resources
|
||||
(like display connections) without being removed from the pipeline.
|
||||
|
||||
Args:
|
||||
name: Name of the stage to clean up
|
||||
"""
|
||||
stage = self._stages.get(name)
|
||||
if stage:
|
||||
try:
|
||||
stage.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def can_hot_swap(self, name: str) -> bool:
|
||||
"""Check if a stage can be safely hot-swapped.
|
||||
|
||||
A stage can be hot-swapped if:
|
||||
1. It exists in the pipeline
|
||||
2. It's not required for basic pipeline function
|
||||
3. It doesn't have strict dependencies that can't be re-resolved
|
||||
|
||||
Args:
|
||||
name: Name of the stage to check
|
||||
|
||||
Returns:
|
||||
True if the stage can be hot-swapped, False otherwise
|
||||
"""
|
||||
# Check if stage exists
|
||||
if name not in self._stages:
|
||||
return False
|
||||
|
||||
# Check if stage is a minimum capability provider
|
||||
stage = self._stages[name]
|
||||
stage_caps = stage.capabilities if hasattr(stage, "capabilities") else set()
|
||||
minimum_caps = self._minimum_capabilities
|
||||
|
||||
# If stage provides a minimum capability, it's more critical
|
||||
# but still potentially swappable if another stage provides the same capability
|
||||
for cap in stage_caps:
|
||||
if cap in minimum_caps:
|
||||
# Check if another stage provides this capability
|
||||
providers = self._capability_map.get(cap, [])
|
||||
# This stage is the sole provider - might be critical
|
||||
# but still allow hot-swap if pipeline is not initialized
|
||||
if len(providers) <= 1 and self._initialized:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def replace_stage(
|
||||
self, name: str, new_stage: Stage, preserve_state: bool = True
|
||||
) -> Stage | None:
|
||||
"""Replace a stage in the pipeline with a new one.
|
||||
|
||||
Args:
|
||||
name: Name of the stage to replace
|
||||
new_stage: New stage instance
|
||||
preserve_state: If True, copy relevant state from old stage
|
||||
|
||||
Returns:
|
||||
The old stage, or None if not found
|
||||
"""
|
||||
old_stage = self._stages.get(name)
|
||||
if not old_stage:
|
||||
return None
|
||||
|
||||
if preserve_state:
|
||||
self._copy_stage_state(old_stage, new_stage)
|
||||
|
||||
old_stage.cleanup()
|
||||
self._stages[name] = new_stage
|
||||
new_stage.init(self.context)
|
||||
|
||||
if self._initialized:
|
||||
self._rebuild()
|
||||
|
||||
return old_stage
|
||||
|
||||
def swap_stages(self, name1: str, name2: str) -> bool:
|
||||
"""Swap two stages in the pipeline.
|
||||
|
||||
Args:
|
||||
name1: First stage name
|
||||
name2: Second stage name
|
||||
|
||||
Returns:
|
||||
True if successful, False if either stage not found
|
||||
"""
|
||||
stage1 = self._stages.get(name1)
|
||||
stage2 = self._stages.get(name2)
|
||||
|
||||
if not stage1 or not stage2:
|
||||
return False
|
||||
|
||||
self._stages[name1] = stage2
|
||||
self._stages[name2] = stage1
|
||||
|
||||
if self._initialized:
|
||||
self._rebuild()
|
||||
|
||||
return True
|
||||
|
||||
def move_stage(
|
||||
self, name: str, after: str | None = None, before: str | None = None
|
||||
) -> bool:
|
||||
"""Move a stage's position in execution order.
|
||||
|
||||
Args:
|
||||
name: Stage to move
|
||||
after: Place this stage after this stage name
|
||||
before: Place this stage before this stage name
|
||||
|
||||
Returns:
|
||||
True if successful, False if stage not found
|
||||
"""
|
||||
if name not in self._stages:
|
||||
return False
|
||||
|
||||
if not self._initialized:
|
||||
return False
|
||||
|
||||
current_order = list(self._execution_order)
|
||||
if name not in current_order:
|
||||
return False
|
||||
|
||||
current_order.remove(name)
|
||||
|
||||
if after and after in current_order:
|
||||
idx = current_order.index(after) + 1
|
||||
current_order.insert(idx, name)
|
||||
elif before and before in current_order:
|
||||
idx = current_order.index(before)
|
||||
current_order.insert(idx, name)
|
||||
else:
|
||||
current_order.append(name)
|
||||
|
||||
self._execution_order = current_order
|
||||
return True
|
||||
|
||||
def _copy_stage_state(self, old_stage: Stage, new_stage: Stage) -> None:
|
||||
"""Copy relevant state from old stage to new stage during replacement.
|
||||
|
||||
Args:
|
||||
old_stage: The old stage being replaced
|
||||
new_stage: The new stage
|
||||
"""
|
||||
if hasattr(old_stage, "_enabled"):
|
||||
new_stage._enabled = old_stage._enabled
|
||||
|
||||
# Preserve camera state
|
||||
if hasattr(old_stage, "save_state") and hasattr(new_stage, "restore_state"):
|
||||
try:
|
||||
state = old_stage.save_state()
|
||||
new_stage.restore_state(state)
|
||||
except Exception:
|
||||
# If state preservation fails, continue without it
|
||||
pass
|
||||
|
||||
def _rebuild(self) -> None:
|
||||
"""Rebuild execution order after mutation or auto-injection."""
|
||||
was_initialized = self._initialized
|
||||
self._initialized = False
|
||||
|
||||
self._capability_map = self._build_capability_map()
|
||||
self._execution_order = self._resolve_dependencies()
|
||||
|
||||
# Note: We intentionally DO NOT validate dependencies here.
|
||||
# Mutation operations (remove/swap/move) might leave the pipeline
|
||||
# temporarily invalid (e.g., removing a stage that others depend on).
|
||||
# Validation is performed explicitly in build() or can be checked
|
||||
# manually via validate_minimum_capabilities().
|
||||
# try:
|
||||
# self._validate_dependencies()
|
||||
# self._validate_types()
|
||||
# except StageError:
|
||||
# pass
|
||||
|
||||
# Restore initialized state
|
||||
self._initialized = was_initialized
|
||||
|
||||
def get_stage(self, name: str) -> Stage | None:
|
||||
"""Get a stage by name."""
|
||||
return self._stages.get(name)
|
||||
|
||||
def build(self) -> "Pipeline":
|
||||
"""Build execution order based on dependencies."""
|
||||
def enable_stage(self, name: str) -> bool:
|
||||
"""Enable a stage in the pipeline.
|
||||
|
||||
Args:
|
||||
name: Stage name to enable
|
||||
|
||||
Returns:
|
||||
True if successful, False if stage not found
|
||||
"""
|
||||
stage = self._stages.get(name)
|
||||
if stage:
|
||||
stage.set_enabled(True)
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable_stage(self, name: str) -> bool:
|
||||
"""Disable a stage in the pipeline.
|
||||
|
||||
Args:
|
||||
name: Stage name to disable
|
||||
|
||||
Returns:
|
||||
True if successful, False if stage not found
|
||||
"""
|
||||
stage = self._stages.get(name)
|
||||
if stage:
|
||||
stage.set_enabled(False)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_stage_info(self, name: str) -> dict | None:
|
||||
"""Get detailed information about a stage.
|
||||
|
||||
Args:
|
||||
name: Stage name
|
||||
|
||||
Returns:
|
||||
Dictionary with stage information, or None if not found
|
||||
"""
|
||||
stage = self._stages.get(name)
|
||||
if not stage:
|
||||
return None
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"category": stage.category,
|
||||
"stage_type": stage.stage_type,
|
||||
"enabled": stage.is_enabled(),
|
||||
"optional": stage.optional,
|
||||
"capabilities": list(stage.capabilities),
|
||||
"dependencies": list(stage.dependencies),
|
||||
"inlet_types": [dt.name for dt in stage.inlet_types],
|
||||
"outlet_types": [dt.name for dt in stage.outlet_types],
|
||||
"render_order": stage.render_order,
|
||||
"is_overlay": stage.is_overlay,
|
||||
}
|
||||
|
||||
def get_pipeline_info(self) -> dict:
|
||||
"""Get comprehensive information about the pipeline.
|
||||
|
||||
Returns:
|
||||
Dictionary with pipeline state
|
||||
"""
|
||||
return {
|
||||
"stages": {name: self.get_stage_info(name) for name in self._stages},
|
||||
"execution_order": self._execution_order.copy(),
|
||||
"initialized": self._initialized,
|
||||
"stage_count": len(self._stages),
|
||||
}
|
||||
|
||||
@property
|
||||
def minimum_capabilities(self) -> set[str]:
|
||||
"""Get minimum capabilities required for pipeline to function."""
|
||||
return self._minimum_capabilities
|
||||
|
||||
@minimum_capabilities.setter
|
||||
def minimum_capabilities(self, value: set[str]):
|
||||
"""Set minimum required capabilities.
|
||||
|
||||
NOTE: Research later - allow presets to override these defaults
|
||||
"""
|
||||
self._minimum_capabilities = value
|
||||
|
||||
def validate_minimum_capabilities(self) -> tuple[bool, list[str]]:
|
||||
"""Validate that all minimum capabilities are provided.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, missing_capabilities)
|
||||
"""
|
||||
missing = []
|
||||
for cap in self._minimum_capabilities:
|
||||
if not self._find_stage_with_capability(cap):
|
||||
missing.append(cap)
|
||||
return len(missing) == 0, missing
|
||||
|
||||
def ensure_minimum_capabilities(self) -> list[str]:
|
||||
"""Automatically inject MVP stages if minimum capabilities are missing.
|
||||
|
||||
Auto-injection is always on, but defaults are trivial to override.
|
||||
Returns:
|
||||
List of stages that were injected
|
||||
"""
|
||||
from engine.camera import Camera
|
||||
from engine.data_sources.sources import EmptyDataSource
|
||||
from engine.display import DisplayRegistry
|
||||
from engine.pipeline.adapters import (
|
||||
CameraClockStage,
|
||||
CameraStage,
|
||||
DataSourceStage,
|
||||
DisplayStage,
|
||||
SourceItemsToBufferStage,
|
||||
)
|
||||
|
||||
injected = []
|
||||
|
||||
# Check for source capability
|
||||
if (
|
||||
not self._find_stage_with_capability("source")
|
||||
and "source" not in self._stages
|
||||
):
|
||||
empty_source = EmptyDataSource(width=80, height=24)
|
||||
self.add_stage("source", DataSourceStage(empty_source, name="empty"))
|
||||
injected.append("source")
|
||||
|
||||
# Check for camera.state capability (must be BEFORE render to accept SOURCE_ITEMS)
|
||||
camera = None
|
||||
if not self._find_stage_with_capability("camera.state"):
|
||||
# Inject static camera (trivial, no movement)
|
||||
camera = Camera.scroll(speed=0.0)
|
||||
camera.set_canvas_size(200, 200)
|
||||
if "camera_update" not in self._stages:
|
||||
self.add_stage(
|
||||
"camera_update", CameraClockStage(camera, name="camera-clock")
|
||||
)
|
||||
injected.append("camera_update")
|
||||
|
||||
# Check for render capability
|
||||
if (
|
||||
not self._find_stage_with_capability("render.output")
|
||||
and "render" not in self._stages
|
||||
):
|
||||
self.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
||||
injected.append("render")
|
||||
|
||||
# Check for camera stage (must be AFTER render to accept TEXT_BUFFER)
|
||||
if camera and "camera" not in self._stages:
|
||||
self.add_stage("camera", CameraStage(camera, name="static"))
|
||||
injected.append("camera")
|
||||
|
||||
# Check for display capability
|
||||
if (
|
||||
not self._find_stage_with_capability("display.output")
|
||||
and "display" not in self._stages
|
||||
):
|
||||
display = 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._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_types()
|
||||
self._initialized = True
|
||||
@@ -151,12 +583,24 @@ class Pipeline:
|
||||
temp_mark.add(name)
|
||||
stage = self._stages.get(name)
|
||||
if stage:
|
||||
# Handle capability-based dependencies
|
||||
for dep in stage.dependencies:
|
||||
# Find a stage that provides this capability
|
||||
dep_stage_name = self._find_stage_with_capability(dep)
|
||||
if 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)
|
||||
visited.add(name)
|
||||
ordered.append(name)
|
||||
@@ -281,8 +725,9 @@ class Pipeline:
|
||||
frame_start = time.perf_counter() if self._metrics_enabled else 0
|
||||
stage_timings: list[StageMetrics] = []
|
||||
|
||||
# Separate overlay stages from regular stages
|
||||
# Separate overlay stages and display stage from regular stages
|
||||
overlay_stages: list[tuple[int, Stage]] = []
|
||||
display_stage: Stage | None = None
|
||||
regular_stages: list[str] = []
|
||||
|
||||
for name in self._execution_order:
|
||||
@@ -290,6 +735,11 @@ class Pipeline:
|
||||
if not stage or not stage.is_enabled():
|
||||
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
|
||||
try:
|
||||
is_overlay = bool(getattr(stage, "is_overlay", False))
|
||||
@@ -306,7 +756,7 @@ class Pipeline:
|
||||
else:
|
||||
regular_stages.append(name)
|
||||
|
||||
# Execute regular stages in dependency order
|
||||
# Execute regular stages in dependency order (excluding display)
|
||||
for name in regular_stages:
|
||||
stage = self._stages.get(name)
|
||||
if not stage or not stage.is_enabled():
|
||||
@@ -397,6 +847,35 @@ class Pipeline:
|
||||
)
|
||||
)
|
||||
|
||||
# Execute display stage LAST (after overlay stages)
|
||||
# This ensures overlay effects like HUD are visible in the final output
|
||||
if display_stage:
|
||||
stage_start = time.perf_counter() if self._metrics_enabled else 0
|
||||
|
||||
try:
|
||||
current_data = display_stage.process(current_data, self.context)
|
||||
except Exception as e:
|
||||
if not display_stage.optional:
|
||||
return StageResult(
|
||||
success=False,
|
||||
data=current_data,
|
||||
error=str(e),
|
||||
stage_name=display_stage.name,
|
||||
)
|
||||
|
||||
if self._metrics_enabled:
|
||||
stage_duration = (time.perf_counter() - stage_start) * 1000
|
||||
chars_in = len(str(data)) if data else 0
|
||||
chars_out = len(str(current_data)) if current_data else 0
|
||||
stage_timings.append(
|
||||
StageMetrics(
|
||||
name=display_stage.name,
|
||||
duration_ms=stage_duration,
|
||||
chars_in=chars_in,
|
||||
chars_out=chars_out,
|
||||
)
|
||||
)
|
||||
|
||||
if self._metrics_enabled:
|
||||
total_duration = (time.perf_counter() - frame_start) * 1000
|
||||
self._frame_metrics.append(
|
||||
|
||||
@@ -155,6 +155,21 @@ class Stage(ABC):
|
||||
"""
|
||||
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:
|
||||
"""Initialize stage with pipeline context.
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ modify these params, which the pipeline then applies to its stages.
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from engine.display import BorderMode
|
||||
except ImportError:
|
||||
BorderMode = object # Fallback for type checking
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineParams:
|
||||
@@ -23,11 +28,11 @@ class PipelineParams:
|
||||
|
||||
# Display config
|
||||
display: str = "terminal"
|
||||
border: bool = False
|
||||
border: bool | BorderMode = False
|
||||
|
||||
# Camera config
|
||||
camera_mode: str = "vertical"
|
||||
camera_speed: float = 1.0
|
||||
camera_speed: float = 1.0 # Default speed
|
||||
camera_x: int = 0 # For horizontal scrolling
|
||||
|
||||
# Effect config
|
||||
|
||||
@@ -11,10 +11,14 @@ Loading order:
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from engine.display import BorderMode
|
||||
from engine.pipeline.params import PipelineParams
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from engine.pipeline.controller import PipelineConfig
|
||||
|
||||
|
||||
def _load_toml_presets() -> dict[str, Any]:
|
||||
"""Load presets from TOML file."""
|
||||
@@ -26,7 +30,6 @@ def _load_toml_presets() -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
# Pre-load TOML presets
|
||||
_YAML_PRESETS = _load_toml_presets()
|
||||
|
||||
|
||||
@@ -47,18 +50,53 @@ class PipelinePreset:
|
||||
display: str = "terminal"
|
||||
camera: str = "scroll"
|
||||
effects: list[str] = field(default_factory=list)
|
||||
border: bool = False
|
||||
border: bool | BorderMode = (
|
||||
False # Border mode: False=off, True=simple, BorderMode.UI for panel
|
||||
)
|
||||
# Extended fields for fine-tuning
|
||||
camera_speed: float = 1.0 # Camera movement speed
|
||||
viewport_width: int = 80 # Viewport width in columns
|
||||
viewport_height: int = 24 # Viewport height in rows
|
||||
source_items: list[dict[str, Any]] | None = None # For ListDataSource
|
||||
enable_metrics: bool = True # Enable performance metrics collection
|
||||
|
||||
def to_params(self) -> PipelineParams:
|
||||
"""Convert to PipelineParams."""
|
||||
"""Convert to PipelineParams (runtime configuration)."""
|
||||
from engine.display import BorderMode
|
||||
|
||||
params = PipelineParams()
|
||||
params.source = self.source
|
||||
params.display = self.display
|
||||
params.border = self.border
|
||||
params.border = (
|
||||
self.border
|
||||
if isinstance(self.border, bool)
|
||||
else BorderMode.UI
|
||||
if self.border == BorderMode.UI
|
||||
else False
|
||||
)
|
||||
params.camera_mode = self.camera
|
||||
params.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
|
||||
|
||||
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
|
||||
def from_yaml(cls, name: str, data: dict[str, Any]) -> "PipelinePreset":
|
||||
"""Create a PipelinePreset from YAML data."""
|
||||
@@ -70,6 +108,11 @@ class PipelinePreset:
|
||||
camera=data.get("camera", "vertical"),
|
||||
effects=data.get("effects", []),
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
@@ -83,6 +126,16 @@ DEMO_PRESET = PipelinePreset(
|
||||
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(
|
||||
name="poetry",
|
||||
description="Poetry feed with subtle effects",
|
||||
@@ -110,15 +163,6 @@ WEBSOCKET_PRESET = PipelinePreset(
|
||||
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(
|
||||
name="firehose",
|
||||
description="High-speed firehose mode",
|
||||
@@ -128,6 +172,16 @@ FIREHOSE_PRESET = PipelinePreset(
|
||||
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
|
||||
def _build_presets() -> dict[str, PipelinePreset]:
|
||||
@@ -145,8 +199,9 @@ def _build_presets() -> dict[str, PipelinePreset]:
|
||||
"poetry": POETRY_PRESET,
|
||||
"pipeline": PIPELINE_VIZ_PRESET,
|
||||
"websocket": WEBSOCKET_PRESET,
|
||||
"sixel": SIXEL_PRESET,
|
||||
"firehose": FIREHOSE_PRESET,
|
||||
"ui": UI_PRESET,
|
||||
"fixture": FIXTURE_PRESET,
|
||||
}
|
||||
|
||||
for name, preset in builtins.items():
|
||||
|
||||
@@ -118,6 +118,14 @@ def discover_stages() -> None:
|
||||
except ImportError:
|
||||
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()
|
||||
|
||||
|
||||
174
engine/pipeline/stages/framebuffer.py
Normal file
174
engine/pipeline/stages/framebuffer.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Frame buffer stage - stores previous frames for temporal effects.
|
||||
|
||||
Provides (per-instance, using instance name):
|
||||
- framebuffer.{name}.history: list of previous buffers (most recent first)
|
||||
- framebuffer.{name}.intensity_history: list of corresponding intensity maps
|
||||
- framebuffer.{name}.current_intensity: intensity map for current frame
|
||||
|
||||
Capability: "framebuffer.history.{name}"
|
||||
"""
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from engine.display import _strip_ansi
|
||||
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||
|
||||
|
||||
@dataclass
|
||||
class FrameBufferConfig:
|
||||
"""Configuration for FrameBufferStage."""
|
||||
|
||||
history_depth: int = 2 # Number of previous frames to keep
|
||||
name: str = "default" # Unique instance name for capability and context keys
|
||||
|
||||
|
||||
class FrameBufferStage(Stage):
|
||||
"""Stores frame history and computes intensity maps.
|
||||
|
||||
Supports multiple instances with unique capabilities and context keys.
|
||||
"""
|
||||
|
||||
name = "framebuffer"
|
||||
category = "effect" # It's an effect that enriches context with frame history
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: FrameBufferConfig | None = None,
|
||||
history_depth: int = 2,
|
||||
name: str = "default",
|
||||
):
|
||||
self.config = config or FrameBufferConfig(
|
||||
history_depth=history_depth, name=name
|
||||
)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"framebuffer.history.{self.config.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
# Depends on rendered output (since we want to capture final buffer)
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
return {DataType.TEXT_BUFFER}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
return {DataType.TEXT_BUFFER} # Pass through unchanged
|
||||
|
||||
def init(self, ctx: PipelineContext) -> bool:
|
||||
"""Initialize framebuffer state in context."""
|
||||
prefix = f"framebuffer.{self.config.name}"
|
||||
ctx.set(f"{prefix}.history", [])
|
||||
ctx.set(f"{prefix}.intensity_history", [])
|
||||
return True
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Store frame in history and compute intensity.
|
||||
|
||||
Args:
|
||||
data: Current text buffer (list[str])
|
||||
ctx: Pipeline context
|
||||
|
||||
Returns:
|
||||
Same buffer (pass-through)
|
||||
"""
|
||||
if not isinstance(data, list):
|
||||
return data
|
||||
|
||||
prefix = f"framebuffer.{self.config.name}"
|
||||
|
||||
# Compute intensity map for current buffer (per-row, length = buffer rows)
|
||||
intensity_map = self._compute_buffer_intensity(data, len(data))
|
||||
|
||||
# Store in context
|
||||
ctx.set(f"{prefix}.current_intensity", intensity_map)
|
||||
|
||||
with self._lock:
|
||||
# Get existing histories
|
||||
history = ctx.get(f"{prefix}.history", [])
|
||||
intensity_hist = ctx.get(f"{prefix}.intensity_history", [])
|
||||
|
||||
# Prepend current frame to history
|
||||
history.insert(0, data.copy())
|
||||
intensity_hist.insert(0, intensity_map)
|
||||
|
||||
# Trim to configured depth
|
||||
max_depth = self.config.history_depth
|
||||
ctx.set(f"{prefix}.history", history[:max_depth])
|
||||
ctx.set(f"{prefix}.intensity_history", intensity_hist[:max_depth])
|
||||
|
||||
return data
|
||||
|
||||
def _compute_buffer_intensity(
|
||||
self, buf: list[str], max_rows: int = 24
|
||||
) -> list[float]:
|
||||
"""Compute average intensity per row in buffer.
|
||||
|
||||
Uses ANSI color if available; falls back to character density.
|
||||
|
||||
Args:
|
||||
buf: Text buffer (list of strings)
|
||||
max_rows: Maximum number of rows to process
|
||||
|
||||
Returns:
|
||||
List of intensity values (0.0-1.0) per row
|
||||
"""
|
||||
intensities = []
|
||||
# Limit to viewport height
|
||||
lines = buf[:max_rows]
|
||||
|
||||
for line in lines:
|
||||
# Strip ANSI codes for length calc
|
||||
|
||||
plain = _strip_ansi(line)
|
||||
if not plain:
|
||||
intensities.append(0.0)
|
||||
continue
|
||||
|
||||
# Simple heuristic: ratio of non-space characters
|
||||
# More sophisticated version could parse ANSI RGB brightness
|
||||
filled = sum(1 for c in plain if c not in (" ", "\t"))
|
||||
total = len(plain)
|
||||
intensity = filled / total if total > 0 else 0.0
|
||||
intensities.append(max(0.0, min(1.0, intensity)))
|
||||
|
||||
# Pad to max_rows if needed
|
||||
while len(intensities) < max_rows:
|
||||
intensities.append(0.0)
|
||||
|
||||
return intensities
|
||||
|
||||
def get_frame(
|
||||
self, index: int = 0, ctx: PipelineContext | None = None
|
||||
) -> list[str] | None:
|
||||
"""Get frame from history by index (0 = current, 1 = previous, etc)."""
|
||||
if ctx is None:
|
||||
return None
|
||||
prefix = f"framebuffer.{self.config.name}"
|
||||
history = ctx.get(f"{prefix}.history", [])
|
||||
if 0 <= index < len(history):
|
||||
return history[index]
|
||||
return None
|
||||
|
||||
def get_intensity(
|
||||
self, index: int = 0, ctx: PipelineContext | None = None
|
||||
) -> list[float] | None:
|
||||
"""Get intensity map from history by index."""
|
||||
if ctx is None:
|
||||
return None
|
||||
prefix = f"framebuffer.{self.config.name}"
|
||||
intensity_hist = ctx.get(f"{prefix}.intensity_history", [])
|
||||
if 0 <= index < len(intensity_hist):
|
||||
return intensity_hist[index]
|
||||
return None
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup resources."""
|
||||
pass
|
||||
674
engine/pipeline/ui.py
Normal file
674
engine/pipeline/ui.py
Normal file
@@ -0,0 +1,674 @@
|
||||
"""
|
||||
Pipeline UI panel - Interactive controls for pipeline configuration.
|
||||
|
||||
Provides:
|
||||
- Stage list with enable/disable toggles
|
||||
- Parameter sliders for selected effect
|
||||
- Keyboard/mouse interaction
|
||||
|
||||
This module implements the right-side UI panel that appears in border="ui" mode.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIConfig:
|
||||
"""Configuration for the UI panel."""
|
||||
|
||||
panel_width: int = 24 # Characters wide
|
||||
stage_list_height: int = 12 # Number of stages to show at once
|
||||
param_height: int = 8 # Space for parameter controls
|
||||
scroll_offset: int = 0 # Scroll position in stage list
|
||||
start_with_preset_picker: bool = False # Show preset picker immediately
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageControl:
|
||||
"""Represents a stage in the UI panel with its toggle state."""
|
||||
|
||||
name: str
|
||||
stage_name: str # Actual pipeline stage name
|
||||
category: str
|
||||
enabled: bool = True
|
||||
selected: bool = False
|
||||
params: dict[str, Any] = field(default_factory=dict) # Current param values
|
||||
param_schema: dict[str, dict] = field(default_factory=dict) # Param metadata
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Toggle enabled state."""
|
||||
self.enabled = not self.enabled
|
||||
|
||||
def get_param(self, name: str) -> Any:
|
||||
"""Get current parameter value."""
|
||||
return self.params.get(name)
|
||||
|
||||
def set_param(self, name: str, value: Any) -> None:
|
||||
"""Set parameter value."""
|
||||
self.params[name] = value
|
||||
|
||||
|
||||
class UIPanel:
|
||||
"""Interactive UI panel for pipeline configuration.
|
||||
|
||||
Manages:
|
||||
- Stage list with enable/disable checkboxes
|
||||
- Parameter sliders for selected stage
|
||||
- Keyboard/mouse event handling
|
||||
- Scroll state for long stage lists
|
||||
|
||||
The panel is rendered as a right border (panel_width characters wide)
|
||||
alongside the main viewport.
|
||||
"""
|
||||
|
||||
def __init__(self, config: UIConfig | None = None):
|
||||
self.config = config or UIConfig()
|
||||
self.stages: dict[str, StageControl] = {} # stage_name -> StageControl
|
||||
self.scroll_offset = 0
|
||||
self.selected_stage: str | None = None
|
||||
self._focused_param: str | None = None # For slider adjustment
|
||||
self._callbacks: dict[str, Callable] = {} # Event callbacks
|
||||
self._presets: list[str] = [] # Available preset names
|
||||
self._current_preset: str = "" # Current preset name
|
||||
self._show_preset_picker: bool = (
|
||||
config.start_with_preset_picker if config else False
|
||||
) # Picker overlay visible
|
||||
self._show_panel: bool = True # UI panel visibility
|
||||
self._preset_scroll_offset: int = 0 # Scroll in preset list
|
||||
|
||||
def save_state(self) -> dict[str, Any]:
|
||||
"""Save UI panel state for restoration after pipeline rebuild.
|
||||
|
||||
Returns:
|
||||
Dictionary containing UI panel state that can be restored
|
||||
"""
|
||||
# Save stage control states (enabled, params, etc.)
|
||||
stage_states = {}
|
||||
for name, ctrl in self.stages.items():
|
||||
stage_states[name] = {
|
||||
"enabled": ctrl.enabled,
|
||||
"selected": ctrl.selected,
|
||||
"params": dict(ctrl.params), # Copy params dict
|
||||
}
|
||||
|
||||
return {
|
||||
"stage_states": stage_states,
|
||||
"scroll_offset": self.scroll_offset,
|
||||
"selected_stage": self.selected_stage,
|
||||
"_focused_param": self._focused_param,
|
||||
"_show_panel": self._show_panel,
|
||||
"_show_preset_picker": self._show_preset_picker,
|
||||
"_preset_scroll_offset": self._preset_scroll_offset,
|
||||
}
|
||||
|
||||
def restore_state(self, state: dict[str, Any]) -> None:
|
||||
"""Restore UI panel state from saved state.
|
||||
|
||||
Args:
|
||||
state: Dictionary containing UI panel state from save_state()
|
||||
"""
|
||||
# Restore stage control states
|
||||
stage_states = state.get("stage_states", {})
|
||||
for name, stage_state in stage_states.items():
|
||||
if name in self.stages:
|
||||
ctrl = self.stages[name]
|
||||
ctrl.enabled = stage_state.get("enabled", True)
|
||||
ctrl.selected = stage_state.get("selected", False)
|
||||
# Restore params
|
||||
saved_params = stage_state.get("params", {})
|
||||
for param_name, param_value in saved_params.items():
|
||||
if param_name in ctrl.params:
|
||||
ctrl.params[param_name] = param_value
|
||||
|
||||
# Restore UI panel state
|
||||
self.scroll_offset = state.get("scroll_offset", 0)
|
||||
self.selected_stage = state.get("selected_stage")
|
||||
self._focused_param = state.get("_focused_param")
|
||||
self._show_panel = state.get("_show_panel", True)
|
||||
self._show_preset_picker = state.get("_show_preset_picker", False)
|
||||
self._preset_scroll_offset = state.get("_preset_scroll_offset", 0)
|
||||
|
||||
def register_stage(self, stage: Any, enabled: bool = True) -> StageControl:
|
||||
"""Register a stage for UI control.
|
||||
|
||||
Args:
|
||||
stage: Stage instance (must have .name, .category attributes)
|
||||
enabled: Initial enabled state
|
||||
|
||||
Returns:
|
||||
The created StageControl instance
|
||||
"""
|
||||
control = StageControl(
|
||||
name=stage.name,
|
||||
stage_name=stage.name,
|
||||
category=stage.category,
|
||||
enabled=enabled,
|
||||
)
|
||||
self.stages[stage.name] = control
|
||||
return control
|
||||
|
||||
def unregister_stage(self, stage_name: str) -> None:
|
||||
"""Remove a stage from UI control."""
|
||||
if stage_name in self.stages:
|
||||
del self.stages[stage_name]
|
||||
|
||||
def get_enabled_stages(self) -> list[str]:
|
||||
"""Get list of stage names that are currently enabled."""
|
||||
return [name for name, ctrl in self.stages.items() if ctrl.enabled]
|
||||
|
||||
def select_stage(self, stage_name: str | None = None) -> None:
|
||||
"""Select a stage (for parameter editing)."""
|
||||
if stage_name in self.stages:
|
||||
self.selected_stage = stage_name
|
||||
self.stages[stage_name].selected = True
|
||||
# Deselect others
|
||||
for name, ctrl in self.stages.items():
|
||||
if name != stage_name:
|
||||
ctrl.selected = False
|
||||
# Auto-focus first parameter when stage selected
|
||||
if self.stages[stage_name].params:
|
||||
self._focused_param = next(iter(self.stages[stage_name].params.keys()))
|
||||
else:
|
||||
self._focused_param = None
|
||||
|
||||
def toggle_stage(self, stage_name: str) -> bool:
|
||||
"""Toggle a stage's enabled state.
|
||||
|
||||
Returns:
|
||||
New enabled state
|
||||
"""
|
||||
if stage_name in self.stages:
|
||||
ctrl = self.stages[stage_name]
|
||||
ctrl.enabled = not ctrl.enabled
|
||||
return ctrl.enabled
|
||||
return False
|
||||
|
||||
def adjust_selected_param(self, delta: float) -> None:
|
||||
"""Adjust the currently focused parameter of selected stage.
|
||||
|
||||
Args:
|
||||
delta: Amount to add (positive or negative)
|
||||
"""
|
||||
if self.selected_stage and self._focused_param:
|
||||
ctrl = self.stages[self.selected_stage]
|
||||
if self._focused_param in ctrl.params:
|
||||
current = ctrl.params[self._focused_param]
|
||||
# Determine step size from schema
|
||||
schema = ctrl.param_schema.get(self._focused_param, {})
|
||||
step = schema.get("step", 0.1 if isinstance(current, float) else 1)
|
||||
new_val = current + delta * step
|
||||
# Clamp to min/max if specified
|
||||
if "min" in schema:
|
||||
new_val = max(schema["min"], new_val)
|
||||
if "max" in schema:
|
||||
new_val = min(schema["max"], new_val)
|
||||
# Only emit if value actually changed
|
||||
if new_val != current:
|
||||
ctrl.params[self._focused_param] = new_val
|
||||
self._emit_event(
|
||||
"param_changed",
|
||||
stage_name=self.selected_stage,
|
||||
param_name=self._focused_param,
|
||||
value=new_val,
|
||||
)
|
||||
|
||||
def scroll_stages(self, delta: int) -> None:
|
||||
"""Scroll the stage list."""
|
||||
max_offset = max(0, len(self.stages) - self.config.stage_list_height)
|
||||
self.scroll_offset = max(0, min(max_offset, self.scroll_offset + delta))
|
||||
|
||||
def render(self, width: int, height: int) -> list[str]:
|
||||
"""Render the UI panel.
|
||||
|
||||
Args:
|
||||
width: Total display width (panel uses last `panel_width` cols)
|
||||
height: Total display height
|
||||
|
||||
Returns:
|
||||
List of strings, each of length `panel_width`, to overlay on right side
|
||||
"""
|
||||
panel_width = min(
|
||||
self.config.panel_width, width - 4
|
||||
) # Reserve at least 2 for main
|
||||
lines = []
|
||||
|
||||
# If panel is hidden, render empty space
|
||||
if not self._show_panel:
|
||||
return [" " * panel_width for _ in range(height)]
|
||||
|
||||
# If preset picker is active, render that overlay instead of normal panel
|
||||
if self._show_preset_picker:
|
||||
picker_lines = self._render_preset_picker(panel_width)
|
||||
# Pad to full panel height if needed
|
||||
while len(picker_lines) < height:
|
||||
picker_lines.append(" " * panel_width)
|
||||
return [
|
||||
line.ljust(panel_width)[:panel_width] for line in picker_lines[:height]
|
||||
]
|
||||
|
||||
# Header
|
||||
title_line = "┌" + "─" * (panel_width - 2) + "┐"
|
||||
lines.append(title_line)
|
||||
|
||||
# Stage list section (occupies most of the panel)
|
||||
list_height = self.config.stage_list_height
|
||||
stage_names = list(self.stages.keys())
|
||||
for i in range(list_height):
|
||||
idx = i + self.scroll_offset
|
||||
if idx < len(stage_names):
|
||||
stage_name = stage_names[idx]
|
||||
ctrl = self.stages[stage_name]
|
||||
status = "✓" if ctrl.enabled else "✗"
|
||||
sel = ">" if ctrl.selected else " "
|
||||
# Truncate to fit panel (leave room for ">✓ " prefix and padding)
|
||||
max_name_len = panel_width - 5
|
||||
display_name = ctrl.name[:max_name_len]
|
||||
line = f"│{sel}{status} {display_name:<{max_name_len}}"
|
||||
lines.append(line[:panel_width])
|
||||
else:
|
||||
lines.append("│" + " " * (panel_width - 2) + "│")
|
||||
|
||||
# Separator
|
||||
lines.append("├" + "─" * (panel_width - 2) + "┤")
|
||||
|
||||
# Parameter section (if stage selected)
|
||||
if self.selected_stage and self.selected_stage in self.stages:
|
||||
ctrl = self.stages[self.selected_stage]
|
||||
if ctrl.params:
|
||||
# Render each parameter as "name: [=====] value" with focus indicator
|
||||
for param_name, param_value in ctrl.params.items():
|
||||
schema = ctrl.param_schema.get(param_name, {})
|
||||
is_focused = param_name == self._focused_param
|
||||
# Format value based on type
|
||||
if isinstance(param_value, float):
|
||||
val_str = f"{param_value:.2f}"
|
||||
elif isinstance(param_value, int):
|
||||
val_str = f"{param_value}"
|
||||
elif isinstance(param_value, bool):
|
||||
val_str = str(param_value)
|
||||
else:
|
||||
val_str = str(param_value)
|
||||
|
||||
# Build parameter line
|
||||
if (
|
||||
isinstance(param_value, (int, float))
|
||||
and "min" in schema
|
||||
and "max" in schema
|
||||
):
|
||||
# Render as slider
|
||||
min_val = schema["min"]
|
||||
max_val = schema["max"]
|
||||
# Normalize to 0-1 for bar length
|
||||
if max_val != min_val:
|
||||
ratio = (param_value - min_val) / (max_val - min_val)
|
||||
else:
|
||||
ratio = 0
|
||||
bar_width = (
|
||||
panel_width - len(param_name) - len(val_str) - 10
|
||||
) # approx space for "[] : ="
|
||||
if bar_width < 1:
|
||||
bar_width = 1
|
||||
filled = int(round(ratio * bar_width))
|
||||
bar = "[" + "=" * filled + " " * (bar_width - filled) + "]"
|
||||
param_line = f"│ {param_name}: {bar} {val_str}"
|
||||
else:
|
||||
# Simple name=value
|
||||
param_line = f"│ {param_name}={val_str}"
|
||||
|
||||
# Highlight focused parameter
|
||||
if is_focused:
|
||||
# Invert colors conceptually - for now use > prefix
|
||||
param_line = "│> " + param_line[2:]
|
||||
|
||||
# Truncate to fit panel width
|
||||
if len(param_line) > panel_width - 1:
|
||||
param_line = param_line[: panel_width - 1]
|
||||
lines.append(param_line + "│")
|
||||
else:
|
||||
lines.append("│ (no params)".ljust(panel_width - 1) + "│")
|
||||
else:
|
||||
lines.append("│ (select a stage)".ljust(panel_width - 1) + "│")
|
||||
|
||||
# Info line before footer
|
||||
info_parts = []
|
||||
if self._current_preset:
|
||||
info_parts.append(f"Preset: {self._current_preset}")
|
||||
if self._presets:
|
||||
info_parts.append("[P] presets")
|
||||
info_str = " | ".join(info_parts) if info_parts else ""
|
||||
if info_str:
|
||||
padded = info_str.ljust(panel_width - 2)
|
||||
lines.append("│" + padded + "│")
|
||||
|
||||
# Footer with instructions
|
||||
footer_line = self._render_footer(panel_width)
|
||||
lines.append(footer_line)
|
||||
|
||||
# Ensure all lines are exactly panel_width
|
||||
return [line.ljust(panel_width)[:panel_width] for line in lines]
|
||||
|
||||
def _render_footer(self, width: int) -> str:
|
||||
"""Render footer with key hints."""
|
||||
if width >= 40:
|
||||
# Show preset name and key hints
|
||||
preset_info = (
|
||||
f"Preset: {self._current_preset}" if self._current_preset else ""
|
||||
)
|
||||
hints = " [S]elect [Space]UI [Tab]Params [Arrows/HJKL]Adjust "
|
||||
if self._presets:
|
||||
hints += "[P]Preset "
|
||||
combined = f"{preset_info}{hints}"
|
||||
if len(combined) > width - 4:
|
||||
combined = combined[: width - 4]
|
||||
footer = "└" + "─" * (width - 2) + "┘"
|
||||
return footer # Just the line, we'll add info above in render
|
||||
else:
|
||||
return "└" + "─" * (width - 2) + "┘"
|
||||
|
||||
def execute_command(self, command: dict) -> bool:
|
||||
"""Execute a command from external control (e.g., WebSocket).
|
||||
|
||||
Supported UI commands:
|
||||
- {"action": "toggle_stage", "stage": "stage_name"}
|
||||
- {"action": "select_stage", "stage": "stage_name"}
|
||||
- {"action": "adjust_param", "stage": "stage_name", "param": "param_name", "delta": 0.1}
|
||||
- {"action": "change_preset", "preset": "preset_name"}
|
||||
- {"action": "cycle_preset", "direction": 1}
|
||||
|
||||
Pipeline Mutation commands are handled by the WebSocket/runner handler:
|
||||
- {"action": "add_stage", "stage": "stage_name", "type": "source|display|camera|effect"}
|
||||
- {"action": "remove_stage", "stage": "stage_name"}
|
||||
- {"action": "replace_stage", "stage": "old_stage_name", "with": "new_stage_type"}
|
||||
- {"action": "swap_stages", "stage1": "name1", "stage2": "name2"}
|
||||
- {"action": "move_stage", "stage": "stage_name", "after": "other_stage"|"before": "other_stage"}
|
||||
- {"action": "enable_stage", "stage": "stage_name"}
|
||||
- {"action": "disable_stage", "stage": "stage_name"}
|
||||
- {"action": "cleanup_stage", "stage": "stage_name"}
|
||||
- {"action": "can_hot_swap", "stage": "stage_name"}
|
||||
|
||||
Returns:
|
||||
True if command was handled, False if not
|
||||
"""
|
||||
action = command.get("action")
|
||||
|
||||
if action == "toggle_stage":
|
||||
stage_name = command.get("stage")
|
||||
if stage_name in self.stages:
|
||||
self.toggle_stage(stage_name)
|
||||
self._emit_event(
|
||||
"stage_toggled",
|
||||
stage_name=stage_name,
|
||||
enabled=self.stages[stage_name].enabled,
|
||||
)
|
||||
return True
|
||||
|
||||
elif action == "select_stage":
|
||||
stage_name = command.get("stage")
|
||||
if stage_name in self.stages:
|
||||
self.select_stage(stage_name)
|
||||
self._emit_event("stage_selected", stage_name=stage_name)
|
||||
return True
|
||||
|
||||
elif action == "adjust_param":
|
||||
stage_name = command.get("stage")
|
||||
param_name = command.get("param")
|
||||
delta = command.get("delta", 0.1)
|
||||
if stage_name == self.selected_stage and param_name:
|
||||
self._focused_param = param_name
|
||||
self.adjust_selected_param(delta)
|
||||
self._emit_event(
|
||||
"param_changed",
|
||||
stage_name=stage_name,
|
||||
param_name=param_name,
|
||||
value=self.stages[stage_name].params.get(param_name),
|
||||
)
|
||||
return True
|
||||
|
||||
elif action == "change_preset":
|
||||
preset_name = command.get("preset")
|
||||
if preset_name in self._presets:
|
||||
self._current_preset = preset_name
|
||||
self._emit_event("preset_changed", preset_name=preset_name)
|
||||
return True
|
||||
|
||||
elif action == "cycle_preset":
|
||||
direction = command.get("direction", 1)
|
||||
self.cycle_preset(direction)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def process_key_event(self, key: str | int, modifiers: int = 0) -> bool:
|
||||
"""Process a keyboard event.
|
||||
|
||||
Args:
|
||||
key: Key symbol (e.g., ' ', 's', pygame.K_UP, etc.)
|
||||
modifiers: Modifier bits (Shift, Ctrl, Alt)
|
||||
|
||||
Returns:
|
||||
True if event was handled, False if not
|
||||
"""
|
||||
# Normalize to string for simplicity
|
||||
key_str = self._normalize_key(key, modifiers)
|
||||
|
||||
# Space: toggle UI panel visibility (only when preset picker not active)
|
||||
if key_str == " " and not self._show_preset_picker:
|
||||
self._show_panel = not getattr(self, "_show_panel", True)
|
||||
return True
|
||||
|
||||
# Space: toggle UI panel visibility (only when preset picker not active)
|
||||
if key_str == " " and not self._show_preset_picker:
|
||||
self._show_panel = not getattr(self, "_show_panel", True)
|
||||
return True
|
||||
|
||||
# S: select stage (cycle)
|
||||
if key_str == "s" and modifiers == 0:
|
||||
stages = list(self.stages.keys())
|
||||
if not stages:
|
||||
return False
|
||||
if self.selected_stage:
|
||||
current_idx = stages.index(self.selected_stage)
|
||||
next_idx = (current_idx + 1) % len(stages)
|
||||
else:
|
||||
next_idx = 0
|
||||
self.select_stage(stages[next_idx])
|
||||
return True
|
||||
|
||||
# P: toggle preset picker (only when panel is visible)
|
||||
if key_str == "p" and self._show_panel:
|
||||
self._show_preset_picker = not self._show_preset_picker
|
||||
if self._show_preset_picker:
|
||||
self._preset_scroll_offset = 0
|
||||
return True
|
||||
|
||||
# HJKL or Arrow Keys: scroll stage list, preset list, or adjust param
|
||||
# vi-style: K=up, J=down (J is actually next line in vi, but we use for down)
|
||||
# We'll use J for down, K for up, H for left, L for right
|
||||
elif key_str in ("up", "down", "kp8", "kp2", "j", "k"):
|
||||
# If preset picker is open, scroll preset list
|
||||
if self._show_preset_picker:
|
||||
delta = -1 if key_str in ("up", "kp8", "k") else 1
|
||||
self._preset_scroll_offset = max(0, self._preset_scroll_offset + delta)
|
||||
# Ensure scroll doesn't go past end
|
||||
max_offset = max(0, len(self._presets) - 1)
|
||||
self._preset_scroll_offset = min(max_offset, self._preset_scroll_offset)
|
||||
return True
|
||||
# If param is focused, adjust param value
|
||||
elif self.selected_stage and self._focused_param:
|
||||
delta = -1.0 if key_str in ("up", "kp8", "k") else 1.0
|
||||
self.adjust_selected_param(delta)
|
||||
return True
|
||||
# Otherwise scroll stages
|
||||
else:
|
||||
delta = -1 if key_str in ("up", "kp8", "k") else 1
|
||||
self.scroll_stages(delta)
|
||||
return True
|
||||
|
||||
# Left/Right or H/L: adjust param (if param selected)
|
||||
elif key_str in ("left", "right", "kp4", "kp6", "h", "l"):
|
||||
if self.selected_stage:
|
||||
delta = -0.1 if key_str in ("left", "kp4", "h") else 0.1
|
||||
self.adjust_selected_param(delta)
|
||||
return True
|
||||
|
||||
# Tab: cycle through parameters
|
||||
if key_str == "tab" and self.selected_stage:
|
||||
ctrl = self.stages[self.selected_stage]
|
||||
param_names = list(ctrl.params.keys())
|
||||
if param_names:
|
||||
if self._focused_param in param_names:
|
||||
current_idx = param_names.index(self._focused_param)
|
||||
next_idx = (current_idx + 1) % len(param_names)
|
||||
else:
|
||||
next_idx = 0
|
||||
self._focused_param = param_names[next_idx]
|
||||
return True
|
||||
|
||||
# Preset picker navigation
|
||||
if self._show_preset_picker:
|
||||
# Enter: select currently highlighted preset
|
||||
if key_str == "return":
|
||||
if self._presets:
|
||||
idx = self._preset_scroll_offset
|
||||
if idx < len(self._presets):
|
||||
self._current_preset = self._presets[idx]
|
||||
self._emit_event(
|
||||
"preset_changed", preset_name=self._current_preset
|
||||
)
|
||||
self._show_preset_picker = False
|
||||
return True
|
||||
# Escape: close picker without changing
|
||||
elif key_str == "escape":
|
||||
self._show_preset_picker = False
|
||||
return True
|
||||
|
||||
# Escape: deselect stage (only when picker not active)
|
||||
elif key_str == "escape" and self.selected_stage:
|
||||
self.selected_stage = None
|
||||
for ctrl in self.stages.values():
|
||||
ctrl.selected = False
|
||||
self._focused_param = None
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _normalize_key(self, key: str | int, modifiers: int) -> str:
|
||||
"""Normalize key to a string identifier."""
|
||||
# Handle pygame keysyms if imported
|
||||
try:
|
||||
import pygame
|
||||
|
||||
if isinstance(key, int):
|
||||
# Map pygame constants to strings
|
||||
key_map = {
|
||||
pygame.K_UP: "up",
|
||||
pygame.K_DOWN: "down",
|
||||
pygame.K_LEFT: "left",
|
||||
pygame.K_RIGHT: "right",
|
||||
pygame.K_SPACE: " ",
|
||||
pygame.K_ESCAPE: "escape",
|
||||
pygame.K_s: "s",
|
||||
pygame.K_w: "w",
|
||||
# HJKL navigation (vi-style)
|
||||
pygame.K_h: "h",
|
||||
pygame.K_j: "j",
|
||||
pygame.K_k: "k",
|
||||
pygame.K_l: "l",
|
||||
}
|
||||
# Check for keypad keys with KP prefix
|
||||
if hasattr(pygame, "K_KP8") and key == pygame.K_KP8:
|
||||
return "kp8"
|
||||
if hasattr(pygame, "K_KP2") and key == pygame.K_KP2:
|
||||
return "kp2"
|
||||
if hasattr(pygame, "K_KP4") and key == pygame.K_KP4:
|
||||
return "kp4"
|
||||
if hasattr(pygame, "K_KP6") and key == pygame.K_KP6:
|
||||
return "kp6"
|
||||
return key_map.get(key, f"pygame_{key}")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Already a string?
|
||||
if isinstance(key, str):
|
||||
return key.lower()
|
||||
|
||||
return str(key)
|
||||
|
||||
def set_event_callback(self, event_type: str, callback: Callable) -> None:
|
||||
"""Register a callback for UI events.
|
||||
|
||||
Args:
|
||||
event_type: Event type ("stage_toggled", "param_changed", "stage_selected", "preset_changed")
|
||||
callback: Function to call when event occurs
|
||||
"""
|
||||
self._callbacks[event_type] = callback
|
||||
|
||||
def _emit_event(self, event_type: str, **data) -> None:
|
||||
"""Emit an event to registered callbacks."""
|
||||
callback = self._callbacks.get(event_type)
|
||||
if callback:
|
||||
try:
|
||||
callback(**data)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_presets(self, presets: list[str], current: str) -> None:
|
||||
"""Set available presets and current selection.
|
||||
|
||||
Args:
|
||||
presets: List of preset names
|
||||
current: Currently active preset name
|
||||
"""
|
||||
self._presets = presets
|
||||
self._current_preset = current
|
||||
|
||||
def cycle_preset(self, direction: int = 1) -> str:
|
||||
"""Cycle to next/previous preset.
|
||||
|
||||
Args:
|
||||
direction: 1 for next, -1 for previous
|
||||
|
||||
Returns:
|
||||
New preset name
|
||||
"""
|
||||
if not self._presets:
|
||||
return self._current_preset
|
||||
try:
|
||||
current_idx = self._presets.index(self._current_preset)
|
||||
except ValueError:
|
||||
current_idx = 0
|
||||
next_idx = (current_idx + direction) % len(self._presets)
|
||||
self._current_preset = self._presets[next_idx]
|
||||
self._emit_event("preset_changed", preset_name=self._current_preset)
|
||||
return self._current_preset
|
||||
|
||||
def _render_preset_picker(self, panel_width: int) -> list[str]:
|
||||
"""Render a full-screen preset picker overlay."""
|
||||
lines = []
|
||||
picker_height = min(len(self._presets) + 2, self.config.stage_list_height)
|
||||
# Create a centered box
|
||||
title = " Select Preset "
|
||||
box_width = min(40, panel_width - 2)
|
||||
lines.append("┌" + "─" * (box_width - 2) + "┐")
|
||||
lines.append("│" + title.center(box_width - 2) + "│")
|
||||
lines.append("├" + "─" * (box_width - 2) + "┤")
|
||||
# List presets with selection
|
||||
visible_start = self._preset_scroll_offset
|
||||
visible_end = visible_start + picker_height - 2
|
||||
for i in range(visible_start, min(visible_end, len(self._presets))):
|
||||
preset_name = self._presets[i]
|
||||
is_current = preset_name == self._current_preset
|
||||
prefix = "▶ " if is_current else " "
|
||||
line = f"│ {prefix}{preset_name}"
|
||||
if len(line) < box_width - 1:
|
||||
line = line.ljust(box_width - 1)
|
||||
lines.append(line[: box_width - 1] + "│")
|
||||
# Footer with help
|
||||
help_text = "[P] close [↑↓] navigate [Enter] select"
|
||||
footer = "├" + "─" * (box_width - 2) + "┤"
|
||||
lines.append(footer)
|
||||
lines.append("│" + help_text.center(box_width - 2) + "│")
|
||||
lines.append("└" + "─" * (box_width - 2) + "┘")
|
||||
return lines
|
||||
221
engine/pipeline/validation.py
Normal file
221
engine/pipeline/validation.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Pipeline validation and MVP (Minimum Viable Pipeline) injection.
|
||||
|
||||
Provides validation functions to ensure pipelines meet minimum requirements
|
||||
and can auto-inject sensible defaults when fields are missing or invalid.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from engine.display import BorderMode, DisplayRegistry
|
||||
from engine.effects import get_registry
|
||||
from engine.pipeline.params import PipelineParams
|
||||
|
||||
# Known valid values
|
||||
VALID_SOURCES = ["headlines", "poetry", "fixture", "empty", "pipeline-inspect"]
|
||||
VALID_CAMERAS = [
|
||||
"feed",
|
||||
"scroll",
|
||||
"vertical",
|
||||
"horizontal",
|
||||
"omni",
|
||||
"floating",
|
||||
"bounce",
|
||||
"radial",
|
||||
"static",
|
||||
"none",
|
||||
"",
|
||||
]
|
||||
VALID_DISPLAYS = None # Will be populated at runtime from DisplayRegistry
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of validation with changes and warnings."""
|
||||
|
||||
valid: bool
|
||||
warnings: list[str]
|
||||
changes: list[str]
|
||||
config: Any # PipelineConfig (forward ref)
|
||||
params: PipelineParams
|
||||
|
||||
|
||||
# MVP defaults
|
||||
MVP_DEFAULTS = {
|
||||
"source": "fixture",
|
||||
"display": "terminal",
|
||||
"camera": "static", # Static camera provides camera_y=0 for viewport filtering
|
||||
"effects": [],
|
||||
"border": False,
|
||||
}
|
||||
|
||||
|
||||
def validate_pipeline_config(
|
||||
config: Any, params: PipelineParams, allow_unsafe: bool = False
|
||||
) -> ValidationResult:
|
||||
"""Validate pipeline configuration against MVP requirements.
|
||||
|
||||
Args:
|
||||
config: PipelineConfig object (has source, display, camera, effects fields)
|
||||
params: PipelineParams object (has border field)
|
||||
allow_unsafe: If True, don't inject defaults or enforce MVP
|
||||
|
||||
Returns:
|
||||
ValidationResult with validity, warnings, changes, and validated config/params
|
||||
"""
|
||||
warnings = []
|
||||
changes = []
|
||||
|
||||
if allow_unsafe:
|
||||
# Still do basic validation but don't inject defaults
|
||||
# Always return valid=True when allow_unsafe is set
|
||||
warnings.extend(_validate_source(config.source))
|
||||
warnings.extend(_validate_display(config.display))
|
||||
warnings.extend(_validate_camera(config.camera))
|
||||
warnings.extend(_validate_effects(config.effects))
|
||||
warnings.extend(_validate_border(params.border))
|
||||
return ValidationResult(
|
||||
valid=True, # Always valid with allow_unsafe
|
||||
warnings=warnings,
|
||||
changes=[],
|
||||
config=config,
|
||||
params=params,
|
||||
)
|
||||
|
||||
# MVP injection mode
|
||||
# Source
|
||||
source_issues = _validate_source(config.source)
|
||||
if source_issues:
|
||||
warnings.extend(source_issues)
|
||||
config.source = MVP_DEFAULTS["source"]
|
||||
changes.append(f"source → {MVP_DEFAULTS['source']}")
|
||||
|
||||
# Display
|
||||
display_issues = _validate_display(config.display)
|
||||
if display_issues:
|
||||
warnings.extend(display_issues)
|
||||
config.display = MVP_DEFAULTS["display"]
|
||||
changes.append(f"display → {MVP_DEFAULTS['display']}")
|
||||
|
||||
# Camera
|
||||
camera_issues = _validate_camera(config.camera)
|
||||
if camera_issues:
|
||||
warnings.extend(camera_issues)
|
||||
config.camera = MVP_DEFAULTS["camera"]
|
||||
changes.append("camera → static (no camera stage)")
|
||||
|
||||
# Effects
|
||||
effect_issues = _validate_effects(config.effects)
|
||||
if effect_issues:
|
||||
warnings.extend(effect_issues)
|
||||
# Only change if all effects are invalid
|
||||
if len(config.effects) == 0 or all(
|
||||
e not in _get_valid_effects() for e in config.effects
|
||||
):
|
||||
config.effects = MVP_DEFAULTS["effects"]
|
||||
changes.append("effects → [] (none)")
|
||||
else:
|
||||
# Remove invalid effects, keep valid ones
|
||||
valid_effects = [e for e in config.effects if e in _get_valid_effects()]
|
||||
if valid_effects != config.effects:
|
||||
config.effects = valid_effects
|
||||
changes.append(f"effects → {valid_effects}")
|
||||
|
||||
# Border (in params)
|
||||
border_issues = _validate_border(params.border)
|
||||
if border_issues:
|
||||
warnings.extend(border_issues)
|
||||
params.border = MVP_DEFAULTS["border"]
|
||||
changes.append(f"border → {MVP_DEFAULTS['border']}")
|
||||
|
||||
valid = len(warnings) == 0
|
||||
if changes:
|
||||
# If we made changes, pipeline should be valid now
|
||||
valid = True
|
||||
|
||||
return ValidationResult(
|
||||
valid=valid,
|
||||
warnings=warnings,
|
||||
changes=changes,
|
||||
config=config,
|
||||
params=params,
|
||||
)
|
||||
|
||||
|
||||
def _validate_source(source: str) -> list[str]:
|
||||
"""Validate source field."""
|
||||
if not source:
|
||||
return ["source is empty"]
|
||||
if source not in VALID_SOURCES:
|
||||
return [f"unknown source '{source}', valid sources: {VALID_SOURCES}"]
|
||||
return []
|
||||
|
||||
|
||||
def _validate_display(display: str) -> list[str]:
|
||||
"""Validate display field."""
|
||||
if not display:
|
||||
return ["display is empty"]
|
||||
# Check if display is available (lazy load registry)
|
||||
try:
|
||||
available = DisplayRegistry.list_backends()
|
||||
if display not in available:
|
||||
return [f"display '{display}' not available, available: {available}"]
|
||||
except Exception as e:
|
||||
return [f"error checking display availability: {e}"]
|
||||
return []
|
||||
|
||||
|
||||
def _validate_camera(camera: str | None) -> list[str]:
|
||||
"""Validate camera field."""
|
||||
if camera is None:
|
||||
return ["camera is None"]
|
||||
# Empty string is valid (static, no camera stage)
|
||||
if camera == "":
|
||||
return []
|
||||
if camera not in VALID_CAMERAS:
|
||||
return [f"unknown camera '{camera}', valid cameras: {VALID_CAMERAS}"]
|
||||
return []
|
||||
|
||||
|
||||
def _get_valid_effects() -> set[str]:
|
||||
"""Get set of valid effect names."""
|
||||
registry = get_registry()
|
||||
return set(registry.list_all().keys())
|
||||
|
||||
|
||||
def _validate_effects(effects: list[str]) -> list[str]:
|
||||
"""Validate effects list."""
|
||||
if effects is None:
|
||||
return ["effects is None"]
|
||||
valid_effects = _get_valid_effects()
|
||||
issues = []
|
||||
for effect in effects:
|
||||
if effect not in valid_effects:
|
||||
issues.append(
|
||||
f"unknown effect '{effect}', valid effects: {sorted(valid_effects)}"
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def _validate_border(border: bool | BorderMode) -> list[str]:
|
||||
"""Validate border field."""
|
||||
if isinstance(border, bool):
|
||||
return []
|
||||
if isinstance(border, BorderMode):
|
||||
return []
|
||||
return [f"invalid border value, must be bool or BorderMode, got {type(border)}"]
|
||||
|
||||
|
||||
def get_mvp_summary(config: Any, params: PipelineParams) -> str:
|
||||
"""Get a human-readable summary of the MVP pipeline configuration."""
|
||||
camera_text = "none" if not config.camera else config.camera
|
||||
effects_text = "none" if not config.effects else ", ".join(config.effects)
|
||||
return (
|
||||
f"MVP Pipeline Configuration:\n"
|
||||
f" Source: {config.source}\n"
|
||||
f" Display: {config.display}\n"
|
||||
f" Camera: {camera_text} (static if empty)\n"
|
||||
f" Effects: {effects_text}\n"
|
||||
f" Border: {params.border}"
|
||||
)
|
||||
@@ -10,7 +10,8 @@ uv = "latest"
|
||||
# =====================
|
||||
|
||||
test = "uv run pytest"
|
||||
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing", depends = ["sync-all"] }
|
||||
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing -m \"not benchmark\"", depends = ["sync-all"] }
|
||||
benchmark = { run = "uv run python -m engine.benchmark", depends = ["sync-all"] }
|
||||
lint = "uv run ruff check engine/ mainline.py"
|
||||
format = "uv run ruff format engine/ mainline.py"
|
||||
|
||||
@@ -50,7 +51,7 @@ clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache
|
||||
# CI
|
||||
# =====================
|
||||
|
||||
ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] }
|
||||
ci = "mise run topics-init && mise run lint && mise run test-cov && mise run benchmark"
|
||||
topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null"
|
||||
|
||||
# =====================
|
||||
|
||||
1
opencode-instructions.md
Normal file
1
opencode-instructions.md
Normal file
@@ -0,0 +1 @@
|
||||
/home/david/.skills/opencode-instructions/SKILL.md
|
||||
291
presets.toml
291
presets.toml
@@ -9,292 +9,68 @@
|
||||
# - ./presets.toml (local override)
|
||||
|
||||
# ============================================
|
||||
# TEST PRESETS
|
||||
# TEST PRESETS (for CI and development)
|
||||
# ============================================
|
||||
|
||||
[presets.test-single-item]
|
||||
description = "Test: Single item to isolate rendering stage issues"
|
||||
[presets.test-basic]
|
||||
description = "Test: Basic pipeline with no effects"
|
||||
source = "empty"
|
||||
display = "terminal"
|
||||
display = "null"
|
||||
camera = "feed"
|
||||
effects = []
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
viewport_width = 100 # Custom size for testing
|
||||
viewport_height = 30
|
||||
|
||||
[presets.test-single-item-border]
|
||||
description = "Test: Single item with border effect only"
|
||||
[presets.test-border]
|
||||
description = "Test: Single item with border effect"
|
||||
source = "empty"
|
||||
display = "terminal"
|
||||
display = "null"
|
||||
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"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = []
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-sources-poetry]
|
||||
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"
|
||||
[presets.test-scroll-camera]
|
||||
description = "Test: Scrolling camera movement"
|
||||
source = "empty"
|
||||
display = "null"
|
||||
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
|
||||
# DEMO PRESETS (for demonstration and exploration)
|
||||
# ============================================
|
||||
|
||||
[presets.gallery-display-terminal]
|
||||
description = "Gallery: Terminal display"
|
||||
[presets.demo-base]
|
||||
description = "Demo: Base preset for effect hot-swapping"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
effects = [] # Demo script will add/remove effects dynamically
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-display-pygame]
|
||||
description = "Gallery: Pygame display"
|
||||
[presets.demo-pygame]
|
||||
description = "Demo: Pygame display version"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = ["noise"]
|
||||
effects = [] # Demo script will add/remove effects dynamically
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.gallery-display-websocket]
|
||||
description = "Gallery: WebSocket display"
|
||||
[presets.demo-camera-showcase]
|
||||
description = "Demo: Camera mode showcase"
|
||||
source = "headlines"
|
||||
display = "websocket"
|
||||
display = "terminal"
|
||||
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
|
||||
effects = [] # Demo script will cycle through camera modes
|
||||
camera_speed = 0.5
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
@@ -307,9 +83,10 @@ enabled = false
|
||||
threshold_db = 50.0
|
||||
|
||||
[sensors.oscillator]
|
||||
enabled = false
|
||||
enabled = true # Enable for demo script gentle oscillation
|
||||
waveform = "sine"
|
||||
frequency = 1.0
|
||||
frequency = 0.05 # ~20 second cycle (gentle)
|
||||
amplitude = 0.5 # 50% modulation
|
||||
|
||||
# ============================================
|
||||
# EFFECT CONFIGURATIONS
|
||||
@@ -334,3 +111,15 @@ intensity = 1.0
|
||||
[effect_configs.hud]
|
||||
enabled = true
|
||||
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,9 +34,6 @@ mic = [
|
||||
websocket = [
|
||||
"websockets>=12.0",
|
||||
]
|
||||
sixel = [
|
||||
"Pillow>=10.0.0",
|
||||
]
|
||||
pygame = [
|
||||
"pygame>=2.0.0",
|
||||
]
|
||||
|
||||
222
scripts/demo_hot_rebuild.py
Normal file
222
scripts/demo_hot_rebuild.py
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/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()
|
||||
378
scripts/demo_image_oscilloscope.py
Normal file
378
scripts/demo_image_oscilloscope.py
Normal file
@@ -0,0 +1,378 @@
|
||||
#!/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,
|
||||
)
|
||||
137
scripts/demo_oscillator_simple.py
Normal file
137
scripts/demo_oscillator_simple.py
Normal file
@@ -0,0 +1,137 @@
|
||||
#!/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)
|
||||
204
scripts/demo_oscilloscope.py
Normal file
204
scripts/demo_oscilloscope.py
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/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,
|
||||
)
|
||||
380
scripts/demo_oscilloscope_mod.py
Normal file
380
scripts/demo_oscilloscope_mod.py
Normal file
@@ -0,0 +1,380 @@
|
||||
#!/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,
|
||||
)
|
||||
411
scripts/demo_oscilloscope_pipeline.py
Normal file
411
scripts/demo_oscilloscope_pipeline.py
Normal file
@@ -0,0 +1,411 @@
|
||||
#!/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,
|
||||
)
|
||||
111
scripts/oscillator_data_export.py
Normal file
111
scripts/oscillator_data_export.py
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/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,
|
||||
)
|
||||
509
scripts/pipeline_demo.py
Normal file
509
scripts/pipeline_demo.py
Normal file
@@ -0,0 +1,509 @@
|
||||
#!/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)
|
||||
56
test_ui_simple.py
Normal file
56
test_ui_simple.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
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())
|
||||
473
tests/acceptance_report.py
Normal file
473
tests/acceptance_report.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
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)
|
||||
290
tests/test_acceptance.py
Normal file
290
tests/test_acceptance.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
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):
|
||||
"""main() runs default preset (demo) when no args provided."""
|
||||
with patch("engine.app.run_pipeline_mode") as mock_run:
|
||||
with patch("engine.app.main.run_pipeline_mode") as mock_run:
|
||||
sys.argv = ["mainline.py"]
|
||||
main()
|
||||
mock_run.assert_called_once_with("demo")
|
||||
@@ -26,25 +26,23 @@ class TestMain:
|
||||
def test_main_calls_run_pipeline_mode_with_config_preset(self):
|
||||
"""main() uses PRESET from config if set."""
|
||||
with (
|
||||
patch("engine.app.config") as mock_config,
|
||||
patch("engine.app.run_pipeline_mode") as mock_run,
|
||||
patch("engine.config.PIPELINE_DIAGRAM", False),
|
||||
patch("engine.config.PRESET", "demo"),
|
||||
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"]
|
||||
main()
|
||||
mock_run.assert_called_once_with("gallery-sources")
|
||||
mock_run.assert_called_once_with("demo")
|
||||
|
||||
def test_main_exits_on_unknown_preset(self):
|
||||
"""main() exits with error for unknown preset."""
|
||||
with (
|
||||
patch("engine.app.config") as mock_config,
|
||||
patch("engine.app.list_presets", return_value=["demo", "poetry"]),
|
||||
patch("engine.config.PIPELINE_DIAGRAM", False),
|
||||
patch("engine.config.PRESET", "nonexistent"),
|
||||
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"]
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
@@ -70,9 +68,13 @@ class TestRunPipelineMode:
|
||||
def test_run_pipeline_mode_exits_when_no_content_available(self):
|
||||
"""run_pipeline_mode() exits if no content can be fetched."""
|
||||
with (
|
||||
patch("engine.app.load_cache", return_value=None),
|
||||
patch("engine.app.fetch_all", return_value=([], None, None)),
|
||||
patch("engine.app.effects_plugins"),
|
||||
patch("engine.app.pipeline_runner.load_cache", return_value=None),
|
||||
patch("engine.app.pipeline_runner.fetch_all_fast", return_value=[]),
|
||||
patch(
|
||||
"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,
|
||||
):
|
||||
run_pipeline_mode("demo")
|
||||
@@ -82,9 +84,12 @@ class TestRunPipelineMode:
|
||||
"""run_pipeline_mode() uses cached content if available."""
|
||||
cached = ["cached_item"]
|
||||
with (
|
||||
patch("engine.app.load_cache", return_value=cached) as mock_load,
|
||||
patch("engine.app.fetch_all") as mock_fetch,
|
||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||
patch(
|
||||
"engine.app.pipeline_runner.load_cache", return_value=cached
|
||||
) as mock_load,
|
||||
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.init = Mock()
|
||||
@@ -107,7 +112,8 @@ class TestRunPipelineMode:
|
||||
def test_run_pipeline_mode_creates_display(self):
|
||||
"""run_pipeline_mode() creates a display backend."""
|
||||
with (
|
||||
patch("engine.app.load_cache", return_value=["item"]),
|
||||
patch("engine.app.pipeline_runner.load_cache", return_value=["item"]),
|
||||
patch("engine.app.pipeline_runner.fetch_all_fast", return_value=[]),
|
||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||
):
|
||||
mock_display = Mock()
|
||||
@@ -120,7 +126,7 @@ class TestRunPipelineMode:
|
||||
mock_create.return_value = mock_display
|
||||
|
||||
try:
|
||||
run_pipeline_mode("gallery-display-terminal")
|
||||
run_pipeline_mode("demo-base")
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
pass
|
||||
|
||||
@@ -132,7 +138,8 @@ class TestRunPipelineMode:
|
||||
sys.argv = ["mainline.py", "--display", "websocket"]
|
||||
|
||||
with (
|
||||
patch("engine.app.load_cache", return_value=["item"]),
|
||||
patch("engine.app.pipeline_runner.load_cache", return_value=["item"]),
|
||||
patch("engine.app.pipeline_runner.fetch_all_fast", return_value=[]),
|
||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||
):
|
||||
mock_display = Mock()
|
||||
@@ -155,12 +162,14 @@ class TestRunPipelineMode:
|
||||
def test_run_pipeline_mode_fetches_poetry_for_poetry_source(self):
|
||||
"""run_pipeline_mode() fetches poetry for poetry preset."""
|
||||
with (
|
||||
patch("engine.app.load_cache", return_value=None),
|
||||
patch("engine.app.pipeline_runner.load_cache", return_value=None),
|
||||
patch(
|
||||
"engine.app.fetch_poetry", return_value=(["poem"], None, None)
|
||||
"engine.app.pipeline_runner.fetch_poetry",
|
||||
return_value=(["poem"], None, None),
|
||||
) as mock_fetch_poetry,
|
||||
patch("engine.app.fetch_all") as mock_fetch_all,
|
||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||
patch("engine.app.pipeline_runner.fetch_all") as mock_fetch_all,
|
||||
patch("engine.app.pipeline_runner.fetch_all_fast", return_value=[]),
|
||||
patch("engine.app.pipeline_runner.DisplayRegistry.create") as mock_create,
|
||||
):
|
||||
mock_display = Mock()
|
||||
mock_display.init = Mock()
|
||||
@@ -183,9 +192,10 @@ class TestRunPipelineMode:
|
||||
def test_run_pipeline_mode_discovers_effect_plugins(self):
|
||||
"""run_pipeline_mode() discovers available effect plugins."""
|
||||
with (
|
||||
patch("engine.app.load_cache", return_value=["item"]),
|
||||
patch("engine.app.effects_plugins") as mock_effects,
|
||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
||||
patch("engine.app.pipeline_runner.load_cache", return_value=["item"]),
|
||||
patch("engine.app.pipeline_runner.fetch_all_fast", return_value=[]),
|
||||
patch("engine.effects.plugins.discover_plugins") as mock_discover,
|
||||
patch("engine.app.pipeline_runner.DisplayRegistry.create") as mock_create,
|
||||
):
|
||||
mock_display = Mock()
|
||||
mock_display.init = Mock()
|
||||
@@ -202,4 +212,4 @@ class TestRunPipelineMode:
|
||||
pass
|
||||
|
||||
# Verify effects_plugins.discover_plugins was called
|
||||
mock_effects.discover_plugins.assert_called_once()
|
||||
mock_discover.assert_called_once()
|
||||
|
||||
@@ -2,11 +2,52 @@
|
||||
Tests for engine.benchmark module - performance regression tests.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from engine.display import NullDisplay
|
||||
from engine.display import MultiDisplay, NullDisplay, TerminalDisplay
|
||||
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:
|
||||
@@ -21,14 +62,14 @@ class TestBenchmarkNullDisplay:
|
||||
display.init(80, 24)
|
||||
buffer = ["x" * 80 for _ in range(24)]
|
||||
|
||||
iterations = 1000
|
||||
iterations = _get_iterations()
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
display.show(buffer)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
fps = iterations / elapsed
|
||||
min_fps = 20000
|
||||
min_fps = _get_min_fps_threshold(20000)
|
||||
|
||||
assert fps >= min_fps, f"NullDisplay FPS {fps:.0f} below minimum {min_fps}"
|
||||
|
||||
@@ -57,14 +98,14 @@ class TestBenchmarkNullDisplay:
|
||||
has_message=False,
|
||||
)
|
||||
|
||||
iterations = 500
|
||||
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 = 10000
|
||||
min_fps = _get_min_fps_threshold(10000)
|
||||
|
||||
assert fps >= min_fps, (
|
||||
f"Effect processing FPS {fps:.0f} below minimum {min_fps}"
|
||||
@@ -86,15 +127,254 @@ class TestBenchmarkWebSocketDisplay:
|
||||
display.init(80, 24)
|
||||
buffer = ["x" * 80 for _ in range(24)]
|
||||
|
||||
iterations = 500
|
||||
iterations = _get_iterations()
|
||||
start = time.perf_counter()
|
||||
for _ in range(iterations):
|
||||
display.show(buffer)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
fps = iterations / elapsed
|
||||
min_fps = 10000
|
||||
min_fps = _get_min_fps_threshold(10000)
|
||||
|
||||
assert fps >= 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}"
|
||||
)
|
||||
|
||||
826
tests/test_camera_acceptance.py
Normal file
826
tests/test_camera_acceptance.py
Normal file
@@ -0,0 +1,826 @@
|
||||
"""
|
||||
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()
|
||||
assert DisplayRegistry.get("terminal") == TerminalDisplay
|
||||
assert DisplayRegistry.get("null") == NullDisplay
|
||||
from engine.display.backends.sixel import SixelDisplay
|
||||
from engine.display.backends.pygame import PygameDisplay
|
||||
from engine.display.backends.websocket import WebSocketDisplay
|
||||
|
||||
assert DisplayRegistry.get("websocket") == WebSocketDisplay
|
||||
assert DisplayRegistry.get("sixel") == SixelDisplay
|
||||
assert DisplayRegistry.get("pygame") == PygameDisplay
|
||||
|
||||
def test_initialize_idempotent(self):
|
||||
"""initialize can be called multiple times safely."""
|
||||
@@ -120,12 +120,16 @@ class TestTerminalDisplay:
|
||||
|
||||
def test_get_dimensions_returns_cached_value(self):
|
||||
"""get_dimensions returns cached dimensions for stability."""
|
||||
display = TerminalDisplay()
|
||||
display.init(80, 24)
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
# First call should set cache
|
||||
d1 = display.get_dimensions()
|
||||
assert d1 == (80, 24)
|
||||
# Mock terminal size to ensure deterministic dimensions
|
||||
term_size = os.terminal_size((80, 24))
|
||||
with patch("os.get_terminal_size", return_value=term_size):
|
||||
display = TerminalDisplay()
|
||||
display.init(80, 24)
|
||||
d1 = display.get_dimensions()
|
||||
assert d1 == (80, 24)
|
||||
|
||||
def test_show_clears_screen_before_each_frame(self):
|
||||
"""show clears previous frame to prevent visual wobble.
|
||||
|
||||
@@ -31,12 +31,12 @@ class TestFetchFeed:
|
||||
|
||||
@patch("engine.fetch.urllib.request.urlopen")
|
||||
def test_fetch_network_error(self, mock_urlopen):
|
||||
"""Network error returns None."""
|
||||
"""Network error returns tuple with None feed."""
|
||||
mock_urlopen.side_effect = Exception("Network error")
|
||||
|
||||
result = fetch_feed("http://example.com/feed")
|
||||
url, feed = fetch_feed("http://example.com/feed")
|
||||
|
||||
assert result is None
|
||||
assert feed is None
|
||||
|
||||
|
||||
class TestFetchAll:
|
||||
@@ -54,7 +54,7 @@ class TestFetchAll:
|
||||
{"title": "Headline 1", "published_parsed": (2024, 1, 1, 12, 0, 0)},
|
||||
{"title": "Headline 2", "updated_parsed": (2024, 1, 2, 12, 0, 0)},
|
||||
]
|
||||
mock_fetch_feed.return_value = mock_feed
|
||||
mock_fetch_feed.return_value = ("http://example.com", mock_feed)
|
||||
mock_skip.return_value = False
|
||||
mock_strip.side_effect = lambda x: x
|
||||
|
||||
@@ -67,7 +67,7 @@ class TestFetchAll:
|
||||
@patch("engine.fetch.boot_ln")
|
||||
def test_fetch_all_feed_error(self, mock_boot, mock_fetch_feed):
|
||||
"""Feed error increments failed count."""
|
||||
mock_fetch_feed.return_value = None
|
||||
mock_fetch_feed.return_value = ("http://example.com", None)
|
||||
|
||||
items, linked, failed = fetch_all()
|
||||
|
||||
@@ -87,7 +87,7 @@ class TestFetchAll:
|
||||
{"title": "Sports scores"},
|
||||
{"title": "Valid headline"},
|
||||
]
|
||||
mock_fetch_feed.return_value = mock_feed
|
||||
mock_fetch_feed.return_value = ("http://example.com", mock_feed)
|
||||
mock_skip.side_effect = lambda x: x == "Sports scores"
|
||||
mock_strip.side_effect = lambda x: x
|
||||
|
||||
|
||||
195
tests/test_framebuffer_acceptance.py
Normal file
195
tests/test_framebuffer_acceptance.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""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
|
||||
237
tests/test_framebuffer_stage.py
Normal file
237
tests/test_framebuffer_stage.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
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,14 +11,7 @@ import pytest
|
||||
from engine.data_sources.sources import SourceItem
|
||||
from engine.pipeline.adapters import FontStage, ViewportFilterStage
|
||||
from engine.pipeline.core import PipelineContext
|
||||
|
||||
|
||||
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
|
||||
from engine.pipeline.params import PipelineParams
|
||||
|
||||
|
||||
class TestViewportFilterPerformance:
|
||||
@@ -38,12 +31,12 @@ class TestViewportFilterPerformance:
|
||||
|
||||
stage = ViewportFilterStage()
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams(viewport_height=24)
|
||||
ctx.params = PipelineParams(viewport_height=24)
|
||||
|
||||
result = benchmark(stage.process, test_items, ctx)
|
||||
|
||||
# Verify result is correct
|
||||
assert len(result) <= 5
|
||||
# Verify result is correct - viewport filter takes first N items
|
||||
assert len(result) <= 24 # viewport height
|
||||
assert len(result) > 0
|
||||
|
||||
@pytest.mark.benchmark
|
||||
@@ -61,7 +54,7 @@ class TestViewportFilterPerformance:
|
||||
|
||||
font_stage = FontStage()
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams()
|
||||
ctx.params = PipelineParams()
|
||||
|
||||
result = benchmark(font_stage.process, filtered_items, ctx)
|
||||
|
||||
@@ -75,8 +68,8 @@ class TestViewportFilterPerformance:
|
||||
|
||||
With 1438 items and 24-line viewport:
|
||||
- Without filter: FontStage renders all 1438 items
|
||||
- With filter: FontStage renders ~3 items (layout-based)
|
||||
- Expected improvement: 1438 / 3 ≈ 479x
|
||||
- With filter: FontStage renders ~4 items (height-based)
|
||||
- Expected improvement: 1438 / 4 ≈ 360x
|
||||
"""
|
||||
test_items = [
|
||||
SourceItem(f"Headline {i}", "source", str(i)) for i in range(1438)
|
||||
@@ -84,15 +77,15 @@ class TestViewportFilterPerformance:
|
||||
|
||||
stage = ViewportFilterStage()
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams(viewport_height=24)
|
||||
ctx.params = PipelineParams(viewport_height=24)
|
||||
|
||||
filtered = stage.process(test_items, ctx)
|
||||
improvement_factor = len(test_items) / len(filtered)
|
||||
|
||||
# Verify we get expected ~479x improvement (better than old ~288x)
|
||||
assert 400 < improvement_factor < 600
|
||||
# Verify filtered count is reasonable (layout-based is more precise)
|
||||
assert 2 <= len(filtered) <= 5
|
||||
# Verify we get significant improvement (height-based filtering)
|
||||
assert 300 < improvement_factor < 500
|
||||
# Verify filtered count is ~4 (24 viewport / 6 rows per item)
|
||||
assert len(filtered) == 4
|
||||
|
||||
|
||||
class TestPipelinePerformanceWithRealData:
|
||||
@@ -109,7 +102,7 @@ class TestPipelinePerformanceWithRealData:
|
||||
font_stage = FontStage()
|
||||
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams(viewport_height=24)
|
||||
ctx.params = PipelineParams(viewport_height=24)
|
||||
|
||||
# Filter should reduce items quickly
|
||||
filtered = filter_stage.process(large_items, ctx)
|
||||
@@ -129,14 +122,14 @@ class TestPipelinePerformanceWithRealData:
|
||||
|
||||
# Test different viewport heights
|
||||
test_cases = [
|
||||
(12, 3), # 12px height -> ~3 items
|
||||
(24, 5), # 24px height -> ~5 items
|
||||
(48, 9), # 48px height -> ~9 items
|
||||
(12, 12), # 12px height -> 12 items
|
||||
(24, 24), # 24px height -> 24 items
|
||||
(48, 48), # 48px height -> 48 items
|
||||
]
|
||||
|
||||
for viewport_height, expected_max_items in test_cases:
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams(viewport_height=viewport_height)
|
||||
ctx.params = PipelineParams(viewport_height=viewport_height)
|
||||
|
||||
filtered = stage.process(large_items, ctx)
|
||||
|
||||
@@ -159,14 +152,14 @@ class TestPerformanceRegressions:
|
||||
|
||||
stage = ViewportFilterStage()
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams()
|
||||
ctx.params = PipelineParams()
|
||||
|
||||
filtered = stage.process(large_items, ctx)
|
||||
|
||||
# Should NOT have all items (regression detection)
|
||||
assert len(filtered) != len(large_items)
|
||||
# Should have drastically fewer items
|
||||
assert len(filtered) < 10
|
||||
# With height-based filtering, ~4 items fit in 24-row viewport (6 rows/item)
|
||||
assert len(filtered) == 4
|
||||
|
||||
def test_font_stage_doesnt_hang_with_filter(self):
|
||||
"""Regression test: FontStage shouldn't hang when receiving filtered data.
|
||||
@@ -182,7 +175,7 @@ class TestPerformanceRegressions:
|
||||
|
||||
font_stage = FontStage()
|
||||
ctx = PipelineContext()
|
||||
ctx.params = MockParams()
|
||||
ctx.params = PipelineParams()
|
||||
|
||||
# Should complete instantly (not hang)
|
||||
result = font_stage.process(filtered_items, ctx)
|
||||
|
||||
@@ -45,7 +45,6 @@ class TestStageRegistry:
|
||||
assert "pygame" in displays
|
||||
assert "websocket" in displays
|
||||
assert "null" in displays
|
||||
assert "sixel" in displays
|
||||
|
||||
def test_create_source_stage(self):
|
||||
"""StageRegistry.create creates source stages."""
|
||||
@@ -130,7 +129,7 @@ class TestPipeline:
|
||||
|
||||
pipeline.add_stage("source", mock_source)
|
||||
pipeline.add_stage("display", mock_display)
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
assert pipeline._initialized is True
|
||||
assert "source" in pipeline.execution_order
|
||||
@@ -183,7 +182,7 @@ class TestPipeline:
|
||||
pipeline.add_stage("source", mock_source)
|
||||
pipeline.add_stage("effect", mock_effect)
|
||||
pipeline.add_stage("display", mock_display)
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
result = pipeline.execute(None)
|
||||
|
||||
@@ -219,7 +218,7 @@ class TestPipeline:
|
||||
|
||||
pipeline.add_stage("source", mock_source)
|
||||
pipeline.add_stage("failing", mock_failing)
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
result = pipeline.execute(None)
|
||||
|
||||
@@ -255,7 +254,7 @@ class TestPipeline:
|
||||
|
||||
pipeline.add_stage("source", mock_source)
|
||||
pipeline.add_stage("optional", mock_optional)
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
result = pipeline.execute(None)
|
||||
|
||||
@@ -303,7 +302,7 @@ class TestCapabilityBasedDependencies:
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_stage("headlines", SourceStage())
|
||||
pipeline.add_stage("render", RenderStage())
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
assert "headlines" in pipeline.execution_order
|
||||
assert "render" in pipeline.execution_order
|
||||
@@ -335,7 +334,7 @@ class TestCapabilityBasedDependencies:
|
||||
pipeline.add_stage("render", RenderStage())
|
||||
|
||||
try:
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
raise AssertionError("Should have raised StageError")
|
||||
except StageError as e:
|
||||
assert "Missing capabilities" in e.message
|
||||
@@ -395,7 +394,7 @@ class TestCapabilityBasedDependencies:
|
||||
pipeline.add_stage("headlines", SourceA())
|
||||
pipeline.add_stage("poetry", SourceB())
|
||||
pipeline.add_stage("display", DisplayStage())
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
assert pipeline.execution_order[0] == "headlines"
|
||||
|
||||
@@ -546,7 +545,7 @@ class TestPipelinePresets:
|
||||
FIREHOSE_PRESET,
|
||||
PIPELINE_VIZ_PRESET,
|
||||
POETRY_PRESET,
|
||||
SIXEL_PRESET,
|
||||
UI_PRESET,
|
||||
WEBSOCKET_PRESET,
|
||||
)
|
||||
|
||||
@@ -554,8 +553,8 @@ class TestPipelinePresets:
|
||||
assert POETRY_PRESET.name == "poetry"
|
||||
assert FIREHOSE_PRESET.name == "firehose"
|
||||
assert PIPELINE_VIZ_PRESET.name == "pipeline"
|
||||
assert SIXEL_PRESET.name == "sixel"
|
||||
assert WEBSOCKET_PRESET.name == "websocket"
|
||||
assert UI_PRESET.name == "ui"
|
||||
|
||||
def test_preset_to_params(self):
|
||||
"""Presets convert to PipelineParams correctly."""
|
||||
@@ -792,7 +791,7 @@ class TestFullPipeline:
|
||||
pipeline.add_stage("b", StageB())
|
||||
|
||||
try:
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
raise AssertionError("Should detect circular dependency")
|
||||
except Exception:
|
||||
pass
|
||||
@@ -816,7 +815,7 @@ class TestPipelineMetrics:
|
||||
config = PipelineConfig(enable_metrics=True)
|
||||
pipeline = Pipeline(config=config)
|
||||
pipeline.add_stage("dummy", DummyStage())
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
pipeline.execute("test_data")
|
||||
|
||||
@@ -839,7 +838,7 @@ class TestPipelineMetrics:
|
||||
config = PipelineConfig(enable_metrics=False)
|
||||
pipeline = Pipeline(config=config)
|
||||
pipeline.add_stage("dummy", DummyStage())
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
pipeline.execute("test_data")
|
||||
|
||||
@@ -861,7 +860,7 @@ class TestPipelineMetrics:
|
||||
config = PipelineConfig(enable_metrics=True)
|
||||
pipeline = Pipeline(config=config)
|
||||
pipeline.add_stage("dummy", DummyStage())
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
pipeline.execute("test1")
|
||||
pipeline.execute("test2")
|
||||
@@ -965,7 +964,7 @@ class TestOverlayStages:
|
||||
pipeline.add_stage("overlay_a", OverlayStageA())
|
||||
pipeline.add_stage("overlay_b", OverlayStageB())
|
||||
pipeline.add_stage("regular", RegularStage())
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
overlays = pipeline.get_overlay_stages()
|
||||
assert len(overlays) == 2
|
||||
@@ -1007,7 +1006,7 @@ class TestOverlayStages:
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_stage("regular", RegularStage())
|
||||
pipeline.add_stage("overlay", OverlayStage())
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
pipeline.execute("data")
|
||||
|
||||
@@ -1071,7 +1070,7 @@ class TestOverlayStages:
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_stage("test", TestStage())
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
assert pipeline.get_stage_type("test") == "overlay"
|
||||
|
||||
@@ -1093,7 +1092,7 @@ class TestOverlayStages:
|
||||
|
||||
pipeline = Pipeline()
|
||||
pipeline.add_stage("test", TestStage())
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
assert pipeline.get_render_order("test") == 42
|
||||
|
||||
@@ -1143,7 +1142,7 @@ class TestInletOutletTypeValidation:
|
||||
pipeline.add_stage("consumer", ConsumerStage())
|
||||
|
||||
with pytest.raises(StageError) as exc_info:
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
assert "Type mismatch" in str(exc_info.value)
|
||||
assert "TEXT_BUFFER" in str(exc_info.value)
|
||||
@@ -1191,7 +1190,7 @@ class TestInletOutletTypeValidation:
|
||||
pipeline.add_stage("consumer", ConsumerStage())
|
||||
|
||||
# Should not raise
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
def test_any_type_accepts_everything(self):
|
||||
"""DataType.ANY accepts any upstream type."""
|
||||
@@ -1235,7 +1234,7 @@ class TestInletOutletTypeValidation:
|
||||
pipeline.add_stage("consumer", ConsumerStage())
|
||||
|
||||
# Should not raise because consumer accepts ANY
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
def test_multiple_compatible_types(self):
|
||||
"""Stage can declare multiple inlet types."""
|
||||
@@ -1279,7 +1278,7 @@ class TestInletOutletTypeValidation:
|
||||
pipeline.add_stage("consumer", ConsumerStage())
|
||||
|
||||
# Should not raise because consumer accepts SOURCE_ITEMS
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
def test_display_must_accept_text_buffer(self):
|
||||
"""Display stages must accept TEXT_BUFFER type."""
|
||||
@@ -1303,7 +1302,543 @@ class TestInletOutletTypeValidation:
|
||||
pipeline.add_stage("display", BadDisplayStage())
|
||||
|
||||
with pytest.raises(StageError) as exc_info:
|
||||
pipeline.build()
|
||||
pipeline.build(auto_inject=False)
|
||||
|
||||
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,6 +21,7 @@ from engine.pipeline.adapters import (
|
||||
EffectPluginStage,
|
||||
FontStage,
|
||||
SourceItemsToBufferStage,
|
||||
ViewportFilterStage,
|
||||
)
|
||||
from engine.pipeline.core import PipelineContext
|
||||
from engine.pipeline.params import PipelineParams
|
||||
@@ -129,7 +130,28 @@ def _build_pipeline(
|
||||
|
||||
# Render 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"))
|
||||
|
||||
# CameraStage applies viewport transformation to rendered buffer
|
||||
pipeline.add_stage("camera", CameraStage(camera, name="static"))
|
||||
else:
|
||||
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
||||
|
||||
@@ -196,9 +218,10 @@ class TestPipelineE2EHappyPath:
|
||||
|
||||
assert result.success
|
||||
frame = display.frames.get(timeout=1)
|
||||
assert "Line A" in frame
|
||||
assert "Line B" in frame
|
||||
assert "Line C" in frame
|
||||
# Camera stage pads lines to viewport width, so check for substring match
|
||||
assert any("Line A" in line for line in frame)
|
||||
assert any("Line B" in line for line in frame)
|
||||
assert any("Line C" in line for line in frame)
|
||||
|
||||
def test_empty_source_produces_empty_buffer(self):
|
||||
"""An empty source should produce an empty (or blank) frame."""
|
||||
@@ -241,7 +264,10 @@ class TestPipelineE2EEffects:
|
||||
|
||||
assert result.success
|
||||
frame = display.frames.get(timeout=1)
|
||||
assert "[FX1]" in frame, f"Marker not found in frame: {frame}"
|
||||
# Camera stage pads lines to viewport width, so check for substring match
|
||||
assert any("[FX1]" in line for line in frame), (
|
||||
f"Marker not found in frame: {frame}"
|
||||
)
|
||||
assert "Original" in "\n".join(frame)
|
||||
|
||||
def test_effect_chain_ordering(self):
|
||||
@@ -365,7 +391,7 @@ class TestPipelineE2EStageOrder:
|
||||
# All regular (non-overlay) stages should have metrics
|
||||
assert "source" in stage_names
|
||||
assert "render" in stage_names
|
||||
assert "display" in stage_names
|
||||
assert "queue" in stage_names # Display stage is named "queue" in the test
|
||||
assert "effect_m" in stage_names
|
||||
|
||||
|
||||
|
||||
259
tests/test_pipeline_mutation_commands.py
Normal file
259
tests/test_pipeline_mutation_commands.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
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"
|
||||
)
|
||||
405
tests/test_pipeline_rebuild.py
Normal file
405
tests/test_pipeline_rebuild.py
Normal file
@@ -0,0 +1,405 @@
|
||||
"""
|
||||
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()
|
||||
@@ -1,128 +0,0 @@
|
||||
"""
|
||||
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 == ""
|
||||
223
tests/test_streaming.py
Normal file
223
tests/test_streaming.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
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
|
||||
206
tests/test_tint_acceptance.py
Normal file
206
tests/test_tint_acceptance.py
Normal file
@@ -0,0 +1,206 @@
|
||||
"""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() != ""
|
||||
184
tests/test_ui_panel.py
Normal file
184
tests/test_ui_panel.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
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,10 +110,9 @@ class TestViewportFilterStage:
|
||||
filtered = stage.process(test_items, ctx)
|
||||
improvement_factor = len(test_items) / len(filtered)
|
||||
|
||||
# Verify we get at least 400x improvement (better than old ~288x)
|
||||
assert improvement_factor > 400
|
||||
# Verify we get the expected ~479x improvement
|
||||
assert 400 < improvement_factor < 600
|
||||
# Verify we get significant improvement (360x with 4 items vs 1438)
|
||||
assert improvement_factor > 300
|
||||
assert 300 < improvement_factor < 500
|
||||
|
||||
|
||||
class TestViewportFilterIntegration:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
from engine.effects.legacy import vis_offset, vis_trunc
|
||||
|
||||
|
||||
|
||||
@@ -160,3 +160,236 @@ class TestWebSocketDisplayUnavailable:
|
||||
"""show does nothing when websockets unavailable."""
|
||||
display = WebSocketDisplay()
|
||||
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