forked from genewildish/Mainline
Compare commits
45 Commits
main
...
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 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -9,3 +9,7 @@ htmlcov/
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
*.egg-info/
|
||||
coverage.xml
|
||||
*.dot
|
||||
*.png
|
||||
test-reports/
|
||||
|
||||
97
.opencode/skills/mainline-architecture/SKILL.md
Normal file
97
.opencode/skills/mainline-architecture/SKILL.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: mainline-architecture
|
||||
description: Pipeline stages, capability resolution, and core architecture patterns
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: developers
|
||||
source_type: codebase
|
||||
---
|
||||
|
||||
## What This Skill Covers
|
||||
|
||||
This skill covers Mainline's pipeline architecture - the Stage-based system for dependency resolution, data flow, and component composition.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Stage Class (engine/pipeline/core.py)
|
||||
|
||||
The `Stage` ABC is the foundation. All pipeline components inherit from it:
|
||||
|
||||
```python
|
||||
class Stage(ABC):
|
||||
name: str
|
||||
category: str # "source", "effect", "overlay", "display", "camera"
|
||||
optional: bool = False
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
"""What this stage provides (e.g., 'source.headlines')"""
|
||||
return set()
|
||||
|
||||
@property
|
||||
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:
|
||||
- `SOURCE_ITEMS`: List[SourceItem] - raw items from sources
|
||||
- `ITEM_TUPLES`: List[tuple] - (title, source, timestamp) tuples
|
||||
- `TEXT_BUFFER`: List[str] - rendered ANSI buffer
|
||||
- `RAW_TEXT`: str - raw text strings
|
||||
- `PIL_IMAGE`: PIL Image object
|
||||
|
||||
### Pipeline Execution
|
||||
|
||||
The Pipeline (engine/pipeline/controller.py):
|
||||
1. Collects all stages from StageRegistry
|
||||
2. Resolves dependencies using prefix matching
|
||||
3. Executes stages in dependency order
|
||||
4. Handles errors for non-optional stages
|
||||
|
||||
### Canvas & Camera
|
||||
|
||||
- **Canvas** (`engine/canvas.py`): 2D rendering surface with dirty region tracking
|
||||
- **Camera** (`engine/camera.py`): Viewport controller for scrolling content
|
||||
|
||||
Canvas tracks dirty regions automatically when content is written via `put_region`, `put_text`, `fill`, enabling partial buffer updates.
|
||||
|
||||
## Adding New Stages
|
||||
|
||||
1. Create a class inheriting from `Stage`
|
||||
2. Define `capabilities` and `dependencies` properties
|
||||
3. Implement required abstract methods
|
||||
4. Register in StageRegistry or use as adapter
|
||||
|
||||
## Common Patterns
|
||||
|
||||
- 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
|
||||
163
.opencode/skills/mainline-display/SKILL.md
Normal file
163
.opencode/skills/mainline-display/SKILL.md
Normal file
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: mainline-display
|
||||
description: Display backend implementation and the Display protocol
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: developers
|
||||
source_type: codebase
|
||||
---
|
||||
|
||||
## What This Skill Covers
|
||||
|
||||
This skill covers Mainline's display backend system - how to implement new display backends and how the Display protocol works.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Display Protocol
|
||||
|
||||
All backends implement a common Display protocol (in `engine/display/__init__.py`):
|
||||
|
||||
```python
|
||||
class Display(Protocol):
|
||||
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"""
|
||||
...
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the display"""
|
||||
...
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources"""
|
||||
...
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Return (width, height)"""
|
||||
...
|
||||
```
|
||||
|
||||
### DisplayRegistry
|
||||
|
||||
Discovers and manages backends:
|
||||
|
||||
```python
|
||||
from engine.display import DisplayRegistry
|
||||
display = DisplayRegistry.create("terminal") # or "websocket", "null", "multi"
|
||||
```
|
||||
|
||||
### Available Backends
|
||||
|
||||
| Backend | File | Description |
|
||||
|---------|------|-------------|
|
||||
| terminal | backends/terminal.py | ANSI terminal output |
|
||||
| websocket | backends/websocket.py | Web browser via WebSocket |
|
||||
| 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
|
||||
|
||||
- WebSocket server: port 8765
|
||||
- HTTP server: port 8766 (serves client/index.html)
|
||||
- Client has ANSI color parsing and fullscreen support
|
||||
|
||||
### Multi Backend
|
||||
|
||||
Forwards to multiple displays simultaneously - useful for `terminal + websocket`.
|
||||
|
||||
## Adding a New Backend
|
||||
|
||||
1. Create `engine/display/backends/my_backend.py`
|
||||
2. Implement the Display protocol methods
|
||||
3. Register in `engine/display/__init__.py`'s `DisplayRegistry`
|
||||
|
||||
Required methods:
|
||||
- `init(width: int, height: int, reuse: bool = False)` - Initialize display
|
||||
- `show(buf: list[str], border: bool = False)` - Display buffer
|
||||
- `clear()` - Clear screen
|
||||
- `cleanup()` - Clean up resources
|
||||
- `get_dimensions() -> tuple[int, int]` - Get terminal dimensions
|
||||
|
||||
Optional methods:
|
||||
- `title(text: str)` - Set window title
|
||||
- `cursor(show: bool)` - Control cursor
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
python mainline.py --display terminal # default
|
||||
python mainline.py --display 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.
|
||||
113
.opencode/skills/mainline-effects/SKILL.md
Normal file
113
.opencode/skills/mainline-effects/SKILL.md
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: mainline-effects
|
||||
description: How to add new effect plugins to Mainline's effect system
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: developers
|
||||
source_type: codebase
|
||||
---
|
||||
|
||||
## What This Skill Covers
|
||||
|
||||
This skill covers Mainline's effect plugin system - how to create, configure, and integrate visual effects into the pipeline.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### EffectPlugin ABC (engine/effects/types.py)
|
||||
|
||||
All effects must inherit from `EffectPlugin` and implement:
|
||||
|
||||
```python
|
||||
class EffectPlugin(ABC):
|
||||
name: str
|
||||
config: EffectConfig
|
||||
param_bindings: dict[str, dict[str, str | float]] = {}
|
||||
supports_partial_updates: bool = False
|
||||
|
||||
@abstractmethod
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
"""Process buffer with effect applied"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
"""Configure the effect"""
|
||||
...
|
||||
```
|
||||
|
||||
### EffectContext
|
||||
|
||||
Passed to every effect's process method:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class EffectContext:
|
||||
terminal_width: int
|
||||
terminal_height: int
|
||||
scroll_cam: int
|
||||
ticker_height: int
|
||||
camera_x: int = 0
|
||||
mic_excess: float = 0.0
|
||||
grad_offset: float = 0.0
|
||||
frame_number: int = 0
|
||||
has_message: bool = False
|
||||
items: list = field(default_factory=list)
|
||||
_state: dict[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
Access sensor values via `ctx.get_sensor_value("sensor_name")`.
|
||||
|
||||
### EffectConfig
|
||||
|
||||
Configuration dataclass:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class EffectConfig:
|
||||
enabled: bool = True
|
||||
intensity: float = 1.0
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
```
|
||||
|
||||
### Partial Updates
|
||||
|
||||
For performance optimization, set `supports_partial_updates = True` and implement `process_partial`:
|
||||
|
||||
```python
|
||||
class MyEffect(EffectPlugin):
|
||||
supports_partial_updates = True
|
||||
|
||||
def process_partial(self, buf, ctx, partial: PartialUpdate) -> list[str]:
|
||||
# Only process changed regions
|
||||
...
|
||||
```
|
||||
|
||||
## Adding a New Effect
|
||||
|
||||
1. Create file in `effects_plugins/my_effect.py`
|
||||
2. Inherit from `EffectPlugin`
|
||||
3. Implement `process()` and `configure()`
|
||||
4. Add to `effects_plugins/__init__.py` (runtime discovery via issubclass checks)
|
||||
|
||||
## Param Bindings
|
||||
|
||||
Declarative sensor-to-param mappings:
|
||||
|
||||
```python
|
||||
param_bindings = {
|
||||
"intensity": {"sensor": "mic", "transform": "linear"},
|
||||
"rate": {"sensor": "oscillator", "transform": "exponential"},
|
||||
}
|
||||
```
|
||||
|
||||
Transforms: `linear`, `exponential`, `threshold`
|
||||
|
||||
## Effect Chain
|
||||
|
||||
Effects are chained via `engine/effects/chain.py` - processes each effect in order, passing output to next.
|
||||
|
||||
## Existing Effects
|
||||
|
||||
See `effects_plugins/`:
|
||||
- noise.py, fade.py, glitch.py, firehose.py
|
||||
- border.py, crop.py, tint.py, hud.py
|
||||
103
.opencode/skills/mainline-presets/SKILL.md
Normal file
103
.opencode/skills/mainline-presets/SKILL.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: mainline-presets
|
||||
description: Creating pipeline presets in TOML format for Mainline
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: developers
|
||||
source_type: codebase
|
||||
---
|
||||
|
||||
## What This Skill Covers
|
||||
|
||||
This skill covers how to create pipeline presets in TOML format for Mainline's rendering pipeline.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Preset Loading Order
|
||||
|
||||
Presets are loaded from multiple locations (later overrides earlier):
|
||||
1. Built-in: `engine/presets.toml`
|
||||
2. User config: `~/.config/mainline/presets.toml`
|
||||
3. Local override: `./presets.toml`
|
||||
|
||||
### PipelinePreset Dataclass
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class PipelinePreset:
|
||||
name: str
|
||||
description: str = ""
|
||||
source: str = "headlines" # Data source
|
||||
display: str = "terminal" # Display backend
|
||||
camera: str = "scroll" # Camera mode
|
||||
effects: list[str] = field(default_factory=list)
|
||||
border: bool = False
|
||||
```
|
||||
|
||||
### TOML Format
|
||||
|
||||
```toml
|
||||
[presets.my-preset]
|
||||
description = "My custom pipeline"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
camera = "scroll"
|
||||
effects = ["noise", "fade"]
|
||||
border = true
|
||||
```
|
||||
|
||||
## Creating a Preset
|
||||
|
||||
### Option 1: User Config
|
||||
|
||||
Create/edit `~/.config/mainline/presets.toml`:
|
||||
|
||||
```toml
|
||||
[presets.my-cool-preset]
|
||||
description = "Noise and glitch effects"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
effects = ["noise", "glitch"]
|
||||
```
|
||||
|
||||
### Option 2: Local Override
|
||||
|
||||
Create `./presets.toml` in project root:
|
||||
|
||||
```toml
|
||||
[presets.dev-inspect]
|
||||
description = "Pipeline introspection for development"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
effects = ["hud"]
|
||||
```
|
||||
|
||||
### Option 3: Built-in
|
||||
|
||||
Edit `engine/presets.toml` (requires PR to repository).
|
||||
|
||||
## Available Sources
|
||||
|
||||
- `headlines` - RSS news feeds
|
||||
- `poetry` - Literature mode
|
||||
- `pipeline-inspect` - Live DAG visualization
|
||||
|
||||
## Available Displays
|
||||
|
||||
- `terminal` - ANSI terminal
|
||||
- `websocket` - Web browser
|
||||
- `null` - Headless
|
||||
- `moderngl` - GPU-accelerated (optional)
|
||||
|
||||
## Available Effects
|
||||
|
||||
See `effects_plugins/`:
|
||||
- noise, fade, glitch, firehose
|
||||
- border, crop, tint, hud
|
||||
|
||||
## Validation Functions
|
||||
|
||||
Use these from `engine/pipeline/presets.py`:
|
||||
- `validate_preset()` - Validate preset structure
|
||||
- `validate_signal_path()` - Detect circular dependencies
|
||||
- `generate_preset_toml()` - Generate skeleton preset
|
||||
136
.opencode/skills/mainline-sensors/SKILL.md
Normal file
136
.opencode/skills/mainline-sensors/SKILL.md
Normal file
@@ -0,0 +1,136 @@
|
||||
---
|
||||
name: mainline-sensors
|
||||
description: Sensor framework for real-time input in Mainline
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: developers
|
||||
source_type: codebase
|
||||
---
|
||||
|
||||
## What This Skill Covers
|
||||
|
||||
This skill covers Mainline's sensor framework - how to use, create, and integrate sensors for real-time input.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Sensor Base Class (engine/sensors/__init__.py)
|
||||
|
||||
```python
|
||||
class Sensor(ABC):
|
||||
name: str
|
||||
unit: str = ""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Whether sensor is currently available"""
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
def read(self) -> SensorValue | None:
|
||||
"""Read current sensor value"""
|
||||
...
|
||||
|
||||
def start(self) -> None:
|
||||
"""Initialize sensor (optional)"""
|
||||
pass
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Clean up sensor (optional)"""
|
||||
pass
|
||||
```
|
||||
|
||||
### SensorValue Dataclass
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class SensorValue:
|
||||
sensor_name: str
|
||||
value: float
|
||||
timestamp: float
|
||||
unit: str = ""
|
||||
```
|
||||
|
||||
### SensorRegistry
|
||||
|
||||
Discovers and manages sensors globally:
|
||||
|
||||
```python
|
||||
from engine.sensors import SensorRegistry
|
||||
registry = SensorRegistry()
|
||||
sensor = registry.get("mic")
|
||||
```
|
||||
|
||||
### SensorStage
|
||||
|
||||
Pipeline adapter that provides sensor values to effects:
|
||||
|
||||
```python
|
||||
from engine.pipeline.adapters import SensorStage
|
||||
stage = SensorStage(sensor_name="mic")
|
||||
```
|
||||
|
||||
## Built-in Sensors
|
||||
|
||||
| Sensor | File | Description |
|
||||
|--------|------|-------------|
|
||||
| MicSensor | sensors/mic.py | Microphone input (RMS dB) |
|
||||
| OscillatorSensor | sensors/oscillator.py | Test sine wave generator |
|
||||
| PipelineMetricsSensor | sensors/pipeline_metrics.py | FPS, frame time, etc. |
|
||||
|
||||
## Param Bindings
|
||||
|
||||
Effects declare sensor-to-param mappings:
|
||||
|
||||
```python
|
||||
class GlitchEffect(EffectPlugin):
|
||||
param_bindings = {
|
||||
"intensity": {"sensor": "mic", "transform": "linear"},
|
||||
}
|
||||
```
|
||||
|
||||
### Transform Functions
|
||||
|
||||
- `linear` - Direct mapping to param range
|
||||
- `exponential` - Exponential scaling
|
||||
- `threshold` - Binary on/off
|
||||
|
||||
## Adding a New Sensor
|
||||
|
||||
1. Create `engine/sensors/my_sensor.py`
|
||||
2. Inherit from `Sensor` ABC
|
||||
3. Implement required methods
|
||||
4. Register in `SensorRegistry`
|
||||
|
||||
Example:
|
||||
```python
|
||||
class MySensor(Sensor):
|
||||
name = "my-sensor"
|
||||
unit = "units"
|
||||
|
||||
def read(self) -> SensorValue | None:
|
||||
return SensorValue(
|
||||
sensor_name=self.name,
|
||||
value=self._read_hardware(),
|
||||
timestamp=time.time(),
|
||||
unit=self.unit
|
||||
)
|
||||
```
|
||||
|
||||
## Using Sensors in Effects
|
||||
|
||||
Access sensor values via EffectContext:
|
||||
|
||||
```python
|
||||
def process(self, buf, ctx):
|
||||
mic_level = ctx.get_sensor_value("mic")
|
||||
if mic_level and mic_level > 0.5:
|
||||
# Apply intense effect
|
||||
...
|
||||
```
|
||||
|
||||
Or via param_bindings (automatic):
|
||||
|
||||
```python
|
||||
# If intensity is bound to "mic", it's automatically
|
||||
# available in self.config.intensity
|
||||
```
|
||||
87
.opencode/skills/mainline-sources/SKILL.md
Normal file
87
.opencode/skills/mainline-sources/SKILL.md
Normal file
@@ -0,0 +1,87 @@
|
||||
---
|
||||
name: mainline-sources
|
||||
description: Adding new RSS feeds and data sources to Mainline
|
||||
compatibility: opencode
|
||||
metadata:
|
||||
audience: developers
|
||||
source_type: codebase
|
||||
---
|
||||
|
||||
## What This Skill Covers
|
||||
|
||||
This skill covers how to add new data sources (RSS feeds, poetry) to Mainline.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Feeds Dictionary (engine/sources.py)
|
||||
|
||||
All feeds are defined in a simple dictionary:
|
||||
|
||||
```python
|
||||
FEEDS = {
|
||||
"Feed Name": "https://example.com/feed.xml",
|
||||
# Category comments help organize:
|
||||
# Science & Technology
|
||||
# Economics & Business
|
||||
# World & Politics
|
||||
# Culture & Ideas
|
||||
}
|
||||
```
|
||||
|
||||
### Poetry Sources
|
||||
|
||||
Project Gutenberg URLs for public domain literature:
|
||||
|
||||
```python
|
||||
POETRY_SOURCES = {
|
||||
"Author Name": "https://www.gutenberg.org/cache/epub/1234/pg1234.txt",
|
||||
}
|
||||
```
|
||||
|
||||
### Language & Script Mapping
|
||||
|
||||
The sources.py also contains language/script detection mappings used for auto-translation and font selection.
|
||||
|
||||
## Adding a New RSS Feed
|
||||
|
||||
1. Edit `engine/sources.py`
|
||||
2. Add entry to `FEEDS` dict under appropriate category:
|
||||
```python
|
||||
"My Feed": "https://example.com/feed.xml",
|
||||
```
|
||||
3. The feed will be automatically discovered on next run
|
||||
|
||||
### Feed Requirements
|
||||
|
||||
- Must be valid RSS or Atom XML
|
||||
- Should have `<title>` elements for items
|
||||
- Must be HTTP/HTTPS accessible
|
||||
|
||||
## Adding Poetry Sources
|
||||
|
||||
1. Edit `engine/sources.py`
|
||||
2. Add to `POETRY_SOURCES` dict:
|
||||
```python
|
||||
"Author": "https://www.gutenberg.org/cache/epub/XXXX/pgXXXX.txt",
|
||||
```
|
||||
|
||||
### Poetry Requirements
|
||||
|
||||
- Plain text (UTF-8)
|
||||
- Project Gutenberg format preferred
|
||||
- No DRM-protected sources
|
||||
|
||||
## Data Flow
|
||||
|
||||
Feeds are fetched via `engine/fetch.py`:
|
||||
- `fetch_feed(url)` - Fetches and parses RSS/Atom
|
||||
- Results cached for fast restarts
|
||||
- Filtered via `engine/filter.py` for content cleaning
|
||||
|
||||
## Categories
|
||||
|
||||
Organize new feeds by category using comments:
|
||||
- Science & Technology
|
||||
- Economics & Business
|
||||
- World & Politics
|
||||
- Culture & Ideas
|
||||
463
AGENTS.md
463
AGENTS.md
@@ -4,88 +4,208 @@
|
||||
|
||||
This project uses:
|
||||
- **mise** (mise.jdx.dev) - tool version manager and task runner
|
||||
- **hk** (hk.jdx.dev) - git hook manager
|
||||
- **uv** - fast Python package installer
|
||||
- **ruff** - linter and formatter
|
||||
- **pytest** - test runner
|
||||
- **ruff** - linter and formatter (line-length 88, target Python 3.10)
|
||||
- **pytest** - test runner with strict marker enforcement
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
mise run install
|
||||
|
||||
# Or equivalently:
|
||||
uv sync
|
||||
mise run install # Install dependencies
|
||||
# Or: uv sync --all-extras # includes mic, websocket support
|
||||
```
|
||||
|
||||
### Available Commands
|
||||
|
||||
```bash
|
||||
mise run test # Run tests
|
||||
mise run test-v # Run tests verbose
|
||||
mise run test-cov # Run tests with coverage report
|
||||
mise run lint # Run ruff linter
|
||||
mise run lint-fix # Run ruff with auto-fix
|
||||
mise run format # Run ruff formatter
|
||||
mise run ci # Full CI pipeline (sync + test + coverage)
|
||||
# Testing
|
||||
mise run test # Run all tests
|
||||
mise run test-cov # Run tests with coverage report
|
||||
pytest tests/test_foo.py::TestClass::test_method # Run single test
|
||||
|
||||
# Linting & Formatting
|
||||
mise run lint # Run ruff linter
|
||||
mise run lint-fix # Run ruff with auto-fix
|
||||
mise run format # Run ruff formatter
|
||||
|
||||
# CI
|
||||
mise run ci # Full CI pipeline (topics-init + lint + test-cov)
|
||||
```
|
||||
|
||||
## Git Hooks
|
||||
|
||||
**At the start of every agent session**, verify hooks are installed:
|
||||
### Running a Single Test
|
||||
|
||||
```bash
|
||||
ls -la .git/hooks/pre-commit
|
||||
# Run a specific test function
|
||||
pytest tests/test_eventbus.py::TestEventBusInit::test_init_creates_empty_subscribers
|
||||
|
||||
# Run all tests in a file
|
||||
pytest tests/test_eventbus.py
|
||||
|
||||
# Run tests matching a pattern
|
||||
pytest -k "test_subscribe"
|
||||
```
|
||||
|
||||
If hooks are not installed, install them with:
|
||||
### Git Hooks
|
||||
|
||||
Install hooks at start of session:
|
||||
```bash
|
||||
hk init --mise
|
||||
mise run pre-commit
|
||||
ls -la .git/hooks/pre-commit # Verify installed
|
||||
hk init --mise # Install if missing
|
||||
mise run pre-commit # Run manually
|
||||
```
|
||||
|
||||
The project uses hk configured in `hk.pkl`:
|
||||
- **pre-commit**: runs ruff-format and ruff (with auto-fix)
|
||||
- **pre-push**: runs ruff check
|
||||
## Code Style Guidelines
|
||||
|
||||
### Imports (three sections, alphabetical within each)
|
||||
|
||||
```python
|
||||
# 1. Standard library
|
||||
import os
|
||||
import threading
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# 2. Third-party
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
# 3. Local project
|
||||
from engine.events import EventType
|
||||
```
|
||||
|
||||
### Type Hints
|
||||
|
||||
- Use type hints for all function signatures (parameters and return)
|
||||
- Use `|` for unions (Python 3.10+): `EventType | None`
|
||||
- Use `dict[K, V]`, `list[V]` (generic syntax): `dict[str, list[int]]`
|
||||
- Use `Callable[[ArgType], ReturnType]` for callbacks
|
||||
|
||||
```python
|
||||
def subscribe(self, event_type: EventType, callback: Callable[[Any], None]) -> None:
|
||||
...
|
||||
|
||||
def get_sensor_value(self, sensor_name: str) -> float | None:
|
||||
return self._state.get(f"sensor.{sensor_name}")
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Classes**: `PascalCase` (e.g., `EventBus`, `EffectPlugin`)
|
||||
- **Functions/methods**: `snake_case` (e.g., `get_event_bus`, `process_partial`)
|
||||
- **Constants**: `SCREAMING_SNAKE_CASE` (e.g., `CURSOR_OFF`)
|
||||
- **Private methods**: `_snake_case` prefix (e.g., `_initialize`)
|
||||
- **Type variables**: `PascalCase` (e.g., `T`, `EffectT`)
|
||||
|
||||
### Dataclasses
|
||||
|
||||
Use `@dataclass` for simple data containers:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class EffectContext:
|
||||
terminal_width: int
|
||||
terminal_height: int
|
||||
scroll_cam: int
|
||||
ticker_height: int = 0
|
||||
_state: dict[str, Any] = field(default_factory=dict, repr=False)
|
||||
```
|
||||
|
||||
### Abstract Base Classes
|
||||
|
||||
Use ABC for interface enforcement:
|
||||
|
||||
```python
|
||||
class EffectPlugin(ABC):
|
||||
name: str
|
||||
config: EffectConfig
|
||||
|
||||
@abstractmethod
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Catch specific exceptions, not bare `Exception`
|
||||
- Use `try/except` with fallbacks for optional features
|
||||
- Silent pass in event callbacks to prevent one handler from breaking others
|
||||
|
||||
```python
|
||||
# Good: specific exception
|
||||
try:
|
||||
term_size = os.get_terminal_size()
|
||||
except OSError:
|
||||
term_width = 80
|
||||
|
||||
# Good: silent pass in callbacks
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception:
|
||||
pass
|
||||
```
|
||||
|
||||
### Thread Safety
|
||||
|
||||
Use locks for shared state:
|
||||
|
||||
```python
|
||||
class EventBus:
|
||||
def __init__(self):
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def publish(self, event_type: EventType, event: Any = None) -> None:
|
||||
with self._lock:
|
||||
callbacks = list(self._subscribers.get(event_type, []))
|
||||
```
|
||||
|
||||
### Comments
|
||||
|
||||
- **DO NOT ADD comments** unless explicitly required
|
||||
- Let code be self-documenting with good naming
|
||||
- Use docstrings only for public APIs or complex logic
|
||||
|
||||
### Testing Patterns
|
||||
|
||||
Follow pytest conventions:
|
||||
|
||||
```python
|
||||
class TestEventBusSubscribe:
|
||||
"""Tests for EventBus.subscribe method."""
|
||||
|
||||
def test_subscribe_adds_callback(self):
|
||||
"""subscribe() adds a callback for an event type."""
|
||||
bus = EventBus()
|
||||
def callback(e):
|
||||
return None
|
||||
bus.subscribe(EventType.NTFY_MESSAGE, callback)
|
||||
assert bus.subscriber_count(EventType.NTFY_MESSAGE) == 1
|
||||
```
|
||||
|
||||
- Use classes to group related tests (`Test<ClassName>`, `Test<method_name>`)
|
||||
- Test docstrings follow `"<method>() <action>"` pattern
|
||||
- Use descriptive assertion messages via pytest behavior
|
||||
|
||||
## Workflow Rules
|
||||
|
||||
### Before Committing
|
||||
|
||||
1. **Always run the test suite** - never commit code that fails tests:
|
||||
```bash
|
||||
mise run test
|
||||
```
|
||||
|
||||
2. **Always run the linter**:
|
||||
```bash
|
||||
mise run lint
|
||||
```
|
||||
|
||||
3. **Fix any lint errors** before committing (or let the pre-commit hook handle it).
|
||||
|
||||
4. **Review your changes** using `git diff` to understand what will be committed.
|
||||
1. Run tests: `mise run test`
|
||||
2. Run linter: `mise run lint`
|
||||
3. Review changes: `git diff`
|
||||
|
||||
### On Failing Tests
|
||||
|
||||
When tests fail, **determine whether it's an out-of-date test or a correctly failing test**:
|
||||
|
||||
- **Out-of-date test**: The test was written for old behavior that has legitimately changed. Update the test to match the new expected behavior.
|
||||
|
||||
- **Correctly failing test**: The test correctly identifies a broken contract. Fix the implementation, not the test.
|
||||
- **Out-of-date test**: Update test to match new expected behavior
|
||||
- **Correctly failing test**: Fix implementation, not the test
|
||||
|
||||
**Never** modify a test to make it pass without understanding why it failed.
|
||||
|
||||
### Code Review
|
||||
|
||||
Before committing significant changes:
|
||||
- Run `git diff` to review all changes
|
||||
- Ensure new code follows existing patterns in the codebase
|
||||
- Check that type hints are added for new functions
|
||||
- Verify that tests exist for new functionality
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `tests/` and follow the pattern `test_*.py`.
|
||||
@@ -102,9 +222,244 @@ mise run test-cov
|
||||
|
||||
The project uses pytest with strict marker enforcement. Test configuration is in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
||||
|
||||
### Test Coverage Strategy
|
||||
|
||||
Current coverage: 56% (463 tests)
|
||||
|
||||
Key areas with lower coverage (acceptable for now):
|
||||
- **app.py** (8%): Main entry point - integration heavy, requires terminal
|
||||
- **scroll.py** (10%): Terminal-dependent rendering logic (unused)
|
||||
|
||||
Key areas with good coverage:
|
||||
- **display/backends/null.py** (95%): Easy to test headlessly
|
||||
- **display/backends/terminal.py** (96%): Uses mocking
|
||||
- **display/backends/multi.py** (100%): Simple forwarding logic
|
||||
- **effects/performance.py** (99%): Pure Python logic
|
||||
- **eventbus.py** (96%): Simple event system
|
||||
- **effects/controller.py** (95%): Effects command handling
|
||||
|
||||
Areas needing more tests:
|
||||
- **websocket.py** (48%): Network I/O, hard to test in CI
|
||||
- **ntfy.py** (50%): Network I/O, hard to test in CI
|
||||
- **mic.py** (61%): Audio I/O, hard to test in CI
|
||||
|
||||
Note: Terminal-dependent modules (scroll, layers render) are harder to test in CI.
|
||||
Performance regression tests are in `tests/test_benchmark.py` with `@pytest.mark.benchmark`.
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- **ntfy.py** and **mic.py** are standalone modules with zero internal dependencies
|
||||
- **ntfy.py** - standalone notification poller with zero internal dependencies
|
||||
- **sensors/** - Sensor framework (MicSensor, OscillatorSensor) for real-time input
|
||||
- **eventbus.py** provides thread-safe event publishing for decoupled communication
|
||||
- **controller.py** coordinates ntfy/mic monitoring
|
||||
- The render pipeline: fetch → render → effects → scroll → terminal output
|
||||
- **effects/** - plugin architecture with performance monitoring
|
||||
- The new pipeline architecture: source → render → effects → display
|
||||
|
||||
#### Canvas & Camera
|
||||
|
||||
- **Canvas** (`engine/canvas.py`): 2D rendering surface with dirty region tracking
|
||||
- **Camera** (`engine/camera.py`): Viewport controller for scrolling content
|
||||
|
||||
The Canvas tracks dirty regions automatically when content is written (via `put_region`, `put_text`, `fill`), enabling partial buffer updates for optimized effect processing.
|
||||
|
||||
### Pipeline Architecture
|
||||
|
||||
The new Stage-based pipeline architecture provides capability-based dependency resolution:
|
||||
|
||||
- **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
|
||||
- **SensorRegistry**: Discovers available sensors
|
||||
- **SensorStage**: Pipeline adapter that provides sensor values to effects
|
||||
- **MicSensor** (`engine/sensors/mic.py`): Self-contained microphone input
|
||||
- **OscillatorSensor** (`engine/sensors/oscillator.py`): Test sensor for development
|
||||
- **PipelineMetricsSensor** (`engine/sensors/pipeline_metrics.py`): Exposes pipeline metrics as sensor values
|
||||
|
||||
Sensors support param bindings to drive effect parameters in real-time.
|
||||
|
||||
#### Pipeline Introspection
|
||||
|
||||
- **PipelineIntrospectionSource** (`engine/data_sources/pipeline_introspection.py`): Renders live ASCII visualization of pipeline DAG with metrics
|
||||
- **PipelineIntrospectionDemo** (`engine/pipeline/pipeline_introspection_demo.py`): 3-phase demo controller for effect animation
|
||||
|
||||
Preset: `pipeline-inspect` - Live pipeline introspection with DAG and performance metrics
|
||||
|
||||
#### Partial Update Support
|
||||
|
||||
Effect plugins can opt-in to partial buffer updates for performance optimization:
|
||||
- Set `supports_partial_updates = True` on the effect class
|
||||
- Implement `process_partial(buf, ctx, partial)` method
|
||||
- The `PartialUpdate` dataclass indicates which regions changed
|
||||
|
||||
### Preset System
|
||||
|
||||
Presets use TOML format (no external dependencies):
|
||||
|
||||
- Built-in: `engine/presets.toml`
|
||||
- User config: `~/.config/mainline/presets.toml`
|
||||
- Local override: `./presets.toml`
|
||||
|
||||
- **Preset loader** (`engine/pipeline/preset_loader.py`): Loads and validates presets
|
||||
- **PipelinePreset** (`engine/pipeline/presets.py`): Dataclass for preset configuration
|
||||
|
||||
Functions:
|
||||
- `validate_preset()` - Validate preset structure
|
||||
- `validate_signal_path()` - Detect circular dependencies
|
||||
- `generate_preset_toml()` - Generate skeleton preset
|
||||
|
||||
### Display System
|
||||
|
||||
- **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/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
|
||||
- WebSocket server on port 8765
|
||||
- HTTP server on port 8766 (serves HTML client)
|
||||
- Client at `client/index.html` with ANSI color parsing and fullscreen support
|
||||
|
||||
- **Display modes** (`--display` flag):
|
||||
- `terminal` - Default ANSI terminal output
|
||||
- `websocket` - Web browser display (requires websockets package)
|
||||
- `moderngl` - GPU-accelerated rendering (requires moderngl package)
|
||||
|
||||
### Effect Plugin System
|
||||
|
||||
- **EffectPlugin ABC** (`engine/effects/types.py`): abstract base class for effects
|
||||
- All effects must inherit from EffectPlugin and implement `process()` and `configure()`
|
||||
- Runtime discovery via `effects_plugins/__init__.py` using `issubclass()` checks
|
||||
|
||||
- **EffectRegistry** (`engine/effects/registry.py`): manages registered effects
|
||||
- **EffectChain** (`engine/effects/chain.py`): chains effects in pipeline order
|
||||
|
||||
### Command & Control
|
||||
|
||||
- C&C uses separate ntfy topics for commands and responses
|
||||
- `NTFY_CC_CMD_TOPIC` - commands from cmdline.py
|
||||
- `NTFY_CC_RESP_TOPIC` - responses back to cmdline.py
|
||||
- Effects controller handles `/effects` commands (list, on/off, intensity, reorder, stats)
|
||||
|
||||
### Pipeline Documentation
|
||||
|
||||
The rendering pipeline is documented in `docs/PIPELINE.md` using Mermaid diagrams.
|
||||
|
||||
**IMPORTANT**: When making significant architectural changes to the rendering pipeline (new layers, effects, display backends), update `docs/PIPELINE.md` to reflect the changes:
|
||||
1. Edit `docs/PIPELINE.md` with the new architecture
|
||||
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/`.
|
||||
|
||||
### Workflow
|
||||
|
||||
**Before starting work:**
|
||||
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: `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 `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
|
||||
- `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
|
||||
|
||||
This project also has Agent Skills (SKILL.md files) in `.opencode/skills/`. Use the `skill` tool to load them:
|
||||
- `skill({name: "mainline-architecture"})` - Pipeline stages, capability resolution
|
||||
- `skill({name: "mainline-effects"})` - How to add new effect plugins
|
||||
- `skill({name: "mainline-display"})` - Display backend implementation
|
||||
- `skill({name: "mainline-sources"})` - Adding new RSS feeds
|
||||
- `skill({name: "mainline-presets"})` - Creating pipeline presets
|
||||
- `skill({name: "mainline-sensors"})` - Sensor framework usage
|
||||
|
||||
262
README.md
262
README.md
@@ -15,7 +15,7 @@ python3 mainline.py # news stream
|
||||
python3 mainline.py --poetry # literary consciousness mode
|
||||
python3 mainline.py -p # same
|
||||
python3 mainline.py --firehose # dense rapid-fire headline mode
|
||||
python3 mainline.py --refresh # force re-fetch (bypass cache)
|
||||
python3 mainline.py --display websocket # web browser display only
|
||||
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
|
||||
@@ -28,7 +28,20 @@ Or with uv:
|
||||
uv run mainline.py
|
||||
```
|
||||
|
||||
First run bootstraps a local `.mainline_venv/` and installs deps (`feedparser`, `Pillow`, `sounddevice`, `numpy`). Subsequent runs start immediately, loading from cache. With uv, run `uv sync` or `uv sync --all-extras` (includes mic support) instead.
|
||||
First run bootstraps dependencies. Use `uv sync --all-extras` for mic support.
|
||||
|
||||
### Command & Control (C&C)
|
||||
|
||||
Control mainline remotely using `cmdline.py`:
|
||||
|
||||
```bash
|
||||
uv run cmdline.py # Interactive TUI
|
||||
uv run cmdline.py /effects list # List all effects
|
||||
uv run cmdline.py /effects stats # Show performance stats
|
||||
uv run cmdline.py -w /effects stats # Watch mode (auto-refresh)
|
||||
```
|
||||
|
||||
Commands are sent via ntfy.sh topics - useful for controlling a daemonized mainline instance.
|
||||
|
||||
### Config
|
||||
|
||||
@@ -39,20 +52,31 @@ All constants live in `engine/config.py`:
|
||||
| `HEADLINE_LIMIT` | `1000` | Total headlines per session |
|
||||
| `FEED_TIMEOUT` | `10` | Per-feed HTTP timeout (seconds) |
|
||||
| `MIC_THRESHOLD_DB` | `50` | dB floor above which glitches spike |
|
||||
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream for messages |
|
||||
| `NTFY_CC_CMD_TOPIC` | klubhaus URL | ntfy.sh topic for C&C commands |
|
||||
| `NTFY_CC_RESP_TOPIC` | klubhaus URL | ntfy.sh topic for C&C responses |
|
||||
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after dropped SSE |
|
||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||
| `FONT_DIR` | `fonts/` | Folder scanned for `.otf`, `.ttf`, `.ttc` files |
|
||||
| `FONT_PATH` | first file in `FONT_DIR` | Active display font (overridden by picker or `--font-file`) |
|
||||
| `FONT_INDEX` | `0` | Face index within a font collection file |
|
||||
| `FONT_PICKER` | `True` | Show interactive font picker at boot (`--no-font-picker` to skip) |
|
||||
| `FONT_PATH` | first file in `FONT_DIR` | Active display font |
|
||||
| `FONT_PICKER` | `True` | Show interactive font picker at boot |
|
||||
| `FONT_SZ` | `60` | Font render size (affects block density) |
|
||||
| `RENDER_H` | `8` | Terminal rows per headline line |
|
||||
| `SSAA` | `4` | Super-sampling factor (render at 4× then downsample) |
|
||||
| `SSAA` | `4` | Super-sampling factor |
|
||||
| `SCROLL_DUR` | `5.625` | Seconds per headline |
|
||||
| `FRAME_DT` | `0.05` | Frame interval in seconds (20 FPS) |
|
||||
| `GRAD_SPEED` | `0.08` | Gradient sweep speed (cycles/sec, ~12s full sweep) |
|
||||
| `FIREHOSE_H` | `12` | Firehose zone height (terminal rows) |
|
||||
| `NTFY_TOPIC` | klubhaus URL | ntfy.sh JSON stream endpoint |
|
||||
| `NTFY_RECONNECT_DELAY` | `5` | Seconds before reconnecting after a dropped SSE stream |
|
||||
| `MESSAGE_DISPLAY_SECS` | `30` | How long an ntfy message holds the screen |
|
||||
| `GRAD_SPEED` | `0.08` | Gradient sweep speed |
|
||||
|
||||
### Display Modes
|
||||
|
||||
Mainline supports multiple display backends:
|
||||
|
||||
- **Terminal** (`--display terminal`): ANSI terminal output (default)
|
||||
- **WebSocket** (`--display websocket`): Stream to web browser clients
|
||||
- **ModernGL** (`--display moderngl`): GPU-accelerated rendering (optional)
|
||||
|
||||
WebSocket mode serves a web client at http://localhost:8766 with ANSI color support and fullscreen mode.
|
||||
|
||||
### Feeds
|
||||
|
||||
@@ -62,15 +86,15 @@ All constants live in `engine/config.py`:
|
||||
|
||||
### Fonts
|
||||
|
||||
A `fonts/` directory is bundled with demo faces (AgorTechnoDemo, AlphatronDemo, CSBishopDrawn, CubaTechnologyDemo, CyberformDemo, KATA, Microbots, ModernSpaceDemo, Neoform, Pixel Sparta, RaceHugoDemo, Resond, Robocops, Synthetix, Xeonic, and others). On startup, an interactive picker lists all discovered faces with a live half-block preview rendered at your configured size.
|
||||
A `fonts/` directory is bundled with demo faces. On startup, an interactive picker lists all discovered faces with a live half-block preview.
|
||||
|
||||
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select. The selected face persists for that session.
|
||||
Navigation: `↑`/`↓` or `j`/`k` to move, `Enter` or `q` to select.
|
||||
|
||||
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/` (or point `--font-dir` at any other folder). Font collections (`.ttc`, multi-face `.otf`) are enumerated face-by-face.
|
||||
To add your own fonts, drop `.otf`, `.ttf`, or `.ttc` files into `fonts/`.
|
||||
|
||||
### ntfy.sh
|
||||
|
||||
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen for `MESSAGE_DISPLAY_SECS` seconds, then the stream resumes.
|
||||
Mainline polls a configurable ntfy.sh topic in the background. When a message arrives, the scroll pauses and the message renders full-screen.
|
||||
|
||||
To push a message:
|
||||
|
||||
@@ -78,108 +102,68 @@ To push a message:
|
||||
curl -d "Body text" -H "Title: Alert title" https://ntfy.sh/your_topic
|
||||
```
|
||||
|
||||
Update `NTFY_TOPIC` in `engine/config.py` to point at your own topic.
|
||||
|
||||
---
|
||||
|
||||
## Internals
|
||||
|
||||
### How it works
|
||||
|
||||
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection; `--no-font-picker` skips directly to stream
|
||||
- Feeds are fetched and filtered on startup (sports and vapid content stripped); results are cached to `.mainline_cache_news.json` / `.mainline_cache_poetry.json` for fast restarts
|
||||
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters (`▀▄█ `) at the configured font size
|
||||
- The ticker uses a sweeping white-hot → deep green gradient; ntfy messages use a complementary white-hot → magenta/maroon gradient to distinguish them visually
|
||||
- Subject-region detection runs a regex pass on each headline; matches trigger a Google Translate call and font swap to the appropriate script (CJK, Arabic, Devanagari, etc.) using macOS system fonts
|
||||
- The mic stream runs in a background thread, feeding RMS dB into the glitch probability calculation each frame
|
||||
- The viewport scrolls through a virtual canvas of pre-rendered blocks; fade zones at top and bottom dissolve characters probabilistically
|
||||
- An ntfy.sh SSE stream runs in a background thread; incoming messages interrupt the scroll and render full-screen until dismissed or expired
|
||||
- On launch, the font picker scans `fonts/` and presents a live-rendered TUI for face selection
|
||||
- Feeds are fetched and filtered on startup; results are cached for fast restarts
|
||||
- Headlines are rasterized via Pillow with 4× SSAA into half-block characters
|
||||
- The ticker uses a sweeping white-hot → deep green gradient
|
||||
- Subject-region detection triggers Google Translate and font swap for non-Latin scripts
|
||||
- The mic stream runs in a background thread, feeding RMS dB into glitch probability
|
||||
- The viewport scrolls through pre-rendered blocks with fade zones
|
||||
- An ntfy.sh SSE stream runs in a background thread for messages and C&C commands
|
||||
|
||||
### Architecture
|
||||
|
||||
`mainline.py` is a thin entrypoint (venv bootstrap → `engine.app.main()`). All logic lives in the `engine/` package:
|
||||
|
||||
```
|
||||
engine/
|
||||
__init__.py package marker
|
||||
app.py main(), font picker TUI, boot sequence, signal handler
|
||||
config.py constants, CLI flags, glyph tables
|
||||
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
||||
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
||||
filter.py HTML stripping, content filter
|
||||
translate.py Google Translate wrapper + region detection
|
||||
render.py OTF → half-block pipeline (SSAA, gradient)
|
||||
effects.py noise, glitch_bar, fade, firehose
|
||||
fetch.py RSS/Gutenberg fetching + cache load/save
|
||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||
mic.py MicMonitor — standalone, graceful fallback
|
||||
scroll.py stream() frame loop + message rendering
|
||||
viewport.py terminal dimension tracking (tw/th)
|
||||
frame.py scroll step calculation, timing
|
||||
layers.py ticker zone, firehose, message overlay rendering
|
||||
eventbus.py thread-safe event publishing for decoupled communication
|
||||
events.py event types and definitions
|
||||
controller.py coordinates ntfy/mic monitoring and event publishing
|
||||
emitters.py background emitters for ntfy and mic
|
||||
types.py type definitions and dataclasses
|
||||
__init__.py package marker
|
||||
app.py main(), font picker TUI, boot sequence, C&C poller
|
||||
config.py constants, CLI flags, glyph tables
|
||||
sources.py FEEDS, POETRY_SOURCES, language/script maps
|
||||
terminal.py ANSI codes, tw/th, type_out, boot_ln
|
||||
filter.py HTML stripping, content filter
|
||||
translate.py Google Translate wrapper + region detection
|
||||
render.py OTF → half-block pipeline (SSAA, gradient)
|
||||
effects/ plugin architecture for visual effects
|
||||
types.py EffectPlugin ABC, EffectConfig, EffectContext
|
||||
registry.py effect registration and lookup
|
||||
chain.py effect pipeline chaining
|
||||
controller.py handles /effects commands
|
||||
performance.py performance monitoring
|
||||
legacy.py legacy functional effects
|
||||
effects_plugins/ effect plugin implementations
|
||||
noise.py noise effect
|
||||
fade.py fade effect
|
||||
glitch.py glitch effect
|
||||
firehose.py firehose effect
|
||||
fetch.py RSS/Gutenberg fetching + cache
|
||||
ntfy.py NtfyPoller — standalone, zero internal deps
|
||||
mic.py MicMonitor — standalone, graceful fallback
|
||||
scroll.py stream() frame loop + message rendering
|
||||
viewport.py terminal dimension tracking
|
||||
frame.py scroll step calculation, timing
|
||||
layers.py ticker zone, firehose, message overlay
|
||||
eventbus.py thread-safe event publishing
|
||||
events.py event types and definitions
|
||||
controller.py coordinates ntfy/mic monitoring
|
||||
emitters.py background emitters
|
||||
types.py type definitions
|
||||
display/ Display backend system
|
||||
__init__.py DisplayRegistry, get_monitor
|
||||
backends/
|
||||
terminal.py ANSI terminal display
|
||||
websocket.py WebSocket server for browser clients
|
||||
null.py headless display for testing
|
||||
multi.py forwards to multiple displays
|
||||
moderngl.py GPU-accelerated OpenGL rendering
|
||||
benchmark.py performance benchmarking tool
|
||||
```
|
||||
|
||||
`ntfy.py` and `mic.py` have zero internal dependencies and can be imported by any other visualizer.
|
||||
|
||||
---
|
||||
|
||||
## Extending
|
||||
|
||||
`ntfy.py` and `mic.py` are fully standalone and designed to be reused by any terminal visualizer. `engine.render` is the importable rendering pipeline for non-terminal targets.
|
||||
|
||||
### NtfyPoller
|
||||
|
||||
```python
|
||||
from engine.ntfy import NtfyPoller
|
||||
|
||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json")
|
||||
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
|
||||
```
|
||||
|
||||
Dependencies: `urllib.request`, `json`, `threading`, `time` — stdlib only. The `since=` parameter is managed automatically on reconnect.
|
||||
|
||||
### MicMonitor
|
||||
|
||||
```python
|
||||
from engine.mic import MicMonitor
|
||||
|
||||
mic = MicMonitor(threshold_db=50)
|
||||
result = mic.start() # None = sounddevice unavailable; False = stream failed; True = ok
|
||||
if result:
|
||||
excess = mic.excess # dB above threshold, clamped to 0
|
||||
db = mic.db # raw RMS dB level
|
||||
```
|
||||
|
||||
Dependencies: `sounddevice`, `numpy` — both optional; degrades gracefully if unavailable.
|
||||
|
||||
### Render pipeline
|
||||
|
||||
`engine.render` exposes the OTF → raster pipeline independently of the terminal scroll loop. The planned `serve.py` extension will import it directly to pre-render headlines as 1-bit bitmaps for an ESP32 thin client:
|
||||
|
||||
```python
|
||||
# planned — serve.py does not yet exist
|
||||
from engine.render import render_line, big_wrap
|
||||
from engine.fetch import fetch_all
|
||||
|
||||
headlines = fetch_all()
|
||||
for h in headlines:
|
||||
rows = big_wrap(h.text, font, width=800) # list of half-block rows
|
||||
# threshold to 1-bit, pack bytes, serve over HTTP
|
||||
```
|
||||
|
||||
See `Mainline Renderer + ntfy Message Queue for ESP32.md` for the full server + thin client architecture.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
@@ -190,7 +174,7 @@ Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
|
||||
|
||||
```bash
|
||||
uv sync # minimal (no mic)
|
||||
uv sync --all-extras # with mic support (sounddevice + numpy)
|
||||
uv sync --all-extras # with mic support
|
||||
uv sync --all-extras --group dev # full dev environment
|
||||
```
|
||||
|
||||
@@ -200,24 +184,45 @@ With [mise](https://mise.jdx.dev/):
|
||||
|
||||
```bash
|
||||
mise run test # run test suite
|
||||
mise run test-cov # run with coverage report
|
||||
mise run lint # ruff check
|
||||
mise run lint-fix # ruff check --fix
|
||||
mise run format # ruff format
|
||||
mise run run # uv run mainline.py
|
||||
mise run run-poetry # uv run mainline.py --poetry
|
||||
mise run run-firehose # uv run mainline.py --firehose
|
||||
mise run test-cov # run with coverage report
|
||||
|
||||
mise run lint # ruff check
|
||||
mise run lint-fix # ruff check --fix
|
||||
mise run format # ruff format
|
||||
|
||||
mise run run # terminal display
|
||||
mise run run-websocket # web display only
|
||||
mise run run-client # terminal + web
|
||||
|
||||
mise run cmd # C&C command interface
|
||||
mise run cmd-stats # watch effects stats
|
||||
|
||||
mise run benchmark # run performance benchmarks
|
||||
mise run benchmark-json # save as JSON
|
||||
|
||||
mise run topics-init # initialize ntfy topics
|
||||
```
|
||||
|
||||
### Testing
|
||||
|
||||
Tests live in `tests/` and cover `config`, `filter`, `mic`, `ntfy`, `sources`, and `terminal`.
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
uv run pytest --cov=engine --cov-report=term-missing
|
||||
|
||||
# Run with mise
|
||||
mise run test
|
||||
mise run test-cov
|
||||
|
||||
# Run performance benchmarks
|
||||
mise run benchmark
|
||||
mise run benchmark-json
|
||||
|
||||
# Run benchmark hook mode (for CI)
|
||||
uv run python -m engine.benchmark --hook
|
||||
```
|
||||
|
||||
Performance regression tests are in `tests/test_benchmark.py` marked with `@pytest.mark.benchmark`.
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
@@ -232,28 +237,23 @@ Pre-commit hooks run lint automatically via `hk`.
|
||||
## Roadmap
|
||||
|
||||
### Performance
|
||||
- **Concurrent feed fetching** — startup currently blocks sequentially on ~25 HTTP requests; `concurrent.futures.ThreadPoolExecutor` would cut load time to the slowest single feed
|
||||
- **Background refresh** — re-fetch feeds in a daemon thread so a long session stays current without restart
|
||||
- **Translation pre-fetch** — run translate calls concurrently during the boot sequence rather than on first render
|
||||
- Concurrent feed fetching with ThreadPoolExecutor
|
||||
- Background feed refresh daemon
|
||||
- Translation pre-fetch during boot
|
||||
|
||||
### Graphics
|
||||
- **Matrix rain underlay** — katakana column rain rendered at low opacity beneath the scrolling blocks as a background layer
|
||||
- **CRT simulation** — subtle dim scanlines every N rows, occasional brightness ripple across the full screen
|
||||
- **Sixel / iTerm2 inline images** — bypass half-blocks entirely and stream actual bitmap frames for true resolution; would require a capable terminal
|
||||
- **Parallax secondary column** — a second, dimmer, faster-scrolling stream of ambient text at reduced opacity on one side
|
||||
- Matrix rain katakana underlay
|
||||
- CRT scanline simulation
|
||||
- Sixel/iTerm2 inline images
|
||||
- Parallax secondary column
|
||||
|
||||
### Cyberpunk Vibes
|
||||
- **Keyword watch list** — highlight or strobe any headline matching tracked terms (names, topics, tickers)
|
||||
- **Breaking interrupt** — full-screen flash + synthesized blip when a high-priority keyword hits
|
||||
- **Live data overlay** — secondary ticker strip at screen edge: BTC price, ISS position, geomagnetic index
|
||||
- **Theme switcher** — `--amber` (phosphor), `--ice` (electric cyan), `--red` (alert state) palette modes via CLI flag
|
||||
- **Persona modes** — `--surveillance`, `--oracle`, `--underground` as feed presets with matching color themes and boot copy
|
||||
- **Synthesized audio** — short static bursts tied to glitch events, independent of mic input
|
||||
|
||||
### Extensibility
|
||||
- **serve.py** — HTTP server that imports `engine.render` and `engine.fetch` directly to stream 1-bit bitmaps to an ESP32 display
|
||||
- **Rust port** — `ntfy.py` and `render.py` are the natural first targets; clear module boundaries make incremental porting viable
|
||||
- Keyword watch list with strobe effects
|
||||
- Breaking interrupt with synthesized audio
|
||||
- Live data overlay (BTC, ISS position)
|
||||
- Theme switcher (amber, ice, red)
|
||||
- Persona modes (surveillance, oracle, underground)
|
||||
|
||||
---
|
||||
|
||||
*macOS only (script/system font paths for translation are hardcoded). Primary display font is user-selectable via the bundled `fonts/` picker. Python 3.10+.*
|
||||
*Python 3.10+. Primary display font is user-selectable via bundled `fonts/` picker.*
|
||||
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>
|
||||
369
client/index.html
Normal file
369
client/index.html
Normal file
@@ -0,0 +1,369 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mainline Terminal</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #ccc;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
body.fullscreen {
|
||||
padding: 0;
|
||||
}
|
||||
body.fullscreen #controls {
|
||||
display: none;
|
||||
}
|
||||
#container {
|
||||
position: relative;
|
||||
}
|
||||
canvas {
|
||||
background: #000;
|
||||
border: 1px solid #333;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
body.fullscreen canvas {
|
||||
border: none;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
}
|
||||
#controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
#controls button {
|
||||
background: #333;
|
||||
color: #ccc;
|
||||
border: 1px solid #555;
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
#controls button:hover {
|
||||
background: #444;
|
||||
}
|
||||
#controls input {
|
||||
width: 60px;
|
||||
background: #222;
|
||||
color: #ccc;
|
||||
border: 1px solid #444;
|
||||
padding: 4px 8px;
|
||||
font-family: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
#status {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
#status.connected {
|
||||
color: #4f4;
|
||||
}
|
||||
#status.disconnected {
|
||||
color: #f44;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="container">
|
||||
<canvas id="terminal"></canvas>
|
||||
</div>
|
||||
<div id="controls">
|
||||
<label>Cols: <input type="number" id="cols" value="80" min="20" max="200"></label>
|
||||
<label>Rows: <input type="number" id="rows" value="24" min="10" max="60"></label>
|
||||
<button id="apply">Apply</button>
|
||||
<button id="fullscreen">Fullscreen</button>
|
||||
</div>
|
||||
<div id="status" class="disconnected">Connecting...</div>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('terminal');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const status = document.getElementById('status');
|
||||
const colsInput = document.getElementById('cols');
|
||||
const rowsInput = document.getElementById('rows');
|
||||
const applyBtn = document.getElementById('apply');
|
||||
const fullscreenBtn = document.getElementById('fullscreen');
|
||||
|
||||
const CHAR_WIDTH = 9;
|
||||
const CHAR_HEIGHT = 16;
|
||||
|
||||
const ANSI_COLORS = {
|
||||
0: '#000000', 1: '#cd3131', 2: '#0dbc79', 3: '#e5e510',
|
||||
4: '#2472c8', 5: '#bc3fbc', 6: '#11a8cd', 7: '#e5e5e5',
|
||||
8: '#666666', 9: '#f14c4c', 10: '#23d18b', 11: '#f5f543',
|
||||
12: '#3b8eea', 13: '#d670d6', 14: '#29b8db', 15: '#ffffff',
|
||||
};
|
||||
|
||||
let cols = 80;
|
||||
let rows = 24;
|
||||
let ws = null;
|
||||
|
||||
function resizeCanvas() {
|
||||
canvas.width = cols * CHAR_WIDTH;
|
||||
canvas.height = rows * CHAR_HEIGHT;
|
||||
}
|
||||
|
||||
function parseAnsi(text) {
|
||||
if (!text) return [];
|
||||
|
||||
const tokens = [];
|
||||
let currentText = '';
|
||||
let fg = '#cccccc';
|
||||
let bg = '#000000';
|
||||
let bold = false;
|
||||
let i = 0;
|
||||
let inEscape = false;
|
||||
let escapeCode = '';
|
||||
|
||||
while (i < text.length) {
|
||||
const char = text[i];
|
||||
|
||||
if (inEscape) {
|
||||
if (char >= '0' && char <= '9' || char === ';' || char === '[') {
|
||||
escapeCode += char;
|
||||
}
|
||||
|
||||
if (char === 'm') {
|
||||
const codes = escapeCode.replace('\x1b[', '').split(';');
|
||||
|
||||
for (const code of codes) {
|
||||
const num = parseInt(code) || 0;
|
||||
|
||||
if (num === 0) {
|
||||
fg = '#cccccc';
|
||||
bg = '#000000';
|
||||
bold = false;
|
||||
} else if (num === 1) {
|
||||
bold = true;
|
||||
} else if (num === 22) {
|
||||
bold = false;
|
||||
} else if (num === 39) {
|
||||
fg = '#cccccc';
|
||||
} else if (num === 49) {
|
||||
bg = '#000000';
|
||||
} else if (num >= 30 && num <= 37) {
|
||||
fg = ANSI_COLORS[num - 30 + (bold ? 8 : 0)] || '#cccccc';
|
||||
} else if (num >= 40 && num <= 47) {
|
||||
bg = ANSI_COLORS[num - 40] || '#000000';
|
||||
} else if (num >= 90 && num <= 97) {
|
||||
fg = ANSI_COLORS[num - 90 + 8] || '#cccccc';
|
||||
} else if (num >= 100 && num <= 107) {
|
||||
bg = ANSI_COLORS[num - 100 + 8] || '#000000';
|
||||
} else if (num >= 1 && num <= 256) {
|
||||
// 256 colors
|
||||
if (num < 16) {
|
||||
fg = ANSI_COLORS[num] || '#cccccc';
|
||||
} else if (num < 232) {
|
||||
const c = num - 16;
|
||||
const r = Math.floor(c / 36) * 51;
|
||||
const g = Math.floor((c % 36) / 6) * 51;
|
||||
const b = (c % 6) * 51;
|
||||
fg = `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${b.toString(16).padStart(2,'0')}`;
|
||||
} else {
|
||||
const gray = (num - 232) * 10 + 8;
|
||||
fg = `#${gray.toString(16).repeat(2)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
tokens.push({ text: currentText, fg, bg, bold });
|
||||
currentText = '';
|
||||
}
|
||||
inEscape = false;
|
||||
escapeCode = '';
|
||||
}
|
||||
} else if (char === '\x1b' && text[i + 1] === '[') {
|
||||
if (currentText) {
|
||||
tokens.push({ text: currentText, fg, bg, bold });
|
||||
currentText = '';
|
||||
}
|
||||
inEscape = true;
|
||||
escapeCode = '';
|
||||
i++;
|
||||
} else {
|
||||
currentText += char;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
tokens.push({ text: currentText, fg, bg, bold });
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function renderLine(text, x, y, lineHeight) {
|
||||
const tokens = parseAnsi(text);
|
||||
let xOffset = x;
|
||||
|
||||
for (const token of tokens) {
|
||||
if (token.text) {
|
||||
if (token.bold) {
|
||||
ctx.font = 'bold 16px monospace';
|
||||
} else {
|
||||
ctx.font = '16px monospace';
|
||||
}
|
||||
|
||||
const metrics = ctx.measureText(token.text);
|
||||
|
||||
if (token.bg !== '#000000') {
|
||||
ctx.fillStyle = token.bg;
|
||||
ctx.fillRect(xOffset, y - 2, metrics.width + 1, lineHeight);
|
||||
}
|
||||
|
||||
ctx.fillStyle = token.fg;
|
||||
ctx.fillText(token.text, xOffset, y);
|
||||
xOffset += metrics.width;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.hostname}:8765`;
|
||||
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
status.textContent = 'Connected';
|
||||
status.className = 'connected';
|
||||
sendSize();
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
status.textContent = 'Disconnected - Reconnecting...';
|
||||
status.className = 'disconnected';
|
||||
setTimeout(connect, 1000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
status.textContent = 'Connection error';
|
||||
status.className = 'disconnected';
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'frame') {
|
||||
cols = data.width || 80;
|
||||
rows = data.height || 24;
|
||||
colsInput.value = cols;
|
||||
rowsInput.value = rows;
|
||||
resizeCanvas();
|
||||
render(data.lines || []);
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sendSize() {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'resize',
|
||||
width: parseInt(colsInput.value),
|
||||
height: parseInt(rowsInput.value)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function render(lines) {
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.font = '16px monospace';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const lineHeight = CHAR_HEIGHT;
|
||||
const maxLines = Math.min(lines.length, rows);
|
||||
|
||||
for (let i = 0; i < maxLines; i++) {
|
||||
const line = lines[i] || '';
|
||||
renderLine(line, 0, i * lineHeight, lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
function calculateViewportSize() {
|
||||
const isFullscreen = document.fullscreenElement !== null;
|
||||
const padding = isFullscreen ? 0 : 40;
|
||||
const controlsHeight = isFullscreen ? 0 : 60;
|
||||
const availableWidth = window.innerWidth - padding;
|
||||
const availableHeight = window.innerHeight - controlsHeight;
|
||||
cols = Math.max(20, Math.floor(availableWidth / CHAR_WIDTH));
|
||||
rows = Math.max(10, Math.floor(availableHeight / CHAR_HEIGHT));
|
||||
colsInput.value = cols;
|
||||
rowsInput.value = rows;
|
||||
resizeCanvas();
|
||||
console.log('Fullscreen:', isFullscreen, 'Size:', cols, 'x', rows);
|
||||
sendSize();
|
||||
}
|
||||
|
||||
applyBtn.addEventListener('click', () => {
|
||||
cols = parseInt(colsInput.value);
|
||||
rows = parseInt(rowsInput.value);
|
||||
resizeCanvas();
|
||||
sendSize();
|
||||
});
|
||||
|
||||
fullscreenBtn.addEventListener('click', () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.body.classList.add('fullscreen');
|
||||
document.documentElement.requestFullscreen().then(() => {
|
||||
calculateViewportSize();
|
||||
});
|
||||
} else {
|
||||
document.exitFullscreen().then(() => {
|
||||
calculateViewportSize();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.body.classList.remove('fullscreen');
|
||||
calculateViewportSize();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (document.fullscreenElement) {
|
||||
calculateViewportSize();
|
||||
}
|
||||
});
|
||||
|
||||
// Initial setup
|
||||
resizeCanvas();
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Command-line utility for interacting with mainline via ntfy.
|
||||
|
||||
@@ -20,6 +21,11 @@ C&C works like a serial port:
|
||||
3. Cmdline polls for response
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
os.environ["FORCE_COLOR"] = "1"
|
||||
os.environ["TERM"] = "xterm-256color"
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
153
docs/ARCHITECTURE.md
Normal file
153
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Mainline Architecture Diagrams
|
||||
|
||||
> These diagrams use Mermaid. Render with: `npx @mermaid-js/mermaid-cli -i ARCHITECTURE.md` or view in GitHub/GitLab/Notion.
|
||||
|
||||
## Class Hierarchy (Mermaid)
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class Stage {
|
||||
<<abstract>>
|
||||
+str name
|
||||
+set[str] capabilities
|
||||
+set[str] dependencies
|
||||
+process(data, ctx) Any
|
||||
}
|
||||
|
||||
Stage <|-- DataSourceStage
|
||||
Stage <|-- CameraStage
|
||||
Stage <|-- FontStage
|
||||
Stage <|-- ViewportFilterStage
|
||||
Stage <|-- EffectPluginStage
|
||||
Stage <|-- DisplayStage
|
||||
Stage <|-- SourceItemsToBufferStage
|
||||
Stage <|-- PassthroughStage
|
||||
Stage <|-- ImageToTextStage
|
||||
Stage <|-- CanvasStage
|
||||
|
||||
class EffectPlugin {
|
||||
<<abstract>>
|
||||
+str name
|
||||
+EffectConfig config
|
||||
+process(buf, ctx) list[str]
|
||||
+configure(config) None
|
||||
}
|
||||
|
||||
EffectPlugin <|-- NoiseEffect
|
||||
EffectPlugin <|-- FadeEffect
|
||||
EffectPlugin <|-- GlitchEffect
|
||||
EffectPlugin <|-- FirehoseEffect
|
||||
EffectPlugin <|-- CropEffect
|
||||
EffectPlugin <|-- TintEffect
|
||||
|
||||
class Display {
|
||||
<<protocol>>
|
||||
+int width
|
||||
+int height
|
||||
+init(width, height, reuse)
|
||||
+show(buffer, border)
|
||||
+clear() None
|
||||
+cleanup() None
|
||||
}
|
||||
|
||||
Display <|.. TerminalDisplay
|
||||
Display <|.. NullDisplay
|
||||
Display <|.. PygameDisplay
|
||||
Display <|.. WebSocketDisplay
|
||||
|
||||
class Camera {
|
||||
+int viewport_width
|
||||
+int viewport_height
|
||||
+CameraMode mode
|
||||
+apply(buffer, width, height) list[str]
|
||||
}
|
||||
|
||||
class Pipeline {
|
||||
+dict[str, Stage] stages
|
||||
+PipelineContext context
|
||||
+execute(data) StageResult
|
||||
}
|
||||
|
||||
Pipeline --> Stage
|
||||
Stage --> Display
|
||||
```
|
||||
|
||||
## Data Flow (Mermaid)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
DataSource[Data Source] --> DataSourceStage
|
||||
DataSourceStage --> FontStage
|
||||
FontStage --> CameraStage
|
||||
CameraStage --> EffectStages
|
||||
EffectStages --> DisplayStage
|
||||
DisplayStage --> TerminalDisplay
|
||||
DisplayStage --> BrowserWebSocket
|
||||
DisplayStage --> SixelDisplay
|
||||
DisplayStage --> NullDisplay
|
||||
```
|
||||
|
||||
## Effect Chain (Mermaid)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
InputBuffer --> NoiseEffect
|
||||
NoiseEffect --> FadeEffect
|
||||
FadeEffect --> GlitchEffect
|
||||
GlitchEffect --> FirehoseEffect
|
||||
FirehoseEffect --> Output
|
||||
```
|
||||
|
||||
> **Note:** Each effect must preserve buffer dimensions (line count and visible width).
|
||||
|
||||
## Stage Capabilities
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph "Capability Resolution"
|
||||
D[DataSource<br/>provides: source.*]
|
||||
C[Camera<br/>provides: render.output]
|
||||
E[Effects<br/>provides: render.effect]
|
||||
DIS[Display<br/>provides: display.output]
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Legacy ASCII Diagrams
|
||||
|
||||
### Stage Inheritance
|
||||
```
|
||||
Stage(ABC)
|
||||
├── DataSourceStage
|
||||
├── CameraStage
|
||||
├── FontStage
|
||||
├── ViewportFilterStage
|
||||
├── EffectPluginStage
|
||||
├── DisplayStage
|
||||
├── SourceItemsToBufferStage
|
||||
├── PassthroughStage
|
||||
├── ImageToTextStage
|
||||
└── CanvasStage
|
||||
```
|
||||
|
||||
### Display Backends
|
||||
```
|
||||
Display(Protocol)
|
||||
├── TerminalDisplay
|
||||
├── NullDisplay
|
||||
├── PygameDisplay
|
||||
├── WebSocketDisplay
|
||||
└── MultiDisplay
|
||||
```
|
||||
|
||||
### Camera Modes
|
||||
```
|
||||
Camera
|
||||
├── FEED # Static view
|
||||
├── SCROLL # Horizontal scroll
|
||||
├── VERTICAL # Vertical scroll
|
||||
├── HORIZONTAL # Same as scroll
|
||||
├── OMNI # Omnidirectional
|
||||
├── FLOATING # Floating particles
|
||||
└── BOUNCE # Bouncing camera
|
||||
223
docs/PIPELINE.md
Normal file
223
docs/PIPELINE.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# Mainline Pipeline
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The Mainline pipeline uses a **Stage-based architecture** with **capability-based dependency resolution**. Stages declare capabilities (what they provide) and dependencies (what they need), and the Pipeline resolves dependencies using prefix matching.
|
||||
|
||||
```
|
||||
Source Stage → Render Stage → Effect Stages → Display Stage
|
||||
↓
|
||||
Camera Stage (provides camera.state capability)
|
||||
```
|
||||
|
||||
### Capability-Based Dependency Resolution
|
||||
|
||||
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`)
|
||||
|
||||
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
|
||||
|
||||
### Minimum Capabilities
|
||||
|
||||
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 Stages["Stage Pipeline"]
|
||||
subgraph SourceStage["Source Stage (provides: source.*)"]
|
||||
Headlines[HeadlinesSource]
|
||||
Poetry[PoetrySource]
|
||||
Pipeline[PipelineSource]
|
||||
end
|
||||
|
||||
subgraph RenderStage["Render Stage (provides: render.*)"]
|
||||
Render[RenderStage]
|
||||
Canvas[Canvas]
|
||||
Camera[Camera]
|
||||
end
|
||||
|
||||
subgraph EffectStages["Effect Stages (provides: effect.*)"]
|
||||
Noise[NoiseEffect]
|
||||
Fade[FadeEffect]
|
||||
Glitch[GlitchEffect]
|
||||
Firehose[FirehoseEffect]
|
||||
Hud[HudEffect]
|
||||
end
|
||||
|
||||
subgraph DisplayStage["Display Stage (provides: display.*)"]
|
||||
Terminal[TerminalDisplay]
|
||||
Pygame[PygameDisplay]
|
||||
WebSocket[WebSocketDisplay]
|
||||
Null[NullDisplay]
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
SourceStage --> RenderStage
|
||||
RenderStage --> EffectStages
|
||||
EffectStages --> DisplayStage
|
||||
|
||||
SourceStage --> SourceCaps
|
||||
RenderStage --> RenderCaps
|
||||
EffectStages --> EffectCaps
|
||||
DisplayStage --> DisplayCaps
|
||||
|
||||
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
|
||||
flowchart LR
|
||||
subgraph Preset["Preset"]
|
||||
PP[PipelineParams]
|
||||
AC[AnimationController]
|
||||
end
|
||||
|
||||
subgraph AnimationController["AnimationController"]
|
||||
Clock[Clock]
|
||||
Events[Events]
|
||||
Triggers[Triggers]
|
||||
end
|
||||
|
||||
subgraph Triggers["Trigger Types"]
|
||||
TIME[TIME]
|
||||
FRAME[FRAME]
|
||||
CYCLE[CYCLE]
|
||||
COND[CONDITION]
|
||||
MANUAL[MANUAL]
|
||||
end
|
||||
|
||||
PP --> AC
|
||||
Clock --> AC
|
||||
Events --> AC
|
||||
Triggers --> Events
|
||||
```
|
||||
|
||||
## Camera Modes State Diagram
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Vertical
|
||||
Vertical --> Horizontal: mode change
|
||||
Horizontal --> Omni: mode change
|
||||
Omni --> Floating: mode change
|
||||
Floating --> Trace: mode change
|
||||
Trace --> Vertical: mode change
|
||||
|
||||
state Vertical {
|
||||
[*] --> ScrollUp
|
||||
ScrollUp --> ScrollUp: +y each frame
|
||||
}
|
||||
|
||||
state Horizontal {
|
||||
[*] --> ScrollLeft
|
||||
ScrollLeft --> ScrollLeft: +x each frame
|
||||
}
|
||||
|
||||
state Omni {
|
||||
[*] --> Diagonal
|
||||
Diagonal --> Diagonal: +x, +y each frame
|
||||
}
|
||||
|
||||
state Floating {
|
||||
[*] --> Bobbing
|
||||
Bobbing --> Bobbing: sin(time) for x,y
|
||||
}
|
||||
|
||||
state Trace {
|
||||
[*] --> FollowPath
|
||||
FollowPath --> FollowPath: node by node
|
||||
}
|
||||
```
|
||||
@@ -1,11 +1,18 @@
|
||||
# Refactor mainline\.py into modular package
|
||||
#
|
||||
|
||||
Refactor mainline\.py into modular package
|
||||
|
||||
## Problem
|
||||
|
||||
`mainline.py` is a single 1085\-line file with ~10 interleaved concerns\. This prevents:
|
||||
|
||||
* Reusing the ntfy doorbell interrupt in other visualizers
|
||||
* Importing the render pipeline from `serve.py` \(future ESP32 HTTP server\)
|
||||
* Testing any concern in isolation
|
||||
* Porting individual layers to Rust independently
|
||||
|
||||
## Target structure
|
||||
|
||||
```warp-runnable-command
|
||||
mainline.py # thin entrypoint: venv bootstrap → engine.app.main()
|
||||
engine/
|
||||
@@ -23,8 +30,11 @@ engine/
|
||||
scroll.py # stream() frame loop + message rendering
|
||||
app.py # main(), TITLE art, boot sequence, signal handler
|
||||
```
|
||||
|
||||
The package is named `engine/` to avoid a naming conflict with the `mainline.py` entrypoint\.
|
||||
|
||||
## Module dependency graph
|
||||
|
||||
```warp-runnable-command
|
||||
config ← (nothing)
|
||||
sources ← (nothing)
|
||||
@@ -39,64 +49,92 @@ mic ← (nothing — sounddevice only)
|
||||
scroll ← config, terminal, render, effects, ntfy, mic
|
||||
app ← everything above
|
||||
```
|
||||
|
||||
Critical property: **ntfy\.py and mic\.py have zero internal dependencies**, making ntfy reusable by any visualizer\.
|
||||
|
||||
## Module details
|
||||
|
||||
### mainline\.py \(entrypoint — slimmed down\)
|
||||
|
||||
Keeps only the venv bootstrap \(lines 10\-38\) which must run before any third\-party imports\. After bootstrap, delegates to `engine.app.main()`\.
|
||||
|
||||
### engine/config\.py
|
||||
|
||||
From current mainline\.py:
|
||||
|
||||
* `HEADLINE_LIMIT`, `FEED_TIMEOUT`, `MIC_THRESHOLD_DB` \(lines 55\-57\)
|
||||
* `MODE`, `FIREHOSE` CLI flag parsing \(lines 58\-59\)
|
||||
* `NTFY_TOPIC`, `NTFY_POLL_INTERVAL`, `MESSAGE_DISPLAY_SECS` \(lines 62\-64\)
|
||||
* `_FONT_PATH`, `_FONT_SZ`, `_RENDER_H` \(lines 147\-150\)
|
||||
* `_SCROLL_DUR`, `_FRAME_DT`, `FIREHOSE_H` \(lines 505\-507\)
|
||||
* `GLITCH`, `KATA` glyph tables \(lines 143\-144\)
|
||||
|
||||
### engine/sources\.py
|
||||
|
||||
Pure data, no logic:
|
||||
|
||||
* `FEEDS` dict \(lines 102\-140\)
|
||||
* `POETRY_SOURCES` dict \(lines 67\-80\)
|
||||
* `SOURCE_LANGS` dict \(lines 258\-266\)
|
||||
* `_LOCATION_LANGS` dict \(lines 269\-289\)
|
||||
* `_SCRIPT_FONTS` dict \(lines 153\-165\)
|
||||
* `_NO_UPPER` set \(line 167\)
|
||||
|
||||
### engine/terminal\.py
|
||||
|
||||
ANSI primitives and terminal I/O:
|
||||
|
||||
* All ANSI constants: `RST`, `BOLD`, `DIM`, `G_HI`, `G_MID`, `G_LO`, `G_DIM`, `W_COOL`, `W_DIM`, `W_GHOST`, `C_DIM`, `CLR`, `CURSOR_OFF`, `CURSOR_ON` \(lines 83\-99\)
|
||||
* `tw()`, `th()` \(lines 223\-234\)
|
||||
* `type_out()`, `slow_print()`, `boot_ln()` \(lines 355\-386\)
|
||||
|
||||
### engine/filter\.py
|
||||
|
||||
* `_Strip` HTML parser class \(lines 205\-214\)
|
||||
* `strip_tags()` \(lines 217\-220\)
|
||||
* `_SKIP_RE` compiled regex \(lines 322\-346\)
|
||||
* `_skip()` predicate \(lines 349\-351\)
|
||||
|
||||
### engine/translate\.py
|
||||
|
||||
* `_TRANSLATE_CACHE` \(line 291\)
|
||||
* `_detect_location_language()` \(lines 294\-300\) — imports `_LOCATION_LANGS` from sources
|
||||
* `_translate_headline()` \(lines 303\-319\)
|
||||
|
||||
### engine/render\.py
|
||||
|
||||
The OTF→terminal pipeline\. This is exactly what `serve.py` will import to produce 1\-bit bitmaps for the ESP32\.
|
||||
|
||||
* `_GRAD_COLS` gradient table \(lines 169\-182\)
|
||||
* `_font()`, `_font_for_lang()` with lazy\-load \+ cache \(lines 185\-202\)
|
||||
* `_render_line()` — OTF text → half\-block terminal rows \(lines 567\-605\)
|
||||
* `_big_wrap()` — word\-wrap \+ render \(lines 608\-636\)
|
||||
* `_lr_gradient()` — apply left→right color gradient \(lines 639\-656\)
|
||||
* `_make_block()` — composite: translate → render → colorize a headline \(lines 718\-756\)\. Imports from translate, sources\.
|
||||
|
||||
### engine/effects\.py
|
||||
|
||||
Visual effects applied during the frame loop:
|
||||
|
||||
* `noise()` \(lines 237\-245\)
|
||||
* `glitch_bar()` \(lines 248\-252\)
|
||||
* `_fade_line()` — probabilistic character dissolve \(lines 659\-680\)
|
||||
* `_vis_trunc()` — ANSI\-aware width truncation \(lines 683\-701\)
|
||||
* `_firehose_line()` \(lines 759\-801\) — imports config\.MODE, sources\.FEEDS/POETRY\_SOURCES
|
||||
* `_next_headline()` — pool management \(lines 704\-715\)
|
||||
|
||||
### engine/fetch\.py
|
||||
|
||||
* `fetch_feed()` \(lines 390\-396\)
|
||||
* `fetch_all()` \(lines 399\-426\) — imports filter\.\_skip, filter\.strip\_tags, terminal\.boot\_ln
|
||||
* `_fetch_gutenberg()` \(lines 429\-456\)
|
||||
* `fetch_poetry()` \(lines 459\-472\)
|
||||
* `_cache_path()`, `_load_cache()`, `_save_cache()` \(lines 476\-501\)
|
||||
|
||||
### engine/ntfy\.py — standalone, reusable
|
||||
|
||||
Refactored from the current globals \+ thread \(lines 531\-564\) and the message rendering section of `stream()` \(lines 845\-909\) into a class:
|
||||
|
||||
```python
|
||||
class NtfyPoller:
|
||||
def __init__(self, topic_url, poll_interval=15, display_secs=30):
|
||||
@@ -108,8 +146,10 @@ class NtfyPoller:
|
||||
def dismiss(self):
|
||||
"""Manually dismiss current message."""
|
||||
```
|
||||
|
||||
Dependencies: `urllib.request`, `json`, `threading`, `time` — all stdlib\. No internal imports\.
|
||||
Other visualizers use it like:
|
||||
|
||||
```python
|
||||
from engine.ntfy import NtfyPoller
|
||||
poller = NtfyPoller("https://ntfy.sh/my_topic/json?since=20s&poll=1")
|
||||
@@ -120,8 +160,11 @@ if msg:
|
||||
title, body, ts = msg
|
||||
render_my_message(title, body) # visualizer-specific
|
||||
```
|
||||
|
||||
### engine/mic\.py — standalone
|
||||
|
||||
Refactored from the current globals \(lines 508\-528\) into a class:
|
||||
|
||||
```python
|
||||
class MicMonitor:
|
||||
def __init__(self, threshold_db=50):
|
||||
@@ -137,41 +180,75 @@ class MicMonitor:
|
||||
def excess(self) -> float:
|
||||
"""dB above threshold (clamped to 0)."""
|
||||
```
|
||||
|
||||
Dependencies: `sounddevice`, `numpy` \(both optional — graceful fallback\)\.
|
||||
|
||||
### engine/scroll\.py
|
||||
|
||||
The `stream()` function \(lines 804\-990\)\. Receives its dependencies via arguments or imports:
|
||||
|
||||
* `stream(items, ntfy_poller, mic_monitor, config)` or similar
|
||||
* Message rendering \(lines 855\-909\) stays here since it's terminal\-display\-specific — a different visualizer would render messages differently
|
||||
|
||||
### engine/app\.py
|
||||
|
||||
The orchestrator:
|
||||
|
||||
* `TITLE` ASCII art \(lines 994\-1001\)
|
||||
* `main()` \(lines 1004\-1084\): CLI handling, signal setup, boot animation, fetch, wire up ntfy/mic/scroll
|
||||
|
||||
## Execution order
|
||||
|
||||
### Step 1: Create engine/ package skeleton
|
||||
|
||||
Create `engine/__init__.py` and all empty module files\.
|
||||
|
||||
### Step 2: Extract pure data modules \(zero\-dep\)
|
||||
|
||||
Move constants and data dicts into `config.py`, `sources.py`\. These have no logic dependencies\.
|
||||
|
||||
### Step 3: Extract terminal\.py
|
||||
|
||||
Move ANSI codes and terminal I/O helpers\. No internal deps\.
|
||||
|
||||
### Step 4: Extract filter\.py and translate\.py
|
||||
|
||||
Both are small, self\-contained\. translate imports from sources\.
|
||||
|
||||
### Step 5: Extract render\.py
|
||||
|
||||
Font loading \+ the OTF→half\-block pipeline\. Imports from config, terminal, sources\. This is the module `serve.py` will later import\.
|
||||
|
||||
### Step 6: Extract effects\.py
|
||||
|
||||
Visual effects\. Imports from config, terminal, sources\.
|
||||
|
||||
### Step 7: Extract fetch\.py
|
||||
|
||||
Feed/Gutenberg fetching \+ caching\. Imports from config, sources, filter, terminal\.
|
||||
|
||||
### Step 8: Extract ntfy\.py and mic\.py
|
||||
|
||||
Refactor globals\+threads into classes\. Zero internal deps\.
|
||||
|
||||
### Step 9: Extract scroll\.py
|
||||
|
||||
The frame loop\. Last to extract because it depends on everything above\.
|
||||
|
||||
### Step 10: Extract app\.py
|
||||
|
||||
The `main()` function, boot sequence, signal handler\. Wire up all modules\.
|
||||
|
||||
### Step 11: Slim down mainline\.py
|
||||
|
||||
Keep only venv bootstrap \+ `from engine.app import main; main()`\.
|
||||
|
||||
### Step 12: Verify
|
||||
|
||||
Run `python3 mainline.py`, `python3 mainline.py --poetry`, and `python3 mainline.py --firehose` to confirm identical behavior\. No behavioral changes in this refactor\.
|
||||
|
||||
## What this enables
|
||||
|
||||
* **serve\.py** \(future\): `from engine.render import _render_line, _big_wrap` \+ `from engine.fetch import fetch_all` — imports the pipeline directly
|
||||
* **Other visualizers**: `from engine.ntfy import NtfyPoller` — doorbell feature with no coupling to mainline's scroll engine
|
||||
* **Rust port**: Clear boundaries for what to port first \(ntfy client, render pipeline\) vs what stays in Python \(fetching, caching — the server side\)
|
||||
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,37 +0,0 @@
|
||||
import random
|
||||
|
||||
from engine import config
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
from engine.terminal import C_DIM, DIM, G_DIM, G_LO, RST
|
||||
|
||||
|
||||
class GlitchEffect:
|
||||
name = "glitch"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not buf:
|
||||
return buf
|
||||
result = list(buf)
|
||||
intensity = self.config.intensity
|
||||
|
||||
glitch_prob = 0.32 + min(0.9, ctx.mic_excess * 0.16)
|
||||
glitch_prob = glitch_prob * intensity
|
||||
n_hits = 4 + int(ctx.mic_excess / 2)
|
||||
n_hits = int(n_hits * intensity)
|
||||
|
||||
if random.random() < glitch_prob:
|
||||
for _ in range(min(n_hits, len(result))):
|
||||
gi = random.randint(0, len(result) - 1)
|
||||
scr_row = gi + 1
|
||||
result[gi] = f"\033[{scr_row};1H{self._glitch_bar(ctx.terminal_width)}"
|
||||
return result
|
||||
|
||||
def _glitch_bar(self, w: int) -> str:
|
||||
c = random.choice(["░", "▒", "─", "\xc2"])
|
||||
n = random.randint(3, w // 2)
|
||||
o = random.randint(0, w - n)
|
||||
return " " * o + f"{G_LO}{DIM}" + c * n + RST
|
||||
|
||||
def configure(self, cfg: EffectConfig) -> None:
|
||||
self.config = cfg
|
||||
@@ -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
|
||||
|
||||
356
engine/app.py
356
engine/app.py
@@ -1,352 +1,14 @@
|
||||
"""
|
||||
Application orchestrator — boot sequence, signal handling, main loop wiring.
|
||||
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 atexit
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import termios
|
||||
import time
|
||||
import tty
|
||||
# Re-export from the new package structure
|
||||
from engine.app import main, run_pipeline_mode, run_pipeline_mode_direct
|
||||
|
||||
from engine import config, render
|
||||
from engine.fetch import fetch_all, fetch_poetry, load_cache, save_cache
|
||||
from engine.mic import MicMonitor
|
||||
from engine.ntfy import NtfyPoller
|
||||
from engine.scroll import stream
|
||||
from engine.terminal import (
|
||||
CLR,
|
||||
CURSOR_OFF,
|
||||
CURSOR_ON,
|
||||
G_DIM,
|
||||
G_HI,
|
||||
G_MID,
|
||||
RST,
|
||||
W_DIM,
|
||||
W_GHOST,
|
||||
boot_ln,
|
||||
slow_print,
|
||||
tw,
|
||||
)
|
||||
__all__ = ["main", "run_pipeline_mode", "run_pipeline_mode_direct"]
|
||||
|
||||
TITLE = [
|
||||
" ███╗ ███╗ █████╗ ██╗███╗ ██╗██╗ ██╗███╗ ██╗███████╗",
|
||||
" ████╗ ████║██╔══██╗██║████╗ ██║██║ ██║████╗ ██║██╔════╝",
|
||||
" ██╔████╔██║███████║██║██╔██╗ ██║██║ ██║██╔██╗ ██║█████╗ ",
|
||||
" ██║╚██╔╝██║██╔══██║██║██║╚██╗██║██║ ██║██║╚██╗██║██╔══╝ ",
|
||||
" ██║ ╚═╝ ██║██║ ██║██║██║ ╚████║███████╗██║██║ ╚████║███████╗",
|
||||
" ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝",
|
||||
]
|
||||
|
||||
|
||||
def _read_picker_key():
|
||||
ch = sys.stdin.read(1)
|
||||
if ch == "\x03":
|
||||
return "interrupt"
|
||||
if ch in ("\r", "\n"):
|
||||
return "enter"
|
||||
if ch == "\x1b":
|
||||
c1 = sys.stdin.read(1)
|
||||
if c1 != "[":
|
||||
return None
|
||||
c2 = sys.stdin.read(1)
|
||||
if c2 == "A":
|
||||
return "up"
|
||||
if c2 == "B":
|
||||
return "down"
|
||||
return None
|
||||
if ch in ("k", "K"):
|
||||
return "up"
|
||||
if ch in ("j", "J"):
|
||||
return "down"
|
||||
if ch in ("q", "Q"):
|
||||
return "enter"
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_preview_rows(rows):
|
||||
"""Trim shared left padding and trailing spaces for stable on-screen previews."""
|
||||
non_empty = [r for r in rows if r.strip()]
|
||||
if not non_empty:
|
||||
return [""]
|
||||
left_pad = min(len(r) - len(r.lstrip(" ")) for r in non_empty)
|
||||
out = []
|
||||
for row in rows:
|
||||
if left_pad < len(row):
|
||||
out.append(row[left_pad:].rstrip())
|
||||
else:
|
||||
out.append(row.rstrip())
|
||||
return out
|
||||
|
||||
|
||||
def _draw_font_picker(faces, selected):
|
||||
w = tw()
|
||||
h = 24
|
||||
try:
|
||||
h = os.get_terminal_size().lines
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
max_preview_w = max(24, w - 8)
|
||||
header_h = 6
|
||||
footer_h = 3
|
||||
preview_h = max(4, min(config.RENDER_H + 2, max(4, h // 2)))
|
||||
visible = max(1, h - header_h - preview_h - footer_h)
|
||||
top = max(0, selected - (visible // 2))
|
||||
bottom = min(len(faces), top + visible)
|
||||
top = max(0, bottom - visible)
|
||||
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print()
|
||||
print(f" {G_HI}FONT PICKER{RST}")
|
||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||
print(f" {W_DIM}{config.FONT_DIR[:max_preview_w]}{RST}")
|
||||
print(f" {W_GHOST}↑/↓ move · Enter select · q accept current{RST}")
|
||||
print()
|
||||
|
||||
for pos in range(top, bottom):
|
||||
face = faces[pos]
|
||||
active = pos == selected
|
||||
pointer = "▶" if active else " "
|
||||
color = G_HI if active else W_DIM
|
||||
print(
|
||||
f" {color}{pointer} {face['name']}{RST}{W_GHOST} · {face['file_name']}{RST}"
|
||||
)
|
||||
|
||||
if top > 0:
|
||||
print(f" {W_GHOST}… {top} above{RST}")
|
||||
if bottom < len(faces):
|
||||
print(f" {W_GHOST}… {len(faces) - bottom} below{RST}")
|
||||
|
||||
print()
|
||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||
print(
|
||||
f" {W_DIM}Preview: {faces[selected]['name']} · {faces[selected]['file_name']}{RST}"
|
||||
)
|
||||
preview_rows = faces[selected]["preview_rows"][:preview_h]
|
||||
for row in preview_rows:
|
||||
shown = row[:max_preview_w]
|
||||
print(f" {shown}")
|
||||
|
||||
|
||||
def pick_font_face():
|
||||
"""Interactive startup picker for selecting a face from repo OTF files."""
|
||||
if not config.FONT_PICKER:
|
||||
return
|
||||
|
||||
font_files = config.list_repo_font_files()
|
||||
if not font_files:
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print()
|
||||
print(f" {G_HI}FONT PICKER{RST}")
|
||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||
print(f" {G_DIM}> no .otf/.ttf/.ttc files found in: {config.FONT_DIR}{RST}")
|
||||
print(f" {W_GHOST}> add font files to the fonts folder, then rerun{RST}")
|
||||
time.sleep(1.8)
|
||||
sys.exit(1)
|
||||
|
||||
prepared = []
|
||||
for font_path in font_files:
|
||||
try:
|
||||
faces = render.list_font_faces(font_path, max_faces=64)
|
||||
except Exception:
|
||||
fallback = os.path.splitext(os.path.basename(font_path))[0]
|
||||
faces = [{"index": 0, "name": fallback}]
|
||||
for face in faces:
|
||||
idx = face["index"]
|
||||
name = face["name"]
|
||||
file_name = os.path.basename(font_path)
|
||||
try:
|
||||
fnt = render.load_font_face(font_path, idx)
|
||||
rows = _normalize_preview_rows(render.render_line(name, fnt))
|
||||
except Exception:
|
||||
rows = ["(preview unavailable)"]
|
||||
prepared.append(
|
||||
{
|
||||
"font_path": font_path,
|
||||
"font_index": idx,
|
||||
"name": name,
|
||||
"file_name": file_name,
|
||||
"preview_rows": rows,
|
||||
}
|
||||
)
|
||||
|
||||
if not prepared:
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print()
|
||||
print(f" {G_HI}FONT PICKER{RST}")
|
||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||
print(f" {G_DIM}> no readable font faces found in: {config.FONT_DIR}{RST}")
|
||||
time.sleep(1.8)
|
||||
sys.exit(1)
|
||||
|
||||
def _same_path(a, b):
|
||||
try:
|
||||
return os.path.samefile(a, b)
|
||||
except Exception:
|
||||
return os.path.abspath(a) == os.path.abspath(b)
|
||||
|
||||
selected = next(
|
||||
(
|
||||
i
|
||||
for i, f in enumerate(prepared)
|
||||
if _same_path(f["font_path"], config.FONT_PATH)
|
||||
and f["font_index"] == config.FONT_INDEX
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
selected_font = prepared[selected]
|
||||
config.set_font_selection(
|
||||
font_path=selected_font["font_path"],
|
||||
font_index=selected_font["font_index"],
|
||||
)
|
||||
render.clear_font_cache()
|
||||
print(
|
||||
f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}"
|
||||
)
|
||||
time.sleep(0.8)
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print()
|
||||
return
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setcbreak(fd)
|
||||
while True:
|
||||
_draw_font_picker(prepared, selected)
|
||||
key = _read_picker_key()
|
||||
if key == "up":
|
||||
selected = max(0, selected - 1)
|
||||
elif key == "down":
|
||||
selected = min(len(prepared) - 1, selected + 1)
|
||||
elif key == "enter":
|
||||
break
|
||||
elif key == "interrupt":
|
||||
raise KeyboardInterrupt
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
selected_font = prepared[selected]
|
||||
config.set_font_selection(
|
||||
font_path=selected_font["font_path"],
|
||||
font_index=selected_font["font_index"],
|
||||
)
|
||||
render.clear_font_cache()
|
||||
print(
|
||||
f" {G_DIM}> using {selected_font['name']} ({selected_font['file_name']}){RST}"
|
||||
)
|
||||
time.sleep(0.8)
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
atexit.register(lambda: print(CURSOR_ON, end="", flush=True))
|
||||
|
||||
def handle_sigint(*_):
|
||||
print(f"\n\n {G_DIM}> SIGNAL LOST{RST}")
|
||||
print(f" {W_GHOST}> connection terminated{RST}\n")
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, handle_sigint)
|
||||
|
||||
w = tw()
|
||||
print(CLR, end="")
|
||||
print(CURSOR_OFF, end="")
|
||||
pick_font_face()
|
||||
w = tw()
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
|
||||
for ln in TITLE:
|
||||
print(f"{G_HI}{ln}{RST}")
|
||||
time.sleep(0.07)
|
||||
|
||||
print()
|
||||
_subtitle = (
|
||||
"literary consciousness stream"
|
||||
if config.MODE == "poetry"
|
||||
else "digital consciousness stream"
|
||||
)
|
||||
print(f" {W_DIM}v0.1 · {_subtitle}{RST}")
|
||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
|
||||
cached = load_cache() if "--refresh" not in sys.argv else None
|
||||
if cached:
|
||||
items = cached
|
||||
boot_ln("Cache", f"LOADED [{len(items)} SIGNALS]", True)
|
||||
elif config.MODE == "poetry":
|
||||
slow_print(" > INITIALIZING LITERARY CORPUS...\n")
|
||||
time.sleep(0.2)
|
||||
print()
|
||||
items, linked, failed = fetch_poetry()
|
||||
print()
|
||||
print(
|
||||
f" {G_DIM}>{RST} {G_MID}{linked} TEXTS LOADED{RST} {W_GHOST}· {failed} DARK{RST}"
|
||||
)
|
||||
print(f" {G_DIM}>{RST} {G_MID}{len(items)} STANZAS ACQUIRED{RST}")
|
||||
save_cache(items)
|
||||
else:
|
||||
slow_print(" > INITIALIZING FEED ARRAY...\n")
|
||||
time.sleep(0.2)
|
||||
print()
|
||||
items, linked, failed = fetch_all()
|
||||
print()
|
||||
print(
|
||||
f" {G_DIM}>{RST} {G_MID}{linked} SOURCES LINKED{RST} {W_GHOST}· {failed} DARK{RST}"
|
||||
)
|
||||
print(f" {G_DIM}>{RST} {G_MID}{len(items)} SIGNALS ACQUIRED{RST}")
|
||||
save_cache(items)
|
||||
|
||||
if not items:
|
||||
print(f"\n {W_DIM}> NO SIGNAL — check network{RST}")
|
||||
sys.exit(1)
|
||||
|
||||
print()
|
||||
mic = MicMonitor(threshold_db=config.MIC_THRESHOLD_DB)
|
||||
mic_ok = mic.start()
|
||||
if mic.available:
|
||||
boot_ln(
|
||||
"Microphone",
|
||||
"ACTIVE"
|
||||
if mic_ok
|
||||
else "OFFLINE · check System Settings → Privacy → Microphone",
|
||||
bool(mic_ok),
|
||||
)
|
||||
|
||||
ntfy = NtfyPoller(
|
||||
config.NTFY_TOPIC,
|
||||
reconnect_delay=config.NTFY_RECONNECT_DELAY,
|
||||
display_secs=config.MESSAGE_DISPLAY_SECS,
|
||||
)
|
||||
ntfy_ok = ntfy.start()
|
||||
boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok)
|
||||
|
||||
if config.FIREHOSE:
|
||||
boot_ln("Firehose", "ENGAGED", True)
|
||||
|
||||
time.sleep(0.4)
|
||||
slow_print(" > STREAMING...\n")
|
||||
time.sleep(0.2)
|
||||
print(f" {W_GHOST}{'─' * (w - 4)}{RST}")
|
||||
print()
|
||||
time.sleep(0.4)
|
||||
|
||||
stream(items, ntfy, mic)
|
||||
|
||||
print()
|
||||
print(f" {W_GHOST}{'─' * (tw() - 4)}{RST}")
|
||||
print(f" {G_DIM}> {config.HEADLINE_LIMIT} SIGNALS PROCESSED{RST}")
|
||||
print(f" {W_GHOST}> end of stream{RST}")
|
||||
print()
|
||||
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")
|
||||
73
engine/benchmark.py
Normal file
73
engine/benchmark.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Benchmark module for performance testing.
|
||||
|
||||
Usage:
|
||||
python -m engine.benchmark # Run all benchmarks
|
||||
python -m engine.benchmark --hook # Run benchmarks in hook mode (for CI)
|
||||
python -m engine.benchmark --displays null --iterations 20
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run performance benchmarks")
|
||||
parser.add_argument(
|
||||
"--hook",
|
||||
action="store_true",
|
||||
help="Run in hook mode (fail on regression)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--displays",
|
||||
default="null",
|
||||
help="Comma-separated list of displays to benchmark",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--iterations",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of iterations per benchmark",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Run pytest with benchmark markers
|
||||
pytest_args = [
|
||||
"-v",
|
||||
"-m",
|
||||
"benchmark",
|
||||
]
|
||||
|
||||
if args.hook:
|
||||
# Hook mode: stricter settings
|
||||
pytest_args.extend(
|
||||
[
|
||||
"--benchmark-only",
|
||||
"--benchmark-compare",
|
||||
"--benchmark-compare-fail=min:5%", # Fail if >5% slower
|
||||
]
|
||||
)
|
||||
|
||||
# Add display filter if specified
|
||||
if args.displays:
|
||||
pytest_args.extend(["-k", args.displays])
|
||||
|
||||
# Add iterations
|
||||
if args.iterations:
|
||||
# Set environment variable for benchmark tests
|
||||
import os
|
||||
|
||||
os.environ["BENCHMARK_ITERATIONS"] = str(args.iterations)
|
||||
|
||||
# Run pytest
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pytest", "tests/test_benchmark.py"] + pytest_args,
|
||||
cwd=None, # Current directory
|
||||
)
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
473
engine/camera.py
Normal file
473
engine/camera.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
Camera system for viewport scrolling.
|
||||
|
||||
Provides abstraction for camera motion in different modes:
|
||||
- Vertical: traditional upward scroll
|
||||
- Horizontal: left/right movement
|
||||
- Omni: combination of both
|
||||
- Floating: sinusoidal/bobbing motion
|
||||
|
||||
The camera defines a visible viewport into a larger Canvas.
|
||||
"""
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
|
||||
|
||||
class CameraMode(Enum):
|
||||
FEED = auto() # Single item view (static or rapid cycling)
|
||||
SCROLL = auto() # Smooth vertical scrolling (movie credits style)
|
||||
HORIZONTAL = auto()
|
||||
OMNI = auto()
|
||||
FLOATING = auto()
|
||||
BOUNCE = auto()
|
||||
RADIAL = auto() # Polar coordinates (r, theta) for radial scanning
|
||||
|
||||
|
||||
@dataclass
|
||||
class CameraViewport:
|
||||
"""Represents the visible viewport."""
|
||||
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Camera:
|
||||
"""Camera for viewport scrolling.
|
||||
|
||||
The camera defines a visible viewport into a Canvas.
|
||||
It can be smaller than the canvas to allow scrolling,
|
||||
and supports zoom to scale the view.
|
||||
|
||||
Attributes:
|
||||
x: Current horizontal offset (positive = scroll left)
|
||||
y: Current vertical offset (positive = scroll up)
|
||||
mode: Current camera mode
|
||||
speed: Base scroll speed
|
||||
zoom: Zoom factor (1.0 = 100%, 2.0 = 200% zoom out)
|
||||
canvas_width: Width of the canvas being viewed
|
||||
canvas_height: Height of the canvas being viewed
|
||||
custom_update: Optional custom update function
|
||||
"""
|
||||
|
||||
x: int = 0
|
||||
y: int = 0
|
||||
mode: CameraMode = CameraMode.FEED
|
||||
speed: float = 1.0
|
||||
zoom: float = 1.0
|
||||
canvas_width: int = 200 # Larger than viewport for scrolling
|
||||
canvas_height: int = 200
|
||||
custom_update: Callable[["Camera", float], None] | None = None
|
||||
_x_float: float = field(default=0.0, repr=False)
|
||||
_y_float: float = field(default=0.0, repr=False)
|
||||
_time: float = field(default=0.0, repr=False)
|
||||
|
||||
@property
|
||||
def w(self) -> int:
|
||||
"""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."""
|
||||
return self.viewport_height
|
||||
|
||||
@property
|
||||
def viewport_width(self) -> int:
|
||||
"""Get the visible viewport width.
|
||||
|
||||
This is the canvas width divided by zoom.
|
||||
"""
|
||||
return max(1, int(self.canvas_width / self.zoom))
|
||||
|
||||
@property
|
||||
def viewport_height(self) -> int:
|
||||
"""Get the visible viewport height.
|
||||
|
||||
This is the canvas height divided by zoom.
|
||||
"""
|
||||
return max(1, int(self.canvas_height / self.zoom))
|
||||
|
||||
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 = 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))
|
||||
|
||||
return CameraViewport(
|
||||
x=clamped_x,
|
||||
y=clamped_y,
|
||||
width=vw,
|
||||
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.
|
||||
|
||||
Args:
|
||||
zoom: Zoom factor (1.0 = 100%, 2.0 = zoomed out 2x, 0.5 = zoomed in 2x)
|
||||
"""
|
||||
self.zoom = max(0.1, min(10.0, zoom))
|
||||
|
||||
def update(self, dt: float) -> None:
|
||||
"""Update camera position based on mode.
|
||||
|
||||
Args:
|
||||
dt: Delta time in seconds
|
||||
"""
|
||||
self._time += dt
|
||||
|
||||
if self.custom_update:
|
||||
self.custom_update(self, dt)
|
||||
return
|
||||
|
||||
if self.mode == CameraMode.FEED:
|
||||
self._update_feed(dt)
|
||||
elif self.mode == CameraMode.SCROLL:
|
||||
self._update_scroll(dt)
|
||||
elif self.mode == CameraMode.HORIZONTAL:
|
||||
self._update_horizontal(dt)
|
||||
elif self.mode == CameraMode.OMNI:
|
||||
self._update_omni(dt)
|
||||
elif self.mode == CameraMode.FLOATING:
|
||||
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:
|
||||
self._clamp_to_bounds()
|
||||
|
||||
def _clamp_to_bounds(self) -> None:
|
||||
"""Clamp camera position to stay within canvas bounds.
|
||||
|
||||
Only clamps if the viewport is smaller than the canvas.
|
||||
If viewport equals canvas (no scrolling needed), allows any position
|
||||
for backwards compatibility with original behavior.
|
||||
"""
|
||||
vw = self.viewport_width
|
||||
vh = self.viewport_height
|
||||
|
||||
# Only clamp if there's room to scroll
|
||||
if vw < self.canvas_width:
|
||||
self.x = max(0, min(self.x, self.canvas_width - vw))
|
||||
if vh < self.canvas_height:
|
||||
self.y = max(0, min(self.y, self.canvas_height - vh))
|
||||
|
||||
def _update_feed(self, dt: float) -> None:
|
||||
"""Feed mode: rapid scrolling (1 row per frame at speed=1.0)."""
|
||||
self.y += int(self.speed * dt * 60)
|
||||
|
||||
def _update_scroll(self, dt: float) -> None:
|
||||
"""Scroll mode: smooth vertical scrolling with float accumulation."""
|
||||
self._y_float += self.speed * dt * 60
|
||||
self.y = int(self._y_float)
|
||||
|
||||
def _update_horizontal(self, dt: float) -> None:
|
||||
self.x += int(self.speed * dt * 60)
|
||||
|
||||
def _update_omni(self, dt: float) -> None:
|
||||
speed = self.speed * dt * 60
|
||||
self.y += int(speed)
|
||||
self.x += int(speed * 0.5)
|
||||
|
||||
def _update_floating(self, dt: float) -> None:
|
||||
base = self.speed * 30
|
||||
self.y = int(math.sin(self._time * 2) * base)
|
||||
self.x = int(math.cos(self._time * 1.5) * base * 0.5)
|
||||
|
||||
def _update_bounce(self, dt: float) -> None:
|
||||
"""Bouncing DVD-style camera that bounces off canvas edges."""
|
||||
vw = self.viewport_width
|
||||
vh = self.viewport_height
|
||||
|
||||
# Initialize direction if not set
|
||||
if not hasattr(self, "_bounce_dx"):
|
||||
self._bounce_dx = 1
|
||||
self._bounce_dy = 1
|
||||
|
||||
# Calculate max positions
|
||||
max_x = max(0, self.canvas_width - vw)
|
||||
max_y = max(0, self.canvas_height - vh)
|
||||
|
||||
# Move
|
||||
move_speed = self.speed * dt * 60
|
||||
|
||||
# Bounce off edges - reverse direction when hitting bounds
|
||||
self.x += int(move_speed * self._bounce_dx)
|
||||
self.y += int(move_speed * self._bounce_dy)
|
||||
|
||||
# Bounce horizontally
|
||||
if self.x <= 0:
|
||||
self.x = 0
|
||||
self._bounce_dx = 1
|
||||
elif self.x >= max_x:
|
||||
self.x = max_x
|
||||
self._bounce_dx = -1
|
||||
|
||||
# Bounce vertically
|
||||
if self.y <= 0:
|
||||
self.y = 0
|
||||
self._bounce_dy = 1
|
||||
elif self.y >= max_y:
|
||||
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 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.
|
||||
|
||||
Args:
|
||||
width: New canvas width
|
||||
height: New canvas height
|
||||
"""
|
||||
self.canvas_width = width
|
||||
self.canvas_height = height
|
||||
self._clamp_to_bounds()
|
||||
|
||||
def apply(
|
||||
self, buffer: list[str], viewport_width: int, viewport_height: int | None = None
|
||||
) -> list[str]:
|
||||
"""Apply camera viewport to a text buffer.
|
||||
|
||||
Slices the buffer based on camera position (x, y) and viewport dimensions.
|
||||
Handles ANSI escape codes correctly for colored/styled text.
|
||||
|
||||
Args:
|
||||
buffer: List of strings representing lines of text
|
||||
viewport_width: Width of the visible viewport in characters
|
||||
viewport_height: Height of the visible viewport (overrides camera's viewport_height if provided)
|
||||
|
||||
Returns:
|
||||
Sliced buffer containing only the visible lines and columns
|
||||
"""
|
||||
from engine.effects.legacy import vis_offset, vis_trunc
|
||||
|
||||
if not buffer:
|
||||
return buffer
|
||||
|
||||
# Get current viewport bounds (clamped to canvas size)
|
||||
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
|
||||
|
||||
# Vertical slice: extract lines that fit in viewport height
|
||||
start_y = viewport.y
|
||||
end_y = min(viewport.y + vh, len(buffer))
|
||||
|
||||
if start_y >= len(buffer):
|
||||
# Scrolled past end of buffer, return empty viewport
|
||||
return [""] * vh
|
||||
|
||||
vertical_slice = buffer[start_y:end_y]
|
||||
|
||||
# Horizontal slice: apply horizontal offset and truncate to width
|
||||
horizontal_slice = []
|
||||
for line in vertical_slice:
|
||||
# Apply horizontal offset (skip first x characters, handling ANSI)
|
||||
offset_line = vis_offset(line, viewport.x)
|
||||
# Truncate to viewport width (handling ANSI)
|
||||
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 and visible_len > 0:
|
||||
truncated_line += " " * (viewport_width - visible_len)
|
||||
|
||||
horizontal_slice.append(truncated_line)
|
||||
|
||||
# Pad with empty lines if needed to fill viewport height
|
||||
while len(horizontal_slice) < vh:
|
||||
horizontal_slice.append("")
|
||||
|
||||
return horizontal_slice
|
||||
|
||||
@classmethod
|
||||
def feed(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create a feed camera (rapid single-item scrolling, 1 row/frame at speed=1.0)."""
|
||||
return cls(mode=CameraMode.FEED, speed=speed, canvas_height=200)
|
||||
|
||||
@classmethod
|
||||
def scroll(cls, speed: float = 0.5) -> "Camera":
|
||||
"""Create a smooth scrolling camera (movie credits style).
|
||||
|
||||
Uses float accumulation for sub-integer speeds.
|
||||
Sets canvas_width=0 so it matches viewport_width for proper text wrapping.
|
||||
"""
|
||||
return cls(
|
||||
mode=CameraMode.SCROLL, speed=speed, canvas_width=0, canvas_height=200
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def vertical(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Deprecated: Use feed() or scroll() instead."""
|
||||
return cls(mode=CameraMode.FEED, speed=speed, canvas_height=200)
|
||||
|
||||
@classmethod
|
||||
def horizontal(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create a horizontal scrolling camera."""
|
||||
return cls(mode=CameraMode.HORIZONTAL, speed=speed, canvas_width=200)
|
||||
|
||||
@classmethod
|
||||
def omni(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create an omnidirectional scrolling camera."""
|
||||
return cls(
|
||||
mode=CameraMode.OMNI, speed=speed, canvas_width=200, canvas_height=200
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def floating(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create a floating/bobbing camera."""
|
||||
return cls(
|
||||
mode=CameraMode.FLOATING, speed=speed, canvas_width=200, canvas_height=200
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def bounce(cls, speed: float = 1.0) -> "Camera":
|
||||
"""Create a bouncing DVD-style camera that bounces off canvas edges."""
|
||||
return cls(
|
||||
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."""
|
||||
return cls(custom_update=update_fn)
|
||||
186
engine/canvas.py
Normal file
186
engine/canvas.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Canvas - 2D surface for rendering.
|
||||
|
||||
The Canvas represents a full rendered surface that can be larger than the display.
|
||||
The Camera then defines the visible viewport into this canvas.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class CanvasRegion:
|
||||
"""A rectangular region on the canvas."""
|
||||
|
||||
x: int
|
||||
y: int
|
||||
width: int
|
||||
height: int
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Check if region has positive dimensions."""
|
||||
return self.width > 0 and self.height > 0
|
||||
|
||||
def rows(self) -> set[int]:
|
||||
"""Return set of row indices in this region."""
|
||||
return set(range(self.y, self.y + self.height))
|
||||
|
||||
|
||||
class Canvas:
|
||||
"""2D canvas for rendering content.
|
||||
|
||||
The canvas is a 2D grid of cells that can hold text content.
|
||||
It can be larger than the visible viewport (display).
|
||||
|
||||
Attributes:
|
||||
width: Total width in characters
|
||||
height: Total height in characters
|
||||
"""
|
||||
|
||||
def __init__(self, width: int = 80, height: int = 24):
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._grid: list[list[str]] = [
|
||||
[" " for _ in range(width)] for _ in range(height)
|
||||
]
|
||||
self._dirty_regions: list[CanvasRegion] = [] # Track dirty regions
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the entire canvas."""
|
||||
self._grid = [[" " for _ in range(self.width)] for _ in range(self.height)]
|
||||
self._dirty_regions = [CanvasRegion(0, 0, self.width, self.height)]
|
||||
|
||||
def mark_dirty(self, x: int, y: int, width: int, height: int) -> None:
|
||||
"""Mark a region as dirty (caller declares what they changed)."""
|
||||
self._dirty_regions.append(CanvasRegion(x, y, width, height))
|
||||
|
||||
def get_dirty_regions(self) -> list[CanvasRegion]:
|
||||
"""Get all dirty regions and clear the set."""
|
||||
regions = self._dirty_regions
|
||||
self._dirty_regions = []
|
||||
return regions
|
||||
|
||||
def get_dirty_rows(self) -> set[int]:
|
||||
"""Get union of all dirty rows."""
|
||||
rows: set[int] = set()
|
||||
for region in self._dirty_regions:
|
||||
rows.update(region.rows())
|
||||
return rows
|
||||
|
||||
def is_dirty(self) -> bool:
|
||||
"""Check if any region is dirty."""
|
||||
return len(self._dirty_regions) > 0
|
||||
|
||||
def get_region(self, x: int, y: int, width: int, height: int) -> list[list[str]]:
|
||||
"""Get a rectangular region from the canvas.
|
||||
|
||||
Args:
|
||||
x: Left position
|
||||
y: Top position
|
||||
width: Region width
|
||||
height: Region height
|
||||
|
||||
Returns:
|
||||
2D list of characters (height rows, width columns)
|
||||
"""
|
||||
region: list[list[str]] = []
|
||||
for py in range(y, y + height):
|
||||
row: list[str] = []
|
||||
for px in range(x, x + width):
|
||||
if 0 <= py < self.height and 0 <= px < self.width:
|
||||
row.append(self._grid[py][px])
|
||||
else:
|
||||
row.append(" ")
|
||||
region.append(row)
|
||||
return region
|
||||
|
||||
def get_region_flat(self, x: int, y: int, width: int, height: int) -> list[str]:
|
||||
"""Get a rectangular region as flat list of lines.
|
||||
|
||||
Args:
|
||||
x: Left position
|
||||
y: Top position
|
||||
width: Region width
|
||||
height: Region height
|
||||
|
||||
Returns:
|
||||
List of strings (one per row)
|
||||
"""
|
||||
region = self.get_region(x, y, width, height)
|
||||
return ["".join(row) for row in region]
|
||||
|
||||
def put_region(self, x: int, y: int, content: list[list[str]]) -> None:
|
||||
"""Put content into a rectangular region on the canvas.
|
||||
|
||||
Args:
|
||||
x: Left position
|
||||
y: Top position
|
||||
content: 2D list of characters to place
|
||||
"""
|
||||
height = len(content) if content else 0
|
||||
width = len(content[0]) if height > 0 else 0
|
||||
|
||||
for py, row in enumerate(content):
|
||||
for px, char in enumerate(row):
|
||||
canvas_x = x + px
|
||||
canvas_y = y + py
|
||||
if 0 <= canvas_y < self.height and 0 <= canvas_x < self.width:
|
||||
self._grid[canvas_y][canvas_x] = char
|
||||
|
||||
if width > 0 and height > 0:
|
||||
self.mark_dirty(x, y, width, height)
|
||||
|
||||
def put_text(self, x: int, y: int, text: str) -> None:
|
||||
"""Put a single line of text at position.
|
||||
|
||||
Args:
|
||||
x: Left position
|
||||
y: Row position
|
||||
text: Text to place
|
||||
"""
|
||||
text_len = len(text)
|
||||
for i, char in enumerate(text):
|
||||
canvas_x = x + i
|
||||
if 0 <= canvas_x < self.width and 0 <= y < self.height:
|
||||
self._grid[y][canvas_x] = char
|
||||
|
||||
if text_len > 0:
|
||||
self.mark_dirty(x, y, text_len, 1)
|
||||
|
||||
def fill(self, x: int, y: int, width: int, height: int, char: str = " ") -> None:
|
||||
"""Fill a rectangular region with a character.
|
||||
|
||||
Args:
|
||||
x: Left position
|
||||
y: Top position
|
||||
width: Region width
|
||||
height: Region height
|
||||
char: Character to fill with
|
||||
"""
|
||||
for py in range(y, y + height):
|
||||
for px in range(x, x + width):
|
||||
if 0 <= py < self.height and 0 <= px < self.width:
|
||||
self._grid[py][px] = char
|
||||
|
||||
if width > 0 and height > 0:
|
||||
self.mark_dirty(x, y, width, height)
|
||||
|
||||
def resize(self, width: int, height: int) -> None:
|
||||
"""Resize the canvas.
|
||||
|
||||
Args:
|
||||
width: New width
|
||||
height: New height
|
||||
"""
|
||||
if width == self.width and height == self.height:
|
||||
return
|
||||
|
||||
new_grid: list[list[str]] = [[" " for _ in range(width)] for _ in range(height)]
|
||||
|
||||
for py in range(min(self.height, height)):
|
||||
for px in range(min(self.width, width)):
|
||||
new_grid[py][px] = self._grid[py][px]
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._grid = new_grid
|
||||
@@ -105,6 +105,8 @@ class Config:
|
||||
firehose: bool = False
|
||||
|
||||
ntfy_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||
ntfy_cc_cmd_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||
ntfy_cc_resp_topic: str = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||
ntfy_reconnect_delay: int = 5
|
||||
message_display_secs: int = 30
|
||||
|
||||
@@ -127,6 +129,10 @@ class Config:
|
||||
|
||||
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
|
||||
|
||||
display: str = "pygame"
|
||||
websocket: bool = False
|
||||
websocket_port: int = 8765
|
||||
|
||||
@classmethod
|
||||
def from_args(cls, argv: list[str] | None = None) -> "Config":
|
||||
"""Create Config from CLI arguments (or custom argv for testing)."""
|
||||
@@ -148,6 +154,8 @@ class Config:
|
||||
mode="poetry" if "--poetry" in argv or "-p" in argv else "news",
|
||||
firehose="--firehose" in argv,
|
||||
ntfy_topic="https://ntfy.sh/klubhaus_terminal_mainline/json",
|
||||
ntfy_cc_cmd_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json",
|
||||
ntfy_cc_resp_topic="https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json",
|
||||
ntfy_reconnect_delay=5,
|
||||
message_display_secs=30,
|
||||
font_dir=font_dir,
|
||||
@@ -164,6 +172,9 @@ class Config:
|
||||
glitch_glyphs="░▒▓█▌▐╌╍╎╏┃┆┇┊┋",
|
||||
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
|
||||
script_fonts=_get_platform_font_paths(),
|
||||
display=_arg_value("--display", argv) or "terminal",
|
||||
websocket="--websocket" in argv,
|
||||
websocket_port=_arg_int("--websocket-port", 8765, argv),
|
||||
)
|
||||
|
||||
|
||||
@@ -193,6 +204,8 @@ FIREHOSE = "--firehose" in sys.argv
|
||||
|
||||
# ─── NTFY MESSAGE QUEUE ──────────────────────────────────
|
||||
NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json"
|
||||
NTFY_CC_CMD_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd/json"
|
||||
NTFY_CC_RESP_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline_cc_resp/json"
|
||||
NTFY_RECONNECT_DELAY = 5 # seconds before reconnecting after a dropped stream
|
||||
MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen
|
||||
|
||||
@@ -223,6 +236,26 @@ GRAD_SPEED = 0.08 # gradient traversal speed (cycles/sec, ~12s full sweep)
|
||||
GLITCH = "░▒▓█▌▐╌╍╎╏┃┆┇┊┋"
|
||||
KATA = "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ"
|
||||
|
||||
# ─── WEBSOCKET ─────────────────────────────────────────────
|
||||
DISPLAY = _arg_value("--display", sys.argv) or "pygame"
|
||||
WEBSOCKET = "--websocket" in sys.argv
|
||||
WEBSOCKET_PORT = _arg_int("--websocket-port", 8765)
|
||||
|
||||
# ─── DEMO MODE ────────────────────────────────────────────
|
||||
DEMO = "--demo" in sys.argv
|
||||
DEMO_EFFECT_DURATION = 5.0 # seconds per effect
|
||||
PIPELINE_DEMO = "--pipeline-demo" in sys.argv
|
||||
|
||||
# ─── PIPELINE MODE (new unified architecture) ─────────────
|
||||
PIPELINE_MODE = "--pipeline" in sys.argv
|
||||
PIPELINE_PRESET = _arg_value("--pipeline-preset", sys.argv) or "demo"
|
||||
|
||||
# ─── PRESET MODE ────────────────────────────────────────────
|
||||
PRESET = _arg_value("--preset", sys.argv)
|
||||
|
||||
# ─── PIPELINE DIAGRAM ────────────────────────────────────
|
||||
PIPELINE_DIAGRAM = "--pipeline-diagram" in sys.argv
|
||||
|
||||
|
||||
def set_font_selection(font_path=None, font_index=None):
|
||||
"""Set runtime primary font selection."""
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""
|
||||
Stream controller - manages input sources and orchestrates the render stream.
|
||||
"""
|
||||
|
||||
from engine.config import Config, get_config
|
||||
from engine.eventbus import EventBus
|
||||
from engine.events import EventType, StreamEvent
|
||||
from engine.mic import MicMonitor
|
||||
from engine.ntfy import NtfyPoller
|
||||
from engine.scroll import stream
|
||||
|
||||
|
||||
class StreamController:
|
||||
"""Controls the stream lifecycle - initializes sources and runs the stream."""
|
||||
|
||||
def __init__(self, config: Config | None = None, event_bus: EventBus | None = None):
|
||||
self.config = config or get_config()
|
||||
self.event_bus = event_bus
|
||||
self.mic: MicMonitor | None = None
|
||||
self.ntfy: NtfyPoller | None = None
|
||||
|
||||
def initialize_sources(self) -> tuple[bool, bool]:
|
||||
"""Initialize microphone and ntfy sources.
|
||||
|
||||
Returns:
|
||||
(mic_ok, ntfy_ok) - success status for each source
|
||||
"""
|
||||
self.mic = MicMonitor(threshold_db=self.config.mic_threshold_db)
|
||||
mic_ok = self.mic.start() if self.mic.available else False
|
||||
|
||||
self.ntfy = NtfyPoller(
|
||||
self.config.ntfy_topic,
|
||||
reconnect_delay=self.config.ntfy_reconnect_delay,
|
||||
display_secs=self.config.message_display_secs,
|
||||
)
|
||||
ntfy_ok = self.ntfy.start()
|
||||
|
||||
return bool(mic_ok), ntfy_ok
|
||||
|
||||
def run(self, items: list) -> None:
|
||||
"""Run the stream with initialized sources."""
|
||||
if self.mic is None or self.ntfy is None:
|
||||
self.initialize_sources()
|
||||
|
||||
if self.event_bus:
|
||||
self.event_bus.publish(
|
||||
EventType.STREAM_START,
|
||||
StreamEvent(
|
||||
event_type=EventType.STREAM_START,
|
||||
headline_count=len(items),
|
||||
),
|
||||
)
|
||||
|
||||
stream(items, self.ntfy, self.mic)
|
||||
|
||||
if self.event_bus:
|
||||
self.event_bus.publish(
|
||||
EventType.STREAM_END,
|
||||
StreamEvent(
|
||||
event_type=EventType.STREAM_END,
|
||||
headline_count=len(items),
|
||||
),
|
||||
)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
if self.mic:
|
||||
self.mic.stop()
|
||||
12
engine/data_sources/__init__.py
Normal file
12
engine/data_sources/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
Data source implementations for the pipeline architecture.
|
||||
|
||||
Import directly from submodules:
|
||||
from engine.data_sources.sources import DataSource, SourceItem, HeadlinesDataSource
|
||||
from engine.data_sources.pipeline_introspection import PipelineIntrospectionSource
|
||||
"""
|
||||
|
||||
# Re-export for convenience
|
||||
from engine.data_sources.sources import ImageItem, SourceItem
|
||||
|
||||
__all__ = ["ImageItem", "SourceItem"]
|
||||
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")]
|
||||
312
engine/data_sources/pipeline_introspection.py
Normal file
312
engine/data_sources/pipeline_introspection.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Pipeline introspection source - Renders live visualization of pipeline DAG and metrics.
|
||||
|
||||
This DataSource introspects one or more Pipeline instances and renders
|
||||
an ASCII visualization showing:
|
||||
- Stage DAG with signal flow connections
|
||||
- Per-stage execution times
|
||||
- Sparkline of frame times
|
||||
- Stage breakdown bars
|
||||
|
||||
Example:
|
||||
source = PipelineIntrospectionSource(pipelines=[my_pipeline])
|
||||
items = source.fetch() # Returns ASCII visualization
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from engine.data_sources.sources import DataSource, SourceItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from engine.pipeline.controller import Pipeline
|
||||
|
||||
|
||||
SPARKLINE_CHARS = " ▁▂▃▄▅▆▇█"
|
||||
BAR_CHARS = " ▁▂▃▄▅▆▇█"
|
||||
|
||||
|
||||
class PipelineIntrospectionSource(DataSource):
|
||||
"""Data source that renders live pipeline introspection visualization.
|
||||
|
||||
Renders:
|
||||
- DAG of stages with signal flow
|
||||
- Per-stage execution times
|
||||
- Sparkline of frame history
|
||||
- Stage breakdown bars
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pipeline: "Pipeline | None" = None,
|
||||
viewport_width: int = 100,
|
||||
viewport_height: int = 35,
|
||||
):
|
||||
self._pipeline = pipeline # May be None initially, set later via set_pipeline()
|
||||
self.viewport_width = viewport_width
|
||||
self.viewport_height = viewport_height
|
||||
self.frame = 0
|
||||
self._ready = False
|
||||
|
||||
def set_pipeline(self, pipeline: "Pipeline") -> None:
|
||||
"""Set the pipeline to introspect (call after pipeline is built)."""
|
||||
self._pipeline = [pipeline] # Wrap in list for iteration
|
||||
self._ready = True
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
"""Check if source is ready to fetch."""
|
||||
return self._ready
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "pipeline-inspect"
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.NONE}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
from engine.pipeline.core import DataType
|
||||
|
||||
return {DataType.SOURCE_ITEMS}
|
||||
|
||||
def add_pipeline(self, pipeline: "Pipeline") -> None:
|
||||
"""Add a pipeline to visualize."""
|
||||
if self._pipeline is None:
|
||||
self._pipeline = [pipeline]
|
||||
elif isinstance(self._pipeline, list):
|
||||
self._pipeline.append(pipeline)
|
||||
else:
|
||||
self._pipeline = [self._pipeline, pipeline]
|
||||
self._ready = True
|
||||
|
||||
def remove_pipeline(self, pipeline: "Pipeline") -> None:
|
||||
"""Remove a pipeline from visualization."""
|
||||
if self._pipeline is None:
|
||||
return
|
||||
elif isinstance(self._pipeline, list):
|
||||
self._pipeline = [p for p in self._pipeline if p is not pipeline]
|
||||
if not self._pipeline:
|
||||
self._pipeline = None
|
||||
self._ready = False
|
||||
elif self._pipeline is pipeline:
|
||||
self._pipeline = None
|
||||
self._ready = False
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
"""Fetch the introspection visualization."""
|
||||
if not self._ready:
|
||||
# Return a placeholder until ready
|
||||
return [
|
||||
SourceItem(
|
||||
content="Initializing...",
|
||||
source="pipeline-inspect",
|
||||
timestamp="init",
|
||||
)
|
||||
]
|
||||
|
||||
lines = self._render()
|
||||
self.frame += 1
|
||||
content = "\n".join(lines)
|
||||
return [
|
||||
SourceItem(
|
||||
content=content, source="pipeline-inspect", timestamp=f"f{self.frame}"
|
||||
)
|
||||
]
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
return self.fetch()
|
||||
|
||||
def _render(self) -> list[str]:
|
||||
"""Render the full visualization."""
|
||||
lines: list[str] = []
|
||||
|
||||
# Header
|
||||
lines.extend(self._render_header())
|
||||
|
||||
# Render pipeline(s) if ready
|
||||
if self._ready and self._pipeline:
|
||||
pipelines = (
|
||||
self._pipeline if isinstance(self._pipeline, list) else [self._pipeline]
|
||||
)
|
||||
for pipeline in pipelines:
|
||||
lines.extend(self._render_pipeline(pipeline))
|
||||
|
||||
# Footer with sparkline
|
||||
lines.extend(self._render_footer())
|
||||
|
||||
return lines
|
||||
|
||||
@property
|
||||
def _pipelines(self) -> list:
|
||||
"""Return pipelines as a list for iteration."""
|
||||
if self._pipeline is None:
|
||||
return []
|
||||
elif isinstance(self._pipeline, list):
|
||||
return self._pipeline
|
||||
else:
|
||||
return [self._pipeline]
|
||||
|
||||
def _render_header(self) -> list[str]:
|
||||
"""Render the header with frame info and metrics summary."""
|
||||
lines: list[str] = []
|
||||
|
||||
if not self._pipeline:
|
||||
return ["PIPELINE INTROSPECTION"]
|
||||
|
||||
# Get aggregate metrics
|
||||
total_ms = 0.0
|
||||
fps = 0.0
|
||||
frame_count = 0
|
||||
|
||||
for pipeline in self._pipelines:
|
||||
try:
|
||||
metrics = pipeline.get_metrics_summary()
|
||||
if metrics and "error" not in metrics:
|
||||
# Get avg_ms from pipeline metrics
|
||||
pipeline_avg = metrics.get("pipeline", {}).get("avg_ms", 0)
|
||||
total_ms = max(total_ms, pipeline_avg)
|
||||
# Calculate FPS from avg_ms
|
||||
if pipeline_avg > 0:
|
||||
fps = max(fps, 1000.0 / pipeline_avg)
|
||||
frame_count = max(frame_count, metrics.get("frame_count", 0))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
header = f"PIPELINE INTROSPECTION -- frame: {self.frame} -- avg: {total_ms:.1f}ms -- fps: {fps:.1f}"
|
||||
lines.append(header)
|
||||
|
||||
return lines
|
||||
|
||||
def _render_pipeline(self, pipeline: "Pipeline") -> list[str]:
|
||||
"""Render a single pipeline's DAG."""
|
||||
lines: list[str] = []
|
||||
|
||||
stages = pipeline.stages
|
||||
execution_order = pipeline.execution_order
|
||||
|
||||
if not stages:
|
||||
lines.append(" (no stages)")
|
||||
return lines
|
||||
|
||||
# Build stage info
|
||||
stage_infos: list[dict] = []
|
||||
for name in execution_order:
|
||||
stage = stages.get(name)
|
||||
if not stage:
|
||||
continue
|
||||
|
||||
try:
|
||||
metrics = pipeline.get_metrics_summary()
|
||||
stage_ms = metrics.get("stages", {}).get(name, {}).get("avg_ms", 0.0)
|
||||
except Exception:
|
||||
stage_ms = 0.0
|
||||
|
||||
stage_infos.append(
|
||||
{
|
||||
"name": name,
|
||||
"category": stage.category,
|
||||
"ms": stage_ms,
|
||||
}
|
||||
)
|
||||
|
||||
# Calculate total time for percentages
|
||||
total_time = sum(s["ms"] for s in stage_infos) or 1.0
|
||||
|
||||
# Render DAG - group by category
|
||||
lines.append("")
|
||||
lines.append(" Signal Flow:")
|
||||
|
||||
# Group stages by category for display
|
||||
categories: dict[str, list[dict]] = {}
|
||||
for info in stage_infos:
|
||||
cat = info["category"]
|
||||
if cat not in categories:
|
||||
categories[cat] = []
|
||||
categories[cat].append(info)
|
||||
|
||||
# Render categories in order
|
||||
cat_order = ["source", "render", "effect", "overlay", "display", "system"]
|
||||
|
||||
for cat in cat_order:
|
||||
if cat not in categories:
|
||||
continue
|
||||
|
||||
cat_stages = categories[cat]
|
||||
cat_names = [s["name"] for s in cat_stages]
|
||||
lines.append(f" {cat}: {' → '.join(cat_names)}")
|
||||
|
||||
# Render timing breakdown
|
||||
lines.append("")
|
||||
lines.append(" Stage Timings:")
|
||||
|
||||
for info in stage_infos:
|
||||
name = info["name"]
|
||||
ms = info["ms"]
|
||||
pct = (ms / total_time) * 100
|
||||
bar = self._render_bar(pct, 20)
|
||||
lines.append(f" {name:12s} {ms:6.2f}ms {bar} {pct:5.1f}%")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return lines
|
||||
|
||||
def _render_footer(self) -> list[str]:
|
||||
"""Render the footer with sparkline."""
|
||||
lines: list[str] = []
|
||||
|
||||
# Get frame history from first pipeline
|
||||
pipelines = self._pipelines
|
||||
if pipelines:
|
||||
try:
|
||||
frame_times = pipelines[0].get_frame_times()
|
||||
except Exception:
|
||||
frame_times = []
|
||||
else:
|
||||
frame_times = []
|
||||
|
||||
if frame_times:
|
||||
sparkline = self._render_sparkline(frame_times[-60:], 50)
|
||||
lines.append(f" Frame Time History (last {len(frame_times[-60:])} frames)")
|
||||
lines.append(f" {sparkline}")
|
||||
else:
|
||||
lines.append(" Frame Time History")
|
||||
lines.append(" (collecting data...)")
|
||||
|
||||
lines.append("")
|
||||
|
||||
return lines
|
||||
|
||||
def _render_bar(self, percentage: float, width: int) -> str:
|
||||
"""Render a horizontal bar for percentage."""
|
||||
filled = int((percentage / 100.0) * width)
|
||||
bar = "█" * filled + "░" * (width - filled)
|
||||
return bar
|
||||
|
||||
def _render_sparkline(self, values: list[float], width: int) -> str:
|
||||
"""Render a sparkline from values."""
|
||||
if not values:
|
||||
return " " * width
|
||||
|
||||
min_val = min(values)
|
||||
max_val = max(values)
|
||||
range_val = max_val - min_val or 1.0
|
||||
|
||||
result = []
|
||||
for v in values[-width:]:
|
||||
normalized = (v - min_val) / range_val
|
||||
idx = int(normalized * (len(SPARKLINE_CHARS) - 1))
|
||||
idx = max(0, min(idx, len(SPARKLINE_CHARS) - 1))
|
||||
result.append(SPARKLINE_CHARS[idx])
|
||||
|
||||
# Pad to width
|
||||
while len(result) < width:
|
||||
result.insert(0, " ")
|
||||
return "".join(result[:width])
|
||||
490
engine/data_sources/sources.py
Normal file
490
engine/data_sources/sources.py
Normal file
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
Data sources for the pipeline architecture.
|
||||
|
||||
This module contains all DataSource implementations:
|
||||
- DataSource: Abstract base class
|
||||
- SourceItem, ImageItem: Data containers
|
||||
- HeadlinesDataSource, PoetryDataSource, ImageDataSource: Concrete sources
|
||||
- SourceRegistry: Registry for source discovery
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceItem:
|
||||
"""A single item from a data source."""
|
||||
|
||||
content: str
|
||||
source: str
|
||||
timestamp: str
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageItem:
|
||||
"""An image item from a data source - wraps a PIL Image."""
|
||||
|
||||
image: Any # PIL Image
|
||||
source: str
|
||||
timestamp: str
|
||||
path: str | None = None # File path or URL if applicable
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class DataSource(ABC):
|
||||
"""Abstract base class for data sources.
|
||||
|
||||
Static sources: Data fetched once and cached. Safe to call fetch() multiple times.
|
||||
Dynamic sources: Data changes over time. fetch() should be idempotent.
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Display name for this source."""
|
||||
...
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
"""Whether this source updates dynamically while the app runs. Default False."""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
"""Fetch fresh data from the source. Must be idempotent."""
|
||||
...
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
"""Get current items. Default implementation returns cached fetch results."""
|
||||
if not hasattr(self, "_items") or self._items is None:
|
||||
self._items = self.fetch()
|
||||
return self._items
|
||||
|
||||
def refresh(self) -> list[SourceItem]:
|
||||
"""Force refresh - clear cache and fetch fresh data."""
|
||||
self._items = self.fetch()
|
||||
return self._items
|
||||
|
||||
def stream(self):
|
||||
"""Optional: Yield items continuously. Override for streaming sources."""
|
||||
raise NotImplementedError
|
||||
|
||||
def __post_init__(self):
|
||||
self._items: list[SourceItem] | None = None
|
||||
|
||||
|
||||
class HeadlinesDataSource(DataSource):
|
||||
"""Data source for RSS feed headlines."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "headlines"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
from engine.fetch import fetch_all
|
||||
|
||||
items, _, _ = fetch_all()
|
||||
return [SourceItem(content=t, source=s, timestamp=ts) for t, s, ts in items]
|
||||
|
||||
|
||||
class EmptyDataSource(DataSource):
|
||||
"""Empty data source that produces blank lines for testing.
|
||||
|
||||
Useful for testing display borders, effects, and other pipeline
|
||||
components without needing actual content.
|
||||
"""
|
||||
|
||||
def __init__(self, width: int = 80, height: int = 24):
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "empty"
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return False
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
# Return empty lines as content
|
||||
content = "\n".join([" " * self.width for _ in range(self.height)])
|
||||
return [SourceItem(content=content, source="empty", timestamp="0")]
|
||||
|
||||
|
||||
class ListDataSource(DataSource):
|
||||
"""Data source that wraps a pre-fetched list of items.
|
||||
|
||||
Used for bootstrap loading when items are already available in memory.
|
||||
This is a simple wrapper for already-fetched data.
|
||||
"""
|
||||
|
||||
def __init__(self, items, name: str = "list"):
|
||||
self._raw_items = items # Store raw items separately
|
||||
self._items = None # Cache for converted SourceItem objects
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return False
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
# Convert tuple items to SourceItem if needed
|
||||
result = []
|
||||
for item in self._raw_items:
|
||||
if isinstance(item, SourceItem):
|
||||
result.append(item)
|
||||
elif isinstance(item, tuple) and len(item) >= 3:
|
||||
# Assume (content, source, timestamp) tuple format
|
||||
result.append(
|
||||
SourceItem(content=item[0], source=item[1], timestamp=str(item[2]))
|
||||
)
|
||||
else:
|
||||
# Fallback: treat as string content
|
||||
result.append(
|
||||
SourceItem(content=str(item), source="list", timestamp="0")
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class PoetryDataSource(DataSource):
|
||||
"""Data source for Poetry DB."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "poetry"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
from engine.fetch import fetch_poetry
|
||||
|
||||
items, _, _ = fetch_poetry()
|
||||
return [SourceItem(content=t, source=s, timestamp=ts) for t, s, ts in items]
|
||||
|
||||
|
||||
class ImageDataSource(DataSource):
|
||||
"""Data source that loads PNG images from file paths or URLs.
|
||||
|
||||
Supports:
|
||||
- Local file paths (e.g., /path/to/image.png)
|
||||
- URLs (e.g., https://example.com/image.png)
|
||||
|
||||
Yields ImageItem objects containing PIL Image objects that can be
|
||||
converted to text buffers by an ImageToTextTransform stage.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | list[str] | None = None,
|
||||
urls: str | list[str] | None = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
path: Single path or list of paths to PNG files
|
||||
urls: Single URL or list of URLs to PNG images
|
||||
"""
|
||||
self._paths = [path] if isinstance(path, str) else (path or [])
|
||||
self._urls = [urls] if isinstance(urls, str) else (urls or [])
|
||||
self._images: list[ImageItem] = []
|
||||
self._load_images()
|
||||
|
||||
def _load_images(self) -> None:
|
||||
"""Load all images from paths and URLs."""
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from urllib.request import urlopen
|
||||
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
for path in self._paths:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(path)
|
||||
if img.mode != "RGBA":
|
||||
img = img.convert("RGBA")
|
||||
self._images.append(
|
||||
ImageItem(
|
||||
image=img,
|
||||
source=f"file:{path}",
|
||||
timestamp=timestamp,
|
||||
path=path,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for url in self._urls:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with urlopen(url) as response:
|
||||
img = Image.open(BytesIO(response.read()))
|
||||
if img.mode != "RGBA":
|
||||
img = img.convert("RGBA")
|
||||
self._images.append(
|
||||
ImageItem(
|
||||
image=img,
|
||||
source=f"url:{url}",
|
||||
timestamp=timestamp,
|
||||
path=url,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "image"
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return False # Static images, not updating
|
||||
|
||||
def fetch(self) -> list[ImageItem]:
|
||||
"""Return loaded images as ImageItem list."""
|
||||
return self._images
|
||||
|
||||
def get_items(self) -> list[ImageItem]:
|
||||
"""Return current image items."""
|
||||
return self._images
|
||||
|
||||
|
||||
class MetricsDataSource(DataSource):
|
||||
"""Data source that renders live pipeline metrics as ASCII art.
|
||||
|
||||
Wraps a Pipeline and displays active stages with their average execution
|
||||
time and approximate FPS impact. Updates lazily when camera is about to
|
||||
focus on a new node (frame % 15 == 12).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pipeline: Any,
|
||||
viewport_width: int = 80,
|
||||
viewport_height: int = 24,
|
||||
):
|
||||
self.pipeline = pipeline
|
||||
self.viewport_width = viewport_width
|
||||
self.viewport_height = viewport_height
|
||||
self.frame = 0
|
||||
self._cached_metrics: dict | None = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "metrics"
|
||||
|
||||
@property
|
||||
def is_dynamic(self) -> bool:
|
||||
return True
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
if self.frame % 15 == 12:
|
||||
self._cached_metrics = None
|
||||
|
||||
if self._cached_metrics is None:
|
||||
self._cached_metrics = self._fetch_metrics()
|
||||
|
||||
buffer = self._render_metrics(self._cached_metrics)
|
||||
self.frame += 1
|
||||
content = "\n".join(buffer)
|
||||
return [
|
||||
SourceItem(content=content, source="metrics", timestamp=f"f{self.frame}")
|
||||
]
|
||||
|
||||
def _fetch_metrics(self) -> dict:
|
||||
if hasattr(self.pipeline, "get_metrics_summary"):
|
||||
metrics = self.pipeline.get_metrics_summary()
|
||||
if "error" not in metrics:
|
||||
return metrics
|
||||
return {"stages": {}, "pipeline": {"avg_ms": 0}}
|
||||
|
||||
def _render_metrics(self, metrics: dict) -> list[str]:
|
||||
stages = metrics.get("stages", {})
|
||||
|
||||
if not stages:
|
||||
return self._render_empty()
|
||||
|
||||
active_stages = {
|
||||
name: stats for name, stats in stages.items() if stats.get("avg_ms", 0) > 0
|
||||
}
|
||||
|
||||
if not active_stages:
|
||||
return self._render_empty()
|
||||
|
||||
total_avg = sum(s["avg_ms"] for s in active_stages.values())
|
||||
if total_avg == 0:
|
||||
total_avg = 1
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("═" * self.viewport_width)
|
||||
lines.append(" PIPELINE METRICS ".center(self.viewport_width, "─"))
|
||||
lines.append("─" * self.viewport_width)
|
||||
|
||||
header = f"{'STAGE':<20} {'AVG_MS':>8} {'FPS %':>8}"
|
||||
lines.append(header)
|
||||
lines.append("─" * self.viewport_width)
|
||||
|
||||
for name, stats in sorted(active_stages.items()):
|
||||
avg_ms = stats.get("avg_ms", 0)
|
||||
fps_impact = (avg_ms / 16.67) * 100 if avg_ms > 0 else 0
|
||||
|
||||
row = f"{name:<20} {avg_ms:>7.2f} {fps_impact:>7.1f}%"
|
||||
lines.append(row[: self.viewport_width])
|
||||
|
||||
lines.append("─" * self.viewport_width)
|
||||
total_row = (
|
||||
f"{'TOTAL':<20} {total_avg:>7.2f} {(total_avg / 16.67) * 100:>7.1f}%"
|
||||
)
|
||||
lines.append(total_row[: self.viewport_width])
|
||||
lines.append("─" * self.viewport_width)
|
||||
lines.append(
|
||||
f" Frame:{self.frame:04d} Cache:{'HIT' if self._cached_metrics else 'MISS'}"
|
||||
)
|
||||
|
||||
while len(lines) < self.viewport_height:
|
||||
lines.append(" " * self.viewport_width)
|
||||
|
||||
return lines[: self.viewport_height]
|
||||
|
||||
def _render_empty(self) -> list[str]:
|
||||
lines = [" " * self.viewport_width for _ in range(self.viewport_height)]
|
||||
msg = "No metrics available"
|
||||
y = self.viewport_height // 2
|
||||
x = (self.viewport_width - len(msg)) // 2
|
||||
lines[y] = " " * x + msg + " " * (self.viewport_width - x - len(msg))
|
||||
return lines
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
return self.fetch()
|
||||
|
||||
|
||||
class CachedDataSource(DataSource):
|
||||
"""Data source that wraps another source with caching."""
|
||||
|
||||
def __init__(self, source: DataSource, max_items: int = 100):
|
||||
self.source = source
|
||||
self.max_items = max_items
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"cached:{self.source.name}"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
items = self.source.fetch()
|
||||
return items[: self.max_items]
|
||||
|
||||
def get_items(self) -> list[SourceItem]:
|
||||
if not hasattr(self, "_items") or self._items is None:
|
||||
self._items = self.fetch()
|
||||
return self._items
|
||||
|
||||
|
||||
class TransformDataSource(DataSource):
|
||||
"""Data source that transforms items from another source.
|
||||
|
||||
Applies optional filter and map functions to each item.
|
||||
This enables chaining: source → transform → transformed output.
|
||||
|
||||
Args:
|
||||
source: The source to fetch items from
|
||||
filter_fn: Optional function(item: SourceItem) -> bool
|
||||
map_fn: Optional function(item: SourceItem) -> SourceItem
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: DataSource,
|
||||
filter_fn: Callable[[SourceItem], bool] | None = None,
|
||||
map_fn: Callable[[SourceItem], SourceItem] | None = None,
|
||||
):
|
||||
self.source = source
|
||||
self.filter_fn = filter_fn
|
||||
self.map_fn = map_fn
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return f"transform:{self.source.name}"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
items = self.source.fetch()
|
||||
|
||||
if self.filter_fn:
|
||||
items = [item for item in items if self.filter_fn(item)]
|
||||
|
||||
if self.map_fn:
|
||||
items = [self.map_fn(item) for item in items]
|
||||
|
||||
return items
|
||||
|
||||
|
||||
class CompositeDataSource(DataSource):
|
||||
"""Data source that combines multiple sources."""
|
||||
|
||||
def __init__(self, sources: list[DataSource]):
|
||||
self.sources = sources
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "composite"
|
||||
|
||||
def fetch(self) -> list[SourceItem]:
|
||||
items = []
|
||||
for source in self.sources:
|
||||
items.extend(source.fetch())
|
||||
return items
|
||||
|
||||
|
||||
class SourceRegistry:
|
||||
"""Registry for data sources."""
|
||||
|
||||
def __init__(self):
|
||||
self._sources: dict[str, DataSource] = {}
|
||||
self._default: str | None = None
|
||||
|
||||
def register(self, source: DataSource, default: bool = False) -> None:
|
||||
self._sources[source.name] = source
|
||||
if default or self._default is None:
|
||||
self._default = source.name
|
||||
|
||||
def get(self, name: str) -> DataSource | None:
|
||||
return self._sources.get(name)
|
||||
|
||||
def list_all(self) -> dict[str, DataSource]:
|
||||
return dict(self._sources)
|
||||
|
||||
def default(self) -> DataSource | None:
|
||||
if self._default:
|
||||
return self._sources.get(self._default)
|
||||
return None
|
||||
|
||||
def create_headlines(self) -> HeadlinesDataSource:
|
||||
return HeadlinesDataSource()
|
||||
|
||||
def create_poetry(self) -> PoetryDataSource:
|
||||
return PoetryDataSource()
|
||||
|
||||
|
||||
_global_registry: SourceRegistry | None = None
|
||||
|
||||
|
||||
def get_source_registry() -> SourceRegistry:
|
||||
global _global_registry
|
||||
if _global_registry is None:
|
||||
_global_registry = SourceRegistry()
|
||||
return _global_registry
|
||||
|
||||
|
||||
def init_default_sources() -> SourceRegistry:
|
||||
"""Initialize the default source registry with standard sources."""
|
||||
registry = get_source_registry()
|
||||
registry.register(HeadlinesDataSource(), default=True)
|
||||
registry.register(PoetryDataSource())
|
||||
return registry
|
||||
@@ -1,102 +0,0 @@
|
||||
"""
|
||||
Display output abstraction - allows swapping output backends.
|
||||
|
||||
Protocol:
|
||||
- init(width, height): Initialize display with terminal dimensions
|
||||
- show(buffer): Render buffer (list of strings) to display
|
||||
- clear(): Clear the display
|
||||
- cleanup(): Shutdown display
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class Display(Protocol):
|
||||
"""Protocol for display backends."""
|
||||
|
||||
def init(self, width: int, height: int) -> None:
|
||||
"""Initialize display with dimensions."""
|
||||
...
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
"""Show buffer on display."""
|
||||
...
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear display."""
|
||||
...
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Shutdown display."""
|
||||
...
|
||||
|
||||
|
||||
def get_monitor():
|
||||
"""Get the performance monitor."""
|
||||
try:
|
||||
from engine.effects.performance import get_monitor as _get_monitor
|
||||
|
||||
return _get_monitor()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class TerminalDisplay:
|
||||
"""ANSI terminal display backend."""
|
||||
|
||||
def __init__(self):
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
|
||||
def init(self, width: int, height: int) -> None:
|
||||
from engine.terminal import CURSOR_OFF
|
||||
|
||||
self.width = width
|
||||
self.height = height
|
||||
print(CURSOR_OFF, end="", flush=True)
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
import sys
|
||||
|
||||
t0 = time.perf_counter()
|
||||
sys.stdout.buffer.write("".join(buffer).encode())
|
||||
sys.stdout.flush()
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
monitor = get_monitor()
|
||||
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
|
||||
|
||||
print(CLR, end="", flush=True)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
from engine.terminal import CURSOR_ON
|
||||
|
||||
print(CURSOR_ON, end="", flush=True)
|
||||
|
||||
|
||||
class NullDisplay:
|
||||
"""Headless/null display - discards all output."""
|
||||
|
||||
def init(self, width: int, height: int) -> None:
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
def show(self, buffer: list[str]) -> None:
|
||||
monitor = get_monitor()
|
||||
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)
|
||||
|
||||
def clear(self) -> None:
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
pass
|
||||
290
engine/display/__init__.py
Normal file
290
engine/display/__init__.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""
|
||||
Display backend system with registry pattern.
|
||||
|
||||
Allows swapping output backends via the Display protocol.
|
||||
Supports auto-discovery of display backends.
|
||||
"""
|
||||
|
||||
from enum import Enum, auto
|
||||
from typing import Protocol
|
||||
|
||||
# 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.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.
|
||||
|
||||
Required attributes:
|
||||
- width: int
|
||||
- height: int
|
||||
|
||||
Required methods (duck typing - actual signatures may vary):
|
||||
- init(width, height, reuse=False)
|
||||
- show(buffer, border=False)
|
||||
- clear()
|
||||
- cleanup()
|
||||
- get_dimensions() -> (width, height)
|
||||
|
||||
Optional attributes (for UI mode):
|
||||
- ui_panel: UIPanel instance (set by app when border=UI)
|
||||
|
||||
Optional methods:
|
||||
- is_quit_requested() -> bool
|
||||
- clear_quit_request() -> None
|
||||
"""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
|
||||
|
||||
class DisplayRegistry:
|
||||
"""Registry for display backends with auto-discovery."""
|
||||
|
||||
_backends: dict[str, type[Display]] = {}
|
||||
_initialized = False
|
||||
|
||||
@classmethod
|
||||
def register(cls, name: str, backend_class: type[Display]) -> None:
|
||||
cls._backends[name.lower()] = backend_class
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str) -> type[Display] | None:
|
||||
return cls._backends.get(name.lower())
|
||||
|
||||
@classmethod
|
||||
def list_backends(cls) -> list[str]:
|
||||
return list(cls._backends.keys())
|
||||
|
||||
@classmethod
|
||||
def create(cls, name: str, **kwargs) -> Display | None:
|
||||
cls.initialize()
|
||||
backend_class = cls.get(name)
|
||||
if backend_class:
|
||||
return backend_class(**kwargs)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def initialize(cls) -> None:
|
||||
if cls._initialized:
|
||||
return
|
||||
cls.register("terminal", TerminalDisplay)
|
||||
cls.register("null", NullDisplay)
|
||||
cls.register("replay", ReplayDisplay)
|
||||
cls.register("websocket", WebSocketDisplay)
|
||||
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]) -> MultiDisplay | None:
|
||||
displays = []
|
||||
for name in names:
|
||||
backend = cls.create(name)
|
||||
if backend:
|
||||
displays.append(backend)
|
||||
else:
|
||||
return None
|
||||
if not displays:
|
||||
return None
|
||||
return MultiDisplay(displays)
|
||||
|
||||
|
||||
def get_monitor():
|
||||
"""Get the performance monitor."""
|
||||
try:
|
||||
from engine.effects.performance import get_monitor as _get_monitor
|
||||
|
||||
return _get_monitor()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _strip_ansi(s: str) -> str:
|
||||
"""Strip ANSI escape sequences from string for length calculation."""
|
||||
import re
|
||||
|
||||
return re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", s)
|
||||
|
||||
|
||||
def _render_simple_border(
|
||||
buf: list[str], width: int, height: int, fps: float = 0.0, frame_time: float = 0.0
|
||||
) -> list[str]:
|
||||
"""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
|
||||
|
||||
cropped = []
|
||||
for i in range(min(inner_h, len(buf))):
|
||||
line = buf[i]
|
||||
visible_len = len(_strip_ansi(line))
|
||||
if visible_len > inner_w:
|
||||
cropped.append(line[:inner_w])
|
||||
else:
|
||||
cropped.append(line + " " * (inner_w - visible_len))
|
||||
|
||||
while len(cropped) < inner_h:
|
||||
cropped.append(" " * inner_w)
|
||||
|
||||
if fps > 0:
|
||||
fps_str = f" FPS:{fps:.0f}"
|
||||
if len(fps_str) < inner_w:
|
||||
right_len = inner_w - len(fps_str)
|
||||
top_border = "┌" + "─" * right_len + fps_str + "┐"
|
||||
else:
|
||||
top_border = "┌" + "─" * inner_w + "┐"
|
||||
else:
|
||||
top_border = "┌" + "─" * inner_w + "┐"
|
||||
|
||||
if frame_time > 0:
|
||||
ft_str = f" {frame_time:.1f}ms"
|
||||
if len(ft_str) < inner_w:
|
||||
right_len = inner_w - len(ft_str)
|
||||
bottom_border = "└" + "─" * right_len + ft_str + "┘"
|
||||
else:
|
||||
bottom_border = "└" + "─" * inner_w + "┘"
|
||||
else:
|
||||
bottom_border = "└" + "─" * inner_w + "┘"
|
||||
|
||||
result = [top_border]
|
||||
for line in cropped:
|
||||
if len(line) < inner_w:
|
||||
line = line + " " * (inner_w - len(line))
|
||||
elif len(line) > inner_w:
|
||||
line = line[:inner_w]
|
||||
result.append("│" + line + "│")
|
||||
result.append(bottom_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",
|
||||
"MultiDisplay",
|
||||
"PygameDisplay",
|
||||
]
|
||||
|
||||
if _MODERNGL_AVAILABLE:
|
||||
__all__.append("ModernGLDisplay")
|
||||
50
engine/display/backends/multi.py
Normal file
50
engine/display/backends/multi.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Multi display backend - forwards to multiple displays.
|
||||
"""
|
||||
|
||||
|
||||
class MultiDisplay:
|
||||
"""Display that forwards to multiple displays.
|
||||
|
||||
Supports reuse - passes reuse flag to all child displays.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(self, displays: list):
|
||||
self.displays = displays
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize all child displays with dimensions.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: If True, use reuse mode for child displays
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
for d in self.displays:
|
||||
d.init(width, height, reuse=reuse)
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
for d in self.displays:
|
||||
d.show(buffer, border=border)
|
||||
|
||||
def clear(self) -> None:
|
||||
for d in self.displays:
|
||||
d.clear()
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get dimensions from the first child display that supports it."""
|
||||
for d in self.displays:
|
||||
if hasattr(d, "get_dimensions"):
|
||||
return d.get_dimensions()
|
||||
return (self.width, self.height)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
for d in self.displays:
|
||||
d.cleanup()
|
||||
183
engine/display/backends/null.py
Normal file
183
engine/display/backends/null.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Null/headless display backend.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NullDisplay:
|
||||
"""Headless/null display - discards all output.
|
||||
|
||||
This display does nothing - useful for headless benchmarking
|
||||
or when no display output is needed. Captures last buffer
|
||||
for testing purposes. Supports frame recording for replay
|
||||
and file export/import.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
_last_buffer: list[str] | None = None
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: Ignored for NullDisplay (no resources to reuse)
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._last_buffer = None
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
import sys
|
||||
|
||||
from engine.display import get_monitor, render_border
|
||||
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
369
engine/display/backends/pygame.py
Normal file
369
engine/display/backends/pygame.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""
|
||||
Pygame display backend - renders to a native application window.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from engine.display.renderer import parse_ansi
|
||||
|
||||
|
||||
class PygameDisplay:
|
||||
"""Pygame display backend - renders to native window.
|
||||
|
||||
Supports reuse mode - when reuse=True, skips SDL initialization
|
||||
and reuses the existing pygame window from a previous instance.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
window_width: int = 800
|
||||
window_height: int = 600
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
cell_width: int = 10,
|
||||
cell_height: int = 18,
|
||||
window_width: int = 800,
|
||||
window_height: int = 600,
|
||||
target_fps: float = 30.0,
|
||||
):
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self.cell_width = cell_width
|
||||
self.cell_height = cell_height
|
||||
self.window_width = window_width
|
||||
self.window_height = window_height
|
||||
self.target_fps = target_fps
|
||||
self._initialized = False
|
||||
self._pygame = None
|
||||
self._screen = None
|
||||
self._font = None
|
||||
self._resized = False
|
||||
self._quit_requested = False
|
||||
self._last_frame_time = 0.0
|
||||
self._frame_period = 1.0 / target_fps if target_fps > 0 else 0
|
||||
self._glyph_cache = {}
|
||||
|
||||
def _get_font_path(self) -> str | None:
|
||||
"""Get font path for rendering."""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
env_font = os.environ.get("MAINLINE_PYGAME_FONT")
|
||||
if env_font and os.path.exists(env_font):
|
||||
return env_font
|
||||
|
||||
def search_dir(base_path: str) -> str | None:
|
||||
if not os.path.exists(base_path):
|
||||
return None
|
||||
if os.path.isfile(base_path):
|
||||
return base_path
|
||||
for font_file in Path(base_path).rglob("*"):
|
||||
if font_file.suffix.lower() in (".ttf", ".otf", ".ttc"):
|
||||
name = font_file.stem.lower()
|
||||
if "geist" in name and ("nerd" in name or "mono" in name):
|
||||
return str(font_file)
|
||||
return None
|
||||
|
||||
search_dirs = []
|
||||
if sys.platform == "darwin":
|
||||
search_dirs.append(os.path.expanduser("~/Library/Fonts/"))
|
||||
elif sys.platform == "win32":
|
||||
search_dirs.append(
|
||||
os.path.expanduser("~\\AppData\\Local\\Microsoft\\Windows\\Fonts\\")
|
||||
)
|
||||
else:
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~/.local/share/fonts/"),
|
||||
os.path.expanduser("~/.fonts/"),
|
||||
"/usr/share/fonts/",
|
||||
]
|
||||
)
|
||||
|
||||
for search_dir_path in search_dirs:
|
||||
found = search_dir(search_dir_path)
|
||||
if found:
|
||||
return found
|
||||
|
||||
return 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: If True, attach to existing pygame window instead of creating new
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
try:
|
||||
import pygame
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
if reuse and PygameDisplay._pygame_initialized:
|
||||
self._pygame = pygame
|
||||
self._initialized = True
|
||||
return
|
||||
|
||||
pygame.init()
|
||||
pygame.display.set_caption("Mainline")
|
||||
|
||||
self._screen = pygame.display.set_mode(
|
||||
(self.window_width, self.window_height),
|
||||
pygame.RESIZABLE,
|
||||
)
|
||||
self._pygame = pygame
|
||||
PygameDisplay._pygame_initialized = True
|
||||
|
||||
# Calculate character dimensions from actual window size
|
||||
self.width = max(1, self.window_width // self.cell_width)
|
||||
self.height = max(1, self.window_height // self.cell_height)
|
||||
|
||||
font_path = self._get_font_path()
|
||||
if font_path:
|
||||
try:
|
||||
self._font = pygame.font.Font(font_path, self.cell_height - 2)
|
||||
except Exception:
|
||||
self._font = pygame.font.SysFont("monospace", self.cell_height - 2)
|
||||
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:
|
||||
if not self._initialized or not self._pygame:
|
||||
return
|
||||
|
||||
t0 = time.perf_counter()
|
||||
|
||||
for event in self._pygame.event.get():
|
||||
if event.type == self._pygame.QUIT:
|
||||
self._quit_requested = True
|
||||
elif event.type == self._pygame.KEYDOWN:
|
||||
if event.key in (self._pygame.K_ESCAPE, self._pygame.K_c):
|
||||
if event.key == self._pygame.K_c and not (
|
||||
event.mod & self._pygame.KMOD_LCTRL
|
||||
or event.mod & self._pygame.KMOD_RCTRL
|
||||
):
|
||||
continue
|
||||
self._quit_requested = True
|
||||
elif event.type == self._pygame.VIDEORESIZE:
|
||||
self.window_width = event.w
|
||||
self.window_height = event.h
|
||||
self.width = max(1, self.window_width // self.cell_width)
|
||||
self.height = max(1, self.window_height // self.cell_height)
|
||||
self._resized = True
|
||||
|
||||
# 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:
|
||||
return # Skip this frame
|
||||
self._last_frame_time = now
|
||||
|
||||
# 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
|
||||
|
||||
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]):
|
||||
if row_idx >= self.height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = content_offset_x
|
||||
|
||||
for text, fg, bg, _bold in tokens:
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# Use None as key for no background
|
||||
bg_key = bg if bg != (0, 0, 0) else None
|
||||
cache_key = (text, fg, bg_key)
|
||||
|
||||
if cache_key not in self._glyph_cache:
|
||||
# Render and cache
|
||||
if bg_key is not None:
|
||||
self._glyph_cache[cache_key] = self._font.render(
|
||||
text, True, fg, bg_key
|
||||
)
|
||||
else:
|
||||
self._glyph_cache[cache_key] = self._font.render(text, True, fg)
|
||||
|
||||
surface = self._glyph_cache[cache_key]
|
||||
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
|
||||
|
||||
if monitor:
|
||||
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))
|
||||
self._pygame.display.flip()
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current terminal dimensions based on window size.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
# Query actual window size and recalculate character cells
|
||||
if self._screen and self._pygame:
|
||||
try:
|
||||
w, h = self._screen.get_size()
|
||||
if w != self.window_width or h != self.window_height:
|
||||
self.window_width = w
|
||||
self.window_height = h
|
||||
self.width = max(1, w // self.cell_width)
|
||||
self.height = max(1, h // self.cell_height)
|
||||
except Exception:
|
||||
pass
|
||||
return self.width, self.height
|
||||
|
||||
def cleanup(self, quit_pygame: bool = True) -> None:
|
||||
"""Cleanup display resources.
|
||||
|
||||
Args:
|
||||
quit_pygame: If True, quit pygame entirely. Set to False when
|
||||
reusing the display to avoid closing shared window.
|
||||
"""
|
||||
if quit_pygame and self._pygame:
|
||||
self._pygame.quit()
|
||||
PygameDisplay._pygame_initialized = False
|
||||
|
||||
@classmethod
|
||||
def reset_state(cls) -> None:
|
||||
"""Reset pygame state - useful for testing."""
|
||||
cls._pygame_initialized = False
|
||||
|
||||
def is_quit_requested(self) -> bool:
|
||||
"""Check if user requested quit (Ctrl+C, Ctrl+Q, or Escape).
|
||||
|
||||
Returns True if the user pressed Ctrl+C, Ctrl+Q, or Escape.
|
||||
The main loop should check this and raise KeyboardInterrupt.
|
||||
"""
|
||||
return self._quit_requested
|
||||
|
||||
def clear_quit_request(self) -> bool:
|
||||
"""Clear the quit request flag after handling.
|
||||
|
||||
Returns the previous quit request state.
|
||||
"""
|
||||
was_requested = self._quit_requested
|
||||
self._quit_requested = False
|
||||
return was_requested
|
||||
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
|
||||
133
engine/display/backends/terminal.py
Normal file
133
engine/display/backends/terminal.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
ANSI terminal display backend.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class TerminalDisplay:
|
||||
"""ANSI terminal display backend.
|
||||
|
||||
Renders buffer to stdout using ANSI escape codes.
|
||||
Supports reuse - when reuse=True, skips re-initializing terminal state.
|
||||
Auto-detects terminal dimensions on init.
|
||||
"""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
_initialized: bool = False
|
||||
|
||||
def __init__(self, target_fps: float = 30.0):
|
||||
self.target_fps = target_fps
|
||||
self._frame_period = 1.0 / target_fps if target_fps > 0 else 0
|
||||
self._last_frame_time = 0.0
|
||||
self._cached_dimensions: tuple[int, int] | None = None
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions.
|
||||
|
||||
If width/height are not provided (0/None), auto-detects terminal size.
|
||||
Otherwise uses provided dimensions or falls back to terminal size
|
||||
if the provided dimensions exceed terminal capacity.
|
||||
|
||||
Args:
|
||||
width: Desired terminal width (0 = auto-detect)
|
||||
height: Desired terminal height (0 = auto-detect)
|
||||
reuse: If True, skip terminal re-initialization
|
||||
"""
|
||||
from engine.terminal import CURSOR_OFF
|
||||
|
||||
# Auto-detect terminal size (handle case where no terminal)
|
||||
try:
|
||||
term_size = os.get_terminal_size()
|
||||
term_width = term_size.columns
|
||||
term_height = term_size.lines
|
||||
except OSError:
|
||||
# No terminal available (e.g., in tests)
|
||||
term_width = width if width > 0 else 80
|
||||
term_height = height if height > 0 else 24
|
||||
|
||||
# Use provided dimensions if valid, otherwise use terminal size
|
||||
if width > 0 and height > 0:
|
||||
self.width = min(width, term_width)
|
||||
self.height = min(height, term_height)
|
||||
else:
|
||||
self.width = term_width
|
||||
self.height = term_height
|
||||
|
||||
if not reuse or not self._initialized:
|
||||
print(CURSOR_OFF, end="", flush=True)
|
||||
self._initialized = True
|
||||
|
||||
def get_dimensions(self) -> tuple[int, int]:
|
||||
"""Get current terminal dimensions.
|
||||
|
||||
Returns cached dimensions to avoid querying terminal every frame,
|
||||
which can cause inconsistent results. Dimensions are only refreshed
|
||||
when they actually change.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
try:
|
||||
term_size = os.get_terminal_size()
|
||||
new_dims = (term_size.columns, term_size.lines)
|
||||
except OSError:
|
||||
new_dims = (self.width, self.height)
|
||||
|
||||
# Only update cached dimensions if they actually changed
|
||||
if self._cached_dimensions is None or self._cached_dimensions != new_dims:
|
||||
self._cached_dimensions = new_dims
|
||||
self.width = new_dims[0]
|
||||
self.height = new_dims[1]
|
||||
|
||||
return self._cached_dimensions
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
import sys
|
||||
|
||||
from engine.display import get_monitor, render_border
|
||||
|
||||
# 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
|
||||
frame_time = 0.0
|
||||
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
|
||||
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
|
||||
output = "\033[H\033[J" + "".join(buffer)
|
||||
sys.stdout.buffer.write(output.encode())
|
||||
sys.stdout.flush()
|
||||
|
||||
def clear(self) -> None:
|
||||
from engine.terminal import CLR
|
||||
|
||||
print(CLR, end="", flush=True)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
from engine.terminal import CURSOR_ON
|
||||
|
||||
print(CURSOR_ON, end="", flush=True)
|
||||
|
||||
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
|
||||
464
engine/display/backends/websocket.py
Normal file
464
engine/display/backends/websocket.py
Normal file
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
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
|
||||
except ImportError:
|
||||
websockets = None
|
||||
|
||||
|
||||
def get_monitor():
|
||||
"""Get the performance monitor."""
|
||||
try:
|
||||
from engine.effects.performance import get_monitor as _get_monitor
|
||||
|
||||
return _get_monitor()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class WebSocketDisplay:
|
||||
"""WebSocket display backend - broadcasts to HTML Canvas clients."""
|
||||
|
||||
width: int = 80
|
||||
height: int = 24
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8765,
|
||||
http_port: int = 8766,
|
||||
streaming_mode: StreamingMode = StreamingMode.JSON,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.http_port = http_port
|
||||
self.width = 80
|
||||
self.height = 24
|
||||
self._clients: set = set()
|
||||
self._server_running = False
|
||||
self._http_running = False
|
||||
self._server_thread: threading.Thread | None = None
|
||||
self._http_thread: threading.Thread | None = None
|
||||
self._available = True
|
||||
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
|
||||
|
||||
self._available = _ws is not None
|
||||
except ImportError:
|
||||
self._available = False
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""Check if WebSocket support is available."""
|
||||
return self._available
|
||||
|
||||
def init(self, width: int, height: int, reuse: bool = False) -> None:
|
||||
"""Initialize display with dimensions and start server.
|
||||
|
||||
Args:
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
reuse: If True, skip starting servers (assume already running)
|
||||
"""
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
if not reuse or not self._server_running:
|
||||
self.start_server()
|
||||
self.start_http_server()
|
||||
|
||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||
"""Broadcast buffer to all connected clients using streaming protocol."""
|
||||
t0 = time.perf_counter()
|
||||
|
||||
# Get metrics for border display
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
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)
|
||||
|
||||
if not self._clients:
|
||||
self._last_buffer = buffer
|
||||
return
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
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
|
||||
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:
|
||||
clear_data = {"type": "clear"}
|
||||
message = json.dumps(clear_data)
|
||||
for client in list(self._clients):
|
||||
try:
|
||||
asyncio.run(client.send(message))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Stop the servers."""
|
||||
self.stop_server()
|
||||
self.stop_http_server()
|
||||
|
||||
async def _websocket_handler(self, websocket):
|
||||
"""Handle WebSocket connections."""
|
||||
if len(self._clients) >= self._max_clients:
|
||||
await websocket.close()
|
||||
return
|
||||
|
||||
self._clients.add(websocket)
|
||||
if self._client_connected_callback:
|
||||
self._client_connected_callback(websocket)
|
||||
|
||||
try:
|
||||
async for message in websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
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:
|
||||
pass
|
||||
finally:
|
||||
self._clients.discard(websocket)
|
||||
if self._client_disconnected_callback:
|
||||
self._client_disconnected_callback(websocket)
|
||||
|
||||
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)
|
||||
|
||||
async def _run_http_server(self):
|
||||
"""Run simple HTTP server for the client."""
|
||||
import os
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
|
||||
# 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):
|
||||
super().__init__(*args, directory=client_dir, **kwargs)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
pass
|
||||
|
||||
httpd = HTTPServer((self.host, self.http_port), Handler)
|
||||
# Store reference for shutdown
|
||||
self._httpd = httpd
|
||||
# Serve requests continuously
|
||||
httpd.serve_forever()
|
||||
|
||||
def _run_async(self, coro):
|
||||
"""Run coroutine in background."""
|
||||
try:
|
||||
asyncio.run(coro)
|
||||
except Exception as e:
|
||||
print(f"WebSocket async error: {e}")
|
||||
|
||||
def start_server(self):
|
||||
"""Start the WebSocket server in a background thread."""
|
||||
if not self._available:
|
||||
return
|
||||
if self._server_thread is not None:
|
||||
return
|
||||
|
||||
self._server_running = True
|
||||
self._server_thread = threading.Thread(
|
||||
target=self._run_async, args=(self._run_websocket_server(),), daemon=True
|
||||
)
|
||||
self._server_thread.start()
|
||||
|
||||
def stop_server(self):
|
||||
"""Stop the WebSocket server."""
|
||||
self._server_running = False
|
||||
self._server_thread = None
|
||||
|
||||
def start_http_server(self):
|
||||
"""Start the HTTP server in a background thread."""
|
||||
if not self._available:
|
||||
return
|
||||
if self._http_thread is not None:
|
||||
return
|
||||
|
||||
self._http_running = True
|
||||
|
||||
self._http_running = True
|
||||
self._http_thread = threading.Thread(
|
||||
target=self._run_async, args=(self._run_http_server(),), daemon=True
|
||||
)
|
||||
self._http_thread.start()
|
||||
|
||||
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:
|
||||
"""Return number of connected clients."""
|
||||
return len(self._clients)
|
||||
|
||||
def get_ws_port(self) -> int:
|
||||
"""Return WebSocket port."""
|
||||
return self.port
|
||||
|
||||
def get_http_port(self) -> int:
|
||||
"""Return HTTP port."""
|
||||
return self.http_port
|
||||
|
||||
def set_frame_delay(self, delay: float) -> None:
|
||||
"""Set delay between frames in seconds."""
|
||||
self._frame_delay = delay
|
||||
|
||||
def get_frame_delay(self) -> float:
|
||||
"""Get delay between frames."""
|
||||
return self._frame_delay
|
||||
|
||||
def set_client_connected_callback(self, callback) -> None:
|
||||
"""Set callback for client connections."""
|
||||
self._client_connected_callback = callback
|
||||
|
||||
def set_client_disconnected_callback(self, callback) -> None:
|
||||
"""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.
|
||||
|
||||
Returns:
|
||||
(width, height) in character cells
|
||||
"""
|
||||
return (self.width, self.height)
|
||||
280
engine/display/renderer.py
Normal file
280
engine/display/renderer.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Shared display rendering utilities.
|
||||
|
||||
Provides common functionality for displays that render text to images
|
||||
(Pygame, Sixel, Kitty displays).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
ANSI_COLORS = {
|
||||
0: (0, 0, 0),
|
||||
1: (205, 49, 49),
|
||||
2: (13, 188, 121),
|
||||
3: (229, 229, 16),
|
||||
4: (36, 114, 200),
|
||||
5: (188, 63, 188),
|
||||
6: (17, 168, 205),
|
||||
7: (229, 229, 229),
|
||||
8: (102, 102, 102),
|
||||
9: (241, 76, 76),
|
||||
10: (35, 209, 139),
|
||||
11: (245, 245, 67),
|
||||
12: (59, 142, 234),
|
||||
13: (214, 112, 214),
|
||||
14: (41, 184, 219),
|
||||
15: (255, 255, 255),
|
||||
}
|
||||
|
||||
|
||||
def parse_ansi(
|
||||
text: str,
|
||||
) -> list[tuple[str, tuple[int, int, int], tuple[int, int, int], bool]]:
|
||||
"""Parse ANSI escape sequences into text tokens with colors.
|
||||
|
||||
Args:
|
||||
text: Text containing ANSI escape sequences
|
||||
|
||||
Returns:
|
||||
List of (text, fg_rgb, bg_rgb, bold) tuples
|
||||
"""
|
||||
tokens = []
|
||||
current_text = ""
|
||||
fg = (204, 204, 204)
|
||||
bg = (0, 0, 0)
|
||||
bold = False
|
||||
i = 0
|
||||
|
||||
ANSI_COLORS_4BIT = {
|
||||
0: (0, 0, 0),
|
||||
1: (205, 49, 49),
|
||||
2: (13, 188, 121),
|
||||
3: (229, 229, 16),
|
||||
4: (36, 114, 200),
|
||||
5: (188, 63, 188),
|
||||
6: (17, 168, 205),
|
||||
7: (229, 229, 229),
|
||||
8: (102, 102, 102),
|
||||
9: (241, 76, 76),
|
||||
10: (35, 209, 139),
|
||||
11: (245, 245, 67),
|
||||
12: (59, 142, 234),
|
||||
13: (214, 112, 214),
|
||||
14: (41, 184, 219),
|
||||
15: (255, 255, 255),
|
||||
}
|
||||
|
||||
while i < len(text):
|
||||
char = text[i]
|
||||
|
||||
if char == "\x1b" and i + 1 < len(text) and text[i + 1] == "[":
|
||||
if current_text:
|
||||
tokens.append((current_text, fg, bg, bold))
|
||||
current_text = ""
|
||||
|
||||
i += 2
|
||||
code = ""
|
||||
while i < len(text):
|
||||
c = text[i]
|
||||
if c.isalpha():
|
||||
break
|
||||
code += c
|
||||
i += 1
|
||||
|
||||
if code:
|
||||
codes = code.split(";")
|
||||
for c in codes:
|
||||
if c == "0":
|
||||
fg = (204, 204, 204)
|
||||
bg = (0, 0, 0)
|
||||
bold = False
|
||||
elif c == "1":
|
||||
bold = True
|
||||
elif c == "22":
|
||||
bold = False
|
||||
elif c == "39":
|
||||
fg = (204, 204, 204)
|
||||
elif c == "49":
|
||||
bg = (0, 0, 0)
|
||||
elif c.isdigit():
|
||||
color_idx = int(c)
|
||||
if color_idx in ANSI_COLORS_4BIT:
|
||||
fg = ANSI_COLORS_4BIT[color_idx]
|
||||
elif 30 <= color_idx <= 37:
|
||||
fg = ANSI_COLORS_4BIT.get(color_idx - 30, fg)
|
||||
elif 40 <= color_idx <= 47:
|
||||
bg = ANSI_COLORS_4BIT.get(color_idx - 40, bg)
|
||||
elif 90 <= color_idx <= 97:
|
||||
fg = ANSI_COLORS_4BIT.get(color_idx - 90 + 8, fg)
|
||||
elif 100 <= color_idx <= 107:
|
||||
bg = ANSI_COLORS_4BIT.get(color_idx - 100 + 8, bg)
|
||||
elif c.startswith("38;5;"):
|
||||
idx = int(c.split(";")[-1])
|
||||
if idx < 256:
|
||||
if idx < 16:
|
||||
fg = ANSI_COLORS_4BIT.get(idx, fg)
|
||||
elif idx < 232:
|
||||
c_idx = idx - 16
|
||||
fg = (
|
||||
(c_idx >> 4) * 51,
|
||||
((c_idx >> 2) & 7) * 51,
|
||||
(c_idx & 3) * 85,
|
||||
)
|
||||
else:
|
||||
gray = (idx - 232) * 10 + 8
|
||||
fg = (gray, gray, gray)
|
||||
elif c.startswith("48;5;"):
|
||||
idx = int(c.split(";")[-1])
|
||||
if idx < 256:
|
||||
if idx < 16:
|
||||
bg = ANSI_COLORS_4BIT.get(idx, bg)
|
||||
elif idx < 232:
|
||||
c_idx = idx - 16
|
||||
bg = (
|
||||
(c_idx >> 4) * 51,
|
||||
((c_idx >> 2) & 7) * 51,
|
||||
(c_idx & 3) * 85,
|
||||
)
|
||||
else:
|
||||
gray = (idx - 232) * 10 + 8
|
||||
bg = (gray, gray, gray)
|
||||
i += 1
|
||||
else:
|
||||
current_text += char
|
||||
i += 1
|
||||
|
||||
if current_text:
|
||||
tokens.append((current_text, fg, bg, bold))
|
||||
|
||||
return tokens if tokens else [("", fg, bg, bold)]
|
||||
|
||||
|
||||
def get_default_font_path() -> str | None:
|
||||
"""Get the path to a default monospace font."""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def search_dir(base_path: str) -> str | None:
|
||||
if not os.path.exists(base_path):
|
||||
return None
|
||||
if os.path.isfile(base_path):
|
||||
return base_path
|
||||
for font_file in Path(base_path).rglob("*"):
|
||||
if font_file.suffix.lower() in (".ttf", ".otf", ".ttc"):
|
||||
name = font_file.stem.lower()
|
||||
if "geist" in name and ("nerd" in name or "mono" in name):
|
||||
return str(font_file)
|
||||
if "mono" in name or "courier" in name or "terminal" in name:
|
||||
return str(font_file)
|
||||
return None
|
||||
|
||||
search_dirs = []
|
||||
if sys.platform == "darwin":
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~/Library/Fonts/"),
|
||||
"/System/Library/Fonts/",
|
||||
]
|
||||
)
|
||||
elif sys.platform == "win32":
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~\\AppData\\Local\\Microsoft\\Windows\\Fonts\\"),
|
||||
"C:\\Windows\\Fonts\\",
|
||||
]
|
||||
)
|
||||
else:
|
||||
search_dirs.extend(
|
||||
[
|
||||
os.path.expanduser("~/.local/share/fonts/"),
|
||||
os.path.expanduser("~/.fonts/"),
|
||||
"/usr/share/fonts/",
|
||||
]
|
||||
)
|
||||
|
||||
for search_dir_path in search_dirs:
|
||||
found = search_dir(search_dir_path)
|
||||
if found:
|
||||
return found
|
||||
|
||||
if sys.platform != "win32":
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
for pattern in ["monospace", "DejaVuSansMono", "LiberationMono"]:
|
||||
result = subprocess.run(
|
||||
["fc-match", "-f", "%{file}", pattern],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
font_file = result.stdout.strip()
|
||||
if os.path.exists(font_file):
|
||||
return font_file
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def render_to_pil(
|
||||
buffer: list[str],
|
||||
width: int,
|
||||
height: int,
|
||||
cell_width: int = 10,
|
||||
cell_height: int = 18,
|
||||
font_path: str | None = None,
|
||||
) -> Any:
|
||||
"""Render buffer to a PIL Image.
|
||||
|
||||
Args:
|
||||
buffer: List of text lines to render
|
||||
width: Terminal width in characters
|
||||
height: Terminal height in rows
|
||||
cell_width: Width of each character cell in pixels
|
||||
cell_height: Height of each character cell in pixels
|
||||
font_path: Path to TTF/OTF font file (optional)
|
||||
|
||||
Returns:
|
||||
PIL Image object
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
img_width = width * cell_width
|
||||
img_height = height * cell_height
|
||||
|
||||
img = Image.new("RGBA", (img_width, img_height), (0, 0, 0, 255))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
if font_path:
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, cell_height - 2)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
else:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
for row_idx, line in enumerate(buffer[:height]):
|
||||
if row_idx >= height:
|
||||
break
|
||||
|
||||
tokens = parse_ansi(line)
|
||||
x_pos = 0
|
||||
y_pos = row_idx * 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))
|
||||
|
||||
draw.text((x_pos, y_pos), text, fill=(*fg, 255), font=font)
|
||||
|
||||
if font:
|
||||
x_pos += draw.textlength(text, font=font)
|
||||
|
||||
return img
|
||||
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]
|
||||
@@ -6,18 +6,17 @@ from engine.effects.legacy import (
|
||||
glitch_bar,
|
||||
next_headline,
|
||||
noise,
|
||||
vis_offset,
|
||||
vis_trunc,
|
||||
)
|
||||
from engine.effects.performance import PerformanceMonitor, get_monitor, set_monitor
|
||||
from engine.effects.registry import EffectRegistry, get_registry, set_registry
|
||||
from engine.effects.types import EffectConfig, EffectContext, PipelineConfig
|
||||
|
||||
|
||||
def get_effect_chain():
|
||||
from engine.layers import get_effect_chain as _chain
|
||||
|
||||
return _chain()
|
||||
|
||||
from engine.effects.types import (
|
||||
EffectConfig,
|
||||
EffectContext,
|
||||
PipelineConfig,
|
||||
create_effect_context,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EffectChain",
|
||||
@@ -25,9 +24,9 @@ __all__ = [
|
||||
"EffectConfig",
|
||||
"EffectContext",
|
||||
"PipelineConfig",
|
||||
"create_effect_context",
|
||||
"get_registry",
|
||||
"set_registry",
|
||||
"get_effect_chain",
|
||||
"get_monitor",
|
||||
"set_monitor",
|
||||
"PerformanceMonitor",
|
||||
@@ -39,4 +38,5 @@ __all__ = [
|
||||
"noise",
|
||||
"next_headline",
|
||||
"vis_trunc",
|
||||
"vis_offset",
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@ import time
|
||||
|
||||
from engine.effects.performance import PerformanceMonitor, get_monitor
|
||||
from engine.effects.registry import EffectRegistry
|
||||
from engine.effects.types import EffectContext
|
||||
from engine.effects.types import EffectContext, PartialUpdate
|
||||
|
||||
|
||||
class EffectChain:
|
||||
@@ -51,6 +51,18 @@ class EffectChain:
|
||||
frame_number = ctx.frame_number
|
||||
monitor.start_frame(frame_number)
|
||||
|
||||
# Get dirty regions from canvas via context (set by CanvasStage)
|
||||
dirty_rows = ctx.get_state("canvas.dirty_rows")
|
||||
|
||||
# Create PartialUpdate for effects that support it
|
||||
full_buffer = dirty_rows is None or len(dirty_rows) == 0
|
||||
partial = PartialUpdate(
|
||||
rows=None,
|
||||
cols=None,
|
||||
dirty=dirty_rows,
|
||||
full_buffer=full_buffer,
|
||||
)
|
||||
|
||||
frame_start = time.perf_counter()
|
||||
result = list(buf)
|
||||
for name in self._order:
|
||||
@@ -59,7 +71,11 @@ class EffectChain:
|
||||
chars_in = sum(len(line) for line in result)
|
||||
effect_start = time.perf_counter()
|
||||
try:
|
||||
result = plugin.process(result, ctx)
|
||||
# Use process_partial if supported, otherwise fall back to process
|
||||
if getattr(plugin, "supports_partial_updates", False):
|
||||
result = plugin.process_partial(result, ctx, partial)
|
||||
else:
|
||||
result = plugin.process(result, ctx)
|
||||
except Exception:
|
||||
plugin.config.enabled = False
|
||||
elapsed = time.perf_counter() - effect_start
|
||||
|
||||
@@ -6,14 +6,7 @@ _effect_chain_ref = None
|
||||
|
||||
def _get_effect_chain():
|
||||
global _effect_chain_ref
|
||||
if _effect_chain_ref is not None:
|
||||
return _effect_chain_ref
|
||||
try:
|
||||
from engine.layers import get_effect_chain as _chain
|
||||
|
||||
return _chain()
|
||||
except Exception:
|
||||
return None
|
||||
return _effect_chain_ref
|
||||
|
||||
|
||||
def set_effect_chain_ref(chain) -> None:
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
"""
|
||||
Visual effects: noise, glitch, fade, ANSI-aware truncation, firehose, headline pool.
|
||||
Depends on: config, terminal, sources.
|
||||
|
||||
These are low-level functional implementations of visual effects. They are used
|
||||
internally by the EffectPlugin system (effects_plugins/*.py) and also directly
|
||||
by layers.py and scroll.py for rendering.
|
||||
|
||||
The plugin system provides a higher-level OOP interface with configuration
|
||||
support, while these legacy functions provide direct functional access.
|
||||
Both systems coexist - there are no current plans to deprecate the legacy functions.
|
||||
"""
|
||||
|
||||
import random
|
||||
@@ -74,6 +82,37 @@ def vis_trunc(s, w):
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def vis_offset(s, offset):
|
||||
"""Offset string by skipping first offset visual characters, skipping ANSI escape codes."""
|
||||
if offset <= 0:
|
||||
return s
|
||||
result = []
|
||||
vw = 0
|
||||
i = 0
|
||||
skipping = True
|
||||
while i < len(s):
|
||||
if s[i] == "\033" and i + 1 < len(s) and s[i + 1] == "[":
|
||||
j = i + 2
|
||||
while j < len(s) and not s[j].isalpha():
|
||||
j += 1
|
||||
if skipping:
|
||||
i = j + 1
|
||||
continue
|
||||
result.append(s[i : j + 1])
|
||||
i = j + 1
|
||||
else:
|
||||
if skipping:
|
||||
if vw >= offset:
|
||||
skipping = False
|
||||
result.append(s[i])
|
||||
vw += 1
|
||||
i += 1
|
||||
else:
|
||||
result.append(s[i])
|
||||
i += 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def next_headline(pool, items, seen):
|
||||
"""Pull the next unique headline from pool, refilling as needed."""
|
||||
while True:
|
||||
|
||||
@@ -5,6 +5,7 @@ PLUGIN_DIR = Path(__file__).parent
|
||||
|
||||
def discover_plugins():
|
||||
from engine.effects.registry import get_registry
|
||||
from engine.effects.types import EffectPlugin
|
||||
|
||||
registry = get_registry()
|
||||
imported = {}
|
||||
@@ -17,16 +18,18 @@ def discover_plugins():
|
||||
continue
|
||||
|
||||
try:
|
||||
module = __import__(f"effects_plugins.{module_name}", fromlist=[""])
|
||||
module = __import__(f"engine.effects.plugins.{module_name}", fromlist=[""])
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name)
|
||||
if (
|
||||
isinstance(attr, type)
|
||||
and hasattr(attr, "name")
|
||||
and hasattr(attr, "process")
|
||||
and issubclass(attr, EffectPlugin)
|
||||
and attr is not EffectPlugin
|
||||
and attr_name.endswith("Effect")
|
||||
):
|
||||
plugin = attr()
|
||||
if not isinstance(plugin, EffectPlugin):
|
||||
continue
|
||||
registry.register(plugin)
|
||||
imported[plugin.name] = plugin
|
||||
except Exception:
|
||||
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
|
||||
105
engine/effects/plugins/border.py
Normal file
105
engine/effects/plugins/border.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class BorderEffect(EffectPlugin):
|
||||
"""Simple border effect for terminal display.
|
||||
|
||||
Draws a border around the buffer and optionally displays
|
||||
performance metrics in the border corners.
|
||||
|
||||
Internally crops to display dimensions to ensure border fits.
|
||||
"""
|
||||
|
||||
name = "border"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not buf:
|
||||
return buf
|
||||
|
||||
# Get actual display dimensions from context
|
||||
display_w = ctx.terminal_width
|
||||
display_h = ctx.terminal_height
|
||||
|
||||
# If dimensions are reasonable, crop first - use slightly smaller to ensure fit
|
||||
if display_w >= 10 and display_h >= 3:
|
||||
# Subtract 2 for border characters (left and right)
|
||||
crop_w = display_w - 2
|
||||
crop_h = display_h - 2
|
||||
buf = self._crop_to_size(buf, crop_w, crop_h)
|
||||
w = display_w
|
||||
h = display_h
|
||||
else:
|
||||
# Use buffer dimensions
|
||||
h = len(buf)
|
||||
w = max(len(line) for line in buf) if buf else 0
|
||||
|
||||
if w < 3 or h < 3:
|
||||
return buf
|
||||
|
||||
inner_w = w - 2
|
||||
|
||||
# Get metrics from context
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
metrics = ctx.get_state("metrics")
|
||||
if metrics:
|
||||
avg_ms = metrics.get("avg_ms")
|
||||
frame_count = metrics.get("frame_count", 0)
|
||||
if avg_ms and frame_count > 0:
|
||||
fps = 1000.0 / avg_ms
|
||||
frame_time = avg_ms
|
||||
|
||||
# Build borders
|
||||
# Top border: ┌────────────────────┐ or with FPS
|
||||
if fps > 0:
|
||||
fps_str = f" FPS:{fps:.0f}"
|
||||
if len(fps_str) < inner_w:
|
||||
right_len = inner_w - len(fps_str)
|
||||
top_border = "┌" + "─" * right_len + fps_str + "┐"
|
||||
else:
|
||||
top_border = "┌" + "─" * inner_w + "┐"
|
||||
else:
|
||||
top_border = "┌" + "─" * inner_w + "┐"
|
||||
|
||||
# Bottom border: └────────────────────┘ or with frame time
|
||||
if frame_time > 0:
|
||||
ft_str = f" {frame_time:.1f}ms"
|
||||
if len(ft_str) < inner_w:
|
||||
right_len = inner_w - len(ft_str)
|
||||
bottom_border = "└" + "─" * right_len + ft_str + "┘"
|
||||
else:
|
||||
bottom_border = "└" + "─" * inner_w + "┘"
|
||||
else:
|
||||
bottom_border = "└" + "─" * inner_w + "┘"
|
||||
|
||||
# Build result with left/right borders
|
||||
result = [top_border]
|
||||
for line in buf[: h - 2]:
|
||||
if len(line) >= inner_w:
|
||||
result.append("│" + line[:inner_w] + "│")
|
||||
else:
|
||||
result.append("│" + line + " " * (inner_w - len(line)) + "│")
|
||||
|
||||
result.append(bottom_border)
|
||||
|
||||
return result
|
||||
|
||||
def _crop_to_size(self, buf: list[str], w: int, h: int) -> list[str]:
|
||||
"""Crop buffer to fit within w x h."""
|
||||
result = []
|
||||
for i in range(min(h, len(buf))):
|
||||
line = buf[i]
|
||||
if len(line) > w:
|
||||
result.append(line[:w])
|
||||
else:
|
||||
result.append(line + " " * (w - len(line)))
|
||||
|
||||
# Pad with empty lines if needed (for border)
|
||||
while len(result) < h:
|
||||
result.append(" " * w)
|
||||
|
||||
return result
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
42
engine/effects/plugins/crop.py
Normal file
42
engine/effects/plugins/crop.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class CropEffect(EffectPlugin):
|
||||
"""Crop effect that crops the input buffer to fit the display.
|
||||
|
||||
This ensures the output buffer matches the actual display dimensions,
|
||||
useful when the source produces a buffer larger than the viewport.
|
||||
"""
|
||||
|
||||
name = "crop"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not buf:
|
||||
return buf
|
||||
|
||||
# Get actual display dimensions from context
|
||||
w = (
|
||||
ctx.terminal_width
|
||||
if ctx.terminal_width > 0
|
||||
else max(len(line) for line in buf)
|
||||
)
|
||||
h = ctx.terminal_height if ctx.terminal_height > 0 else len(buf)
|
||||
|
||||
# Crop buffer to fit
|
||||
result = []
|
||||
for i in range(min(h, len(buf))):
|
||||
line = buf[i]
|
||||
if len(line) > w:
|
||||
result.append(line[:w])
|
||||
else:
|
||||
result.append(line + " " * (w - len(line)))
|
||||
|
||||
# Pad with empty lines if needed
|
||||
while len(result) < h:
|
||||
result.append(" " * w)
|
||||
|
||||
return result
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
@@ -3,9 +3,9 @@ import random
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class FadeEffect:
|
||||
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:
|
||||
@@ -36,7 +36,7 @@ class FadeEffect:
|
||||
if fade >= 1.0:
|
||||
return s
|
||||
if fade <= 0.0:
|
||||
return ""
|
||||
return s # Preserve original line length - don't return empty
|
||||
result = []
|
||||
i = 0
|
||||
while i < len(s):
|
||||
@@ -54,5 +54,5 @@ class FadeEffect:
|
||||
i += 1
|
||||
return "".join(result)
|
||||
|
||||
def configure(self, cfg: EffectConfig) -> None:
|
||||
self.config = cfg
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
@@ -7,9 +7,9 @@ from engine.sources import FEEDS, POETRY_SOURCES
|
||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||
|
||||
|
||||
class FirehoseEffect:
|
||||
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
|
||||
@@ -68,5 +68,5 @@ class FirehoseEffect:
|
||||
color = random.choice([G_LO, C_DIM, W_GHOST])
|
||||
return f"{color}{text}{RST}"
|
||||
|
||||
def configure(self, cfg: EffectConfig) -> None:
|
||||
self.config = cfg
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
52
engine/effects/plugins/glitch.py
Normal file
52
engine/effects/plugins/glitch.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import random
|
||||
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
from engine.terminal import DIM, G_LO, RST
|
||||
|
||||
|
||||
class GlitchEffect(EffectPlugin):
|
||||
name = "glitch"
|
||||
config = EffectConfig(enabled=True, intensity=1.0, entropy=0.8)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not buf:
|
||||
return buf
|
||||
result = list(buf)
|
||||
intensity = self.config.intensity
|
||||
|
||||
glitch_prob = 0.32 + min(0.9, ctx.mic_excess * 0.16)
|
||||
glitch_prob = glitch_prob * intensity
|
||||
n_hits = 4 + int(ctx.mic_excess / 2)
|
||||
n_hits = int(n_hits * intensity)
|
||||
|
||||
if random.random() < glitch_prob:
|
||||
# Store original visible lengths before any modifications
|
||||
# Strip ANSI codes to get visible length
|
||||
import re
|
||||
|
||||
ansi_pattern = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")
|
||||
original_lengths = [len(ansi_pattern.sub("", line)) for line in result]
|
||||
for _ in range(min(n_hits, len(result))):
|
||||
gi = random.randint(0, len(result) - 1)
|
||||
result[gi]
|
||||
target_len = original_lengths[gi] # Use stored original length
|
||||
glitch_bar = self._glitch_bar(target_len)
|
||||
result[gi] = glitch_bar
|
||||
return result
|
||||
|
||||
def _glitch_bar(self, target_len: int) -> str:
|
||||
c = random.choice(["░", "▒", "─", "\xc2"])
|
||||
n = random.randint(3, max(3, target_len // 2))
|
||||
o = random.randint(0, max(0, target_len - n))
|
||||
|
||||
glitch_chars = c * n
|
||||
trailing_spaces = target_len - o - n
|
||||
trailing_spaces = max(0, trailing_spaces)
|
||||
|
||||
glitch_part = f"{G_LO}{DIM}" + glitch_chars + RST
|
||||
result = " " * o + glitch_part + " " * trailing_spaces
|
||||
|
||||
return result
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
102
engine/effects/plugins/hud.py
Normal file
102
engine/effects/plugins/hud.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from engine.effects.types import (
|
||||
EffectConfig,
|
||||
EffectContext,
|
||||
EffectPlugin,
|
||||
PartialUpdate,
|
||||
)
|
||||
|
||||
|
||||
class HudEffect(EffectPlugin):
|
||||
name = "hud"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
supports_partial_updates = True # Enable partial update optimization
|
||||
|
||||
# Cache last HUD content to detect changes
|
||||
_last_hud_content: tuple | None = None
|
||||
|
||||
def process_partial(
|
||||
self, buf: list[str], ctx: EffectContext, partial: PartialUpdate
|
||||
) -> list[str]:
|
||||
# If full buffer requested, process normally
|
||||
if partial.full_buffer:
|
||||
return self.process(buf, ctx)
|
||||
|
||||
# If HUD rows (0, 1, 2) aren't dirty, skip processing
|
||||
if partial.dirty:
|
||||
hud_rows = {0, 1, 2}
|
||||
dirty_hud_rows = partial.dirty & hud_rows
|
||||
if not dirty_hud_rows:
|
||||
return buf # Nothing for HUD to do
|
||||
|
||||
# Proceed with full processing
|
||||
return self.process(buf, ctx)
|
||||
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
result = list(buf)
|
||||
|
||||
# Read metrics from pipeline context (first-class citizen)
|
||||
# Falls back to global monitor for backwards compatibility
|
||||
metrics = ctx.get_state("metrics")
|
||||
if not metrics:
|
||||
# Fallback to global monitor for backwards compatibility
|
||||
from engine.effects.performance import get_monitor
|
||||
|
||||
monitor = get_monitor()
|
||||
if monitor:
|
||||
stats = monitor.get_stats()
|
||||
if stats and "pipeline" in stats:
|
||||
metrics = stats
|
||||
|
||||
fps = 0.0
|
||||
frame_time = 0.0
|
||||
if metrics:
|
||||
if "error" in metrics:
|
||||
pass # No metrics available yet
|
||||
elif "pipeline" in metrics:
|
||||
frame_time = metrics["pipeline"].get("avg_ms", 0.0)
|
||||
frame_count = metrics.get("frame_count", 0)
|
||||
if frame_count > 0 and frame_time > 0:
|
||||
fps = 1000.0 / frame_time
|
||||
elif "avg_ms" in metrics:
|
||||
# Direct metrics format
|
||||
frame_time = metrics.get("avg_ms", 0.0)
|
||||
frame_count = metrics.get("frame_count", 0)
|
||||
if frame_count > 0 and frame_time > 0:
|
||||
fps = 1000.0 / frame_time
|
||||
|
||||
effect_name = self.config.params.get("display_effect", "none")
|
||||
effect_intensity = self.config.params.get("display_intensity", 0.0)
|
||||
|
||||
hud_lines = []
|
||||
hud_lines.append(
|
||||
f"\033[1;1H\033[38;5;46mMAINLINE DEMO\033[0m \033[38;5;245m|\033[0m \033[38;5;39mFPS: {fps:.1f}\033[0m \033[38;5;245m|\033[0m \033[38;5;208m{frame_time:.1f}ms\033[0m"
|
||||
)
|
||||
|
||||
bar_width = 20
|
||||
filled = int(bar_width * effect_intensity)
|
||||
bar = (
|
||||
"\033[38;5;82m"
|
||||
+ "█" * filled
|
||||
+ "\033[38;5;240m"
|
||||
+ "░" * (bar_width - filled)
|
||||
+ "\033[0m"
|
||||
)
|
||||
hud_lines.append(
|
||||
f"\033[2;1H\033[38;5;45mEFFECT:\033[0m \033[1;38;5;227m{effect_name:12s}\033[0m \033[38;5;245m|\033[0m {bar} \033[38;5;245m|\033[0m \033[38;5;219m{effect_intensity * 100:.0f}%\033[0m"
|
||||
)
|
||||
|
||||
# Get pipeline order from context
|
||||
pipeline_order = ctx.get_state("pipeline_order")
|
||||
pipeline_str = ",".join(pipeline_order) if pipeline_order else "(none)"
|
||||
hud_lines.append(f"\033[3;1H\033[38;5;44mPIPELINE:\033[0m {pipeline_str}")
|
||||
|
||||
for i, line in enumerate(hud_lines):
|
||||
if i < len(result):
|
||||
result[i] = line
|
||||
else:
|
||||
result.append(line)
|
||||
|
||||
return result
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
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
|
||||
@@ -5,9 +5,9 @@ from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
from engine.terminal import C_DIM, G_DIM, G_LO, RST, W_GHOST
|
||||
|
||||
|
||||
class NoiseEffect:
|
||||
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:
|
||||
@@ -19,7 +19,8 @@ class NoiseEffect:
|
||||
for r in range(len(result)):
|
||||
cy = ctx.scroll_cam + r
|
||||
if random.random() < probability:
|
||||
result[r] = self._generate_noise(ctx.terminal_width, cy)
|
||||
original_line = result[r]
|
||||
result[r] = self._generate_noise(len(original_line), cy)
|
||||
return result
|
||||
|
||||
def _generate_noise(self, w: int, cy: int) -> str:
|
||||
@@ -32,5 +33,5 @@ class NoiseEffect:
|
||||
for _ in range(w)
|
||||
)
|
||||
|
||||
def configure(self, cfg: EffectConfig) -> None:
|
||||
self.config = cfg
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
99
engine/effects/plugins/tint.py
Normal file
99
engine/effects/plugins/tint.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
|
||||
|
||||
|
||||
class TintEffect(EffectPlugin):
|
||||
"""Tint effect that applies an RGB color overlay to the buffer.
|
||||
|
||||
Uses ANSI escape codes to tint text with the specified RGB values.
|
||||
Supports transparency (0-100%) for blending.
|
||||
|
||||
Inlets:
|
||||
- r: Red component (0-255)
|
||||
- g: Green component (0-255)
|
||||
- b: Blue component (0-255)
|
||||
- a: Alpha/transparency (0.0-1.0, where 0.0 = fully transparent)
|
||||
"""
|
||||
|
||||
name = "tint"
|
||||
config = EffectConfig(enabled=True, intensity=1.0)
|
||||
|
||||
# Define inlet types for PureData-style typing
|
||||
@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, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
if not buf:
|
||||
return buf
|
||||
|
||||
# Get tint values from effect params or sensors
|
||||
r = self.config.params.get("r", 255)
|
||||
g = self.config.params.get("g", 255)
|
||||
b = self.config.params.get("b", 255)
|
||||
a = self.config.params.get("a", 0.3) # Default 30% tint
|
||||
|
||||
# Clamp values
|
||||
r = max(0, min(255, int(r)))
|
||||
g = max(0, min(255, int(g)))
|
||||
b = max(0, min(255, int(b)))
|
||||
a = max(0.0, min(1.0, float(a)))
|
||||
|
||||
if a <= 0:
|
||||
return buf
|
||||
|
||||
# Convert RGB to ANSI 256 color
|
||||
ansi_color = self._rgb_to_ansi256(r, g, b)
|
||||
|
||||
# Apply tint with transparency effect
|
||||
result = []
|
||||
for line in buf:
|
||||
if not line.strip():
|
||||
result.append(line)
|
||||
continue
|
||||
|
||||
# Check if line already has ANSI codes
|
||||
if "\033[" in line:
|
||||
# For lines with existing colors, wrap the whole line
|
||||
result.append(f"\033[38;5;{ansi_color}m{line}\033[0m")
|
||||
else:
|
||||
# Apply tint to plain text lines
|
||||
result.append(f"\033[38;5;{ansi_color}m{line}\033[0m")
|
||||
|
||||
return result
|
||||
|
||||
def _rgb_to_ansi256(self, r: int, g: int, b: int) -> int:
|
||||
"""Convert RGB (0-255 each) to ANSI 256 color code."""
|
||||
if r == g == b == 0:
|
||||
return 16
|
||||
if r == g == b == 255:
|
||||
return 231
|
||||
|
||||
# Calculate grayscale
|
||||
gray = int((0.299 * r + 0.587 * g + 0.114 * b) / 255 * 24) + 232
|
||||
|
||||
# Calculate color cube
|
||||
ri = int(r / 51)
|
||||
gi = int(g / 51)
|
||||
bi = int(b / 51)
|
||||
color = 16 + 36 * ri + 6 * gi + bi
|
||||
|
||||
# Use whichever is closer - gray or color
|
||||
gray_dist = abs(r - gray)
|
||||
color_dist = (
|
||||
(r - ri * 51) ** 2 + (g - gi * 51) ** 2 + (b - bi * 51) ** 2
|
||||
) ** 0.5
|
||||
|
||||
if gray_dist < color_dist:
|
||||
return gray
|
||||
return color
|
||||
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
self.config = config
|
||||
@@ -1,39 +1,281 @@
|
||||
"""
|
||||
Visual effects type definitions and base classes.
|
||||
|
||||
EffectPlugin Architecture:
|
||||
- Uses ABC (Abstract Base Class) for interface enforcement
|
||||
- Runtime discovery via directory scanning (effects_plugins/)
|
||||
- Configuration via EffectConfig dataclass
|
||||
- Context passed through EffectContext dataclass
|
||||
|
||||
Plugin System Research (see AGENTS.md for references):
|
||||
- VST: Standardized audio interfaces, chaining, presets (FXP/FXB)
|
||||
- Python Entry Points: Namespace packages, importlib.metadata discovery
|
||||
- Shadertoy: Shader-based with uniforms as context
|
||||
|
||||
Current gaps vs industry patterns:
|
||||
- No preset save/load system
|
||||
- No external plugin distribution via entry points
|
||||
- No plugin metadata (version, author, description)
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class PartialUpdate:
|
||||
"""Represents a partial buffer update for optimized rendering.
|
||||
|
||||
Instead of processing the full buffer every frame, effects that support
|
||||
partial updates can process only changed regions.
|
||||
|
||||
Attributes:
|
||||
rows: Row indices that changed (None = all rows)
|
||||
cols: Column range that changed (None = full width)
|
||||
dirty: Set of dirty row indices
|
||||
"""
|
||||
|
||||
rows: tuple[int, int] | None = None # (start, end) inclusive
|
||||
cols: tuple[int, int] | None = None # (start, end) inclusive
|
||||
dirty: set[int] | None = None # Set of dirty row indices
|
||||
full_buffer: bool = True # If True, process entire buffer
|
||||
|
||||
|
||||
@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
|
||||
ticker_height: int
|
||||
mic_excess: float
|
||||
grad_offset: float
|
||||
frame_number: int
|
||||
has_message: bool
|
||||
camera_x: int = 0
|
||||
mic_excess: float = 0.0
|
||||
grad_offset: float = 0.0
|
||||
frame_number: int = 0
|
||||
has_message: bool = False
|
||||
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.
|
||||
|
||||
Args:
|
||||
sensor_name: Name of the sensor (e.g., "mic", "camera")
|
||||
|
||||
Returns:
|
||||
Sensor value as float, or None if not available.
|
||||
"""
|
||||
return self._state.get(f"sensor.{sensor_name}")
|
||||
|
||||
def set_state(self, key: str, value: Any) -> None:
|
||||
"""Set a state value in the context."""
|
||||
self._state[key] = value
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""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)
|
||||
|
||||
|
||||
class EffectPlugin:
|
||||
class EffectPlugin(ABC):
|
||||
"""Abstract base class for effect plugins.
|
||||
|
||||
Subclasses must define:
|
||||
- name: str - unique identifier for the effect
|
||||
- config: EffectConfig - current configuration
|
||||
|
||||
Optional class attribute:
|
||||
- param_bindings: dict - Declarative sensor-to-param bindings
|
||||
Example:
|
||||
param_bindings = {
|
||||
"intensity": {"sensor": "mic", "transform": "linear"},
|
||||
"rate": {"sensor": "mic", "transform": "exponential"},
|
||||
}
|
||||
|
||||
And implement:
|
||||
- process(buf, ctx) -> list[str]
|
||||
- configure(config) -> None
|
||||
|
||||
Effect Behavior with ticker_height=0:
|
||||
- NoiseEffect: Returns buffer unchanged (no ticker to apply noise to)
|
||||
- FadeEffect: Returns buffer unchanged (no ticker to fade)
|
||||
- GlitchEffect: Processes normally (doesn't depend on ticker_height)
|
||||
- FirehoseEffect: Returns buffer unchanged if no items in context
|
||||
|
||||
Effects should handle missing or zero context values gracefully by
|
||||
returning the input buffer unchanged rather than raising errors.
|
||||
|
||||
The param_bindings system enables PureData-style signal routing:
|
||||
- Sensors emit values that effects can bind to
|
||||
- Transform functions map sensor values to param ranges
|
||||
- Effects read bound values from context.state["sensor.{name}"]
|
||||
"""
|
||||
|
||||
name: str
|
||||
config: EffectConfig
|
||||
param_bindings: dict[str, dict[str, str | float]] = {}
|
||||
supports_partial_updates: bool = False # Override in subclasses for optimization
|
||||
|
||||
@abstractmethod
|
||||
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
|
||||
raise NotImplementedError
|
||||
"""Process the buffer with this effect applied.
|
||||
|
||||
Args:
|
||||
buf: List of lines to process
|
||||
ctx: Effect context with terminal state
|
||||
|
||||
Returns:
|
||||
Processed buffer (may be same object or new list)
|
||||
"""
|
||||
...
|
||||
|
||||
def process_partial(
|
||||
self, buf: list[str], ctx: EffectContext, partial: PartialUpdate
|
||||
) -> list[str]:
|
||||
"""Process a partial buffer for optimized rendering.
|
||||
|
||||
Override this in subclasses that support partial updates for performance.
|
||||
Default implementation falls back to full buffer processing.
|
||||
|
||||
Args:
|
||||
buf: List of lines to process
|
||||
ctx: Effect context with terminal state
|
||||
partial: PartialUpdate indicating which regions changed
|
||||
|
||||
Returns:
|
||||
Processed buffer (may be same object or new list)
|
||||
"""
|
||||
# Default: fall back to full processing
|
||||
return self.process(buf, ctx)
|
||||
|
||||
@abstractmethod
|
||||
def configure(self, config: EffectConfig) -> None:
|
||||
raise NotImplementedError
|
||||
"""Configure the effect with new settings.
|
||||
|
||||
Args:
|
||||
config: New configuration to apply
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def create_effect_context(
|
||||
terminal_width: int = 80,
|
||||
terminal_height: int = 24,
|
||||
scroll_cam: int = 0,
|
||||
ticker_height: int = 0,
|
||||
mic_excess: float = 0.0,
|
||||
grad_offset: float = 0.0,
|
||||
frame_number: int = 0,
|
||||
has_message: bool = False,
|
||||
items: list | None = None,
|
||||
) -> EffectContext:
|
||||
"""Factory function to create EffectContext with sensible defaults."""
|
||||
return EffectContext(
|
||||
terminal_width=terminal_width,
|
||||
terminal_height=terminal_height,
|
||||
scroll_cam=scroll_cam,
|
||||
ticker_height=ticker_height,
|
||||
mic_excess=mic_excess,
|
||||
grad_offset=grad_offset,
|
||||
frame_number=frame_number,
|
||||
has_message=has_message,
|
||||
items=items or [],
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelineConfig:
|
||||
order: list[str] = field(default_factory=list)
|
||||
effects: dict[str, EffectConfig] = field(default_factory=dict)
|
||||
|
||||
|
||||
def apply_param_bindings(
|
||||
effect: "EffectPlugin",
|
||||
ctx: EffectContext,
|
||||
) -> EffectConfig:
|
||||
"""Apply sensor bindings to effect config.
|
||||
|
||||
This resolves param_bindings declarations by reading sensor values
|
||||
from the context and applying transform functions.
|
||||
|
||||
Args:
|
||||
effect: The effect with param_bindings to apply
|
||||
ctx: EffectContext containing sensor values
|
||||
|
||||
Returns:
|
||||
Modified EffectConfig with sensor-driven values applied.
|
||||
"""
|
||||
import copy
|
||||
|
||||
if not effect.param_bindings:
|
||||
return effect.config
|
||||
|
||||
config = copy.deepcopy(effect.config)
|
||||
|
||||
for param_name, binding in effect.param_bindings.items():
|
||||
sensor_name: str = binding.get("sensor", "")
|
||||
transform: str = binding.get("transform", "linear")
|
||||
|
||||
if not sensor_name:
|
||||
continue
|
||||
|
||||
sensor_value = ctx.get_sensor_value(sensor_name)
|
||||
if sensor_value is None:
|
||||
continue
|
||||
|
||||
if transform == "linear":
|
||||
applied_value: float = sensor_value
|
||||
elif transform == "exponential":
|
||||
applied_value = sensor_value**2
|
||||
elif transform == "threshold":
|
||||
threshold = float(binding.get("threshold", 0.5))
|
||||
applied_value = 1.0 if sensor_value > threshold else 0.0
|
||||
elif transform == "inverse":
|
||||
applied_value = 1.0 - sensor_value
|
||||
else:
|
||||
applied_value = sensor_value
|
||||
|
||||
config.params[f"{param_name}_sensor"] = applied_value
|
||||
|
||||
if param_name == "intensity":
|
||||
base_intensity = effect.config.intensity
|
||||
config.intensity = base_intensity * (0.5 + applied_value * 0.5)
|
||||
|
||||
return config
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""
|
||||
Event emitter protocols - abstract interfaces for event-producing components.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class EventEmitter(Protocol):
|
||||
"""Protocol for components that emit events."""
|
||||
|
||||
def subscribe(self, callback: Callable[[Any], None]) -> None: ...
|
||||
def unsubscribe(self, callback: Callable[[Any], None]) -> None: ...
|
||||
|
||||
|
||||
class Startable(Protocol):
|
||||
"""Protocol for components that can be started."""
|
||||
|
||||
def start(self) -> Any: ...
|
||||
|
||||
|
||||
class Stoppable(Protocol):
|
||||
"""Protocol for components that can be stopped."""
|
||||
|
||||
def stop(self) -> None: ...
|
||||
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": []}
|
||||
73
engine/interfaces/__init__.py
Normal file
73
engine/interfaces/__init__.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Core interfaces for the mainline pipeline architecture.
|
||||
|
||||
This module provides all abstract base classes and protocols that define
|
||||
the contracts between pipeline components:
|
||||
|
||||
- Stage: Base class for pipeline components (imported from pipeline.core)
|
||||
- DataSource: Abstract data providers (imported from data_sources.sources)
|
||||
- EffectPlugin: Visual effects interface (imported from effects.types)
|
||||
- Sensor: Real-time input interface (imported from sensors)
|
||||
- Display: Output backend protocol (imported from display)
|
||||
|
||||
This module provides a centralized import location for all interfaces.
|
||||
"""
|
||||
|
||||
from engine.data_sources.sources import (
|
||||
DataSource,
|
||||
ImageItem,
|
||||
SourceItem,
|
||||
)
|
||||
from engine.display import Display
|
||||
from engine.effects.types import (
|
||||
EffectConfig,
|
||||
EffectContext,
|
||||
EffectPlugin,
|
||||
PartialUpdate,
|
||||
PipelineConfig,
|
||||
apply_param_bindings,
|
||||
create_effect_context,
|
||||
)
|
||||
from engine.pipeline.core import (
|
||||
DataType,
|
||||
Stage,
|
||||
StageConfig,
|
||||
StageError,
|
||||
StageResult,
|
||||
create_stage_error,
|
||||
)
|
||||
from engine.sensors import (
|
||||
Sensor,
|
||||
SensorStage,
|
||||
SensorValue,
|
||||
create_sensor_stage,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Stage interfaces
|
||||
"DataType",
|
||||
"Stage",
|
||||
"StageConfig",
|
||||
"StageError",
|
||||
"StageResult",
|
||||
"create_stage_error",
|
||||
# Data source interfaces
|
||||
"DataSource",
|
||||
"ImageItem",
|
||||
"SourceItem",
|
||||
# Effect interfaces
|
||||
"EffectConfig",
|
||||
"EffectContext",
|
||||
"EffectPlugin",
|
||||
"PartialUpdate",
|
||||
"PipelineConfig",
|
||||
"apply_param_bindings",
|
||||
"create_effect_context",
|
||||
# Sensor interfaces
|
||||
"Sensor",
|
||||
"SensorStage",
|
||||
"SensorValue",
|
||||
"create_sensor_stage",
|
||||
# Display protocol
|
||||
"Display",
|
||||
]
|
||||
260
engine/layers.py
260
engine/layers.py
@@ -1,260 +0,0 @@
|
||||
"""
|
||||
Layer compositing — message overlay, ticker zone, firehose, noise.
|
||||
Depends on: config, render, effects.
|
||||
"""
|
||||
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from engine import config
|
||||
from engine.effects import (
|
||||
EffectChain,
|
||||
EffectContext,
|
||||
fade_line,
|
||||
firehose_line,
|
||||
glitch_bar,
|
||||
noise,
|
||||
vis_trunc,
|
||||
)
|
||||
from engine.render import big_wrap, lr_gradient, lr_gradient_opposite
|
||||
from engine.terminal import RST, W_COOL
|
||||
|
||||
MSG_META = "\033[38;5;245m"
|
||||
MSG_BORDER = "\033[2;38;5;37m"
|
||||
|
||||
|
||||
def render_message_overlay(
|
||||
msg: tuple[str, str, float] | None,
|
||||
w: int,
|
||||
h: int,
|
||||
msg_cache: tuple,
|
||||
) -> tuple[list[str], tuple]:
|
||||
"""Render ntfy message overlay.
|
||||
|
||||
Args:
|
||||
msg: (title, body, timestamp) or None
|
||||
w: terminal width
|
||||
h: terminal height
|
||||
msg_cache: (cache_key, rendered_rows) for caching
|
||||
|
||||
Returns:
|
||||
(list of ANSI strings, updated cache)
|
||||
"""
|
||||
overlay = []
|
||||
if msg is None:
|
||||
return overlay, msg_cache
|
||||
|
||||
m_title, m_body, m_ts = msg
|
||||
display_text = m_body or m_title or "(empty)"
|
||||
display_text = re.sub(r"\s+", " ", display_text.upper())
|
||||
|
||||
cache_key = (display_text, w)
|
||||
if msg_cache[0] != cache_key:
|
||||
msg_rows = big_wrap(display_text, w - 4)
|
||||
msg_cache = (cache_key, msg_rows)
|
||||
else:
|
||||
msg_rows = msg_cache[1]
|
||||
|
||||
msg_rows = lr_gradient_opposite(
|
||||
msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||
)
|
||||
|
||||
elapsed_s = int(time.monotonic() - m_ts)
|
||||
remaining = max(0, config.MESSAGE_DISPLAY_SECS - elapsed_s)
|
||||
ts_str = datetime.now().strftime("%H:%M:%S")
|
||||
panel_h = len(msg_rows) + 2
|
||||
panel_top = max(0, (h - panel_h) // 2)
|
||||
|
||||
row_idx = 0
|
||||
for mr in msg_rows:
|
||||
ln = vis_trunc(mr, w)
|
||||
overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}\033[0m\033[K")
|
||||
row_idx += 1
|
||||
|
||||
meta_parts = []
|
||||
if m_title and m_title != m_body:
|
||||
meta_parts.append(m_title)
|
||||
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s")
|
||||
meta = (
|
||||
" " + " \u00b7 ".join(meta_parts)
|
||||
if len(meta_parts) > 1
|
||||
else " " + meta_parts[0]
|
||||
)
|
||||
overlay.append(f"\033[{panel_top + row_idx + 1};1H{MSG_META}{meta}\033[0m\033[K")
|
||||
row_idx += 1
|
||||
|
||||
bar = "\u2500" * (w - 4)
|
||||
overlay.append(f"\033[{panel_top + row_idx + 1};1H {MSG_BORDER}{bar}\033[0m\033[K")
|
||||
|
||||
return overlay, msg_cache
|
||||
|
||||
|
||||
def render_ticker_zone(
|
||||
active: list,
|
||||
scroll_cam: int,
|
||||
ticker_h: int,
|
||||
w: int,
|
||||
noise_cache: dict,
|
||||
grad_offset: float,
|
||||
) -> tuple[list[str], dict]:
|
||||
"""Render the ticker scroll zone.
|
||||
|
||||
Args:
|
||||
active: list of (content_rows, color, canvas_y, meta_idx)
|
||||
scroll_cam: camera position (viewport top)
|
||||
ticker_h: height of ticker zone
|
||||
w: terminal width
|
||||
noise_cache: dict of cy -> noise string
|
||||
grad_offset: gradient animation offset
|
||||
|
||||
Returns:
|
||||
(list of ANSI strings, updated noise_cache)
|
||||
"""
|
||||
buf = []
|
||||
top_zone = max(1, int(ticker_h * 0.25))
|
||||
bot_zone = max(1, int(ticker_h * 0.10))
|
||||
|
||||
def noise_at(cy):
|
||||
if cy not in noise_cache:
|
||||
noise_cache[cy] = noise(w) if random.random() < 0.15 else None
|
||||
return noise_cache[cy]
|
||||
|
||||
for r in range(ticker_h):
|
||||
scr_row = r + 1
|
||||
cy = scroll_cam + r
|
||||
top_f = min(1.0, r / top_zone) if top_zone > 0 else 1.0
|
||||
bot_f = min(1.0, (ticker_h - 1 - r) / bot_zone) if bot_zone > 0 else 1.0
|
||||
row_fade = min(top_f, bot_f)
|
||||
drawn = False
|
||||
|
||||
for content, hc, by, midx in active:
|
||||
cr = cy - by
|
||||
if 0 <= cr < len(content):
|
||||
raw = content[cr]
|
||||
if cr != midx:
|
||||
colored = lr_gradient([raw], grad_offset)[0]
|
||||
else:
|
||||
colored = raw
|
||||
ln = vis_trunc(colored, w)
|
||||
if row_fade < 1.0:
|
||||
ln = fade_line(ln, row_fade)
|
||||
|
||||
if cr == midx:
|
||||
buf.append(f"\033[{scr_row};1H{W_COOL}{ln}{RST}\033[K")
|
||||
elif ln.strip():
|
||||
buf.append(f"\033[{scr_row};1H{ln}{RST}\033[K")
|
||||
else:
|
||||
buf.append(f"\033[{scr_row};1H\033[K")
|
||||
drawn = True
|
||||
break
|
||||
|
||||
if not drawn:
|
||||
n = noise_at(cy)
|
||||
if row_fade < 1.0 and n:
|
||||
n = fade_line(n, row_fade)
|
||||
if n:
|
||||
buf.append(f"\033[{scr_row};1H{n}")
|
||||
else:
|
||||
buf.append(f"\033[{scr_row};1H\033[K")
|
||||
|
||||
return buf, noise_cache
|
||||
|
||||
|
||||
def apply_glitch(
|
||||
buf: list[str],
|
||||
ticker_buf_start: int,
|
||||
mic_excess: float,
|
||||
w: int,
|
||||
) -> list[str]:
|
||||
"""Apply glitch effect to ticker buffer.
|
||||
|
||||
Args:
|
||||
buf: current buffer
|
||||
ticker_buf_start: index where ticker starts in buffer
|
||||
mic_excess: mic level above threshold
|
||||
w: terminal width
|
||||
|
||||
Returns:
|
||||
Updated buffer with glitches applied
|
||||
"""
|
||||
glitch_prob = 0.32 + min(0.9, mic_excess * 0.16)
|
||||
n_hits = 4 + int(mic_excess / 2)
|
||||
ticker_buf_len = len(buf) - ticker_buf_start
|
||||
|
||||
if random.random() < glitch_prob and ticker_buf_len > 0:
|
||||
for _ in range(min(n_hits, ticker_buf_len)):
|
||||
gi = random.randint(0, ticker_buf_len - 1)
|
||||
scr_row = gi + 1
|
||||
buf[ticker_buf_start + gi] = f"\033[{scr_row};1H{glitch_bar(w)}"
|
||||
|
||||
return buf
|
||||
|
||||
|
||||
def render_firehose(items: list, w: int, fh: int, h: int) -> list[str]:
|
||||
"""Render firehose strip at bottom of screen."""
|
||||
buf = []
|
||||
if fh > 0:
|
||||
for fr in range(fh):
|
||||
scr_row = h - fh + fr + 1
|
||||
fline = firehose_line(items, w)
|
||||
buf.append(f"\033[{scr_row};1H{fline}\033[K")
|
||||
return buf
|
||||
|
||||
|
||||
_effect_chain = None
|
||||
|
||||
|
||||
def init_effects() -> None:
|
||||
"""Initialize effect plugins and chain."""
|
||||
global _effect_chain
|
||||
from engine.effects import EffectChain, get_registry
|
||||
|
||||
registry = get_registry()
|
||||
|
||||
import effects_plugins
|
||||
|
||||
effects_plugins.discover_plugins()
|
||||
|
||||
chain = EffectChain(registry)
|
||||
chain.set_order(["noise", "fade", "glitch", "firehose"])
|
||||
_effect_chain = chain
|
||||
|
||||
|
||||
def process_effects(
|
||||
buf: list[str],
|
||||
w: int,
|
||||
h: int,
|
||||
scroll_cam: int,
|
||||
ticker_h: int,
|
||||
mic_excess: float,
|
||||
grad_offset: float,
|
||||
frame_number: int,
|
||||
has_message: bool,
|
||||
items: list,
|
||||
) -> list[str]:
|
||||
"""Process buffer through effect chain."""
|
||||
if _effect_chain is None:
|
||||
init_effects()
|
||||
|
||||
ctx = EffectContext(
|
||||
terminal_width=w,
|
||||
terminal_height=h,
|
||||
scroll_cam=scroll_cam,
|
||||
ticker_height=ticker_h,
|
||||
mic_excess=mic_excess,
|
||||
grad_offset=grad_offset,
|
||||
frame_number=frame_number,
|
||||
has_message=has_message,
|
||||
items=items,
|
||||
)
|
||||
return _effect_chain.process(buf, ctx)
|
||||
|
||||
|
||||
def get_effect_chain() -> EffectChain | None:
|
||||
"""Get the effect chain instance."""
|
||||
global _effect_chain
|
||||
if _effect_chain is None:
|
||||
init_effects()
|
||||
return _effect_chain
|
||||
@@ -1,96 +0,0 @@
|
||||
"""
|
||||
Microphone input monitor — standalone, no internal dependencies.
|
||||
Gracefully degrades if sounddevice/numpy are unavailable.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
import numpy as _np
|
||||
import sounddevice as _sd
|
||||
|
||||
_HAS_MIC = True
|
||||
except Exception:
|
||||
_HAS_MIC = False
|
||||
|
||||
|
||||
from engine.events import MicLevelEvent
|
||||
|
||||
|
||||
class MicMonitor:
|
||||
"""Background mic stream that exposes current RMS dB level."""
|
||||
|
||||
def __init__(self, threshold_db=50):
|
||||
self.threshold_db = threshold_db
|
||||
self._db = -99.0
|
||||
self._stream = None
|
||||
self._subscribers: list[Callable[[MicLevelEvent], None]] = []
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""True if sounddevice is importable."""
|
||||
return _HAS_MIC
|
||||
|
||||
@property
|
||||
def db(self):
|
||||
"""Current RMS dB level."""
|
||||
return self._db
|
||||
|
||||
@property
|
||||
def excess(self):
|
||||
"""dB above threshold (clamped to 0)."""
|
||||
return max(0.0, self._db - self.threshold_db)
|
||||
|
||||
def subscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||
"""Register a callback to be called when mic level changes."""
|
||||
self._subscribers.append(callback)
|
||||
|
||||
def unsubscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||
"""Remove a registered callback."""
|
||||
if callback in self._subscribers:
|
||||
self._subscribers.remove(callback)
|
||||
|
||||
def _emit(self, event: MicLevelEvent) -> None:
|
||||
"""Emit an event to all subscribers."""
|
||||
for cb in self._subscribers:
|
||||
try:
|
||||
cb(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
"""Start background mic stream. Returns True on success, False/None otherwise."""
|
||||
if not _HAS_MIC:
|
||||
return None
|
||||
|
||||
def _cb(indata, frames, t, status):
|
||||
rms = float(_np.sqrt(_np.mean(indata**2)))
|
||||
self._db = 20 * _np.log10(rms) if rms > 0 else -99.0
|
||||
if self._subscribers:
|
||||
event = MicLevelEvent(
|
||||
db_level=self._db,
|
||||
excess_above_threshold=max(0.0, self._db - self.threshold_db),
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
self._emit(event)
|
||||
|
||||
try:
|
||||
self._stream = _sd.InputStream(
|
||||
callback=_cb, channels=1, samplerate=44100, blocksize=2048
|
||||
)
|
||||
self._stream.start()
|
||||
atexit.register(self.stop)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def stop(self):
|
||||
"""Stop the mic stream if running."""
|
||||
if self._stream:
|
||||
try:
|
||||
self._stream.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._stream = None
|
||||
106
engine/pipeline/__init__.py
Normal file
106
engine/pipeline/__init__.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Unified Pipeline Architecture.
|
||||
|
||||
This module provides a clean, dependency-managed pipeline system:
|
||||
- Stage: Base class for all pipeline components
|
||||
- Pipeline: DAG-based execution orchestrator
|
||||
- PipelineParams: Runtime configuration for animation
|
||||
- PipelinePreset: Pre-configured pipeline configurations
|
||||
- StageRegistry: Unified registration for all stage types
|
||||
|
||||
The pipeline architecture supports:
|
||||
- Sources: Data providers (headlines, poetry, pipeline viz)
|
||||
- Effects: Post-processors (noise, fade, glitch, hud)
|
||||
- Displays: Output backends (terminal, pygame, websocket)
|
||||
- Cameras: Viewport controllers (vertical, horizontal, omni)
|
||||
|
||||
Example:
|
||||
from engine.pipeline import Pipeline, PipelineConfig, StageRegistry
|
||||
|
||||
pipeline = Pipeline(PipelineConfig(source="headlines", display="terminal"))
|
||||
pipeline.add_stage("source", StageRegistry.create("source", "headlines"))
|
||||
pipeline.add_stage("display", StageRegistry.create("display", "terminal"))
|
||||
pipeline.build().initialize()
|
||||
|
||||
result = pipeline.execute(initial_data)
|
||||
"""
|
||||
|
||||
from engine.pipeline.controller import (
|
||||
Pipeline,
|
||||
PipelineConfig,
|
||||
PipelineRunner,
|
||||
create_default_pipeline,
|
||||
create_pipeline_from_params,
|
||||
)
|
||||
from engine.pipeline.core import (
|
||||
PipelineContext,
|
||||
Stage,
|
||||
StageConfig,
|
||||
StageError,
|
||||
StageResult,
|
||||
)
|
||||
from engine.pipeline.params import (
|
||||
DEFAULT_HEADLINE_PARAMS,
|
||||
DEFAULT_PIPELINE_PARAMS,
|
||||
DEFAULT_PYGAME_PARAMS,
|
||||
PipelineParams,
|
||||
)
|
||||
from engine.pipeline.presets import (
|
||||
DEMO_PRESET,
|
||||
FIREHOSE_PRESET,
|
||||
PIPELINE_VIZ_PRESET,
|
||||
POETRY_PRESET,
|
||||
UI_PRESET,
|
||||
WEBSOCKET_PRESET,
|
||||
PipelinePreset,
|
||||
create_preset_from_params,
|
||||
get_preset,
|
||||
list_presets,
|
||||
)
|
||||
from engine.pipeline.registry import (
|
||||
StageRegistry,
|
||||
discover_stages,
|
||||
register_camera,
|
||||
register_display,
|
||||
register_effect,
|
||||
register_source,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Core
|
||||
"Stage",
|
||||
"StageConfig",
|
||||
"StageError",
|
||||
"StageResult",
|
||||
"PipelineContext",
|
||||
# Controller
|
||||
"Pipeline",
|
||||
"PipelineConfig",
|
||||
"PipelineRunner",
|
||||
"create_default_pipeline",
|
||||
"create_pipeline_from_params",
|
||||
# Params
|
||||
"PipelineParams",
|
||||
"DEFAULT_HEADLINE_PARAMS",
|
||||
"DEFAULT_PIPELINE_PARAMS",
|
||||
"DEFAULT_PYGAME_PARAMS",
|
||||
# Presets
|
||||
"PipelinePreset",
|
||||
"PRESETS",
|
||||
"DEMO_PRESET",
|
||||
"POETRY_PRESET",
|
||||
"PIPELINE_VIZ_PRESET",
|
||||
"WEBSOCKET_PRESET",
|
||||
"FIREHOSE_PRESET",
|
||||
"UI_PRESET",
|
||||
"get_preset",
|
||||
"list_presets",
|
||||
"create_preset_from_params",
|
||||
# Registry
|
||||
"StageRegistry",
|
||||
"discover_stages",
|
||||
"register_source",
|
||||
"register_effect",
|
||||
"register_display",
|
||||
"register_camera",
|
||||
]
|
||||
50
engine/pipeline/adapters.py
Normal file
50
engine/pipeline/adapters.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
# 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
|
||||
1055
engine/pipeline/controller.py
Normal file
1055
engine/pipeline/controller.py
Normal file
File diff suppressed because it is too large
Load Diff
321
engine/pipeline/core.py
Normal file
321
engine/pipeline/core.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""
|
||||
Pipeline core - Unified Stage abstraction and PipelineContext.
|
||||
|
||||
This module provides the foundation for a clean, dependency-managed pipeline:
|
||||
- Stage: Base class for all pipeline components (sources, effects, displays, cameras)
|
||||
- PipelineContext: Dependency injection context for runtime data exchange
|
||||
- Capability system: Explicit capability declarations with duck-typing support
|
||||
- DataType: PureData-style inlet/outlet typing for validation
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from engine.pipeline.params import PipelineParams
|
||||
|
||||
|
||||
class DataType(Enum):
|
||||
"""PureData-style data types for inlet/outlet validation.
|
||||
|
||||
Each type represents a specific data format that flows through the pipeline.
|
||||
This enables compile-time-like validation of connections.
|
||||
|
||||
Examples:
|
||||
SOURCE_ITEMS: List[SourceItem] - raw items from sources
|
||||
ITEM_TUPLES: List[tuple] - (title, source, timestamp) tuples
|
||||
TEXT_BUFFER: List[str] - rendered ANSI buffer for display
|
||||
RAW_TEXT: str - raw text strings
|
||||
PIL_IMAGE: PIL Image object
|
||||
"""
|
||||
|
||||
SOURCE_ITEMS = auto() # List[SourceItem] - from DataSource
|
||||
ITEM_TUPLES = auto() # List[tuple] - (title, source, ts)
|
||||
TEXT_BUFFER = auto() # List[str] - ANSI buffer
|
||||
RAW_TEXT = auto() # str - raw text
|
||||
PIL_IMAGE = auto() # PIL Image object
|
||||
ANY = auto() # Accepts any type
|
||||
NONE = auto() # No data (terminator)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageConfig:
|
||||
"""Configuration for a single stage."""
|
||||
|
||||
name: str
|
||||
category: str
|
||||
enabled: bool = True
|
||||
optional: bool = False
|
||||
params: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class Stage(ABC):
|
||||
"""Abstract base class for all pipeline stages.
|
||||
|
||||
A Stage is a single component in the rendering pipeline. Stages can be:
|
||||
- Sources: Data providers (headlines, poetry, pipeline viz)
|
||||
- Effects: Post-processors (noise, fade, glitch, hud)
|
||||
- Displays: Output backends (terminal, pygame, websocket)
|
||||
- Cameras: Viewport controllers (vertical, horizontal, omni)
|
||||
- Overlays: UI elements that compose on top (HUD)
|
||||
|
||||
Stages declare:
|
||||
- capabilities: What they provide to other stages
|
||||
- dependencies: What they need from other stages
|
||||
- stage_type: Category of stage (source, effect, overlay, display)
|
||||
- render_order: Execution order within category
|
||||
- is_overlay: If True, output is composited on top, not passed downstream
|
||||
|
||||
Duck-typing is supported: any class with the required methods can act as a Stage.
|
||||
"""
|
||||
|
||||
name: str
|
||||
category: str # "source", "effect", "overlay", "display", "camera"
|
||||
optional: bool = False # If True, pipeline continues even if stage fails
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
"""Category of stage for ordering.
|
||||
|
||||
Valid values: "source", "effect", "overlay", "display", "camera"
|
||||
Defaults to category for backwards compatibility.
|
||||
"""
|
||||
return self.category
|
||||
|
||||
@property
|
||||
def render_order(self) -> int:
|
||||
"""Execution order within stage_type group.
|
||||
|
||||
Higher values execute later. Useful for ordering overlays
|
||||
or effects that need specific execution order.
|
||||
"""
|
||||
return 0
|
||||
|
||||
@property
|
||||
def is_overlay(self) -> bool:
|
||||
"""If True, this stage's output is composited on top of the buffer.
|
||||
|
||||
Overlay stages don't pass their output to the next stage.
|
||||
Instead, their output is layered on top of the final buffer.
|
||||
Use this for HUD, status displays, and similar UI elements.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set[DataType]:
|
||||
"""Return set of data types this stage accepts.
|
||||
|
||||
PureData-style inlet typing. If the connected upstream stage's
|
||||
outlet_type is not in this set, the pipeline will raise an error.
|
||||
|
||||
Examples:
|
||||
- Source stages: {DataType.NONE} (no input needed)
|
||||
- Transform stages: {DataType.ITEM_TUPLES, DataType.TEXT_BUFFER}
|
||||
- Display stages: {DataType.TEXT_BUFFER}
|
||||
"""
|
||||
return {DataType.ANY}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set[DataType]:
|
||||
"""Return set of data types this stage produces.
|
||||
|
||||
PureData-style outlet typing. Downstream stages must accept
|
||||
this type in their inlet_types.
|
||||
|
||||
Examples:
|
||||
- Source stages: {DataType.SOURCE_ITEMS}
|
||||
- Transform stages: {DataType.TEXT_BUFFER}
|
||||
- Display stages: {DataType.NONE} (consumes data)
|
||||
"""
|
||||
return {DataType.ANY}
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
"""Return set of capabilities this stage provides.
|
||||
|
||||
Examples:
|
||||
- "source.headlines"
|
||||
- "effect.noise"
|
||||
- "display.output"
|
||||
- "camera"
|
||||
"""
|
||||
return {f"{self.category}.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
"""Return set of capability names this stage needs.
|
||||
|
||||
Examples:
|
||||
- {"display.output"}
|
||||
- {"source.headlines"}
|
||||
- {"camera"}
|
||||
"""
|
||||
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.
|
||||
|
||||
Args:
|
||||
ctx: PipelineContext for accessing services
|
||||
|
||||
Returns:
|
||||
True if initialization succeeded, False otherwise
|
||||
"""
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
def process(self, data: Any, ctx: "PipelineContext") -> Any:
|
||||
"""Process input data and return output.
|
||||
|
||||
Args:
|
||||
data: Input data from previous stage (or initial data for first stage)
|
||||
ctx: PipelineContext for accessing services and state
|
||||
|
||||
Returns:
|
||||
Processed data for next stage
|
||||
"""
|
||||
...
|
||||
|
||||
def cleanup(self) -> None: # noqa: B027
|
||||
"""Clean up resources when pipeline shuts down."""
|
||||
pass
|
||||
|
||||
def get_config(self) -> StageConfig:
|
||||
"""Return current configuration of this stage."""
|
||||
return StageConfig(
|
||||
name=self.name,
|
||||
category=self.category,
|
||||
optional=self.optional,
|
||||
)
|
||||
|
||||
def set_enabled(self, enabled: bool) -> None:
|
||||
"""Enable or disable this stage."""
|
||||
self._enabled = enabled # type: ignore[attr-defined]
|
||||
|
||||
def is_enabled(self) -> bool:
|
||||
"""Check if stage is enabled."""
|
||||
return getattr(self, "_enabled", True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StageResult:
|
||||
"""Result of stage processing, including success/failure info."""
|
||||
|
||||
success: bool
|
||||
data: Any
|
||||
error: str | None = None
|
||||
stage_name: str = ""
|
||||
|
||||
|
||||
class PipelineContext:
|
||||
"""Dependency injection context passed through the pipeline.
|
||||
|
||||
Provides:
|
||||
- services: Named services (display, config, event_bus, etc.)
|
||||
- state: Runtime state shared between stages
|
||||
- params: PipelineParams for animation-driven config
|
||||
|
||||
Services can be injected at construction time or lazily resolved.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
services: dict[str, Any] | None = None,
|
||||
initial_state: dict[str, Any] | None = None,
|
||||
):
|
||||
self.services: dict[str, Any] = services or {}
|
||||
self.state: dict[str, Any] = initial_state or {}
|
||||
self._params: PipelineParams | None = None
|
||||
|
||||
# Lazy resolvers for common services
|
||||
self._lazy_resolvers: dict[str, Callable[[], Any]] = {
|
||||
"config": self._resolve_config,
|
||||
"event_bus": self._resolve_event_bus,
|
||||
}
|
||||
|
||||
def _resolve_config(self) -> Any:
|
||||
from engine.config import get_config
|
||||
|
||||
return get_config()
|
||||
|
||||
def _resolve_event_bus(self) -> Any:
|
||||
from engine.eventbus import get_event_bus
|
||||
|
||||
return get_event_bus()
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a service or state value by key.
|
||||
|
||||
First checks services, then state, then lazy resolution.
|
||||
"""
|
||||
if key in self.services:
|
||||
return self.services[key]
|
||||
if key in self.state:
|
||||
return self.state[key]
|
||||
if key in self._lazy_resolvers:
|
||||
try:
|
||||
return self._lazy_resolvers[key]()
|
||||
except Exception:
|
||||
return default
|
||||
return default
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set a service or state value."""
|
||||
self.services[key] = value
|
||||
|
||||
def set_state(self, key: str, value: Any) -> None:
|
||||
"""Set a runtime state value."""
|
||||
self.state[key] = value
|
||||
|
||||
def get_state(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a runtime state value."""
|
||||
return self.state.get(key, default)
|
||||
|
||||
@property
|
||||
def params(self) -> "PipelineParams | None":
|
||||
"""Get current pipeline params (for animation)."""
|
||||
return self._params
|
||||
|
||||
@params.setter
|
||||
def params(self, value: "PipelineParams") -> None:
|
||||
"""Set pipeline params (from animation controller)."""
|
||||
self._params = value
|
||||
|
||||
def has_capability(self, capability: str) -> bool:
|
||||
"""Check if a capability is available."""
|
||||
return capability in self.services or capability in self._lazy_resolvers
|
||||
|
||||
|
||||
class StageError(Exception):
|
||||
"""Raised when a stage fails to process."""
|
||||
|
||||
def __init__(self, stage_name: str, message: str, is_optional: bool = False):
|
||||
self.stage_name = stage_name
|
||||
self.message = message
|
||||
self.is_optional = is_optional
|
||||
super().__init__(f"Stage '{stage_name}' failed: {message}")
|
||||
|
||||
|
||||
def create_stage_error(
|
||||
stage_name: str, error: Exception, is_optional: bool = False
|
||||
) -> StageError:
|
||||
"""Helper to create a StageError from an exception."""
|
||||
return StageError(stage_name, str(error), is_optional)
|
||||
150
engine/pipeline/params.py
Normal file
150
engine/pipeline/params.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Pipeline parameters - Runtime configuration layer for animation control.
|
||||
|
||||
PipelineParams is the target for AnimationController - animation events
|
||||
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:
|
||||
"""Runtime configuration for the pipeline.
|
||||
|
||||
This is the canonical config object that AnimationController modifies.
|
||||
Stages read from these params to adjust their behavior.
|
||||
"""
|
||||
|
||||
# Source config
|
||||
source: str = "headlines"
|
||||
source_refresh_interval: float = 60.0
|
||||
|
||||
# Display config
|
||||
display: str = "terminal"
|
||||
border: bool | BorderMode = False
|
||||
|
||||
# Camera config
|
||||
camera_mode: str = "vertical"
|
||||
camera_speed: float = 1.0 # Default speed
|
||||
camera_x: int = 0 # For horizontal scrolling
|
||||
|
||||
# Effect config
|
||||
effect_order: list[str] = field(
|
||||
default_factory=lambda: ["noise", "fade", "glitch", "firehose"]
|
||||
)
|
||||
effect_enabled: dict[str, bool] = field(default_factory=dict)
|
||||
effect_intensity: dict[str, float] = field(default_factory=dict)
|
||||
|
||||
# Animation-driven state (set by AnimationController)
|
||||
pulse: float = 0.0
|
||||
current_effect: str | None = None
|
||||
path_progress: float = 0.0
|
||||
|
||||
# Viewport
|
||||
viewport_width: int = 80
|
||||
viewport_height: int = 24
|
||||
|
||||
# Firehose
|
||||
firehose_enabled: bool = False
|
||||
|
||||
# Runtime state
|
||||
frame_number: int = 0
|
||||
fps: float = 60.0
|
||||
|
||||
def get_effect_config(self, name: str) -> tuple[bool, float]:
|
||||
"""Get (enabled, intensity) for an effect."""
|
||||
enabled = self.effect_enabled.get(name, True)
|
||||
intensity = self.effect_intensity.get(name, 1.0)
|
||||
return enabled, intensity
|
||||
|
||||
def set_effect_config(self, name: str, enabled: bool, intensity: float) -> None:
|
||||
"""Set effect configuration."""
|
||||
self.effect_enabled[name] = enabled
|
||||
self.effect_intensity[name] = intensity
|
||||
|
||||
def is_effect_enabled(self, name: str) -> bool:
|
||||
"""Check if an effect is enabled."""
|
||||
if name not in self.effect_enabled:
|
||||
return True # Default to enabled
|
||||
return self.effect_enabled.get(name, True)
|
||||
|
||||
def get_effect_intensity(self, name: str) -> float:
|
||||
"""Get effect intensity (0.0 to 1.0)."""
|
||||
return self.effect_intensity.get(name, 1.0)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"source": self.source,
|
||||
"display": self.display,
|
||||
"camera_mode": self.camera_mode,
|
||||
"camera_speed": self.camera_speed,
|
||||
"effect_order": self.effect_order,
|
||||
"effect_enabled": self.effect_enabled.copy(),
|
||||
"effect_intensity": self.effect_intensity.copy(),
|
||||
"pulse": self.pulse,
|
||||
"current_effect": self.current_effect,
|
||||
"viewport_width": self.viewport_width,
|
||||
"viewport_height": self.viewport_height,
|
||||
"firehose_enabled": self.firehose_enabled,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "PipelineParams":
|
||||
"""Create from dictionary."""
|
||||
params = cls()
|
||||
for key, value in data.items():
|
||||
if hasattr(params, key):
|
||||
setattr(params, key, value)
|
||||
return params
|
||||
|
||||
def copy(self) -> "PipelineParams":
|
||||
"""Create a copy of this params object."""
|
||||
params = PipelineParams()
|
||||
params.source = self.source
|
||||
params.display = self.display
|
||||
params.camera_mode = self.camera_mode
|
||||
params.camera_speed = self.camera_speed
|
||||
params.camera_x = self.camera_x
|
||||
params.effect_order = self.effect_order.copy()
|
||||
params.effect_enabled = self.effect_enabled.copy()
|
||||
params.effect_intensity = self.effect_intensity.copy()
|
||||
params.pulse = self.pulse
|
||||
params.current_effect = self.current_effect
|
||||
params.path_progress = self.path_progress
|
||||
params.viewport_width = self.viewport_width
|
||||
params.viewport_height = self.viewport_height
|
||||
params.firehose_enabled = self.firehose_enabled
|
||||
params.frame_number = self.frame_number
|
||||
params.fps = self.fps
|
||||
return params
|
||||
|
||||
|
||||
# Default params for different modes
|
||||
DEFAULT_HEADLINE_PARAMS = PipelineParams(
|
||||
source="headlines",
|
||||
display="terminal",
|
||||
camera_mode="vertical",
|
||||
effect_order=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
DEFAULT_PYGAME_PARAMS = PipelineParams(
|
||||
source="headlines",
|
||||
display="pygame",
|
||||
camera_mode="vertical",
|
||||
effect_order=["noise", "fade", "glitch", "firehose"],
|
||||
)
|
||||
|
||||
DEFAULT_PIPELINE_PARAMS = PipelineParams(
|
||||
source="pipeline",
|
||||
display="pygame",
|
||||
camera_mode="trace",
|
||||
effect_order=[], # No effects for pipeline viz
|
||||
)
|
||||
300
engine/pipeline/pipeline_introspection_demo.py
Normal file
300
engine/pipeline/pipeline_introspection_demo.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
Pipeline introspection demo controller - 3-phase animation system.
|
||||
|
||||
Phase 1: Toggle each effect on/off one at a time (3s each, 1s gap)
|
||||
Phase 2: LFO drives intensity default → max → min → default for each effect
|
||||
Phase 3: All effects with shared LFO driving full waveform
|
||||
|
||||
This controller manages the animation and updates the pipeline accordingly.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from typing import Any
|
||||
|
||||
from engine.effects import get_registry
|
||||
from engine.sensors.oscillator import OscillatorSensor
|
||||
|
||||
|
||||
class DemoPhase(Enum):
|
||||
"""The three phases of the pipeline introspection demo."""
|
||||
|
||||
PHASE_1_TOGGLE = auto() # Toggle each effect on/off
|
||||
PHASE_2_LFO = auto() # LFO drives intensity up/down
|
||||
PHASE_3_SHARED_LFO = auto() # All effects with shared LFO
|
||||
|
||||
|
||||
@dataclass
|
||||
class PhaseState:
|
||||
"""State for a single phase of the demo."""
|
||||
|
||||
phase: DemoPhase
|
||||
start_time: float
|
||||
current_effect_index: int = 0
|
||||
effect_start_time: float = 0.0
|
||||
lfo_phase: float = 0.0 # 0.0 to 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class DemoConfig:
|
||||
"""Configuration for the demo animation."""
|
||||
|
||||
effect_cycle_duration: float = 3.0 # seconds per effect
|
||||
gap_duration: float = 1.0 # seconds between effects
|
||||
lfo_duration: float = (
|
||||
4.0 # seconds for full LFO cycle (default → max → min → default)
|
||||
)
|
||||
phase_2_effect_duration: float = 4.0 # seconds per effect in phase 2
|
||||
phase_3_lfo_duration: float = 6.0 # seconds for full waveform in phase 3
|
||||
|
||||
|
||||
class PipelineIntrospectionDemo:
|
||||
"""Controller for the 3-phase pipeline introspection demo.
|
||||
|
||||
Manages effect toggling and LFO modulation across the pipeline.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pipeline: Any,
|
||||
effect_names: list[str] | None = None,
|
||||
config: DemoConfig | None = None,
|
||||
):
|
||||
self._pipeline = pipeline
|
||||
self._config = config or DemoConfig()
|
||||
self._effect_names = effect_names or ["noise", "fade", "glitch", "firehose"]
|
||||
self._phase = DemoPhase.PHASE_1_TOGGLE
|
||||
self._phase_state = PhaseState(
|
||||
phase=DemoPhase.PHASE_1_TOGGLE,
|
||||
start_time=time.time(),
|
||||
)
|
||||
self._shared_oscillator: OscillatorSensor | None = None
|
||||
self._frame = 0
|
||||
|
||||
# Register shared oscillator for phase 3
|
||||
self._shared_oscillator = OscillatorSensor(
|
||||
name="demo-lfo",
|
||||
waveform="sine",
|
||||
frequency=1.0 / self._config.phase_3_lfo_duration,
|
||||
)
|
||||
|
||||
@property
|
||||
def phase(self) -> DemoPhase:
|
||||
return self._phase
|
||||
|
||||
@property
|
||||
def phase_display(self) -> str:
|
||||
"""Get a human-readable phase description."""
|
||||
phase_num = {
|
||||
DemoPhase.PHASE_1_TOGGLE: 1,
|
||||
DemoPhase.PHASE_2_LFO: 2,
|
||||
DemoPhase.PHASE_3_SHARED_LFO: 3,
|
||||
}
|
||||
return f"Phase {phase_num[self._phase]}"
|
||||
|
||||
@property
|
||||
def effect_names(self) -> list[str]:
|
||||
return self._effect_names
|
||||
|
||||
@property
|
||||
def shared_oscillator(self) -> OscillatorSensor | None:
|
||||
return self._shared_oscillator
|
||||
|
||||
def update(self) -> dict[str, Any]:
|
||||
"""Update the demo state and return current parameters.
|
||||
|
||||
Returns:
|
||||
dict with current effect settings for the pipeline
|
||||
"""
|
||||
self._frame += 1
|
||||
current_time = time.time()
|
||||
elapsed = current_time - self._phase_state.start_time
|
||||
|
||||
# Phase transition logic
|
||||
phase_duration = self._get_phase_duration()
|
||||
if elapsed >= phase_duration:
|
||||
self._advance_phase()
|
||||
|
||||
# Update based on current phase
|
||||
if self._phase == DemoPhase.PHASE_1_TOGGLE:
|
||||
return self._update_phase_1(current_time)
|
||||
elif self._phase == DemoPhase.PHASE_2_LFO:
|
||||
return self._update_phase_2(current_time)
|
||||
else:
|
||||
return self._update_phase_3(current_time)
|
||||
|
||||
def _get_phase_duration(self) -> float:
|
||||
"""Get duration of current phase in seconds."""
|
||||
if self._phase == DemoPhase.PHASE_1_TOGGLE:
|
||||
# Duration = (effect_time + gap) * num_effects + final_gap
|
||||
return (
|
||||
self._config.effect_cycle_duration + self._config.gap_duration
|
||||
) * len(self._effect_names) + self._config.gap_duration
|
||||
elif self._phase == DemoPhase.PHASE_2_LFO:
|
||||
return self._config.phase_2_effect_duration * len(self._effect_names)
|
||||
else:
|
||||
# Phase 3 runs indefinitely
|
||||
return float("inf")
|
||||
|
||||
def _advance_phase(self) -> None:
|
||||
"""Advance to the next phase."""
|
||||
if self._phase == DemoPhase.PHASE_1_TOGGLE:
|
||||
self._phase = DemoPhase.PHASE_2_LFO
|
||||
elif self._phase == DemoPhase.PHASE_2_LFO:
|
||||
self._phase = DemoPhase.PHASE_3_SHARED_LFO
|
||||
# Start the shared oscillator
|
||||
if self._shared_oscillator:
|
||||
self._shared_oscillator.start()
|
||||
else:
|
||||
# Phase 3 loops indefinitely - reset for demo replay after long time
|
||||
self._phase = DemoPhase.PHASE_1_TOGGLE
|
||||
|
||||
self._phase_state = PhaseState(
|
||||
phase=self._phase,
|
||||
start_time=time.time(),
|
||||
)
|
||||
|
||||
def _update_phase_1(self, current_time: float) -> dict[str, Any]:
|
||||
"""Phase 1: Toggle each effect on/off one at a time."""
|
||||
effect_time = current_time - self._phase_state.effect_start_time
|
||||
|
||||
# Check if we should move to next effect
|
||||
cycle_time = self._config.effect_cycle_duration + self._config.gap_duration
|
||||
effect_index = int((current_time - self._phase_state.start_time) / cycle_time)
|
||||
|
||||
# Clamp to valid range
|
||||
if effect_index >= len(self._effect_names):
|
||||
effect_index = len(self._effect_names) - 1
|
||||
|
||||
# Calculate current effect state
|
||||
in_gap = effect_time >= self._config.effect_cycle_duration
|
||||
|
||||
# Build effect states
|
||||
effect_states: dict[str, dict[str, Any]] = {}
|
||||
for i, name in enumerate(self._effect_names):
|
||||
if i < effect_index:
|
||||
# Past effects - leave at default
|
||||
effect_states[name] = {"enabled": False, "intensity": 0.5}
|
||||
elif i == effect_index:
|
||||
# Current effect - toggle on/off
|
||||
if in_gap:
|
||||
effect_states[name] = {"enabled": False, "intensity": 0.5}
|
||||
else:
|
||||
effect_states[name] = {"enabled": True, "intensity": 1.0}
|
||||
else:
|
||||
# Future effects - off
|
||||
effect_states[name] = {"enabled": False, "intensity": 0.5}
|
||||
|
||||
# Apply to effect registry
|
||||
self._apply_effect_states(effect_states)
|
||||
|
||||
return {
|
||||
"phase": "PHASE_1_TOGGLE",
|
||||
"phase_display": self.phase_display,
|
||||
"current_effect": self._effect_names[effect_index]
|
||||
if effect_index < len(self._effect_names)
|
||||
else None,
|
||||
"effect_states": effect_states,
|
||||
"frame": self._frame,
|
||||
}
|
||||
|
||||
def _update_phase_2(self, current_time: float) -> dict[str, Any]:
|
||||
"""Phase 2: LFO drives intensity default → max → min → default."""
|
||||
elapsed = current_time - self._phase_state.start_time
|
||||
effect_index = int(elapsed / self._config.phase_2_effect_duration)
|
||||
effect_index = min(effect_index, len(self._effect_names) - 1)
|
||||
|
||||
# Calculate LFO position (0 → 1 → 0)
|
||||
effect_elapsed = elapsed % self._config.phase_2_effect_duration
|
||||
lfo_position = effect_elapsed / self._config.phase_2_effect_duration
|
||||
|
||||
# LFO: 0 → 1 → 0 (triangle wave)
|
||||
if lfo_position < 0.5:
|
||||
lfo_value = lfo_position * 2 # 0 → 1
|
||||
else:
|
||||
lfo_value = 2 - lfo_position * 2 # 1 → 0
|
||||
|
||||
# Map to intensity: 0.3 (default) → 1.0 (max) → 0.0 (min) → 0.3 (default)
|
||||
if lfo_position < 0.25:
|
||||
# 0.3 → 1.0
|
||||
intensity = 0.3 + (lfo_position / 0.25) * 0.7
|
||||
elif lfo_position < 0.75:
|
||||
# 1.0 → 0.0
|
||||
intensity = 1.0 - ((lfo_position - 0.25) / 0.5) * 1.0
|
||||
else:
|
||||
# 0.0 → 0.3
|
||||
intensity = ((lfo_position - 0.75) / 0.25) * 0.3
|
||||
|
||||
# Build effect states
|
||||
effect_states: dict[str, dict[str, Any]] = {}
|
||||
for i, name in enumerate(self._effect_names):
|
||||
if i < effect_index:
|
||||
# Past effects - default
|
||||
effect_states[name] = {"enabled": True, "intensity": 0.5}
|
||||
elif i == effect_index:
|
||||
# Current effect - LFO modulated
|
||||
effect_states[name] = {"enabled": True, "intensity": intensity}
|
||||
else:
|
||||
# Future effects - off
|
||||
effect_states[name] = {"enabled": False, "intensity": 0.5}
|
||||
|
||||
# Apply to effect registry
|
||||
self._apply_effect_states(effect_states)
|
||||
|
||||
return {
|
||||
"phase": "PHASE_2_LFO",
|
||||
"phase_display": self.phase_display,
|
||||
"current_effect": self._effect_names[effect_index],
|
||||
"lfo_value": lfo_value,
|
||||
"intensity": intensity,
|
||||
"effect_states": effect_states,
|
||||
"frame": self._frame,
|
||||
}
|
||||
|
||||
def _update_phase_3(self, current_time: float) -> dict[str, Any]:
|
||||
"""Phase 3: All effects with shared LFO driving full waveform."""
|
||||
# Read shared oscillator
|
||||
lfo_value = 0.5 # Default
|
||||
if self._shared_oscillator:
|
||||
sensor_val = self._shared_oscillator.read()
|
||||
if sensor_val:
|
||||
lfo_value = sensor_val.value
|
||||
|
||||
# All effects enabled with shared LFO
|
||||
effect_states: dict[str, dict[str, Any]] = {}
|
||||
for name in self._effect_names:
|
||||
effect_states[name] = {"enabled": True, "intensity": lfo_value}
|
||||
|
||||
# Apply to effect registry
|
||||
self._apply_effect_states(effect_states)
|
||||
|
||||
return {
|
||||
"phase": "PHASE_3_SHARED_LFO",
|
||||
"phase_display": self.phase_display,
|
||||
"lfo_value": lfo_value,
|
||||
"effect_states": effect_states,
|
||||
"frame": self._frame,
|
||||
}
|
||||
|
||||
def _apply_effect_states(self, effect_states: dict[str, dict[str, Any]]) -> None:
|
||||
"""Apply effect states to the effect registry."""
|
||||
try:
|
||||
registry = get_registry()
|
||||
for name, state in effect_states.items():
|
||||
effect = registry.get(name)
|
||||
if effect:
|
||||
effect.config.enabled = state["enabled"]
|
||||
effect.config.intensity = state["intensity"]
|
||||
except Exception:
|
||||
pass # Silently fail if registry not available
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
if self._shared_oscillator:
|
||||
self._shared_oscillator.stop()
|
||||
|
||||
# Reset all effects to default
|
||||
self._apply_effect_states(
|
||||
{name: {"enabled": False, "intensity": 0.5} for name in self._effect_names}
|
||||
)
|
||||
280
engine/pipeline/preset_loader.py
Normal file
280
engine/pipeline/preset_loader.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Preset loader - Loads presets from TOML files.
|
||||
|
||||
Supports:
|
||||
- Built-in presets.toml in the package
|
||||
- User overrides in ~/.config/mainline/presets.toml
|
||||
- Local override in ./presets.toml
|
||||
- Fallback DEFAULT_PRESET when loading fails
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import tomllib
|
||||
|
||||
DEFAULT_PRESET: dict[str, Any] = {
|
||||
"description": "Default fallback preset",
|
||||
"source": "headlines",
|
||||
"display": "terminal",
|
||||
"camera": "vertical",
|
||||
"effects": [],
|
||||
"viewport": {"width": 80, "height": 24},
|
||||
"camera_speed": 1.0,
|
||||
"firehose_enabled": False,
|
||||
}
|
||||
|
||||
|
||||
def get_preset_paths() -> list[Path]:
|
||||
"""Get list of preset file paths in load order (later overrides earlier)."""
|
||||
paths = []
|
||||
|
||||
builtin = Path(__file__).parent.parent / "presets.toml"
|
||||
if builtin.exists():
|
||||
paths.append(builtin)
|
||||
|
||||
user_config = Path(os.path.expanduser("~/.config/mainline/presets.toml"))
|
||||
if user_config.exists():
|
||||
paths.append(user_config)
|
||||
|
||||
local = Path("presets.toml")
|
||||
if local.exists():
|
||||
paths.append(local)
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def load_presets() -> dict[str, Any]:
|
||||
"""Load all presets, merging from multiple sources."""
|
||||
merged: dict[str, Any] = {"presets": {}, "sensors": {}, "effect_configs": {}}
|
||||
|
||||
for path in get_preset_paths():
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
if "presets" in data:
|
||||
merged["presets"].update(data["presets"])
|
||||
|
||||
if "sensors" in data:
|
||||
merged["sensors"].update(data["sensors"])
|
||||
|
||||
if "effect_configs" in data:
|
||||
merged["effect_configs"].update(data["effect_configs"])
|
||||
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load presets from {path}: {e}")
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
def get_preset(name: str) -> dict[str, Any] | None:
|
||||
"""Get a preset by name."""
|
||||
presets = load_presets()
|
||||
return presets.get("presets", {}).get(name)
|
||||
|
||||
|
||||
def list_preset_names() -> list[str]:
|
||||
"""List all available preset names."""
|
||||
presets = load_presets()
|
||||
return list(presets.get("presets", {}).keys())
|
||||
|
||||
|
||||
def get_sensor_config(name: str) -> dict[str, Any] | None:
|
||||
"""Get sensor configuration by name."""
|
||||
sensors = load_presets()
|
||||
return sensors.get("sensors", {}).get(name)
|
||||
|
||||
|
||||
def get_effect_config(name: str) -> dict[str, Any] | None:
|
||||
"""Get effect configuration by name."""
|
||||
configs = load_presets()
|
||||
return configs.get("effect_configs", {}).get(name)
|
||||
|
||||
|
||||
def get_all_effect_configs() -> dict[str, Any]:
|
||||
"""Get all effect configurations."""
|
||||
configs = load_presets()
|
||||
return configs.get("effect_configs", {})
|
||||
|
||||
|
||||
def get_preset_or_default(name: str) -> dict[str, Any]:
|
||||
"""Get a preset by name, or return DEFAULT_PRESET if not found."""
|
||||
preset = get_preset(name)
|
||||
if preset is not None:
|
||||
return preset
|
||||
return DEFAULT_PRESET.copy()
|
||||
|
||||
|
||||
def ensure_preset_available(name: str | None) -> dict[str, Any]:
|
||||
"""Ensure a preset is available, falling back to DEFAULT_PRESET."""
|
||||
if name is None:
|
||||
return DEFAULT_PRESET.copy()
|
||||
return get_preset_or_default(name)
|
||||
|
||||
|
||||
class PresetValidationError(Exception):
|
||||
"""Raised when preset validation fails."""
|
||||
|
||||
|
||||
def validate_preset(preset: dict[str, Any]) -> list[str]:
|
||||
"""Validate a preset and return list of errors (empty if valid)."""
|
||||
errors: list[str] = []
|
||||
|
||||
required_fields = ["source", "display", "effects"]
|
||||
for field in required_fields:
|
||||
if field not in preset:
|
||||
errors.append(f"Missing required field: {field}")
|
||||
|
||||
if "effects" in preset:
|
||||
if not isinstance(preset["effects"], list):
|
||||
errors.append("'effects' must be a list")
|
||||
else:
|
||||
for effect in preset["effects"]:
|
||||
if not isinstance(effect, str):
|
||||
errors.append(
|
||||
f"Effect must be string, got {type(effect)}: {effect}"
|
||||
)
|
||||
|
||||
if "viewport" in preset:
|
||||
viewport = preset["viewport"]
|
||||
if not isinstance(viewport, dict):
|
||||
errors.append("'viewport' must be a dict")
|
||||
else:
|
||||
if "width" in viewport and not isinstance(viewport["width"], int):
|
||||
errors.append("'viewport.width' must be an int")
|
||||
if "height" in viewport and not isinstance(viewport["height"], int):
|
||||
errors.append("'viewport.height' must be an int")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_signal_flow(stages: list[dict]) -> list[str]:
|
||||
"""Validate signal flow based on inlet/outlet types.
|
||||
|
||||
This validates that the preset's stage configuration produces valid
|
||||
data flow using the PureData-style type system.
|
||||
|
||||
Args:
|
||||
stages: List of stage configs with 'name', 'category', 'inlet_types', 'outlet_types'
|
||||
|
||||
Returns:
|
||||
List of errors (empty if valid)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not stages:
|
||||
errors.append("Signal flow is empty")
|
||||
return errors
|
||||
|
||||
# Define expected types for each category
|
||||
type_map = {
|
||||
"source": {"inlet": "NONE", "outlet": "SOURCE_ITEMS"},
|
||||
"data": {"inlet": "ANY", "outlet": "SOURCE_ITEMS"},
|
||||
"transform": {"inlet": "SOURCE_ITEMS", "outlet": "TEXT_BUFFER"},
|
||||
"effect": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
|
||||
"overlay": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
|
||||
"camera": {"inlet": "TEXT_BUFFER", "outlet": "TEXT_BUFFER"},
|
||||
"display": {"inlet": "TEXT_BUFFER", "outlet": "NONE"},
|
||||
"render": {"inlet": "SOURCE_ITEMS", "outlet": "TEXT_BUFFER"},
|
||||
}
|
||||
|
||||
# Check stage order and type compatibility
|
||||
for i, stage in enumerate(stages):
|
||||
category = stage.get("category", "unknown")
|
||||
name = stage.get("name", f"stage_{i}")
|
||||
|
||||
if category not in type_map:
|
||||
continue # Skip unknown categories
|
||||
|
||||
expected = type_map[category]
|
||||
|
||||
# Check against previous stage
|
||||
if i > 0:
|
||||
prev = stages[i - 1]
|
||||
prev_category = prev.get("category", "unknown")
|
||||
if prev_category in type_map:
|
||||
prev_outlet = type_map[prev_category]["outlet"]
|
||||
inlet = expected["inlet"]
|
||||
|
||||
# Validate type compatibility
|
||||
if inlet != "ANY" and prev_outlet != "ANY" and inlet != prev_outlet:
|
||||
errors.append(
|
||||
f"Type mismatch at '{name}': "
|
||||
f"expects {inlet} but previous stage outputs {prev_outlet}"
|
||||
)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_signal_path(stages: list[str]) -> list[str]:
|
||||
"""Validate signal path for circular dependencies and connectivity.
|
||||
|
||||
Args:
|
||||
stages: List of stage names in execution order
|
||||
|
||||
Returns:
|
||||
List of errors (empty if valid)
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if not stages:
|
||||
errors.append("Signal path is empty")
|
||||
return errors
|
||||
|
||||
seen: set[str] = set()
|
||||
for i, stage in enumerate(stages):
|
||||
if stage in seen:
|
||||
errors.append(
|
||||
f"Circular dependency: '{stage}' appears multiple times at index {i}"
|
||||
)
|
||||
seen.add(stage)
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def generate_preset_toml(
|
||||
name: str,
|
||||
source: str = "headlines",
|
||||
display: str = "terminal",
|
||||
effects: list[str] | None = None,
|
||||
viewport_width: int = 80,
|
||||
viewport_height: int = 24,
|
||||
camera: str = "vertical",
|
||||
camera_speed: float = 1.0,
|
||||
firehose_enabled: bool = False,
|
||||
) -> str:
|
||||
"""Generate a TOML preset skeleton with default values.
|
||||
|
||||
Args:
|
||||
name: Preset name
|
||||
source: Data source name
|
||||
display: Display backend
|
||||
effects: List of effect names
|
||||
viewport_width: Viewport width in columns
|
||||
viewport_height: Viewport height in rows
|
||||
camera: Camera mode
|
||||
camera_speed: Camera scroll speed
|
||||
firehose_enabled: Enable firehose mode
|
||||
|
||||
Returns:
|
||||
TOML string for the preset
|
||||
"""
|
||||
|
||||
if effects is None:
|
||||
effects = ["fade"]
|
||||
|
||||
output = []
|
||||
output.append(f"[presets.{name}]")
|
||||
output.append(f'description = "Auto-generated preset: {name}"')
|
||||
output.append(f'source = "{source}"')
|
||||
output.append(f'display = "{display}"')
|
||||
output.append(f'camera = "{camera}"')
|
||||
output.append(f"effects = {effects}")
|
||||
output.append(f"viewport_width = {viewport_width}")
|
||||
output.append(f"viewport_height = {viewport_height}")
|
||||
output.append(f"camera_speed = {camera_speed}")
|
||||
output.append(f"firehose_enabled = {str(firehose_enabled).lower()}")
|
||||
|
||||
return "\n".join(output)
|
||||
237
engine/pipeline/presets.py
Normal file
237
engine/pipeline/presets.py
Normal file
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Pipeline presets - Pre-configured pipeline configurations.
|
||||
|
||||
Provides PipelinePreset as a unified preset system.
|
||||
Presets can be loaded from TOML files (presets.toml) or defined in code.
|
||||
|
||||
Loading order:
|
||||
1. Built-in presets.toml in the package
|
||||
2. User config ~/.config/mainline/presets.toml
|
||||
3. Local ./presets.toml (overrides earlier)
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
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."""
|
||||
try:
|
||||
from engine.pipeline.preset_loader import load_presets
|
||||
|
||||
return load_presets()
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
_YAML_PRESETS = _load_toml_presets()
|
||||
|
||||
|
||||
@dataclass
|
||||
class PipelinePreset:
|
||||
"""Pre-configured pipeline with stages and animation.
|
||||
|
||||
A PipelinePreset packages:
|
||||
- Initial params: Starting configuration
|
||||
- Stages: List of stage configurations to create
|
||||
|
||||
This is the new unified preset that works with the Pipeline class.
|
||||
"""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
source: str = "headlines"
|
||||
display: str = "terminal"
|
||||
camera: str = "scroll"
|
||||
effects: list[str] = field(default_factory=list)
|
||||
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 (runtime configuration)."""
|
||||
from engine.display import BorderMode
|
||||
|
||||
params = PipelineParams()
|
||||
params.source = self.source
|
||||
params.display = self.display
|
||||
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."""
|
||||
return cls(
|
||||
name=name,
|
||||
description=data.get("description", ""),
|
||||
source=data.get("source", "headlines"),
|
||||
display=data.get("display", "terminal"),
|
||||
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),
|
||||
)
|
||||
|
||||
|
||||
# Built-in presets
|
||||
DEMO_PRESET = PipelinePreset(
|
||||
name="demo",
|
||||
description="Demo mode with effect cycling and camera modes",
|
||||
source="headlines",
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
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",
|
||||
source="poetry",
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
effects=["fade"],
|
||||
)
|
||||
|
||||
PIPELINE_VIZ_PRESET = PipelinePreset(
|
||||
name="pipeline",
|
||||
description="Pipeline visualization mode",
|
||||
source="pipeline",
|
||||
display="terminal",
|
||||
camera="trace",
|
||||
effects=[],
|
||||
)
|
||||
|
||||
WEBSOCKET_PRESET = PipelinePreset(
|
||||
name="websocket",
|
||||
description="WebSocket display mode",
|
||||
source="headlines",
|
||||
display="websocket",
|
||||
camera="scroll",
|
||||
effects=["noise", "fade", "glitch"],
|
||||
)
|
||||
|
||||
FIREHOSE_PRESET = PipelinePreset(
|
||||
name="firehose",
|
||||
description="High-speed firehose mode",
|
||||
source="headlines",
|
||||
display="pygame",
|
||||
camera="scroll",
|
||||
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]:
|
||||
"""Build preset dictionary from all sources."""
|
||||
result = {}
|
||||
|
||||
# Add YAML presets
|
||||
yaml_presets = _YAML_PRESETS.get("presets", {})
|
||||
for name, data in yaml_presets.items():
|
||||
result[name] = PipelinePreset.from_yaml(name, data)
|
||||
|
||||
# Add built-in presets as fallback (if not in YAML)
|
||||
builtins = {
|
||||
"demo": DEMO_PRESET,
|
||||
"poetry": POETRY_PRESET,
|
||||
"pipeline": PIPELINE_VIZ_PRESET,
|
||||
"websocket": WEBSOCKET_PRESET,
|
||||
"firehose": FIREHOSE_PRESET,
|
||||
"ui": UI_PRESET,
|
||||
"fixture": FIXTURE_PRESET,
|
||||
}
|
||||
|
||||
for name, preset in builtins.items():
|
||||
if name not in result:
|
||||
result[name] = preset
|
||||
|
||||
return result
|
||||
|
||||
|
||||
PRESETS: dict[str, PipelinePreset] = _build_presets()
|
||||
|
||||
|
||||
def get_preset(name: str) -> PipelinePreset | None:
|
||||
"""Get a preset by name."""
|
||||
return PRESETS.get(name)
|
||||
|
||||
|
||||
def list_presets() -> list[str]:
|
||||
"""List all available preset names."""
|
||||
return list(PRESETS.keys())
|
||||
|
||||
|
||||
def create_preset_from_params(
|
||||
params: PipelineParams, name: str = "custom"
|
||||
) -> PipelinePreset:
|
||||
"""Create a preset from PipelineParams."""
|
||||
return PipelinePreset(
|
||||
name=name,
|
||||
source=params.source,
|
||||
display=params.display,
|
||||
camera=params.camera_mode,
|
||||
effects=params.effect_order.copy() if hasattr(params, "effect_order") else [],
|
||||
)
|
||||
189
engine/pipeline/registry.py
Normal file
189
engine/pipeline/registry.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Stage registry - Unified registration for all pipeline stages.
|
||||
|
||||
Provides a single registry for sources, effects, displays, and cameras.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
|
||||
from engine.pipeline.core import Stage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from engine.pipeline.core import Stage
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class StageRegistry:
|
||||
"""Unified registry for all pipeline stage types."""
|
||||
|
||||
_categories: dict[str, dict[str, type[Any]]] = {}
|
||||
_discovered: bool = False
|
||||
_instances: dict[str, Stage] = {}
|
||||
|
||||
@classmethod
|
||||
def register(cls, category: str, stage_class: type[Any]) -> None:
|
||||
"""Register a stage class in a category.
|
||||
|
||||
Args:
|
||||
category: Category name (source, effect, display, camera)
|
||||
stage_class: Stage subclass to register
|
||||
"""
|
||||
if category not in cls._categories:
|
||||
cls._categories[category] = {}
|
||||
|
||||
key = getattr(stage_class, "__name__", stage_class.__class__.__name__)
|
||||
cls._categories[category][key] = stage_class
|
||||
|
||||
@classmethod
|
||||
def get(cls, category: str, name: str) -> type[Any] | None:
|
||||
"""Get a stage class by category and name."""
|
||||
return cls._categories.get(category, {}).get(name)
|
||||
|
||||
@classmethod
|
||||
def list(cls, category: str) -> list[str]:
|
||||
"""List all stage names in a category."""
|
||||
return list(cls._categories.get(category, {}).keys())
|
||||
|
||||
@classmethod
|
||||
def list_categories(cls) -> list[str]:
|
||||
"""List all registered categories."""
|
||||
return list(cls._categories.keys())
|
||||
|
||||
@classmethod
|
||||
def create(cls, category: str, name: str, **kwargs) -> Stage | None:
|
||||
"""Create a stage instance by category and name."""
|
||||
stage_class = cls.get(category, name)
|
||||
if stage_class:
|
||||
return stage_class(**kwargs)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def create_instance(cls, stage: Stage | type[Stage], **kwargs) -> Stage:
|
||||
"""Create an instance from a stage class or return as-is."""
|
||||
if isinstance(stage, Stage):
|
||||
return stage
|
||||
if isinstance(stage, type) and issubclass(stage, Stage):
|
||||
return stage(**kwargs)
|
||||
raise TypeError(f"Expected Stage class or instance, got {type(stage)}")
|
||||
|
||||
@classmethod
|
||||
def register_instance(cls, name: str, stage: Stage) -> None:
|
||||
"""Register a stage instance by name."""
|
||||
cls._instances[name] = stage
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls, name: str) -> Stage | None:
|
||||
"""Get a registered stage instance by name."""
|
||||
return cls._instances.get(name)
|
||||
|
||||
|
||||
def discover_stages() -> None:
|
||||
"""Auto-discover and register all stage implementations."""
|
||||
if StageRegistry._discovered:
|
||||
return
|
||||
|
||||
# Import and register all stage implementations
|
||||
try:
|
||||
from engine.data_sources.sources import (
|
||||
HeadlinesDataSource,
|
||||
PoetryDataSource,
|
||||
)
|
||||
|
||||
StageRegistry.register("source", HeadlinesDataSource)
|
||||
StageRegistry.register("source", PoetryDataSource)
|
||||
|
||||
StageRegistry._categories["source"]["headlines"] = HeadlinesDataSource
|
||||
StageRegistry._categories["source"]["poetry"] = PoetryDataSource
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Register pipeline introspection source
|
||||
try:
|
||||
from engine.data_sources.pipeline_introspection import (
|
||||
PipelineIntrospectionSource,
|
||||
)
|
||||
|
||||
StageRegistry.register("source", PipelineIntrospectionSource)
|
||||
StageRegistry._categories["source"]["pipeline-inspect"] = (
|
||||
PipelineIntrospectionSource
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from engine.effects.types import EffectPlugin # noqa: F401
|
||||
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()
|
||||
|
||||
StageRegistry._discovered = True
|
||||
|
||||
|
||||
def _register_display_stages() -> None:
|
||||
"""Register display backends as stages."""
|
||||
try:
|
||||
from engine.display import DisplayRegistry
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
DisplayRegistry.initialize()
|
||||
|
||||
for backend_name in DisplayRegistry.list_backends():
|
||||
factory = _DisplayStageFactory(backend_name)
|
||||
StageRegistry._categories.setdefault("display", {})[backend_name] = factory
|
||||
|
||||
|
||||
class _DisplayStageFactory:
|
||||
"""Factory that creates DisplayStage instances for a specific backend."""
|
||||
|
||||
def __init__(self, backend_name: str):
|
||||
self._backend_name = backend_name
|
||||
|
||||
def __call__(self):
|
||||
from engine.display import DisplayRegistry
|
||||
from engine.pipeline.adapters import DisplayStage
|
||||
|
||||
display = DisplayRegistry.create(self._backend_name)
|
||||
if display is None:
|
||||
raise RuntimeError(
|
||||
f"Failed to create display backend: {self._backend_name}"
|
||||
)
|
||||
return DisplayStage(display, name=self._backend_name)
|
||||
|
||||
@property
|
||||
def __name__(self) -> str:
|
||||
return self._backend_name.capitalize() + "Stage"
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def register_source(stage_class: type[Stage]) -> None:
|
||||
"""Register a source stage."""
|
||||
StageRegistry.register("source", stage_class)
|
||||
|
||||
|
||||
def register_effect(stage_class: type[Stage]) -> None:
|
||||
"""Register an effect stage."""
|
||||
StageRegistry.register("effect", stage_class)
|
||||
|
||||
|
||||
def register_display(stage_class: type[Stage]) -> None:
|
||||
"""Register a display stage."""
|
||||
StageRegistry.register("display", stage_class)
|
||||
|
||||
|
||||
def register_camera(stage_class: type[Stage]) -> None:
|
||||
"""Register a camera stage."""
|
||||
StageRegistry.register("camera", stage_class)
|
||||
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}"
|
||||
)
|
||||
37
engine/render/__init__.py
Normal file
37
engine/render/__init__.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Modern block rendering system - OTF font to terminal half-block conversion.
|
||||
|
||||
This module provides the core rendering capabilities for big block letters
|
||||
and styled text output using PIL fonts and ANSI terminal rendering.
|
||||
|
||||
Exports:
|
||||
- make_block: Render a headline into a content block with color
|
||||
- big_wrap: Word-wrap text and render with OTF font
|
||||
- render_line: Render a line of text as terminal rows using half-blocks
|
||||
- font_for_lang: Get appropriate font for a language
|
||||
- clear_font_cache: Reset cached font objects
|
||||
- lr_gradient: Color block characters with left-to-right gradient
|
||||
- lr_gradient_opposite: Complementary gradient coloring
|
||||
"""
|
||||
|
||||
from engine.render.blocks import (
|
||||
big_wrap,
|
||||
clear_font_cache,
|
||||
font_for_lang,
|
||||
list_font_faces,
|
||||
load_font_face,
|
||||
make_block,
|
||||
render_line,
|
||||
)
|
||||
from engine.render.gradient import lr_gradient, lr_gradient_opposite
|
||||
|
||||
__all__ = [
|
||||
"big_wrap",
|
||||
"clear_font_cache",
|
||||
"font_for_lang",
|
||||
"list_font_faces",
|
||||
"load_font_face",
|
||||
"lr_gradient",
|
||||
"lr_gradient_opposite",
|
||||
"make_block",
|
||||
"render_line",
|
||||
]
|
||||
@@ -1,7 +1,6 @@
|
||||
"""
|
||||
OTF → terminal half-block rendering pipeline.
|
||||
Font loading, text rasterization, word-wrap, gradient coloring, headline block assembly.
|
||||
Depends on: config, terminal, sources, translate.
|
||||
"""Block rendering core - Font loading, text rasterization, word-wrap, and headline assembly.
|
||||
|
||||
Provides PIL font-based rendering to terminal half-block characters.
|
||||
"""
|
||||
|
||||
import random
|
||||
@@ -12,41 +11,51 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from engine import config
|
||||
from engine.sources import NO_UPPER, SCRIPT_FONTS, SOURCE_LANGS
|
||||
from engine.terminal import RST
|
||||
from engine.translate import detect_location_language, translate_headline
|
||||
|
||||
# ─── GRADIENT ─────────────────────────────────────────────
|
||||
# Left → right: white-hot leading edge fades to near-black
|
||||
GRAD_COLS = [
|
||||
"\033[1;38;5;231m", # white
|
||||
"\033[1;38;5;195m", # pale cyan-white
|
||||
"\033[38;5;123m", # bright cyan
|
||||
"\033[38;5;118m", # bright lime
|
||||
"\033[38;5;82m", # lime
|
||||
"\033[38;5;46m", # bright green
|
||||
"\033[38;5;40m", # green
|
||||
"\033[38;5;34m", # medium green
|
||||
"\033[38;5;28m", # dark green
|
||||
"\033[38;5;22m", # deep green
|
||||
"\033[2;38;5;22m", # dim deep green
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
# Complementary sweep for queue messages (opposite hue family from ticker greens)
|
||||
MSG_GRAD_COLS = [
|
||||
"\033[1;38;5;231m", # white
|
||||
"\033[1;38;5;225m", # pale pink-white
|
||||
"\033[38;5;219m", # bright pink
|
||||
"\033[38;5;213m", # hot pink
|
||||
"\033[38;5;207m", # magenta
|
||||
"\033[38;5;201m", # bright magenta
|
||||
"\033[38;5;165m", # orchid-red
|
||||
"\033[38;5;161m", # ruby-magenta
|
||||
"\033[38;5;125m", # dark magenta
|
||||
"\033[38;5;89m", # deep maroon-magenta
|
||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
def estimate_block_height(title: str, width: int, fnt=None) -> int:
|
||||
"""Estimate rendered block height without full PIL rendering.
|
||||
|
||||
Uses font bbox measurement to count wrapped lines, then computes:
|
||||
height = num_lines * RENDER_H + (num_lines - 1) + 2
|
||||
|
||||
Args:
|
||||
title: Headline text to measure
|
||||
width: Terminal width in characters
|
||||
fnt: Optional PIL font (uses default if None)
|
||||
|
||||
Returns:
|
||||
Estimated height in terminal rows
|
||||
"""
|
||||
if fnt is None:
|
||||
fnt = font()
|
||||
text = re.sub(r"\s+", " ", title.upper())
|
||||
words = text.split()
|
||||
lines = 0
|
||||
cur = ""
|
||||
for word in words:
|
||||
test = f"{cur} {word}".strip() if cur else word
|
||||
bbox = fnt.getbbox(test)
|
||||
if bbox:
|
||||
img_h = bbox[3] - bbox[1] + 8
|
||||
pix_h = config.RENDER_H * 2
|
||||
scale = pix_h / max(img_h, 1)
|
||||
term_w = int((bbox[2] - bbox[0] + 8) * scale)
|
||||
else:
|
||||
term_w = 0
|
||||
max_term_w = width - 4 - 4
|
||||
if term_w > max_term_w and cur:
|
||||
lines += 1
|
||||
cur = word
|
||||
else:
|
||||
cur = test
|
||||
if cur:
|
||||
lines += 1
|
||||
if lines == 0:
|
||||
lines = 1
|
||||
return lines * config.RENDER_H + max(0, lines - 1) + 2
|
||||
|
||||
|
||||
# ─── FONT LOADING ─────────────────────────────────────────
|
||||
_FONT_OBJ = None
|
||||
@@ -189,36 +198,22 @@ def big_wrap(text, max_w, fnt=None):
|
||||
return out
|
||||
|
||||
|
||||
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
||||
"""Color each non-space block character with a shifting left-to-right gradient."""
|
||||
cols = grad_cols or GRAD_COLS
|
||||
n = len(cols)
|
||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||
out = []
|
||||
for row in rows:
|
||||
if not row.strip():
|
||||
out.append(row)
|
||||
continue
|
||||
buf = []
|
||||
for x, ch in enumerate(row):
|
||||
if ch == " ":
|
||||
buf.append(" ")
|
||||
else:
|
||||
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
||||
idx = min(round(shifted * (n - 1)), n - 1)
|
||||
buf.append(f"{cols[idx]}{ch}{RST}")
|
||||
out.append("".join(buf))
|
||||
return out
|
||||
|
||||
|
||||
def lr_gradient_opposite(rows, offset=0.0):
|
||||
"""Complementary (opposite wheel) gradient used for queue message panels."""
|
||||
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||
|
||||
|
||||
# ─── HEADLINE BLOCK ASSEMBLY ─────────────────────────────
|
||||
def make_block(title, src, ts, w):
|
||||
"""Render a headline into a content block with color."""
|
||||
"""Render a headline into a content block with color.
|
||||
|
||||
Args:
|
||||
title: Headline text to render
|
||||
src: Source identifier (for metadata)
|
||||
ts: Timestamp string (for metadata)
|
||||
w: Width constraint in terminal characters
|
||||
|
||||
Returns:
|
||||
tuple: (content_lines, color_code, meta_row_index)
|
||||
- content_lines: List of rendered text lines
|
||||
- color_code: ANSI color code for display
|
||||
- meta_row_index: Row index of metadata line
|
||||
"""
|
||||
target_lang = (
|
||||
(SOURCE_LANGS.get(src) or detect_location_language(title))
|
||||
if config.MODE == "news"
|
||||
82
engine/render/gradient.py
Normal file
82
engine/render/gradient.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Gradient coloring for rendered block characters.
|
||||
|
||||
Provides left-to-right and complementary gradient effects for terminal display.
|
||||
"""
|
||||
|
||||
from engine.terminal import RST
|
||||
|
||||
# Left → right: white-hot leading edge fades to near-black
|
||||
GRAD_COLS = [
|
||||
"\033[1;38;5;231m", # white
|
||||
"\033[1;38;5;195m", # pale cyan-white
|
||||
"\033[38;5;123m", # bright cyan
|
||||
"\033[38;5;118m", # bright lime
|
||||
"\033[38;5;82m", # lime
|
||||
"\033[38;5;46m", # bright green
|
||||
"\033[38;5;40m", # green
|
||||
"\033[38;5;34m", # medium green
|
||||
"\033[38;5;28m", # dark green
|
||||
"\033[38;5;22m", # deep green
|
||||
"\033[2;38;5;22m", # dim deep green
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
# Complementary sweep for queue messages (opposite hue family from ticker greens)
|
||||
MSG_GRAD_COLS = [
|
||||
"\033[1;38;5;231m", # white
|
||||
"\033[1;38;5;225m", # pale pink-white
|
||||
"\033[38;5;219m", # bright pink
|
||||
"\033[38;5;213m", # hot pink
|
||||
"\033[38;5;207m", # magenta
|
||||
"\033[38;5;201m", # bright magenta
|
||||
"\033[38;5;165m", # orchid-red
|
||||
"\033[38;5;161m", # ruby-magenta
|
||||
"\033[38;5;125m", # dark magenta
|
||||
"\033[38;5;89m", # deep maroon-magenta
|
||||
"\033[2;38;5;89m", # dim deep maroon-magenta
|
||||
"\033[2;38;5;235m", # near black
|
||||
]
|
||||
|
||||
|
||||
def lr_gradient(rows, offset=0.0, grad_cols=None):
|
||||
"""Color each non-space block character with a shifting left-to-right gradient.
|
||||
|
||||
Args:
|
||||
rows: List of text lines with block characters
|
||||
offset: Gradient offset (0.0-1.0) for animation
|
||||
grad_cols: List of ANSI color codes (default: GRAD_COLS)
|
||||
|
||||
Returns:
|
||||
List of lines with gradient coloring applied
|
||||
"""
|
||||
cols = grad_cols or GRAD_COLS
|
||||
n = len(cols)
|
||||
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
|
||||
out = []
|
||||
for row in rows:
|
||||
if not row.strip():
|
||||
out.append(row)
|
||||
continue
|
||||
buf = []
|
||||
for x, ch in enumerate(row):
|
||||
if ch == " ":
|
||||
buf.append(" ")
|
||||
else:
|
||||
shifted = (x / max(max_x - 1, 1) + offset) % 1.0
|
||||
idx = min(round(shifted * (n - 1)), n - 1)
|
||||
buf.append(f"{cols[idx]}{ch}{RST}")
|
||||
out.append("".join(buf))
|
||||
return out
|
||||
|
||||
|
||||
def lr_gradient_opposite(rows, offset=0.0):
|
||||
"""Complementary (opposite wheel) gradient used for queue message panels.
|
||||
|
||||
Args:
|
||||
rows: List of text lines with block characters
|
||||
offset: Gradient offset (0.0-1.0) for animation
|
||||
|
||||
Returns:
|
||||
List of lines with complementary gradient coloring applied
|
||||
"""
|
||||
return lr_gradient(rows, offset, MSG_GRAD_COLS)
|
||||
141
engine/scroll.py
141
engine/scroll.py
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
Render engine — ticker content, scroll motion, message panel, and firehose overlay.
|
||||
Orchestrates viewport, frame timing, and layers.
|
||||
"""
|
||||
|
||||
import random
|
||||
import time
|
||||
|
||||
from engine import config
|
||||
from engine.display import (
|
||||
Display,
|
||||
TerminalDisplay,
|
||||
)
|
||||
from engine.display import (
|
||||
get_monitor as _get_display_monitor,
|
||||
)
|
||||
from engine.frame import calculate_scroll_step
|
||||
from engine.layers import (
|
||||
apply_glitch,
|
||||
process_effects,
|
||||
render_firehose,
|
||||
render_message_overlay,
|
||||
render_ticker_zone,
|
||||
)
|
||||
from engine.viewport import th, tw
|
||||
|
||||
USE_EFFECT_CHAIN = True
|
||||
|
||||
|
||||
def stream(items, ntfy_poller, mic_monitor, display: Display | None = None):
|
||||
"""Main render loop with four layers: message, ticker, scroll motion, firehose."""
|
||||
if display is None:
|
||||
display = TerminalDisplay()
|
||||
random.shuffle(items)
|
||||
pool = list(items)
|
||||
seen = set()
|
||||
queued = 0
|
||||
|
||||
time.sleep(0.5)
|
||||
w, h = tw(), th()
|
||||
display.init(w, h)
|
||||
display.clear()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
ticker_view_h = h - fh
|
||||
GAP = 3
|
||||
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
||||
|
||||
active = []
|
||||
scroll_cam = 0
|
||||
ticker_next_y = ticker_view_h
|
||||
noise_cache = {}
|
||||
scroll_motion_accum = 0.0
|
||||
msg_cache = (None, None)
|
||||
frame_number = 0
|
||||
|
||||
while True:
|
||||
if queued >= config.HEADLINE_LIMIT and not active:
|
||||
break
|
||||
|
||||
t0 = time.monotonic()
|
||||
w, h = tw(), th()
|
||||
fh = config.FIREHOSE_H if config.FIREHOSE else 0
|
||||
ticker_view_h = h - fh
|
||||
scroll_step_interval = calculate_scroll_step(config.SCROLL_DUR, ticker_view_h)
|
||||
|
||||
msg = ntfy_poller.get_active_message()
|
||||
msg_overlay, msg_cache = render_message_overlay(msg, w, h, msg_cache)
|
||||
|
||||
buf = []
|
||||
ticker_h = ticker_view_h
|
||||
|
||||
scroll_motion_accum += config.FRAME_DT
|
||||
while scroll_motion_accum >= scroll_step_interval:
|
||||
scroll_motion_accum -= scroll_step_interval
|
||||
scroll_cam += 1
|
||||
|
||||
while (
|
||||
ticker_next_y < scroll_cam + ticker_view_h + 10
|
||||
and queued < config.HEADLINE_LIMIT
|
||||
):
|
||||
from engine.effects import next_headline
|
||||
from engine.render import make_block
|
||||
|
||||
t, src, ts = next_headline(pool, items, seen)
|
||||
ticker_content, hc, midx = make_block(t, src, ts, w)
|
||||
active.append((ticker_content, hc, ticker_next_y, midx))
|
||||
ticker_next_y += len(ticker_content) + GAP
|
||||
queued += 1
|
||||
|
||||
active = [
|
||||
(c, hc, by, mi) for c, hc, by, mi in active if by + len(c) > scroll_cam
|
||||
]
|
||||
for k in list(noise_cache):
|
||||
if k < scroll_cam:
|
||||
del noise_cache[k]
|
||||
|
||||
grad_offset = (time.monotonic() * config.GRAD_SPEED) % 1.0
|
||||
ticker_buf_start = len(buf)
|
||||
|
||||
ticker_buf, noise_cache = render_ticker_zone(
|
||||
active, scroll_cam, ticker_h, w, noise_cache, grad_offset
|
||||
)
|
||||
buf.extend(ticker_buf)
|
||||
|
||||
mic_excess = mic_monitor.excess
|
||||
render_start = time.perf_counter()
|
||||
|
||||
if USE_EFFECT_CHAIN:
|
||||
buf = process_effects(
|
||||
buf,
|
||||
w,
|
||||
h,
|
||||
scroll_cam,
|
||||
ticker_h,
|
||||
mic_excess,
|
||||
grad_offset,
|
||||
frame_number,
|
||||
msg is not None,
|
||||
items,
|
||||
)
|
||||
else:
|
||||
buf = apply_glitch(buf, ticker_buf_start, mic_excess, w)
|
||||
firehose_buf = render_firehose(items, w, fh, h)
|
||||
buf.extend(firehose_buf)
|
||||
|
||||
if msg_overlay:
|
||||
buf.extend(msg_overlay)
|
||||
|
||||
render_elapsed = (time.perf_counter() - render_start) * 1000
|
||||
monitor = _get_display_monitor()
|
||||
if monitor:
|
||||
chars = sum(len(line) for line in buf)
|
||||
monitor.record_effect("render", render_elapsed, chars, chars)
|
||||
|
||||
display.show(buf)
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
time.sleep(max(0, config.FRAME_DT - elapsed))
|
||||
frame_number += 1
|
||||
|
||||
display.cleanup()
|
||||
203
engine/sensors/__init__.py
Normal file
203
engine/sensors/__init__.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Sensor framework - PureData-style real-time input system.
|
||||
|
||||
Sensors are data sources that emit values over time, similar to how
|
||||
PureData objects emit signals. Effects can bind to sensors to modulate
|
||||
their parameters dynamically.
|
||||
|
||||
Architecture:
|
||||
- Sensor: Base class for all sensors (mic, camera, ntfy, OSC, etc.)
|
||||
- SensorRegistry: Global registry for sensor discovery
|
||||
- SensorStage: Pipeline stage wrapper for sensors
|
||||
- Effect param_bindings: Declarative sensor-to-param routing
|
||||
|
||||
Example:
|
||||
class GlitchEffect(EffectPlugin):
|
||||
param_bindings = {
|
||||
"intensity": {"sensor": "mic", "transform": "linear"},
|
||||
}
|
||||
|
||||
This binds the mic sensor to the glitch intensity parameter.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from engine.pipeline.core import PipelineContext
|
||||
|
||||
|
||||
@dataclass
|
||||
class SensorValue:
|
||||
"""A sensor reading with metadata."""
|
||||
|
||||
sensor_name: str
|
||||
value: float
|
||||
timestamp: float
|
||||
unit: str = ""
|
||||
|
||||
|
||||
class Sensor(ABC):
|
||||
"""Abstract base class for sensors.
|
||||
|
||||
Sensors are real-time data sources that emit values. They can be:
|
||||
- Physical: mic, camera, joystick, MIDI, OSC
|
||||
- Virtual: ntfy, timer, random, noise
|
||||
|
||||
Each sensor has a name and emits SensorValue objects.
|
||||
"""
|
||||
|
||||
name: str
|
||||
unit: str = ""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Whether the sensor is currently available."""
|
||||
return True
|
||||
|
||||
@abstractmethod
|
||||
def read(self) -> SensorValue | None:
|
||||
"""Read current sensor value.
|
||||
|
||||
Returns:
|
||||
SensorValue if available, None if sensor is not ready.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def start(self) -> bool:
|
||||
"""Start the sensor.
|
||||
|
||||
Returns:
|
||||
True if started successfully.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def stop(self) -> None:
|
||||
"""Stop the sensor and release resources."""
|
||||
...
|
||||
|
||||
|
||||
class SensorRegistry:
|
||||
"""Global registry for sensors.
|
||||
|
||||
Provides:
|
||||
- Registration of sensor instances
|
||||
- Lookup by name
|
||||
- Global start/stop
|
||||
"""
|
||||
|
||||
_sensors: dict[str, Sensor] = {}
|
||||
_started: bool = False
|
||||
|
||||
@classmethod
|
||||
def register(cls, sensor: Sensor) -> None:
|
||||
"""Register a sensor instance."""
|
||||
cls._sensors[sensor.name] = sensor
|
||||
|
||||
@classmethod
|
||||
def get(cls, name: str) -> Sensor | None:
|
||||
"""Get a sensor by name."""
|
||||
return cls._sensors.get(name)
|
||||
|
||||
@classmethod
|
||||
def list_sensors(cls) -> list[str]:
|
||||
"""List all registered sensor names."""
|
||||
return list(cls._sensors.keys())
|
||||
|
||||
@classmethod
|
||||
def start_all(cls) -> bool:
|
||||
"""Start all sensors.
|
||||
|
||||
Returns:
|
||||
True if all sensors started successfully.
|
||||
"""
|
||||
if cls._started:
|
||||
return True
|
||||
|
||||
all_started = True
|
||||
for sensor in cls._sensors.values():
|
||||
if sensor.available and not sensor.start():
|
||||
all_started = False
|
||||
|
||||
cls._started = all_started
|
||||
return all_started
|
||||
|
||||
@classmethod
|
||||
def stop_all(cls) -> None:
|
||||
"""Stop all sensors."""
|
||||
for sensor in cls._sensors.values():
|
||||
sensor.stop()
|
||||
cls._started = False
|
||||
|
||||
@classmethod
|
||||
def read_all(cls) -> dict[str, float]:
|
||||
"""Read all sensor values.
|
||||
|
||||
Returns:
|
||||
Dict mapping sensor name to current value.
|
||||
"""
|
||||
result = {}
|
||||
for name, sensor in cls._sensors.items():
|
||||
value = sensor.read()
|
||||
if value:
|
||||
result[name] = value.value
|
||||
return result
|
||||
|
||||
|
||||
class SensorStage:
|
||||
"""Pipeline stage wrapper for sensors.
|
||||
|
||||
Provides sensor data to the pipeline context.
|
||||
Sensors don't transform data - they inject sensor values into context.
|
||||
"""
|
||||
|
||||
def __init__(self, sensor: Sensor, name: str | None = None):
|
||||
self._sensor = sensor
|
||||
self.name = name or sensor.name
|
||||
self.category = "sensor"
|
||||
self.optional = True
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "sensor"
|
||||
|
||||
@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}
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
return {f"sensor.{self.name}"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
|
||||
def init(self, ctx: "PipelineContext") -> bool:
|
||||
return self._sensor.start()
|
||||
|
||||
def process(self, data: Any, ctx: "PipelineContext") -> Any:
|
||||
value = self._sensor.read()
|
||||
if value:
|
||||
ctx.set_state(f"sensor.{self.name}", value.value)
|
||||
ctx.set_state(f"sensor.{self.name}.full", value)
|
||||
return data
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self._sensor.stop()
|
||||
|
||||
|
||||
def create_sensor_stage(sensor: Sensor, name: str | None = None) -> SensorStage:
|
||||
"""Create a pipeline stage from a sensor."""
|
||||
return SensorStage(sensor, name)
|
||||
145
engine/sensors/mic.py
Normal file
145
engine/sensors/mic.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Mic sensor - audio input as a pipeline sensor.
|
||||
|
||||
Self-contained implementation that handles audio input directly,
|
||||
with graceful degradation if sounddevice is unavailable.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import numpy as np
|
||||
import sounddevice as sd
|
||||
|
||||
_HAS_AUDIO = True
|
||||
except Exception:
|
||||
np = None # type: ignore
|
||||
sd = None # type: ignore
|
||||
_HAS_AUDIO = False
|
||||
|
||||
|
||||
from engine.events import MicLevelEvent
|
||||
from engine.sensors import Sensor, SensorRegistry, SensorValue
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioConfig:
|
||||
"""Configuration for audio input."""
|
||||
|
||||
threshold_db: float = 50.0
|
||||
sample_rate: float = 44100.0
|
||||
block_size: int = 1024
|
||||
|
||||
|
||||
class MicSensor(Sensor):
|
||||
"""Microphone sensor for pipeline integration.
|
||||
|
||||
Self-contained implementation with graceful degradation.
|
||||
No external dependencies required - works with or without sounddevice.
|
||||
"""
|
||||
|
||||
def __init__(self, threshold_db: float = 50.0, name: str = "mic"):
|
||||
self.name = name
|
||||
self.unit = "dB"
|
||||
self._config = AudioConfig(threshold_db=threshold_db)
|
||||
self._db: float = -99.0
|
||||
self._stream: Any = None
|
||||
self._subscribers: list[Callable[[MicLevelEvent], None]] = []
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Check if audio input is available."""
|
||||
return _HAS_AUDIO and self._stream is not None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the microphone stream."""
|
||||
if not _HAS_AUDIO or sd is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
self._stream = sd.InputStream(
|
||||
samplerate=self._config.sample_rate,
|
||||
blocksize=self._config.block_size,
|
||||
channels=1,
|
||||
callback=self._audio_callback,
|
||||
)
|
||||
self._stream.start()
|
||||
atexit.register(self.stop)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the microphone stream."""
|
||||
if self._stream:
|
||||
try:
|
||||
self._stream.stop()
|
||||
self._stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._stream = None
|
||||
|
||||
def _audio_callback(self, indata, frames, time_info, status) -> None:
|
||||
"""Process audio data from sounddevice."""
|
||||
if not _HAS_AUDIO or np is None:
|
||||
return
|
||||
|
||||
rms = np.sqrt(np.mean(indata**2))
|
||||
if rms > 0:
|
||||
db = 20 * np.log10(rms)
|
||||
else:
|
||||
db = -99.0
|
||||
|
||||
self._db = db
|
||||
|
||||
excess = max(0.0, db - self._config.threshold_db)
|
||||
event = MicLevelEvent(
|
||||
db_level=db, excess_above_threshold=excess, timestamp=datetime.now()
|
||||
)
|
||||
self._emit(event)
|
||||
|
||||
def _emit(self, event: MicLevelEvent) -> None:
|
||||
"""Emit event to all subscribers."""
|
||||
for callback in self._subscribers:
|
||||
try:
|
||||
callback(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def subscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||
"""Subscribe to mic level events."""
|
||||
if callback not in self._subscribers:
|
||||
self._subscribers.append(callback)
|
||||
|
||||
def unsubscribe(self, callback: Callable[[MicLevelEvent], None]) -> None:
|
||||
"""Unsubscribe from mic level events."""
|
||||
if callback in self._subscribers:
|
||||
self._subscribers.remove(callback)
|
||||
|
||||
def read(self) -> SensorValue | None:
|
||||
"""Read current mic level as sensor value."""
|
||||
if not self.available:
|
||||
return None
|
||||
|
||||
excess = max(0.0, self._db - self._config.threshold_db)
|
||||
return SensorValue(
|
||||
sensor_name=self.name,
|
||||
value=excess,
|
||||
timestamp=time.time(),
|
||||
unit=self.unit,
|
||||
)
|
||||
|
||||
|
||||
def register_mic_sensor() -> None:
|
||||
"""Register the mic sensor with the global registry."""
|
||||
sensor = MicSensor()
|
||||
SensorRegistry.register(sensor)
|
||||
|
||||
|
||||
# Auto-register when imported
|
||||
register_mic_sensor()
|
||||
161
engine/sensors/oscillator.py
Normal file
161
engine/sensors/oscillator.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Oscillator sensor - Modular synth-style oscillator as a pipeline sensor.
|
||||
|
||||
Provides various waveforms that can be:
|
||||
1. Self-driving (phase accumulates over time)
|
||||
2. Sensor-driven (phase modulated by external sensor)
|
||||
|
||||
Built-in waveforms:
|
||||
- sine: Pure sine wave
|
||||
- square: Square wave (0 to 1)
|
||||
- sawtooth: Rising sawtooth (0 to 1, wraps)
|
||||
- triangle: Triangle wave (0 to 1 to 0)
|
||||
- noise: Random values (0 to 1)
|
||||
|
||||
Example usage:
|
||||
osc = OscillatorSensor(waveform="sine", frequency=0.5)
|
||||
# Or driven by mic sensor:
|
||||
osc = OscillatorSensor(waveform="sine", frequency=1.0, input_sensor="mic")
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from enum import Enum
|
||||
|
||||
from engine.sensors import Sensor, SensorRegistry, SensorValue
|
||||
|
||||
|
||||
class Waveform(Enum):
|
||||
"""Built-in oscillator waveforms."""
|
||||
|
||||
SINE = "sine"
|
||||
SQUARE = "square"
|
||||
SAWTOOTH = "sawtooth"
|
||||
TRIANGLE = "triangle"
|
||||
NOISE = "noise"
|
||||
|
||||
|
||||
class OscillatorSensor(Sensor):
|
||||
"""Oscillator sensor that generates periodic or random values.
|
||||
|
||||
Can run in two modes:
|
||||
- Self-driving: phase accumulates based on frequency
|
||||
- Sensor-driven: phase modulated by external sensor value
|
||||
"""
|
||||
|
||||
WAVEFORMS = {
|
||||
"sine": lambda p: (math.sin(2 * math.pi * p) + 1) / 2,
|
||||
"square": lambda p: 1.0 if (p % 1.0) < 0.5 else 0.0,
|
||||
"sawtooth": lambda p: p % 1.0,
|
||||
"triangle": lambda p: 2 * abs(2 * (p % 1.0) - 1) - 1,
|
||||
"noise": lambda _: random.random(),
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "osc",
|
||||
waveform: str = "sine",
|
||||
frequency: float = 1.0,
|
||||
input_sensor: str | None = None,
|
||||
input_scale: float = 1.0,
|
||||
):
|
||||
"""Initialize oscillator sensor.
|
||||
|
||||
Args:
|
||||
name: Sensor name
|
||||
waveform: Waveform type (sine, square, sawtooth, triangle, noise)
|
||||
frequency: Frequency in Hz (self-driving mode)
|
||||
input_sensor: Optional sensor name to drive phase
|
||||
input_scale: Scale factor for input sensor
|
||||
"""
|
||||
self.name = name
|
||||
self.unit = ""
|
||||
self._waveform = waveform
|
||||
self._frequency = frequency
|
||||
self._input_sensor = input_sensor
|
||||
self._input_scale = input_scale
|
||||
self._phase = 0.0
|
||||
self._start_time = time.time()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def waveform(self) -> str:
|
||||
return self._waveform
|
||||
|
||||
@waveform.setter
|
||||
def waveform(self, value: str) -> None:
|
||||
if value not in self.WAVEFORMS:
|
||||
raise ValueError(f"Unknown waveform: {value}")
|
||||
self._waveform = value
|
||||
|
||||
@property
|
||||
def frequency(self) -> float:
|
||||
return self._frequency
|
||||
|
||||
@frequency.setter
|
||||
def frequency(self, value: float) -> None:
|
||||
self._frequency = max(0.0, value)
|
||||
|
||||
def start(self) -> bool:
|
||||
self._phase = 0.0
|
||||
self._start_time = time.time()
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
pass
|
||||
|
||||
def _get_input_value(self) -> float:
|
||||
"""Get value from input sensor if configured."""
|
||||
if self._input_sensor:
|
||||
from engine.sensors import SensorRegistry
|
||||
|
||||
sensor = SensorRegistry.get(self._input_sensor)
|
||||
if sensor:
|
||||
reading = sensor.read()
|
||||
if reading:
|
||||
return reading.value * self._input_scale
|
||||
return 0.0
|
||||
|
||||
def read(self) -> SensorValue | None:
|
||||
current_time = time.time()
|
||||
elapsed = current_time - self._start_time
|
||||
|
||||
if self._input_sensor:
|
||||
input_val = self._get_input_value()
|
||||
phase_increment = (self._frequency * elapsed) + input_val
|
||||
else:
|
||||
phase_increment = self._frequency * elapsed
|
||||
|
||||
self._phase += phase_increment
|
||||
|
||||
waveform_fn = self.WAVEFORMS.get(self._waveform)
|
||||
if waveform_fn is None:
|
||||
return None
|
||||
|
||||
value = waveform_fn(self._phase)
|
||||
value = max(0.0, min(1.0, value))
|
||||
|
||||
return SensorValue(
|
||||
sensor_name=self.name,
|
||||
value=value,
|
||||
timestamp=current_time,
|
||||
unit=self.unit,
|
||||
)
|
||||
|
||||
def set_waveform(self, waveform: str) -> None:
|
||||
"""Change waveform at runtime."""
|
||||
self.waveform = waveform
|
||||
|
||||
def set_frequency(self, frequency: float) -> None:
|
||||
"""Change frequency at runtime."""
|
||||
self.frequency = frequency
|
||||
|
||||
|
||||
def register_oscillator_sensor(name: str = "osc", **kwargs) -> None:
|
||||
"""Register an oscillator sensor with the global registry."""
|
||||
sensor = OscillatorSensor(name=name, **kwargs)
|
||||
SensorRegistry.register(sensor)
|
||||
114
engine/sensors/pipeline_metrics.py
Normal file
114
engine/sensors/pipeline_metrics.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Pipeline metrics sensor - Exposes pipeline performance data as sensor values.
|
||||
|
||||
This sensor reads metrics from a Pipeline instance and provides them
|
||||
as sensor values that can drive effect parameters.
|
||||
|
||||
Example:
|
||||
sensor = PipelineMetricsSensor(pipeline)
|
||||
sensor.read() # Returns SensorValue with total_ms, fps, etc.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from engine.sensors import Sensor, SensorValue
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from engine.pipeline.controller import Pipeline
|
||||
|
||||
|
||||
class PipelineMetricsSensor(Sensor):
|
||||
"""Sensor that reads metrics from a Pipeline instance.
|
||||
|
||||
Provides real-time performance data:
|
||||
- total_ms: Total frame time in milliseconds
|
||||
- fps: Calculated frames per second
|
||||
- stage_timings: Dict of stage name -> duration_ms
|
||||
|
||||
Can be bound to effect parameters for reactive visuals.
|
||||
"""
|
||||
|
||||
def __init__(self, pipeline: "Pipeline | None" = None, name: str = "pipeline"):
|
||||
self._pipeline = pipeline
|
||||
self.name = name
|
||||
self.unit = "ms"
|
||||
self._last_values: dict[str, float] = {
|
||||
"total_ms": 0.0,
|
||||
"fps": 0.0,
|
||||
"avg_ms": 0.0,
|
||||
"min_ms": 0.0,
|
||||
"max_ms": 0.0,
|
||||
}
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self._pipeline is not None
|
||||
|
||||
def set_pipeline(self, pipeline: "Pipeline") -> None:
|
||||
"""Set or update the pipeline to read metrics from."""
|
||||
self._pipeline = pipeline
|
||||
|
||||
def read(self) -> SensorValue | None:
|
||||
"""Read current metrics from the pipeline."""
|
||||
if not self._pipeline:
|
||||
return None
|
||||
|
||||
try:
|
||||
metrics = self._pipeline.get_metrics_summary()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not metrics or "error" in metrics:
|
||||
return None
|
||||
|
||||
self._last_values["total_ms"] = metrics.get("total_ms", 0.0)
|
||||
self._last_values["fps"] = metrics.get("fps", 0.0)
|
||||
self._last_values["avg_ms"] = metrics.get("avg_ms", 0.0)
|
||||
self._last_values["min_ms"] = metrics.get("min_ms", 0.0)
|
||||
self._last_values["max_ms"] = metrics.get("max_ms", 0.0)
|
||||
|
||||
# Provide total_ms as primary value (for LFO-style effects)
|
||||
return SensorValue(
|
||||
sensor_name=self.name,
|
||||
value=self._last_values["total_ms"],
|
||||
timestamp=0.0,
|
||||
unit=self.unit,
|
||||
)
|
||||
|
||||
def get_stage_timing(self, stage_name: str) -> float:
|
||||
"""Get timing for a specific stage."""
|
||||
if not self._pipeline:
|
||||
return 0.0
|
||||
try:
|
||||
metrics = self._pipeline.get_metrics_summary()
|
||||
stages = metrics.get("stages", {})
|
||||
return stages.get(stage_name, {}).get("avg_ms", 0.0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def get_all_timings(self) -> dict[str, float]:
|
||||
"""Get all stage timings as a dict."""
|
||||
if not self._pipeline:
|
||||
return {}
|
||||
try:
|
||||
metrics = self._pipeline.get_metrics_summary()
|
||||
return metrics.get("stages", {})
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def get_frame_history(self) -> list[float]:
|
||||
"""Get historical frame times for sparklines."""
|
||||
if not self._pipeline:
|
||||
return []
|
||||
try:
|
||||
return self._pipeline.get_frame_times()
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the sensor (no-op for read-only metrics)."""
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the sensor (no-op for read-only metrics)."""
|
||||
pass
|
||||
3
hk.pkl
3
hk.pkl
@@ -22,6 +22,9 @@ hooks {
|
||||
prefix = "uv run"
|
||||
check = "ruff check engine/ tests/"
|
||||
}
|
||||
["benchmark"] {
|
||||
check = "uv run python -m engine.benchmark --hook --displays null --iterations 20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
66
mise.toml
66
mise.toml
@@ -2,28 +2,40 @@
|
||||
python = "3.12"
|
||||
hk = "latest"
|
||||
pkl = "latest"
|
||||
uv = "latest"
|
||||
|
||||
[tasks]
|
||||
# =====================
|
||||
# Development
|
||||
# Core
|
||||
# =====================
|
||||
|
||||
test = "uv run pytest"
|
||||
test-v = "uv run pytest -v"
|
||||
test-cov = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html"
|
||||
test-cov-open = "uv run pytest --cov=engine --cov-report=term-missing --cov-report=html && open htmlcov/index.html"
|
||||
|
||||
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing -m \"not benchmark\"", depends = ["sync-all"] }
|
||||
benchmark = { run = "uv run python -m engine.benchmark", depends = ["sync-all"] }
|
||||
lint = "uv run ruff check engine/ mainline.py"
|
||||
lint-fix = "uv run ruff check --fix engine/ mainline.py"
|
||||
format = "uv run ruff format engine/ mainline.py"
|
||||
|
||||
# =====================
|
||||
# Runtime
|
||||
# Run
|
||||
# =====================
|
||||
|
||||
run = "uv run mainline.py"
|
||||
run-poetry = "uv run mainline.py --poetry"
|
||||
run-firehose = "uv run mainline.py --firehose"
|
||||
run-pygame = { run = "uv run mainline.py --display pygame", depends = ["sync-all"] }
|
||||
run-terminal = { run = "uv run mainline.py --display terminal", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Presets
|
||||
# =====================
|
||||
|
||||
run-demo = { run = "uv run mainline.py --preset demo --display pygame", depends = ["sync-all"] }
|
||||
|
||||
# =====================
|
||||
# Daemon
|
||||
# =====================
|
||||
|
||||
daemon = "nohup uv run mainline.py > nohup.out 2>&1 &"
|
||||
daemon-stop = "pkill -f 'uv run mainline.py' 2>/dev/null || true"
|
||||
daemon-restart = "mise run daemon-stop && sleep 2 && mise run daemon"
|
||||
|
||||
# =====================
|
||||
# Environment
|
||||
@@ -31,22 +43,38 @@ run-firehose = "uv run mainline.py --firehose"
|
||||
|
||||
sync = "uv sync"
|
||||
sync-all = "uv sync --all-extras"
|
||||
install = "uv sync"
|
||||
install-dev = "uv sync --group dev"
|
||||
|
||||
bootstrap = "uv sync && uv run mainline.py --help"
|
||||
|
||||
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache"
|
||||
install = "mise run sync"
|
||||
clean = "rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||
clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache .mainline_cache_*.json nohup.out"
|
||||
|
||||
# =====================
|
||||
# CI/CD
|
||||
# CI
|
||||
# =====================
|
||||
|
||||
ci = "uv sync --group dev && uv run pytest --cov=engine --cov-report=term-missing --cov-report=xml"
|
||||
ci-lint = "uv run ruff check engine/ mainline.py"
|
||||
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"
|
||||
|
||||
# =====================
|
||||
# Git Hooks (via hk)
|
||||
# Hooks
|
||||
# =====================
|
||||
|
||||
pre-commit = "hk run pre-commit"
|
||||
|
||||
# =====================
|
||||
# Diagrams
|
||||
# =====================
|
||||
|
||||
# Render Mermaid diagrams to ASCII art
|
||||
diagram-ascii = "python3 scripts/render-diagrams.py docs/ARCHITECTURE.md"
|
||||
|
||||
# Validate Mermaid syntax in docs (check all diagrams parse)
|
||||
# Note: classDiagram not supported by mermaid-ascii but works in GitHub/GitLab
|
||||
diagram-validate = """
|
||||
python3 scripts/validate-diagrams.py
|
||||
"""
|
||||
|
||||
# Render diagrams and check they match expected output
|
||||
diagram-check = "mise run diagram-validate"
|
||||
|
||||
[env]
|
||||
KAGI_API_KEY = "lOp6AGyX6TUB0kGzAli1BlAx5-VjlIN1OPCPYEXDdQc.FOKLieOa7NgWUUZi4mTZvHmrW2uNnOr8hfgv7jMvRQM"
|
||||
|
||||
1
opencode-instructions.md
Normal file
1
opencode-instructions.md
Normal file
@@ -0,0 +1 @@
|
||||
/home/david/.skills/opencode-instructions/SKILL.md
|
||||
125
presets.toml
Normal file
125
presets.toml
Normal file
@@ -0,0 +1,125 @@
|
||||
# Mainline Presets Configuration
|
||||
# Human- and machine-readable preset definitions
|
||||
#
|
||||
# Format: TOML
|
||||
# Usage: mainline --preset <name>
|
||||
#
|
||||
# Built-in presets can be overridden by user presets in:
|
||||
# - ~/.config/mainline/presets.toml
|
||||
# - ./presets.toml (local override)
|
||||
|
||||
# ============================================
|
||||
# TEST PRESETS (for CI and development)
|
||||
# ============================================
|
||||
|
||||
[presets.test-basic]
|
||||
description = "Test: Basic pipeline with no effects"
|
||||
source = "empty"
|
||||
display = "null"
|
||||
camera = "feed"
|
||||
effects = []
|
||||
viewport_width = 100 # Custom size for testing
|
||||
viewport_height = 30
|
||||
|
||||
[presets.test-border]
|
||||
description = "Test: Single item with border effect"
|
||||
source = "empty"
|
||||
display = "null"
|
||||
camera = "feed"
|
||||
effects = ["border"]
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.test-scroll-camera]
|
||||
description = "Test: Scrolling camera movement"
|
||||
source = "empty"
|
||||
display = "null"
|
||||
camera = "scroll"
|
||||
effects = []
|
||||
camera_speed = 0.5
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
# ============================================
|
||||
# DEMO PRESETS (for demonstration and exploration)
|
||||
# ============================================
|
||||
|
||||
[presets.demo-base]
|
||||
description = "Demo: Base preset for effect hot-swapping"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
camera = "feed"
|
||||
effects = [] # Demo script will add/remove effects dynamically
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.demo-pygame]
|
||||
description = "Demo: Pygame display version"
|
||||
source = "headlines"
|
||||
display = "pygame"
|
||||
camera = "feed"
|
||||
effects = [] # Demo script will add/remove effects dynamically
|
||||
camera_speed = 0.1
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
[presets.demo-camera-showcase]
|
||||
description = "Demo: Camera mode showcase"
|
||||
source = "headlines"
|
||||
display = "terminal"
|
||||
camera = "feed"
|
||||
effects = [] # Demo script will cycle through camera modes
|
||||
camera_speed = 0.5
|
||||
viewport_width = 80
|
||||
viewport_height = 24
|
||||
|
||||
# ============================================
|
||||
# SENSOR CONFIGURATION
|
||||
# ============================================
|
||||
|
||||
[sensors.mic]
|
||||
enabled = false
|
||||
threshold_db = 50.0
|
||||
|
||||
[sensors.oscillator]
|
||||
enabled = true # Enable for demo script gentle oscillation
|
||||
waveform = "sine"
|
||||
frequency = 0.05 # ~20 second cycle (gentle)
|
||||
amplitude = 0.5 # 50% modulation
|
||||
|
||||
# ============================================
|
||||
# EFFECT CONFIGURATIONS
|
||||
# ============================================
|
||||
|
||||
[effect_configs.noise]
|
||||
enabled = true
|
||||
intensity = 1.0
|
||||
|
||||
[effect_configs.fade]
|
||||
enabled = true
|
||||
intensity = 1.0
|
||||
|
||||
[effect_configs.glitch]
|
||||
enabled = true
|
||||
intensity = 0.5
|
||||
|
||||
[effect_configs.firehose]
|
||||
enabled = true
|
||||
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
|
||||
@@ -23,6 +23,7 @@ dependencies = [
|
||||
"feedparser>=6.0.0",
|
||||
"Pillow>=10.0.0",
|
||||
"pyright>=1.1.408",
|
||||
"numpy>=1.24.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -30,8 +31,18 @@ mic = [
|
||||
"sounddevice>=0.4.0",
|
||||
"numpy>=1.24.0",
|
||||
]
|
||||
websocket = [
|
||||
"websockets>=12.0",
|
||||
]
|
||||
pygame = [
|
||||
"pygame>=2.0.0",
|
||||
]
|
||||
browser = [
|
||||
"playwright>=1.40.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-benchmark>=4.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-mock>=3.12.0",
|
||||
"ruff>=0.1.0",
|
||||
@@ -47,6 +58,7 @@ build-backend = "hatchling.build"
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-benchmark>=4.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-mock>=3.12.0",
|
||||
"ruff>=0.1.0",
|
||||
@@ -61,6 +73,12 @@ addopts = [
|
||||
"--tb=short",
|
||||
"-v",
|
||||
]
|
||||
markers = [
|
||||
"benchmark: marks tests as performance benchmarks (may be slow)",
|
||||
"e2e: marks tests as end-to-end tests (require network/display)",
|
||||
"integration: marks tests as integration tests (require external services)",
|
||||
"ntfy: marks tests that require ntfy service",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
]
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
pytest>=8.0.0
|
||||
pytest-cov>=4.1.0
|
||||
pytest-mock>=3.12.0
|
||||
ruff>=0.1.0
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user