fix(performance): use simple height estimation instead of PIL rendering
- Replace estimate_block_height (PIL-based) with estimate_simple_height (word wrap) - Update viewport filter tests to match new height-based filtering (~4 items vs 24) - Fix CI task duplication in mise.toml (remove redundant depends) Closes #38 Closes #36
This commit is contained in:
313
client/editor.html
Normal file
313
client/editor.html
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Mainline Pipeline Editor</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #eee;
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
#sidebar {
|
||||||
|
width: 300px;
|
||||||
|
background: #222;
|
||||||
|
padding: 15px;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
#main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.stage-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.stage-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: #333;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.stage-item:hover { background: #444; }
|
||||||
|
.stage-item.selected { background: #0066cc; }
|
||||||
|
.stage-item input[type="checkbox"] {
|
||||||
|
margin-right: 8px;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
.stage-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.param-group {
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.param-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.param-name {
|
||||||
|
width: 100px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
.param-slider {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0 10px;
|
||||||
|
}
|
||||||
|
.param-value {
|
||||||
|
width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
color: #4f4;
|
||||||
|
}
|
||||||
|
.preset-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.preset-btn {
|
||||||
|
background: #333;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #ccc;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.preset-btn:hover { background: #444; }
|
||||||
|
.preset-btn.active { background: #0066cc; border-color: #0077ff; color: #fff; }
|
||||||
|
button.action-btn {
|
||||||
|
background: #0066cc;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
button.action-btn:hover { background: #0077ee; }
|
||||||
|
#status {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 10px;
|
||||||
|
left: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
#status.connected { color: #4f4; }
|
||||||
|
#status.disconnected { color: #f44; }
|
||||||
|
#pipeline-view {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.pipeline-node {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin: 2px;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.pipeline-node.enabled { border-left: 3px solid #4f4; }
|
||||||
|
.pipeline-node.disabled { border-left: 3px solid #666; opacity: 0.6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="sidebar">
|
||||||
|
<div class="section">
|
||||||
|
<h2>Preset</h2>
|
||||||
|
<div id="preset-list" class="preset-list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h2>Stages</h2>
|
||||||
|
<ul id="stage-list" class="stage-list"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h2>Parameters</h2>
|
||||||
|
<div id="param-editor" class="param-group"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="main">
|
||||||
|
<h2>Pipeline</h2>
|
||||||
|
<div id="pipeline-view"></div>
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
<button class="action-btn" onclick="sendCommand({action: 'cycle_preset', direction: 1})">Next Preset</button>
|
||||||
|
<button class="action-btn" onclick="sendCommand({action: 'cycle_preset', direction: -1})">Prev Preset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="status">Disconnected</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const ws = new WebSocket(`ws://${location.hostname}:8765`);
|
||||||
|
let state = { stages: {}, preset: '', presets: [], selected_stage: null };
|
||||||
|
|
||||||
|
function updateStatus(connected) {
|
||||||
|
const status = document.getElementById('status');
|
||||||
|
status.textContent = connected ? 'Connected' : 'Disconnected';
|
||||||
|
status.className = connected ? 'connected' : 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
ws.onopen = () => {
|
||||||
|
updateStatus(true);
|
||||||
|
// Request initial state
|
||||||
|
ws.send(JSON.stringify({ type: 'state_request' }));
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
updateStatus(false);
|
||||||
|
setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
ws.onerror = () => {
|
||||||
|
updateStatus(false);
|
||||||
|
};
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'state') {
|
||||||
|
state = data.state;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Parse error:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendCommand(command) {
|
||||||
|
ws.send(JSON.stringify({ type: 'command', command }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderPresets();
|
||||||
|
renderStageList();
|
||||||
|
renderPipeline();
|
||||||
|
renderParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPresets() {
|
||||||
|
const container = document.getElementById('preset-list');
|
||||||
|
container.innerHTML = '';
|
||||||
|
(state.presets || []).forEach(preset => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'preset-btn' + (preset === state.preset ? ' active' : '');
|
||||||
|
btn.textContent = preset;
|
||||||
|
btn.onclick = () => sendCommand({ action: 'change_preset', preset });
|
||||||
|
container.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStageList() {
|
||||||
|
const list = document.getElementById('stage-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
Object.entries(state.stages || {}).forEach(([name, info]) => {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'stage-item' + (name === state.selected_stage ? ' selected' : '');
|
||||||
|
li.innerHTML = `
|
||||||
|
<input type="checkbox" ${info.enabled ? 'checked' : ''}
|
||||||
|
onchange="sendCommand({action: 'toggle_stage', stage: '${name}'})">
|
||||||
|
<span class="stage-name">${name}</span>
|
||||||
|
`;
|
||||||
|
li.onclick = (e) => {
|
||||||
|
if (e.target.type !== 'checkbox') {
|
||||||
|
sendCommand({ action: 'select_stage', stage: name });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPipeline() {
|
||||||
|
const view = document.getElementById('pipeline-view');
|
||||||
|
view.innerHTML = '';
|
||||||
|
const stages = Object.entries(state.stages || {});
|
||||||
|
if (stages.length === 0) {
|
||||||
|
view.textContent = '(No stages)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stages.forEach(([name, info]) => {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'pipeline-node' + (info.enabled ? ' enabled' : ' disabled');
|
||||||
|
span.textContent = name;
|
||||||
|
view.appendChild(span);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderParams() {
|
||||||
|
const container = document.getElementById('param-editor');
|
||||||
|
container.innerHTML = '';
|
||||||
|
const selected = state.selected_stage;
|
||||||
|
if (!selected || !state.stages[selected]) {
|
||||||
|
container.innerHTML = '<div style="color:#666;font-size:11px;">(select a stage)</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stage = state.stages[selected];
|
||||||
|
if (!stage.params || Object.keys(stage.params).length === 0) {
|
||||||
|
container.innerHTML = '<div style="color:#666;font-size:11px;">(no params)</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Object.entries(stage.params).forEach(([key, value]) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'param-row';
|
||||||
|
// Infer min/max/step from typical ranges
|
||||||
|
let min = 0, max = 1, step = 0.1;
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (value > 1) { max = value * 2; step = 1; }
|
||||||
|
else { max = 1; step = 0.1; }
|
||||||
|
}
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="param-name">${key}</div>
|
||||||
|
<input type="range" class="param-slider" min="${min}" max="${max}" step="${step}"
|
||||||
|
value="${value}"
|
||||||
|
oninput="adjustParam('${key}', this.value)">
|
||||||
|
<div class="param-value">${typeof value === 'number' ? Number(value).toFixed(2) : value}</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustParam(param, newValue) {
|
||||||
|
const selected = state.selected_stage;
|
||||||
|
if (!selected) return;
|
||||||
|
// Update display immediately for responsiveness
|
||||||
|
const num = parseFloat(newValue);
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
// Show updated value
|
||||||
|
document.querySelectorAll('.param-value').forEach(el => {
|
||||||
|
if (el.parentElement.querySelector('.param-name').textContent === param) {
|
||||||
|
el.textContent = num.toFixed(2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Send command
|
||||||
|
sendCommand({
|
||||||
|
action: 'adjust_param',
|
||||||
|
stage: selected,
|
||||||
|
param: param,
|
||||||
|
delta: num - (state.stages[selected].params[param] || 0)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -277,6 +277,9 @@
|
|||||||
} else if (data.type === 'clear') {
|
} else if (data.type === 'clear') {
|
||||||
ctx.fillStyle = '#000';
|
ctx.fillStyle = '#000';
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
} else if (data.type === 'state') {
|
||||||
|
// Log state updates for debugging (can be extended for UI)
|
||||||
|
console.log('State update:', data.state);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse message:', e);
|
console.error('Failed to parse message:', e);
|
||||||
|
|||||||
1031
engine/app.py
1031
engine/app.py
File diff suppressed because it is too large
Load Diff
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",
|
||||||
|
]
|
||||||
420
engine/app/main.py
Normal file
420
engine/app/main.py
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
"""
|
||||||
|
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_poetry, load_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
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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 == "--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()
|
||||||
|
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 = 80
|
||||||
|
params.viewport_height = 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"))
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
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:
|
||||||
|
print(" \033[38;5;196mPipeline execution failed\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")
|
||||||
701
engine/app/pipeline_runner.py
Normal file
701
engine/app/pipeline_runner.py
Normal file
@@ -0,0 +1,701 @@
|
|||||||
|
"""
|
||||||
|
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_poetry, load_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 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()
|
||||||
|
params.viewport_width = 80
|
||||||
|
params.viewport_height = 24
|
||||||
|
|
||||||
|
pipeline = Pipeline(
|
||||||
|
config=PipelineConfig(
|
||||||
|
source=preset.source,
|
||||||
|
display=preset.display,
|
||||||
|
camera=preset.camera,
|
||||||
|
effects=preset.effects,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
print(" \033[38;5;245mFetching content...\033[0m")
|
||||||
|
|
||||||
|
# Handle special sources that don't need traditional fetching
|
||||||
|
introspection_source = None
|
||||||
|
if preset.source == "pipeline-inspect":
|
||||||
|
items = []
|
||||||
|
print(" \033[38;5;245mUsing pipeline introspection source\033[0m")
|
||||||
|
elif preset.source == "empty":
|
||||||
|
items = []
|
||||||
|
print(" \033[38;5;245mUsing empty source (no content)\033[0m")
|
||||||
|
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
|
||||||
|
elif preset.source == "poetry":
|
||||||
|
items, _, _ = fetch_poetry()
|
||||||
|
else:
|
||||||
|
items, _, _ = fetch_all()
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
print(" \033[38;5;196mNo content available\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
print(f" \033[38;5;82mLoaded {len(items)} items\033[0m")
|
||||||
|
|
||||||
|
# CLI --display flag takes priority over preset
|
||||||
|
# Check if --display was explicitly provided
|
||||||
|
display_name = preset.display
|
||||||
|
if "--display" in sys.argv:
|
||||||
|
idx = sys.argv.index("--display")
|
||||||
|
if idx + 1 < len(sys.argv):
|
||||||
|
display_name = sys.argv[idx + 1]
|
||||||
|
|
||||||
|
display = DisplayRegistry.create(display_name)
|
||||||
|
if not display and not display_name.startswith("multi"):
|
||||||
|
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Handle multi display (format: "multi:terminal,pygame")
|
||||||
|
if not display and display_name.startswith("multi"):
|
||||||
|
parts = display_name[6:].split(
|
||||||
|
","
|
||||||
|
) # "multi:terminal,pygame" -> ["terminal", "pygame"]
|
||||||
|
display = DisplayRegistry.create_multi(parts)
|
||||||
|
if not display:
|
||||||
|
print(f" \033[38;5;196mFailed to create multi display: {parts}\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not display:
|
||||||
|
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
display.init(0, 0)
|
||||||
|
|
||||||
|
# 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 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 preset.camera:
|
||||||
|
from engine.camera import Camera
|
||||||
|
from engine.pipeline.adapters import CameraStage
|
||||||
|
|
||||||
|
camera = None
|
||||||
|
speed = getattr(preset, "camera_speed", 1.0)
|
||||||
|
if preset.camera == "feed":
|
||||||
|
camera = Camera.feed(speed=speed)
|
||||||
|
elif preset.camera == "scroll":
|
||||||
|
camera = Camera.scroll(speed=speed)
|
||||||
|
elif preset.camera == "vertical":
|
||||||
|
camera = Camera.scroll(speed=speed) # Backwards compat
|
||||||
|
elif preset.camera == "horizontal":
|
||||||
|
camera = Camera.horizontal(speed=speed)
|
||||||
|
elif preset.camera == "omni":
|
||||||
|
camera = Camera.omni(speed=speed)
|
||||||
|
elif preset.camera == "floating":
|
||||||
|
camera = Camera.floating(speed=speed)
|
||||||
|
elif preset.camera == "bounce":
|
||||||
|
camera = Camera.bounce(speed=speed)
|
||||||
|
|
||||||
|
if camera:
|
||||||
|
pipeline.add_stage("camera", CameraStage(camera, name=preset.camera))
|
||||||
|
|
||||||
|
for effect_name in preset.effects:
|
||||||
|
effect = effect_registry.get(effect_name)
|
||||||
|
if effect:
|
||||||
|
pipeline.add_stage(
|
||||||
|
f"effect_{effect_name}", create_stage_from_effect(effect, effect_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline.add_stage("display", create_stage_from_display(display, display_name))
|
||||||
|
|
||||||
|
pipeline.build()
|
||||||
|
|
||||||
|
# For pipeline-inspect, set the pipeline after build to avoid circular dependency
|
||||||
|
if introspection_source is not None:
|
||||||
|
introspection_source.set_pipeline(pipeline)
|
||||||
|
|
||||||
|
if not pipeline.initialize():
|
||||||
|
print(" \033[38;5;196mFailed to initialize pipeline\033[0m")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 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."""
|
||||||
|
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")
|
||||||
|
|
||||||
|
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 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)
|
||||||
|
|
||||||
|
if camera:
|
||||||
|
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]
|
||||||
|
|
||||||
|
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 = 80
|
||||||
|
current_height = 24
|
||||||
|
|
||||||
|
if hasattr(display, "get_dimensions"):
|
||||||
|
current_width, current_height = display.get_dimensions()
|
||||||
|
params.viewport_width = current_width
|
||||||
|
params.viewport_height = current_height
|
||||||
|
|
||||||
|
try:
|
||||||
|
frame = 0
|
||||||
|
while True:
|
||||||
|
params.frame_number = frame
|
||||||
|
ctx.params = params
|
||||||
|
|
||||||
|
result = pipeline.execute(items)
|
||||||
|
if result.success:
|
||||||
|
# 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 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")
|
||||||
@@ -101,7 +101,7 @@ class PygameDisplay:
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ["SDL_VIDEODRIVER"] = "x11"
|
os.environ["SDL_VIDEODRIVER"] = "dummy"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pygame
|
import pygame
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
WebSocket display backend - broadcasts frame buffer to connected web clients.
|
WebSocket display backend - broadcasts frame buffer to connected web clients.
|
||||||
|
|
||||||
|
Supports streaming protocols:
|
||||||
|
- Full frame (JSON) - default for compatibility
|
||||||
|
- Binary streaming - efficient binary protocol
|
||||||
|
- Diff streaming - only sends changed lines
|
||||||
|
|
||||||
TODO: Transform to a true streaming backend with:
|
TODO: Transform to a true streaming backend with:
|
||||||
- Proper WebSocket message streaming (currently sends full buffer each frame)
|
- Proper WebSocket message streaming (currently sends full buffer each frame)
|
||||||
- Connection pooling and backpressure handling
|
- Connection pooling and backpressure handling
|
||||||
@@ -12,9 +17,28 @@ Current implementation: Simple broadcast of text frames to all connected clients
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from enum import IntFlag
|
||||||
|
|
||||||
|
from engine.display.streaming import (
|
||||||
|
MessageType,
|
||||||
|
compress_frame,
|
||||||
|
compute_diff,
|
||||||
|
encode_binary_message,
|
||||||
|
encode_diff_message,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StreamingMode(IntFlag):
|
||||||
|
"""Streaming modes for WebSocket display."""
|
||||||
|
|
||||||
|
JSON = 0x01 # Full JSON frames (default, compatible)
|
||||||
|
BINARY = 0x02 # Binary compression
|
||||||
|
DIFF = 0x04 # Differential updates
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import websockets
|
import websockets
|
||||||
@@ -43,6 +67,7 @@ class WebSocketDisplay:
|
|||||||
host: str = "0.0.0.0",
|
host: str = "0.0.0.0",
|
||||||
port: int = 8765,
|
port: int = 8765,
|
||||||
http_port: int = 8766,
|
http_port: int = 8766,
|
||||||
|
streaming_mode: StreamingMode = StreamingMode.JSON,
|
||||||
):
|
):
|
||||||
self.host = host
|
self.host = host
|
||||||
self.port = port
|
self.port = port
|
||||||
@@ -58,7 +83,15 @@ class WebSocketDisplay:
|
|||||||
self._max_clients = 10
|
self._max_clients = 10
|
||||||
self._client_connected_callback = None
|
self._client_connected_callback = None
|
||||||
self._client_disconnected_callback = None
|
self._client_disconnected_callback = None
|
||||||
|
self._command_callback = None
|
||||||
|
self._controller = None # Reference to UI panel or pipeline controller
|
||||||
self._frame_delay = 0.0
|
self._frame_delay = 0.0
|
||||||
|
self._httpd = None # HTTP server instance
|
||||||
|
|
||||||
|
# Streaming configuration
|
||||||
|
self._streaming_mode = streaming_mode
|
||||||
|
self._last_buffer: list[str] = []
|
||||||
|
self._client_capabilities: dict = {} # Track client capabilities
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import websockets as _ws
|
import websockets as _ws
|
||||||
@@ -87,7 +120,7 @@ class WebSocketDisplay:
|
|||||||
self.start_http_server()
|
self.start_http_server()
|
||||||
|
|
||||||
def show(self, buffer: list[str], border: bool = False) -> None:
|
def show(self, buffer: list[str], border: bool = False) -> None:
|
||||||
"""Broadcast buffer to all connected clients."""
|
"""Broadcast buffer to all connected clients using streaming protocol."""
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
|
|
||||||
# Get metrics for border display
|
# Get metrics for border display
|
||||||
@@ -108,19 +141,25 @@ class WebSocketDisplay:
|
|||||||
|
|
||||||
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
|
||||||
|
|
||||||
if self._clients:
|
if not self._clients:
|
||||||
frame_data = {
|
self._last_buffer = buffer
|
||||||
"type": "frame",
|
return
|
||||||
"width": self.width,
|
|
||||||
"height": self.height,
|
|
||||||
"lines": buffer,
|
|
||||||
}
|
|
||||||
message = json.dumps(frame_data)
|
|
||||||
|
|
||||||
|
# Send to each client based on their capabilities
|
||||||
disconnected = set()
|
disconnected = set()
|
||||||
for client in list(self._clients):
|
for client in list(self._clients):
|
||||||
try:
|
try:
|
||||||
asyncio.run(client.send(message))
|
client_id = id(client)
|
||||||
|
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:
|
except Exception:
|
||||||
disconnected.add(client)
|
disconnected.add(client)
|
||||||
|
|
||||||
@@ -129,12 +168,55 @@ class WebSocketDisplay:
|
|||||||
if self._client_disconnected_callback:
|
if self._client_disconnected_callback:
|
||||||
self._client_disconnected_callback(client)
|
self._client_disconnected_callback(client)
|
||||||
|
|
||||||
|
self._last_buffer = buffer
|
||||||
|
|
||||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
monitor = get_monitor()
|
|
||||||
if monitor:
|
if monitor:
|
||||||
chars_in = sum(len(line) for line in buffer)
|
chars_in = sum(len(line) for line in buffer)
|
||||||
monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
|
monitor.record_effect("websocket_display", elapsed_ms, chars_in, chars_in)
|
||||||
|
|
||||||
|
def _send_json_frame(self, client, buffer: list[str]) -> None:
|
||||||
|
"""Send frame as JSON."""
|
||||||
|
frame_data = {
|
||||||
|
"type": "frame",
|
||||||
|
"width": self.width,
|
||||||
|
"height": self.height,
|
||||||
|
"lines": buffer,
|
||||||
|
}
|
||||||
|
message = json.dumps(frame_data)
|
||||||
|
asyncio.run(client.send(message))
|
||||||
|
|
||||||
|
def _send_binary_frame(self, client, buffer: list[str]) -> None:
|
||||||
|
"""Send frame as compressed binary."""
|
||||||
|
compressed = compress_frame(buffer)
|
||||||
|
message = encode_binary_message(
|
||||||
|
MessageType.FULL_FRAME, self.width, self.height, compressed
|
||||||
|
)
|
||||||
|
encoded = base64.b64encode(message).decode("utf-8")
|
||||||
|
asyncio.run(client.send(encoded))
|
||||||
|
|
||||||
|
def _send_diff_frame(self, client, buffer: list[str]) -> None:
|
||||||
|
"""Send frame as diff."""
|
||||||
|
diff = compute_diff(self._last_buffer, buffer)
|
||||||
|
|
||||||
|
if not diff.changed_lines:
|
||||||
|
return
|
||||||
|
|
||||||
|
diff_payload = encode_diff_message(diff)
|
||||||
|
message = encode_binary_message(
|
||||||
|
MessageType.DIFF_FRAME, self.width, self.height, diff_payload
|
||||||
|
)
|
||||||
|
encoded = base64.b64encode(message).decode("utf-8")
|
||||||
|
asyncio.run(client.send(encoded))
|
||||||
|
|
||||||
|
def set_streaming_mode(self, mode: StreamingMode) -> None:
|
||||||
|
"""Set the default streaming mode for new clients."""
|
||||||
|
self._streaming_mode = mode
|
||||||
|
|
||||||
|
def get_streaming_mode(self) -> StreamingMode:
|
||||||
|
"""Get the current streaming mode."""
|
||||||
|
return self._streaming_mode
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
"""Broadcast clear command to all clients."""
|
"""Broadcast clear command to all clients."""
|
||||||
if self._clients:
|
if self._clients:
|
||||||
@@ -165,9 +247,21 @@ class WebSocketDisplay:
|
|||||||
async for message in websocket:
|
async for message in websocket:
|
||||||
try:
|
try:
|
||||||
data = json.loads(message)
|
data = json.loads(message)
|
||||||
if data.get("type") == "resize":
|
msg_type = data.get("type")
|
||||||
|
|
||||||
|
if msg_type == "resize":
|
||||||
self.width = data.get("width", 80)
|
self.width = data.get("width", 80)
|
||||||
self.height = data.get("height", 24)
|
self.height = data.get("height", 24)
|
||||||
|
elif msg_type == "command" and self._command_callback:
|
||||||
|
# Forward commands to the pipeline controller
|
||||||
|
command = data.get("command", {})
|
||||||
|
self._command_callback(command)
|
||||||
|
elif msg_type == "state_request":
|
||||||
|
# Send current state snapshot
|
||||||
|
state = self._get_state_snapshot()
|
||||||
|
if state:
|
||||||
|
response = {"type": "state", "state": state}
|
||||||
|
await websocket.send(json.dumps(response))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -179,6 +273,8 @@ class WebSocketDisplay:
|
|||||||
|
|
||||||
async def _run_websocket_server(self):
|
async def _run_websocket_server(self):
|
||||||
"""Run the WebSocket server."""
|
"""Run the WebSocket server."""
|
||||||
|
if not websockets:
|
||||||
|
return
|
||||||
async with websockets.serve(self._websocket_handler, self.host, self.port):
|
async with websockets.serve(self._websocket_handler, self.host, self.port):
|
||||||
while self._server_running:
|
while self._server_running:
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
@@ -188,8 +284,22 @@ class WebSocketDisplay:
|
|||||||
import os
|
import os
|
||||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||||
|
|
||||||
|
# Find the project root by locating 'engine' directory in the path
|
||||||
|
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(
|
client_dir = os.path.join(
|
||||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "client"
|
os.path.dirname(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
),
|
||||||
|
"client",
|
||||||
)
|
)
|
||||||
|
|
||||||
class Handler(SimpleHTTPRequestHandler):
|
class Handler(SimpleHTTPRequestHandler):
|
||||||
@@ -200,8 +310,10 @@ class WebSocketDisplay:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
httpd = HTTPServer((self.host, self.http_port), Handler)
|
httpd = HTTPServer((self.host, self.http_port), Handler)
|
||||||
while self._http_running:
|
# Store reference for shutdown
|
||||||
httpd.handle_request()
|
self._httpd = httpd
|
||||||
|
# Serve requests continuously
|
||||||
|
httpd.serve_forever()
|
||||||
|
|
||||||
def _run_async(self, coro):
|
def _run_async(self, coro):
|
||||||
"""Run coroutine in background."""
|
"""Run coroutine in background."""
|
||||||
@@ -246,6 +358,8 @@ class WebSocketDisplay:
|
|||||||
def stop_http_server(self):
|
def stop_http_server(self):
|
||||||
"""Stop the HTTP server."""
|
"""Stop the HTTP server."""
|
||||||
self._http_running = False
|
self._http_running = False
|
||||||
|
if hasattr(self, "_httpd") and self._httpd:
|
||||||
|
self._httpd.shutdown()
|
||||||
self._http_thread = None
|
self._http_thread = None
|
||||||
|
|
||||||
def client_count(self) -> int:
|
def client_count(self) -> int:
|
||||||
@@ -276,6 +390,71 @@ class WebSocketDisplay:
|
|||||||
"""Set callback for client disconnections."""
|
"""Set callback for client disconnections."""
|
||||||
self._client_disconnected_callback = callback
|
self._client_disconnected_callback = callback
|
||||||
|
|
||||||
|
def set_command_callback(self, callback) -> None:
|
||||||
|
"""Set callback for incoming command messages from clients."""
|
||||||
|
self._command_callback = callback
|
||||||
|
|
||||||
|
def set_controller(self, controller) -> None:
|
||||||
|
"""Set controller (UI panel or pipeline) for state queries and command execution."""
|
||||||
|
self._controller = controller
|
||||||
|
|
||||||
|
def broadcast_state(self, state: dict) -> None:
|
||||||
|
"""Broadcast state update to all connected clients.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: Dictionary containing state data to send to clients
|
||||||
|
"""
|
||||||
|
if not self._clients:
|
||||||
|
return
|
||||||
|
|
||||||
|
message = json.dumps({"type": "state", "state": state})
|
||||||
|
|
||||||
|
disconnected = set()
|
||||||
|
for client in list(self._clients):
|
||||||
|
try:
|
||||||
|
asyncio.run(client.send(message))
|
||||||
|
except Exception:
|
||||||
|
disconnected.add(client)
|
||||||
|
|
||||||
|
for client in disconnected:
|
||||||
|
self._clients.discard(client)
|
||||||
|
if self._client_disconnected_callback:
|
||||||
|
self._client_disconnected_callback(client)
|
||||||
|
|
||||||
|
def _get_state_snapshot(self) -> dict | None:
|
||||||
|
"""Get current state snapshot from controller."""
|
||||||
|
if not self._controller:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Expect controller to have methods we need
|
||||||
|
state = {}
|
||||||
|
|
||||||
|
# Get stages info if UIPanel
|
||||||
|
if hasattr(self._controller, "stages"):
|
||||||
|
state["stages"] = {
|
||||||
|
name: {
|
||||||
|
"enabled": ctrl.enabled,
|
||||||
|
"params": ctrl.params,
|
||||||
|
"selected": ctrl.selected,
|
||||||
|
}
|
||||||
|
for name, ctrl in self._controller.stages.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get current preset
|
||||||
|
if hasattr(self._controller, "_current_preset"):
|
||||||
|
state["preset"] = self._controller._current_preset
|
||||||
|
if hasattr(self._controller, "_presets"):
|
||||||
|
state["presets"] = self._controller._presets
|
||||||
|
|
||||||
|
# Get selected stage
|
||||||
|
if hasattr(self._controller, "selected_stage"):
|
||||||
|
state["selected_stage"] = self._controller.selected_stage
|
||||||
|
|
||||||
|
return state
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def get_dimensions(self) -> tuple[int, int]:
|
def get_dimensions(self) -> tuple[int, int]:
|
||||||
"""Get current dimensions.
|
"""Get current dimensions.
|
||||||
|
|
||||||
|
|||||||
268
engine/display/streaming.py
Normal file
268
engine/display/streaming.py
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
"""
|
||||||
|
Streaming protocol utilities for efficient frame transmission.
|
||||||
|
|
||||||
|
Provides:
|
||||||
|
- Frame differencing: Only send changed lines
|
||||||
|
- Run-length encoding: Compress repeated lines
|
||||||
|
- Binary encoding: Compact message format
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import zlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import IntEnum
|
||||||
|
|
||||||
|
|
||||||
|
class MessageType(IntEnum):
|
||||||
|
"""Message types for streaming protocol."""
|
||||||
|
|
||||||
|
FULL_FRAME = 1
|
||||||
|
DIFF_FRAME = 2
|
||||||
|
STATE = 3
|
||||||
|
CLEAR = 4
|
||||||
|
PING = 5
|
||||||
|
PONG = 6
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FrameDiff:
|
||||||
|
"""Represents a diff between two frames."""
|
||||||
|
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
changed_lines: list[tuple[int, str]] # (line_index, content)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_diff(old_buffer: list[str], new_buffer: list[str]) -> FrameDiff:
|
||||||
|
"""Compute differences between old and new buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_buffer: Previous frame buffer
|
||||||
|
new_buffer: Current frame buffer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FrameDiff with only changed lines
|
||||||
|
"""
|
||||||
|
height = len(new_buffer)
|
||||||
|
changed_lines = []
|
||||||
|
|
||||||
|
for i, line in enumerate(new_buffer):
|
||||||
|
if i >= len(old_buffer) or line != old_buffer[i]:
|
||||||
|
changed_lines.append((i, line))
|
||||||
|
|
||||||
|
return FrameDiff(
|
||||||
|
width=len(new_buffer[0]) if new_buffer else 0,
|
||||||
|
height=height,
|
||||||
|
changed_lines=changed_lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def encode_rle(lines: list[tuple[int, str]]) -> list[tuple[int, str, int]]:
|
||||||
|
"""Run-length encode consecutive identical lines.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lines: List of (index, content) tuples (must be sorted by index)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (start_index, content, run_length) tuples
|
||||||
|
"""
|
||||||
|
if not lines:
|
||||||
|
return []
|
||||||
|
|
||||||
|
encoded = []
|
||||||
|
start_idx = lines[0][0]
|
||||||
|
current_line = lines[0][1]
|
||||||
|
current_rle = 1
|
||||||
|
|
||||||
|
for idx, line in lines[1:]:
|
||||||
|
if line == current_line:
|
||||||
|
current_rle += 1
|
||||||
|
else:
|
||||||
|
encoded.append((start_idx, current_line, current_rle))
|
||||||
|
start_idx = idx
|
||||||
|
current_line = line
|
||||||
|
current_rle = 1
|
||||||
|
|
||||||
|
encoded.append((start_idx, current_line, current_rle))
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
|
||||||
|
def decode_rle(encoded: list[tuple[int, str, int]]) -> list[tuple[int, str]]:
|
||||||
|
"""Decode run-length encoded lines.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encoded: List of (start_index, content, run_length) tuples
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (index, content) tuples
|
||||||
|
"""
|
||||||
|
result = []
|
||||||
|
for start_idx, line, rle in encoded:
|
||||||
|
for i in range(rle):
|
||||||
|
result.append((start_idx + i, line))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def compress_frame(buffer: list[str], level: int = 6) -> bytes:
|
||||||
|
"""Compress a frame buffer using zlib.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
buffer: Frame buffer (list of lines)
|
||||||
|
level: Compression level (0-9)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Compressed bytes
|
||||||
|
"""
|
||||||
|
content = "\n".join(buffer)
|
||||||
|
return zlib.compress(content.encode("utf-8"), level)
|
||||||
|
|
||||||
|
|
||||||
|
def decompress_frame(data: bytes, height: int) -> list[str]:
|
||||||
|
"""Decompress a frame buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Compressed bytes
|
||||||
|
height: Number of lines in original buffer
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Frame buffer (list of lines)
|
||||||
|
"""
|
||||||
|
content = zlib.decompress(data).decode("utf-8")
|
||||||
|
lines = content.split("\n")
|
||||||
|
if len(lines) > height:
|
||||||
|
lines = lines[:height]
|
||||||
|
while len(lines) < height:
|
||||||
|
lines.append("")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def encode_binary_message(
|
||||||
|
msg_type: MessageType, width: int, height: int, payload: bytes
|
||||||
|
) -> bytes:
|
||||||
|
"""Encode a binary message.
|
||||||
|
|
||||||
|
Message format:
|
||||||
|
- 1 byte: message type
|
||||||
|
- 2 bytes: width (uint16)
|
||||||
|
- 2 bytes: height (uint16)
|
||||||
|
- 4 bytes: payload length (uint32)
|
||||||
|
- N bytes: payload
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_type: Message type
|
||||||
|
width: Frame width
|
||||||
|
height: Frame height
|
||||||
|
payload: Message payload
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded binary message
|
||||||
|
"""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
header = struct.pack("!BHHI", msg_type.value, width, height, len(payload))
|
||||||
|
return header + payload
|
||||||
|
|
||||||
|
|
||||||
|
def decode_binary_message(data: bytes) -> tuple[MessageType, int, int, bytes]:
|
||||||
|
"""Decode a binary message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Binary message data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (msg_type, width, height, payload)
|
||||||
|
"""
|
||||||
|
import struct
|
||||||
|
|
||||||
|
msg_type_val, width, height, payload_len = struct.unpack("!BHHI", data[:9])
|
||||||
|
payload = data[9 : 9 + payload_len]
|
||||||
|
return MessageType(msg_type_val), width, height, payload
|
||||||
|
|
||||||
|
|
||||||
|
def encode_diff_message(diff: FrameDiff, use_rle: bool = True) -> bytes:
|
||||||
|
"""Encode a diff message for transmission.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
diff: Frame diff
|
||||||
|
use_rle: Whether to use run-length encoding
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encoded diff payload
|
||||||
|
"""
|
||||||
|
|
||||||
|
if use_rle:
|
||||||
|
encoded_lines = encode_rle(diff.changed_lines)
|
||||||
|
data = [[idx, line, rle] for idx, line, rle in encoded_lines]
|
||||||
|
else:
|
||||||
|
data = [[idx, line] for idx, line in diff.changed_lines]
|
||||||
|
|
||||||
|
payload = json.dumps(data).encode("utf-8")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def decode_diff_message(payload: bytes, use_rle: bool = True) -> list[tuple[int, str]]:
|
||||||
|
"""Decode a diff message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Encoded diff payload
|
||||||
|
use_rle: Whether run-length encoding was used
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (line_index, content) tuples
|
||||||
|
"""
|
||||||
|
|
||||||
|
data = json.loads(payload.decode("utf-8"))
|
||||||
|
|
||||||
|
if use_rle:
|
||||||
|
return decode_rle([(idx, line, rle) for idx, line, rle in data])
|
||||||
|
else:
|
||||||
|
return [(idx, line) for idx, line in data]
|
||||||
|
|
||||||
|
|
||||||
|
def should_use_diff(
|
||||||
|
old_buffer: list[str], new_buffer: list[str], threshold: float = 0.3
|
||||||
|
) -> bool:
|
||||||
|
"""Determine if diff or full frame is more efficient.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_buffer: Previous frame
|
||||||
|
new_buffer: Current frame
|
||||||
|
threshold: Max changed ratio to use diff (0.0-1.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if diff is more efficient
|
||||||
|
"""
|
||||||
|
if not old_buffer or not new_buffer:
|
||||||
|
return False
|
||||||
|
|
||||||
|
diff = compute_diff(old_buffer, new_buffer)
|
||||||
|
total_lines = len(new_buffer)
|
||||||
|
changed_ratio = len(diff.changed_lines) / total_lines if total_lines > 0 else 1.0
|
||||||
|
|
||||||
|
return changed_ratio <= threshold
|
||||||
|
|
||||||
|
|
||||||
|
def apply_diff(old_buffer: list[str], diff: FrameDiff) -> list[str]:
|
||||||
|
"""Apply a diff to an old buffer to get the new buffer.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_buffer: Previous frame buffer
|
||||||
|
diff: Frame diff to apply
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
New frame buffer
|
||||||
|
"""
|
||||||
|
new_buffer = list(old_buffer)
|
||||||
|
|
||||||
|
for line_idx, content in diff.changed_lines:
|
||||||
|
if line_idx < len(new_buffer):
|
||||||
|
new_buffer[line_idx] = content
|
||||||
|
else:
|
||||||
|
while len(new_buffer) < line_idx:
|
||||||
|
new_buffer.append("")
|
||||||
|
new_buffer.append(content)
|
||||||
|
|
||||||
|
while len(new_buffer) < diff.height:
|
||||||
|
new_buffer.append("")
|
||||||
|
|
||||||
|
return new_buffer[: diff.height]
|
||||||
@@ -3,843 +3,48 @@ Stage adapters - Bridge existing components to the Stage interface.
|
|||||||
|
|
||||||
This module provides adapters that wrap existing components
|
This module provides adapters that wrap existing components
|
||||||
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
|
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
|
||||||
|
|
||||||
|
DEPRECATED: This file is now a compatibility wrapper.
|
||||||
|
Use `engine.pipeline.adapters` package instead.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any
|
# Re-export from the new package structure for backward compatibility
|
||||||
|
from engine.pipeline.adapters import (
|
||||||
from engine.pipeline.core import PipelineContext, Stage
|
# Adapter classes
|
||||||
|
CameraStage,
|
||||||
|
CanvasStage,
|
||||||
class EffectPluginStage(Stage):
|
DataSourceStage,
|
||||||
"""Adapter wrapping EffectPlugin as a Stage."""
|
DisplayStage,
|
||||||
|
EffectPluginStage,
|
||||||
def __init__(self, effect_plugin, name: str = "effect"):
|
FontStage,
|
||||||
self._effect = effect_plugin
|
ImageToTextStage,
|
||||||
self.name = name
|
PassthroughStage,
|
||||||
self.category = "effect"
|
SourceItemsToBufferStage,
|
||||||
self.optional = False
|
ViewportFilterStage,
|
||||||
|
# Factory functions
|
||||||
@property
|
create_stage_from_camera,
|
||||||
def stage_type(self) -> str:
|
create_stage_from_display,
|
||||||
"""Return stage_type based on effect name.
|
create_stage_from_effect,
|
||||||
|
create_stage_from_font,
|
||||||
HUD effects are overlays.
|
create_stage_from_source,
|
||||||
"""
|
|
||||||
if self.name == "hud":
|
|
||||||
return "overlay"
|
|
||||||
return self.category
|
|
||||||
|
|
||||||
@property
|
|
||||||
def render_order(self) -> int:
|
|
||||||
"""Return render_order based on effect type.
|
|
||||||
|
|
||||||
HUD effects have high render_order to appear on top.
|
|
||||||
"""
|
|
||||||
if self.name == "hud":
|
|
||||||
return 100 # High order for overlays
|
|
||||||
return 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_overlay(self) -> bool:
|
|
||||||
"""Return True for HUD effects.
|
|
||||||
|
|
||||||
HUD is an overlay - it composes on top of the buffer
|
|
||||||
rather than transforming it for the next stage.
|
|
||||||
"""
|
|
||||||
return self.name == "hud"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self) -> set[str]:
|
|
||||||
return {f"effect.{self.name}"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self) -> set[str]:
|
|
||||||
return set()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
||||||
"""Process data through the effect."""
|
|
||||||
if data is None:
|
|
||||||
return None
|
|
||||||
from engine.effects.types import EffectContext, apply_param_bindings
|
|
||||||
|
|
||||||
w = ctx.params.viewport_width if ctx.params else 80
|
|
||||||
h = ctx.params.viewport_height if ctx.params else 24
|
|
||||||
frame = ctx.params.frame_number if ctx.params else 0
|
|
||||||
|
|
||||||
effect_ctx = EffectContext(
|
|
||||||
terminal_width=w,
|
|
||||||
terminal_height=h,
|
|
||||||
scroll_cam=0,
|
|
||||||
ticker_height=h,
|
|
||||||
camera_x=0,
|
|
||||||
mic_excess=0.0,
|
|
||||||
grad_offset=(frame * 0.01) % 1.0,
|
|
||||||
frame_number=frame,
|
|
||||||
has_message=False,
|
|
||||||
items=ctx.get("items", []),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Copy sensor state from PipelineContext to EffectContext
|
__all__ = [
|
||||||
for key, value in ctx.state.items():
|
# Adapter classes
|
||||||
if key.startswith("sensor."):
|
"EffectPluginStage",
|
||||||
effect_ctx.set_state(key, value)
|
"DisplayStage",
|
||||||
|
"DataSourceStage",
|
||||||
# Copy metrics from PipelineContext to EffectContext
|
"PassthroughStage",
|
||||||
if "metrics" in ctx.state:
|
"SourceItemsToBufferStage",
|
||||||
effect_ctx.set_state("metrics", ctx.state["metrics"])
|
"CameraStage",
|
||||||
|
"ViewportFilterStage",
|
||||||
# Apply sensor param bindings if effect has them
|
"FontStage",
|
||||||
if hasattr(self._effect, "param_bindings") and self._effect.param_bindings:
|
"ImageToTextStage",
|
||||||
bound_config = apply_param_bindings(self._effect, effect_ctx)
|
"CanvasStage",
|
||||||
self._effect.configure(bound_config)
|
# Factory functions
|
||||||
|
"create_stage_from_display",
|
||||||
return self._effect.process(data, effect_ctx)
|
"create_stage_from_effect",
|
||||||
|
"create_stage_from_source",
|
||||||
|
"create_stage_from_camera",
|
||||||
class DisplayStage(Stage):
|
"create_stage_from_font",
|
||||||
"""Adapter wrapping Display as a Stage."""
|
]
|
||||||
|
|
||||||
def __init__(self, display, name: str = "terminal"):
|
|
||||||
self._display = display
|
|
||||||
self.name = name
|
|
||||||
self.category = "display"
|
|
||||||
self.optional = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self) -> set[str]:
|
|
||||||
return {"display.output"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self) -> set[str]:
|
|
||||||
return {"render.output"} # Display needs rendered content
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER} # Display consumes rendered text
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.NONE} # Display is a terminal stage (no output)
|
|
||||||
|
|
||||||
def init(self, ctx: PipelineContext) -> bool:
|
|
||||||
w = ctx.params.viewport_width if ctx.params else 80
|
|
||||||
h = ctx.params.viewport_height if ctx.params else 24
|
|
||||||
result = self._display.init(w, h, reuse=False)
|
|
||||||
return result is not False
|
|
||||||
|
|
||||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
||||||
"""Output data to display."""
|
|
||||||
if data is not None:
|
|
||||||
self._display.show(data)
|
|
||||||
return data
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
self._display.cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
class DataSourceStage(Stage):
|
|
||||||
"""Adapter wrapping DataSource as a Stage."""
|
|
||||||
|
|
||||||
def __init__(self, data_source, name: str = "headlines"):
|
|
||||||
self._source = data_source
|
|
||||||
self.name = name
|
|
||||||
self.category = "source"
|
|
||||||
self.optional = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self) -> set[str]:
|
|
||||||
return {f"source.{self.name}"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self) -> set[str]:
|
|
||||||
return set()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.NONE} # Sources don't take input
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
||||||
"""Fetch data from source."""
|
|
||||||
if hasattr(self._source, "get_items"):
|
|
||||||
return self._source.get_items()
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class PassthroughStage(Stage):
|
|
||||||
"""Simple stage that passes data through unchanged.
|
|
||||||
|
|
||||||
Used for sources that already provide the data in the correct format
|
|
||||||
(e.g., pipeline introspection that outputs text directly).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, name: str = "passthrough"):
|
|
||||||
self.name = name
|
|
||||||
self.category = "render"
|
|
||||||
self.optional = True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stage_type(self) -> str:
|
|
||||||
return "render"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self) -> set[str]:
|
|
||||||
return {"render.output"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self) -> set[str]:
|
|
||||||
return {"source"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
||||||
"""Pass data through unchanged."""
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class SourceItemsToBufferStage(Stage):
|
|
||||||
"""Convert SourceItem objects to text buffer.
|
|
||||||
|
|
||||||
Takes a list of SourceItem objects and extracts their content,
|
|
||||||
splitting on newlines to create a proper text buffer for display.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, name: str = "items-to-buffer"):
|
|
||||||
self.name = name
|
|
||||||
self.category = "render"
|
|
||||||
self.optional = True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stage_type(self) -> str:
|
|
||||||
return "render"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self) -> set[str]:
|
|
||||||
return {"render.output"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self) -> set[str]:
|
|
||||||
return {"source"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
||||||
"""Convert SourceItem list to text buffer."""
|
|
||||||
if data is None:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# If already a list of strings, return as-is
|
|
||||||
if isinstance(data, list) and data and isinstance(data[0], str):
|
|
||||||
return data
|
|
||||||
|
|
||||||
# If it's a list of SourceItem, extract content
|
|
||||||
from engine.data_sources import SourceItem
|
|
||||||
|
|
||||||
if isinstance(data, list):
|
|
||||||
result = []
|
|
||||||
for item in data:
|
|
||||||
if isinstance(item, SourceItem):
|
|
||||||
# Split content by newline to get individual lines
|
|
||||||
lines = item.content.split("\n")
|
|
||||||
result.extend(lines)
|
|
||||||
elif hasattr(item, "content"): # Has content attribute
|
|
||||||
lines = str(item.content).split("\n")
|
|
||||||
result.extend(lines)
|
|
||||||
else:
|
|
||||||
result.append(str(item))
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Single item
|
|
||||||
if isinstance(data, SourceItem):
|
|
||||||
return data.content.split("\n")
|
|
||||||
|
|
||||||
return [str(data)]
|
|
||||||
|
|
||||||
|
|
||||||
class CameraStage(Stage):
|
|
||||||
"""Adapter wrapping Camera as a Stage."""
|
|
||||||
|
|
||||||
def __init__(self, camera, name: str = "vertical"):
|
|
||||||
self._camera = camera
|
|
||||||
self.name = name
|
|
||||||
self.category = "camera"
|
|
||||||
self.optional = True
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self) -> set[str]:
|
|
||||||
return {"camera"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self) -> set[str]:
|
|
||||||
return {"render.output"} # Depend on rendered output from font or render stage
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER} # Camera works on rendered text
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
||||||
"""Apply camera transformation to data."""
|
|
||||||
if data is None or (isinstance(data, list) and len(data) == 0):
|
|
||||||
return data
|
|
||||||
if hasattr(self._camera, "apply"):
|
|
||||||
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
|
||||||
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
|
||||||
buffer_height = len(data) if isinstance(data, list) else 0
|
|
||||||
|
|
||||||
# Get global layout height for canvas (enables full scrolling range)
|
|
||||||
total_layout_height = ctx.get("total_layout_height", buffer_height)
|
|
||||||
|
|
||||||
# Preserve camera's configured canvas width, but ensure it's at least viewport_width
|
|
||||||
# This allows horizontal/omni/floating/bounce cameras to scroll properly
|
|
||||||
canvas_width = max(
|
|
||||||
viewport_width, getattr(self._camera, "canvas_width", viewport_width)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update camera's viewport dimensions so it knows its actual bounds
|
|
||||||
# Set canvas size to achieve desired viewport (viewport = canvas / zoom)
|
|
||||||
if hasattr(self._camera, "set_canvas_size"):
|
|
||||||
self._camera.set_canvas_size(
|
|
||||||
width=int(viewport_width * self._camera.zoom),
|
|
||||||
height=int(viewport_height * self._camera.zoom),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set canvas to full layout height so camera can scroll through all content
|
|
||||||
self._camera.set_canvas_size(width=canvas_width, height=total_layout_height)
|
|
||||||
|
|
||||||
# Update camera position (scroll) - uses global canvas for clamping
|
|
||||||
if hasattr(self._camera, "update"):
|
|
||||||
self._camera.update(1 / 60)
|
|
||||||
|
|
||||||
# Store camera_y in context for ViewportFilterStage (global y position)
|
|
||||||
ctx.set("camera_y", self._camera.y)
|
|
||||||
|
|
||||||
# Apply camera viewport slicing to the partial buffer
|
|
||||||
# The buffer starts at render_offset_y in global coordinates
|
|
||||||
render_offset_y = ctx.get("render_offset_y", 0)
|
|
||||||
|
|
||||||
# Temporarily shift camera to local buffer coordinates for apply()
|
|
||||||
real_y = self._camera.y
|
|
||||||
local_y = max(0, real_y - render_offset_y)
|
|
||||||
|
|
||||||
# Temporarily shrink canvas to local buffer size so apply() works correctly
|
|
||||||
self._camera.set_canvas_size(width=canvas_width, height=buffer_height)
|
|
||||||
self._camera.y = local_y
|
|
||||||
|
|
||||||
# Apply slicing
|
|
||||||
result = self._camera.apply(data, viewport_width, viewport_height)
|
|
||||||
|
|
||||||
# Restore global canvas and camera position for next frame
|
|
||||||
self._camera.set_canvas_size(width=canvas_width, height=total_layout_height)
|
|
||||||
self._camera.y = real_y
|
|
||||||
|
|
||||||
return result
|
|
||||||
return data
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
if hasattr(self._camera, "reset"):
|
|
||||||
self._camera.reset()
|
|
||||||
|
|
||||||
|
|
||||||
class ViewportFilterStage(Stage):
|
|
||||||
"""Stage that limits items based on layout calculation.
|
|
||||||
|
|
||||||
Computes cumulative y-offsets for all items using cheap height estimation,
|
|
||||||
then returns only items that overlap the camera's viewport window.
|
|
||||||
This prevents FontStage from rendering thousands of items when only a few
|
|
||||||
are visible, while still allowing camera scrolling through all content.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, name: str = "viewport-filter"):
|
|
||||||
self.name = name
|
|
||||||
self.category = "filter"
|
|
||||||
self.optional = False
|
|
||||||
self._cached_count = 0
|
|
||||||
self._layout: list[tuple[int, int]] = []
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stage_type(self) -> str:
|
|
||||||
return "filter"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self) -> set[str]:
|
|
||||||
return {f"filter.{self.name}"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self) -> set[str]:
|
|
||||||
return {"source"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
||||||
"""Filter items based on layout and camera position."""
|
|
||||||
if data is None or not isinstance(data, list):
|
|
||||||
return data
|
|
||||||
|
|
||||||
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
|
||||||
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
|
||||||
camera_y = ctx.get("camera_y", 0)
|
|
||||||
|
|
||||||
# Recompute layout when item count OR viewport width changes
|
|
||||||
cached_width = getattr(self, "_cached_width", None)
|
|
||||||
if len(data) != self._cached_count or cached_width != viewport_width:
|
|
||||||
self._layout = []
|
|
||||||
y = 0
|
|
||||||
from engine.render.blocks import estimate_block_height
|
|
||||||
|
|
||||||
for item in data:
|
|
||||||
if hasattr(item, "content"):
|
|
||||||
title = item.content
|
|
||||||
elif isinstance(item, tuple):
|
|
||||||
title = str(item[0]) if item else ""
|
|
||||||
else:
|
|
||||||
title = str(item)
|
|
||||||
h = estimate_block_height(title, viewport_width)
|
|
||||||
self._layout.append((y, h))
|
|
||||||
y += h
|
|
||||||
self._cached_count = len(data)
|
|
||||||
self._cached_width = viewport_width
|
|
||||||
|
|
||||||
# Find items visible in [camera_y - buffer, camera_y + viewport_height + buffer]
|
|
||||||
buffer_zone = viewport_height
|
|
||||||
vis_start = max(0, camera_y - buffer_zone)
|
|
||||||
vis_end = camera_y + viewport_height + buffer_zone
|
|
||||||
|
|
||||||
visible_items = []
|
|
||||||
render_offset_y = 0
|
|
||||||
first_visible_found = False
|
|
||||||
for i, (start_y, height) in enumerate(self._layout):
|
|
||||||
item_end = start_y + height
|
|
||||||
if item_end > vis_start and start_y < vis_end:
|
|
||||||
if not first_visible_found:
|
|
||||||
render_offset_y = start_y
|
|
||||||
first_visible_found = True
|
|
||||||
visible_items.append(data[i])
|
|
||||||
|
|
||||||
# Compute total layout height for the canvas
|
|
||||||
total_layout_height = 0
|
|
||||||
if self._layout:
|
|
||||||
last_start, last_height = self._layout[-1]
|
|
||||||
total_layout_height = last_start + last_height
|
|
||||||
|
|
||||||
# Store metadata for CameraStage
|
|
||||||
ctx.set("render_offset_y", render_offset_y)
|
|
||||||
ctx.set("total_layout_height", total_layout_height)
|
|
||||||
|
|
||||||
# Always return at least one item to avoid empty buffer errors
|
|
||||||
return visible_items if visible_items else data[:1]
|
|
||||||
|
|
||||||
|
|
||||||
class FontStage(Stage):
|
|
||||||
"""Stage that applies font rendering to content.
|
|
||||||
|
|
||||||
FontStage is a Transform that takes raw content (text, headlines)
|
|
||||||
and renders it to an ANSI-formatted buffer using the configured font.
|
|
||||||
|
|
||||||
This decouples font rendering from data sources, allowing:
|
|
||||||
- Different fonts per source
|
|
||||||
- Runtime font swapping
|
|
||||||
- Font as a pipeline stage
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
font_path: Path to font file (None = use config default)
|
|
||||||
font_size: Font size in points (None = use config default)
|
|
||||||
font_ref: Reference name for registered font ("default", "cjk", etc.)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
font_path: str | None = None,
|
|
||||||
font_size: int | None = None,
|
|
||||||
font_ref: str | None = "default",
|
|
||||||
name: str = "font",
|
|
||||||
):
|
|
||||||
self.name = name
|
|
||||||
self.category = "transform"
|
|
||||||
self.optional = False
|
|
||||||
self._font_path = font_path
|
|
||||||
self._font_size = font_size
|
|
||||||
self._font_ref = font_ref
|
|
||||||
self._font = None
|
|
||||||
self._render_cache: dict[tuple[str, str, str, int], list[str]] = {}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stage_type(self) -> str:
|
|
||||||
return "transform"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self) -> set[str]:
|
|
||||||
return {f"transform.{self.name}", "render.output"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self) -> set[str]:
|
|
||||||
return {"source"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.SOURCE_ITEMS}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
def init(self, ctx: PipelineContext) -> bool:
|
|
||||||
"""Initialize font from config or path."""
|
|
||||||
from engine import config
|
|
||||||
|
|
||||||
if self._font_path:
|
|
||||||
try:
|
|
||||||
from PIL import ImageFont
|
|
||||||
|
|
||||||
size = self._font_size or config.FONT_SZ
|
|
||||||
self._font = ImageFont.truetype(self._font_path, size)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
||||||
"""Render content with font to buffer."""
|
|
||||||
if data is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
from engine.render import make_block
|
|
||||||
|
|
||||||
w = ctx.params.viewport_width if ctx.params else 80
|
|
||||||
|
|
||||||
# If data is already a list of strings (buffer), return as-is
|
|
||||||
if isinstance(data, list) and data and isinstance(data[0], str):
|
|
||||||
return data
|
|
||||||
|
|
||||||
# If data is a list of items, render each with font
|
|
||||||
if isinstance(data, list):
|
|
||||||
result = []
|
|
||||||
for item in data:
|
|
||||||
# Handle SourceItem or tuple (title, source, timestamp)
|
|
||||||
if hasattr(item, "content"):
|
|
||||||
title = item.content
|
|
||||||
src = getattr(item, "source", "unknown")
|
|
||||||
ts = getattr(item, "timestamp", "0")
|
|
||||||
elif isinstance(item, tuple):
|
|
||||||
title = item[0] if len(item) > 0 else ""
|
|
||||||
src = item[1] if len(item) > 1 else "unknown"
|
|
||||||
ts = str(item[2]) if len(item) > 2 else "0"
|
|
||||||
else:
|
|
||||||
title = str(item)
|
|
||||||
src = "unknown"
|
|
||||||
ts = "0"
|
|
||||||
|
|
||||||
# Check cache first
|
|
||||||
cache_key = (title, src, ts, w)
|
|
||||||
if cache_key in self._render_cache:
|
|
||||||
result.extend(self._render_cache[cache_key])
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
block_lines, color_code, meta_idx = make_block(title, src, ts, w)
|
|
||||||
self._render_cache[cache_key] = block_lines
|
|
||||||
result.extend(block_lines)
|
|
||||||
except Exception:
|
|
||||||
result.append(title)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class ImageToTextStage(Stage):
|
|
||||||
"""Transform that converts PIL Image to ASCII text buffer.
|
|
||||||
|
|
||||||
Takes an ImageItem or PIL Image and converts it to a text buffer
|
|
||||||
using ASCII character density mapping. The output can be displayed
|
|
||||||
directly or further processed by effects.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
width: Output width in characters
|
|
||||||
height: Output height in characters
|
|
||||||
charset: Character set for density mapping (default: simple ASCII)
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
width: int = 80,
|
|
||||||
height: int = 24,
|
|
||||||
charset: str = " .:-=+*#%@",
|
|
||||||
name: str = "image-to-text",
|
|
||||||
):
|
|
||||||
self.name = name
|
|
||||||
self.category = "transform"
|
|
||||||
self.optional = False
|
|
||||||
self.width = width
|
|
||||||
self.height = height
|
|
||||||
self.charset = charset
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stage_type(self) -> str:
|
|
||||||
return "transform"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.PIL_IMAGE} # Accepts PIL Image objects or ImageItem
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.TEXT_BUFFER}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self) -> set[str]:
|
|
||||||
return {f"transform.{self.name}", "render.output"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self) -> set[str]:
|
|
||||||
return {"source"}
|
|
||||||
|
|
||||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
||||||
"""Convert PIL Image to text buffer."""
|
|
||||||
if data is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
from engine.data_sources.sources import ImageItem
|
|
||||||
|
|
||||||
# Extract PIL Image from various input types
|
|
||||||
pil_image = None
|
|
||||||
|
|
||||||
if isinstance(data, ImageItem) or hasattr(data, "image"):
|
|
||||||
pil_image = data.image
|
|
||||||
else:
|
|
||||||
# Assume it's already a PIL Image
|
|
||||||
pil_image = data
|
|
||||||
|
|
||||||
# Check if it's a PIL Image
|
|
||||||
if not hasattr(pil_image, "resize"):
|
|
||||||
# Not a PIL Image, return as-is
|
|
||||||
return data if isinstance(data, list) else [str(data)]
|
|
||||||
|
|
||||||
# Convert to grayscale and resize
|
|
||||||
try:
|
|
||||||
if pil_image.mode != "L":
|
|
||||||
pil_image = pil_image.convert("L")
|
|
||||||
except Exception:
|
|
||||||
return ["[image conversion error]"]
|
|
||||||
|
|
||||||
# Calculate cell aspect ratio correction (characters are taller than wide)
|
|
||||||
aspect_ratio = 0.5
|
|
||||||
target_w = self.width
|
|
||||||
target_h = int(self.height * aspect_ratio)
|
|
||||||
|
|
||||||
# Resize image to target dimensions
|
|
||||||
try:
|
|
||||||
resized = pil_image.resize((target_w, target_h))
|
|
||||||
except Exception:
|
|
||||||
return ["[image resize error]"]
|
|
||||||
|
|
||||||
# Map pixels to characters
|
|
||||||
result = []
|
|
||||||
pixels = list(resized.getdata())
|
|
||||||
|
|
||||||
for row in range(target_h):
|
|
||||||
line = ""
|
|
||||||
for col in range(target_w):
|
|
||||||
idx = row * target_w + col
|
|
||||||
if idx < len(pixels):
|
|
||||||
brightness = pixels[idx]
|
|
||||||
char_idx = int((brightness / 255) * (len(self.charset) - 1))
|
|
||||||
line += self.charset[char_idx]
|
|
||||||
else:
|
|
||||||
line += " "
|
|
||||||
result.append(line)
|
|
||||||
|
|
||||||
# Pad or trim to exact height
|
|
||||||
while len(result) < self.height:
|
|
||||||
result.append(" " * self.width)
|
|
||||||
result = result[: self.height]
|
|
||||||
|
|
||||||
# Pad lines to width
|
|
||||||
result = [line.ljust(self.width) for line in result]
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def create_stage_from_display(display, name: str = "terminal") -> DisplayStage:
|
|
||||||
"""Create a Stage from a Display instance."""
|
|
||||||
return DisplayStage(display, name)
|
|
||||||
|
|
||||||
|
|
||||||
def create_stage_from_effect(effect_plugin, name: str) -> EffectPluginStage:
|
|
||||||
"""Create a Stage from an EffectPlugin."""
|
|
||||||
return EffectPluginStage(effect_plugin, name)
|
|
||||||
|
|
||||||
|
|
||||||
def create_stage_from_source(data_source, name: str = "headlines") -> DataSourceStage:
|
|
||||||
"""Create a Stage from a DataSource."""
|
|
||||||
return DataSourceStage(data_source, name)
|
|
||||||
|
|
||||||
|
|
||||||
def create_stage_from_camera(camera, name: str = "vertical") -> CameraStage:
|
|
||||||
"""Create a Stage from a Camera."""
|
|
||||||
return CameraStage(camera, name)
|
|
||||||
|
|
||||||
|
|
||||||
def create_stage_from_font(
|
|
||||||
font_path: str | None = None,
|
|
||||||
font_size: int | None = None,
|
|
||||||
font_ref: str | None = "default",
|
|
||||||
name: str = "font",
|
|
||||||
) -> FontStage:
|
|
||||||
"""Create a FontStage for rendering content with fonts."""
|
|
||||||
return FontStage(
|
|
||||||
font_path=font_path, font_size=font_size, font_ref=font_ref, name=name
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CanvasStage(Stage):
|
|
||||||
"""Stage that manages a Canvas for rendering.
|
|
||||||
|
|
||||||
CanvasStage creates and manages a 2D canvas that can hold rendered content.
|
|
||||||
Other stages can write to and read from the canvas via the pipeline context.
|
|
||||||
|
|
||||||
This enables:
|
|
||||||
- Pre-rendering content off-screen
|
|
||||||
- Multiple cameras viewing different regions
|
|
||||||
- Smooth scrolling (camera moves, content stays)
|
|
||||||
- Layer compositing
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
- Add CanvasStage to pipeline
|
|
||||||
- Other stages access canvas via: ctx.get("canvas")
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
width: int = 80,
|
|
||||||
height: int = 24,
|
|
||||||
name: str = "canvas",
|
|
||||||
):
|
|
||||||
self.name = name
|
|
||||||
self.category = "system"
|
|
||||||
self.optional = True
|
|
||||||
self._width = width
|
|
||||||
self._height = height
|
|
||||||
self._canvas = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stage_type(self) -> str:
|
|
||||||
return "system"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def capabilities(self) -> set[str]:
|
|
||||||
return {"canvas"}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dependencies(self) -> set[str]:
|
|
||||||
return set()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def inlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.ANY}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def outlet_types(self) -> set:
|
|
||||||
from engine.pipeline.core import DataType
|
|
||||||
|
|
||||||
return {DataType.ANY}
|
|
||||||
|
|
||||||
def init(self, ctx: PipelineContext) -> bool:
|
|
||||||
from engine.canvas import Canvas
|
|
||||||
|
|
||||||
self._canvas = Canvas(width=self._width, height=self._height)
|
|
||||||
ctx.set("canvas", self._canvas)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
||||||
"""Pass through data but ensure canvas is in context."""
|
|
||||||
if self._canvas is None:
|
|
||||||
from engine.canvas import Canvas
|
|
||||||
|
|
||||||
self._canvas = Canvas(width=self._width, height=self._height)
|
|
||||||
ctx.set("canvas", self._canvas)
|
|
||||||
|
|
||||||
# Get dirty regions from canvas and expose via context
|
|
||||||
# Effects can access via ctx.get_state("canvas.dirty_rows")
|
|
||||||
if self._canvas.is_dirty():
|
|
||||||
dirty_rows = self._canvas.get_dirty_rows()
|
|
||||||
ctx.set_state("canvas.dirty_rows", dirty_rows)
|
|
||||||
ctx.set_state("canvas.dirty_regions", self._canvas.get_dirty_regions())
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def get_canvas(self):
|
|
||||||
"""Get the canvas instance."""
|
|
||||||
return self._canvas
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
self._canvas = None
|
|
||||||
|
|||||||
43
engine/pipeline/adapters/__init__.py
Normal file
43
engine/pipeline/adapters/__init__.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""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 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",
|
||||||
|
"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",
|
||||||
|
]
|
||||||
48
engine/pipeline/adapters/camera.py
Normal file
48
engine/pipeline/adapters/camera.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Adapter for camera stage."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||||
|
|
||||||
|
|
||||||
|
class CameraStage(Stage):
|
||||||
|
"""Adapter wrapping Camera as a Stage."""
|
||||||
|
|
||||||
|
def __init__(self, camera, name: str = "vertical"):
|
||||||
|
self._camera = camera
|
||||||
|
self.name = name
|
||||||
|
self.category = "camera"
|
||||||
|
self.optional = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def 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
|
||||||
|
|
||||||
|
# Apply camera offset to items
|
||||||
|
if hasattr(self._camera, "apply"):
|
||||||
|
# Extract viewport dimensions from context params
|
||||||
|
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||||
|
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
||||||
|
return self._camera.apply(data, viewport_width, viewport_height)
|
||||||
|
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)]
|
||||||
50
engine/pipeline/adapters/display.py
Normal file
50
engine/pipeline/adapters/display.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"display.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return {"render.output"} # Display needs rendered content
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.TEXT_BUFFER} # Display consumes rendered text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.NONE} # Display is a terminal stage (no output)
|
||||||
|
|
||||||
|
def init(self, ctx: PipelineContext) -> bool:
|
||||||
|
w = ctx.params.viewport_width if ctx.params else 80
|
||||||
|
h = ctx.params.viewport_height if ctx.params else 24
|
||||||
|
result = self._display.init(w, h, reuse=False)
|
||||||
|
return result is not False
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Output data to display."""
|
||||||
|
if data is not None:
|
||||||
|
self._display.show(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
self._display.cleanup()
|
||||||
103
engine/pipeline/adapters/effect_plugin.py
Normal file
103
engine/pipeline/adapters/effect_plugin.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""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."""
|
||||||
|
|
||||||
|
def __init__(self, effect_plugin, name: str = "effect"):
|
||||||
|
self._effect = effect_plugin
|
||||||
|
self.name = name
|
||||||
|
self.category = "effect"
|
||||||
|
self.optional = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
"""Return stage_type based on effect name.
|
||||||
|
|
||||||
|
HUD effects are overlays.
|
||||||
|
"""
|
||||||
|
if self.name == "hud":
|
||||||
|
return "overlay"
|
||||||
|
return self.category
|
||||||
|
|
||||||
|
@property
|
||||||
|
def render_order(self) -> int:
|
||||||
|
"""Return render_order based on effect type.
|
||||||
|
|
||||||
|
HUD effects have high render_order to appear on top.
|
||||||
|
"""
|
||||||
|
if self.name == "hud":
|
||||||
|
return 100 # High order for overlays
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_overlay(self) -> bool:
|
||||||
|
"""Return True for HUD effects.
|
||||||
|
|
||||||
|
HUD is an overlay - it composes on top of the buffer
|
||||||
|
rather than transforming it for the next stage.
|
||||||
|
"""
|
||||||
|
return self.name == "hud"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {f"effect.{self.name}"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Process data through the effect."""
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
from engine.effects.types import EffectContext, apply_param_bindings
|
||||||
|
|
||||||
|
w = ctx.params.viewport_width if ctx.params else 80
|
||||||
|
h = ctx.params.viewport_height if ctx.params else 24
|
||||||
|
frame = ctx.params.frame_number if ctx.params else 0
|
||||||
|
|
||||||
|
effect_ctx = EffectContext(
|
||||||
|
terminal_width=w,
|
||||||
|
terminal_height=h,
|
||||||
|
scroll_cam=0,
|
||||||
|
ticker_height=h,
|
||||||
|
camera_x=0,
|
||||||
|
mic_excess=0.0,
|
||||||
|
grad_offset=(frame * 0.01) % 1.0,
|
||||||
|
frame_number=frame,
|
||||||
|
has_message=False,
|
||||||
|
items=ctx.get("items", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Copy sensor state from PipelineContext to EffectContext
|
||||||
|
for key, value in ctx.state.items():
|
||||||
|
if key.startswith("sensor."):
|
||||||
|
effect_ctx.set_state(key, value)
|
||||||
|
|
||||||
|
# Copy metrics from PipelineContext to EffectContext
|
||||||
|
if "metrics" in ctx.state:
|
||||||
|
effect_ctx.set_state("metrics", ctx.state["metrics"])
|
||||||
|
|
||||||
|
# Apply sensor param bindings if effect has them
|
||||||
|
if hasattr(self._effect, "param_bindings") and self._effect.param_bindings:
|
||||||
|
bound_config = apply_param_bindings(self._effect, effect_ctx)
|
||||||
|
self._effect.configure(bound_config)
|
||||||
|
|
||||||
|
return self._effect.process(data, effect_ctx)
|
||||||
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)
|
||||||
265
engine/pipeline/adapters/transform.py
Normal file
265
engine/pipeline/adapters/transform.py
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
"""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]:
|
||||||
|
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:
|
||||||
|
"""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
|
||||||
|
for i, total_h in enumerate(cumulative_heights):
|
||||||
|
if total_h > start_y:
|
||||||
|
start_idx = i
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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 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 text buffer using font."""
|
||||||
|
if data is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return [str(data)]
|
||||||
|
|
||||||
|
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, SourceItem):
|
||||||
|
title = item.content
|
||||||
|
src = item.source
|
||||||
|
ts = item.timestamp
|
||||||
|
content_lines, _, _ = engine.render.make_block(
|
||||||
|
title, src, ts, viewport_width
|
||||||
|
)
|
||||||
|
result.extend(content_lines)
|
||||||
|
elif hasattr(item, "content"):
|
||||||
|
title = str(item.content)
|
||||||
|
content_lines, _, _ = engine.render.make_block(
|
||||||
|
title, "", "", viewport_width
|
||||||
|
)
|
||||||
|
result.extend(content_lines)
|
||||||
|
else:
|
||||||
|
result.append(str(item))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class ImageToTextStage(Stage):
|
||||||
|
"""Convert image items to text."""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "image-to-text"):
|
||||||
|
self.name = name
|
||||||
|
self.category = "render"
|
||||||
|
self.optional = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
return "render"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"render.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return {"source"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Convert image items to text representation."""
|
||||||
|
if data is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return [str(data)]
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for item in data:
|
||||||
|
# Check if item is an image
|
||||||
|
if hasattr(item, "image_path") or hasattr(item, "image_data"):
|
||||||
|
# Placeholder: would normally render image to ASCII art
|
||||||
|
result.append(f"[Image: {getattr(item, 'image_path', 'data')}]")
|
||||||
|
elif isinstance(item, SourceItem):
|
||||||
|
result.extend(item.content.split("\n"))
|
||||||
|
else:
|
||||||
|
result.append(str(item))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class CanvasStage(Stage):
|
||||||
|
"""Render items to canvas."""
|
||||||
|
|
||||||
|
def __init__(self, name: str = "canvas"):
|
||||||
|
self.name = name
|
||||||
|
self.category = "render"
|
||||||
|
self.optional = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage_type(self) -> str:
|
||||||
|
return "render"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self) -> set[str]:
|
||||||
|
return {"render.output"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self) -> set[str]:
|
||||||
|
return {"source"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self) -> set:
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self) -> set:
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||||
|
"""Render items to canvas."""
|
||||||
|
if data is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return [str(data)]
|
||||||
|
|
||||||
|
# Simple canvas rendering
|
||||||
|
result = []
|
||||||
|
for item in data:
|
||||||
|
if isinstance(item, SourceItem):
|
||||||
|
result.extend(item.content.split("\n"))
|
||||||
|
else:
|
||||||
|
result.append(str(item))
|
||||||
|
return result
|
||||||
@@ -49,6 +49,8 @@ class Pipeline:
|
|||||||
|
|
||||||
Manages the execution of all stages in dependency order,
|
Manages the execution of all stages in dependency order,
|
||||||
handling initialization, processing, and cleanup.
|
handling initialization, processing, and cleanup.
|
||||||
|
|
||||||
|
Supports dynamic mutation during runtime via the mutation API.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -61,26 +63,231 @@ class Pipeline:
|
|||||||
self._stages: dict[str, Stage] = {}
|
self._stages: dict[str, Stage] = {}
|
||||||
self._execution_order: list[str] = []
|
self._execution_order: list[str] = []
|
||||||
self._initialized = False
|
self._initialized = False
|
||||||
|
self._capability_map: dict[str, list[str]] = {}
|
||||||
|
|
||||||
self._metrics_enabled = self.config.enable_metrics
|
self._metrics_enabled = self.config.enable_metrics
|
||||||
self._frame_metrics: list[FrameMetrics] = []
|
self._frame_metrics: list[FrameMetrics] = []
|
||||||
self._max_metrics_frames = 60
|
self._max_metrics_frames = 60
|
||||||
self._current_frame_number = 0
|
self._current_frame_number = 0
|
||||||
|
|
||||||
def add_stage(self, name: str, stage: Stage) -> "Pipeline":
|
def add_stage(self, name: str, stage: Stage, initialize: bool = True) -> "Pipeline":
|
||||||
"""Add a stage to the pipeline."""
|
"""Add a stage to the pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Unique name for the stage
|
||||||
|
stage: Stage instance to add
|
||||||
|
initialize: If True, initialize the stage immediately
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Self for method chaining
|
||||||
|
"""
|
||||||
self._stages[name] = stage
|
self._stages[name] = stage
|
||||||
|
if self._initialized and initialize:
|
||||||
|
stage.init(self.context)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def remove_stage(self, name: str) -> None:
|
def remove_stage(self, name: str, cleanup: bool = True) -> Stage | None:
|
||||||
"""Remove a stage from the pipeline."""
|
"""Remove a stage from the pipeline.
|
||||||
if name in self._stages:
|
|
||||||
del self._stages[name]
|
Args:
|
||||||
|
name: Name of the stage to remove
|
||||||
|
cleanup: If True, call cleanup() on the removed stage
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The removed stage, or None if not found
|
||||||
|
"""
|
||||||
|
stage = self._stages.pop(name, None)
|
||||||
|
if stage and cleanup:
|
||||||
|
try:
|
||||||
|
stage.cleanup()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return stage
|
||||||
|
|
||||||
|
def replace_stage(
|
||||||
|
self, name: str, new_stage: Stage, preserve_state: bool = True
|
||||||
|
) -> Stage | None:
|
||||||
|
"""Replace a stage in the pipeline with a new one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Name of the stage to replace
|
||||||
|
new_stage: New stage instance
|
||||||
|
preserve_state: If True, copy relevant state from old stage
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The old stage, or None if not found
|
||||||
|
"""
|
||||||
|
old_stage = self._stages.get(name)
|
||||||
|
if not old_stage:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if preserve_state:
|
||||||
|
self._copy_stage_state(old_stage, new_stage)
|
||||||
|
|
||||||
|
old_stage.cleanup()
|
||||||
|
self._stages[name] = new_stage
|
||||||
|
new_stage.init(self.context)
|
||||||
|
|
||||||
|
if self._initialized:
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
return old_stage
|
||||||
|
|
||||||
|
def swap_stages(self, name1: str, name2: str) -> bool:
|
||||||
|
"""Swap two stages in the pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name1: First stage name
|
||||||
|
name2: Second stage name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False if either stage not found
|
||||||
|
"""
|
||||||
|
stage1 = self._stages.get(name1)
|
||||||
|
stage2 = self._stages.get(name2)
|
||||||
|
|
||||||
|
if not stage1 or not stage2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._stages[name1] = stage2
|
||||||
|
self._stages[name2] = stage1
|
||||||
|
|
||||||
|
if self._initialized:
|
||||||
|
self._rebuild()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def move_stage(
|
||||||
|
self, name: str, after: str | None = None, before: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Move a stage's position in execution order.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Stage to move
|
||||||
|
after: Place this stage after this stage name
|
||||||
|
before: Place this stage before this stage name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False if stage not found
|
||||||
|
"""
|
||||||
|
if name not in self._stages:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self._initialized:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_order = list(self._execution_order)
|
||||||
|
if name not in current_order:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_order.remove(name)
|
||||||
|
|
||||||
|
if after and after in current_order:
|
||||||
|
idx = current_order.index(after) + 1
|
||||||
|
current_order.insert(idx, name)
|
||||||
|
elif before and before in current_order:
|
||||||
|
idx = current_order.index(before)
|
||||||
|
current_order.insert(idx, name)
|
||||||
|
else:
|
||||||
|
current_order.append(name)
|
||||||
|
|
||||||
|
self._execution_order = current_order
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _copy_stage_state(self, old_stage: Stage, new_stage: Stage) -> None:
|
||||||
|
"""Copy relevant state from old stage to new stage during replacement.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_stage: The old stage being replaced
|
||||||
|
new_stage: The new stage
|
||||||
|
"""
|
||||||
|
if hasattr(old_stage, "_enabled"):
|
||||||
|
new_stage._enabled = old_stage._enabled
|
||||||
|
|
||||||
|
def _rebuild(self) -> None:
|
||||||
|
"""Rebuild execution order after mutation without full reinitialization."""
|
||||||
|
self._capability_map = self._build_capability_map()
|
||||||
|
self._execution_order = self._resolve_dependencies()
|
||||||
|
try:
|
||||||
|
self._validate_dependencies()
|
||||||
|
self._validate_types()
|
||||||
|
except StageError:
|
||||||
|
pass
|
||||||
|
|
||||||
def get_stage(self, name: str) -> Stage | None:
|
def get_stage(self, name: str) -> Stage | None:
|
||||||
"""Get a stage by name."""
|
"""Get a stage by name."""
|
||||||
return self._stages.get(name)
|
return self._stages.get(name)
|
||||||
|
|
||||||
|
def enable_stage(self, name: str) -> bool:
|
||||||
|
"""Enable a stage in the pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Stage name to enable
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False if stage not found
|
||||||
|
"""
|
||||||
|
stage = self._stages.get(name)
|
||||||
|
if stage:
|
||||||
|
stage.set_enabled(True)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disable_stage(self, name: str) -> bool:
|
||||||
|
"""Disable a stage in the pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Stage name to disable
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if successful, False if stage not found
|
||||||
|
"""
|
||||||
|
stage = self._stages.get(name)
|
||||||
|
if stage:
|
||||||
|
stage.set_enabled(False)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_stage_info(self, name: str) -> dict | None:
|
||||||
|
"""Get detailed information about a stage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Stage name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with stage information, or None if not found
|
||||||
|
"""
|
||||||
|
stage = self._stages.get(name)
|
||||||
|
if not stage:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": name,
|
||||||
|
"category": stage.category,
|
||||||
|
"stage_type": stage.stage_type,
|
||||||
|
"enabled": stage.is_enabled(),
|
||||||
|
"optional": stage.optional,
|
||||||
|
"capabilities": list(stage.capabilities),
|
||||||
|
"dependencies": list(stage.dependencies),
|
||||||
|
"inlet_types": [dt.name for dt in stage.inlet_types],
|
||||||
|
"outlet_types": [dt.name for dt in stage.outlet_types],
|
||||||
|
"render_order": stage.render_order,
|
||||||
|
"is_overlay": stage.is_overlay,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_pipeline_info(self) -> dict:
|
||||||
|
"""Get comprehensive information about the pipeline.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with pipeline state
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"stages": {name: self.get_stage_info(name) for name in self._stages},
|
||||||
|
"execution_order": self._execution_order.copy(),
|
||||||
|
"initialized": self._initialized,
|
||||||
|
"stage_count": len(self._stages),
|
||||||
|
}
|
||||||
|
|
||||||
def build(self) -> "Pipeline":
|
def build(self) -> "Pipeline":
|
||||||
"""Build execution order based on dependencies."""
|
"""Build execution order based on dependencies."""
|
||||||
self._capability_map = self._build_capability_map()
|
self._capability_map = self._build_capability_map()
|
||||||
|
|||||||
@@ -315,6 +315,68 @@ class UIPanel:
|
|||||||
else:
|
else:
|
||||||
return "└" + "─" * (width - 2) + "┘"
|
return "└" + "─" * (width - 2) + "┘"
|
||||||
|
|
||||||
|
def execute_command(self, command: dict) -> bool:
|
||||||
|
"""Execute a command from external control (e.g., WebSocket).
|
||||||
|
|
||||||
|
Supported 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}
|
||||||
|
|
||||||
|
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:
|
def process_key_event(self, key: str | int, modifiers: int = 0) -> bool:
|
||||||
"""Process a keyboard event.
|
"""Process a keyboard event.
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ uv = "latest"
|
|||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
test = "uv run pytest"
|
test = "uv run pytest"
|
||||||
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing", depends = ["sync-all"] }
|
test-cov = { run = "uv run pytest --cov=engine --cov-report=term-missing -m \"not benchmark\"", depends = ["sync-all"] }
|
||||||
|
benchmark = { run = "uv run python -m engine.benchmark", depends = ["sync-all"] }
|
||||||
lint = "uv run ruff check engine/ mainline.py"
|
lint = "uv run ruff check engine/ mainline.py"
|
||||||
format = "uv run ruff format engine/ mainline.py"
|
format = "uv run ruff format engine/ mainline.py"
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ clobber = "git clean -fdx && rm -rf .venv htmlcov .coverage tests/.pytest_cache
|
|||||||
# CI
|
# CI
|
||||||
# =====================
|
# =====================
|
||||||
|
|
||||||
ci = { run = "mise run topics-init && mise run lint && mise run test-cov", depends = ["topics-init", "lint", "test-cov"] }
|
ci = "mise run topics-init && mise run lint && mise run test-cov && mise run benchmark"
|
||||||
topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null"
|
topics-init = "curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_cmd > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline_cc_resp > /dev/null && curl -s -d 'init' https://ntfy.sh/klubhaus_terminal_mainline > /dev/null"
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class TestMain:
|
|||||||
|
|
||||||
def test_main_calls_run_pipeline_mode_with_default_preset(self):
|
def test_main_calls_run_pipeline_mode_with_default_preset(self):
|
||||||
"""main() runs default preset (demo) when no args provided."""
|
"""main() runs default preset (demo) when no args provided."""
|
||||||
with patch("engine.app.run_pipeline_mode") as mock_run:
|
with patch("engine.app.main.run_pipeline_mode") as mock_run:
|
||||||
sys.argv = ["mainline.py"]
|
sys.argv = ["mainline.py"]
|
||||||
main()
|
main()
|
||||||
mock_run.assert_called_once_with("demo")
|
mock_run.assert_called_once_with("demo")
|
||||||
@@ -26,12 +26,11 @@ class TestMain:
|
|||||||
def test_main_calls_run_pipeline_mode_with_config_preset(self):
|
def test_main_calls_run_pipeline_mode_with_config_preset(self):
|
||||||
"""main() uses PRESET from config if set."""
|
"""main() uses PRESET from config if set."""
|
||||||
with (
|
with (
|
||||||
patch("engine.app.config") as mock_config,
|
patch("engine.config.PIPELINE_DIAGRAM", False),
|
||||||
patch("engine.app.run_pipeline_mode") as mock_run,
|
patch("engine.config.PRESET", "gallery-sources"),
|
||||||
|
patch("engine.config.PIPELINE_MODE", False),
|
||||||
|
patch("engine.app.main.run_pipeline_mode") as mock_run,
|
||||||
):
|
):
|
||||||
mock_config.PIPELINE_DIAGRAM = False
|
|
||||||
mock_config.PRESET = "gallery-sources"
|
|
||||||
mock_config.PIPELINE_MODE = False
|
|
||||||
sys.argv = ["mainline.py"]
|
sys.argv = ["mainline.py"]
|
||||||
main()
|
main()
|
||||||
mock_run.assert_called_once_with("gallery-sources")
|
mock_run.assert_called_once_with("gallery-sources")
|
||||||
@@ -39,12 +38,11 @@ class TestMain:
|
|||||||
def test_main_exits_on_unknown_preset(self):
|
def test_main_exits_on_unknown_preset(self):
|
||||||
"""main() exits with error for unknown preset."""
|
"""main() exits with error for unknown preset."""
|
||||||
with (
|
with (
|
||||||
patch("engine.app.config") as mock_config,
|
patch("engine.config.PIPELINE_DIAGRAM", False),
|
||||||
patch("engine.app.list_presets", return_value=["demo", "poetry"]),
|
patch("engine.config.PRESET", "nonexistent"),
|
||||||
|
patch("engine.config.PIPELINE_MODE", False),
|
||||||
|
patch("engine.pipeline.list_presets", return_value=["demo", "poetry"]),
|
||||||
):
|
):
|
||||||
mock_config.PIPELINE_DIAGRAM = False
|
|
||||||
mock_config.PRESET = "nonexistent"
|
|
||||||
mock_config.PIPELINE_MODE = False
|
|
||||||
sys.argv = ["mainline.py"]
|
sys.argv = ["mainline.py"]
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
main()
|
main()
|
||||||
@@ -70,9 +68,11 @@ class TestRunPipelineMode:
|
|||||||
def test_run_pipeline_mode_exits_when_no_content_available(self):
|
def test_run_pipeline_mode_exits_when_no_content_available(self):
|
||||||
"""run_pipeline_mode() exits if no content can be fetched."""
|
"""run_pipeline_mode() exits if no content can be fetched."""
|
||||||
with (
|
with (
|
||||||
patch("engine.app.load_cache", return_value=None),
|
patch("engine.app.pipeline_runner.load_cache", return_value=None),
|
||||||
patch("engine.app.fetch_all", return_value=([], None, None)),
|
patch(
|
||||||
patch("engine.app.effects_plugins"),
|
"engine.app.pipeline_runner.fetch_all", return_value=([], None, None)
|
||||||
|
),
|
||||||
|
patch("engine.effects.plugins.discover_plugins"),
|
||||||
pytest.raises(SystemExit) as exc_info,
|
pytest.raises(SystemExit) as exc_info,
|
||||||
):
|
):
|
||||||
run_pipeline_mode("demo")
|
run_pipeline_mode("demo")
|
||||||
@@ -82,9 +82,11 @@ class TestRunPipelineMode:
|
|||||||
"""run_pipeline_mode() uses cached content if available."""
|
"""run_pipeline_mode() uses cached content if available."""
|
||||||
cached = ["cached_item"]
|
cached = ["cached_item"]
|
||||||
with (
|
with (
|
||||||
patch("engine.app.load_cache", return_value=cached) as mock_load,
|
patch(
|
||||||
patch("engine.app.fetch_all") as mock_fetch,
|
"engine.app.pipeline_runner.load_cache", return_value=cached
|
||||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
) as mock_load,
|
||||||
|
patch("engine.app.pipeline_runner.fetch_all") as mock_fetch,
|
||||||
|
patch("engine.app.pipeline_runner.DisplayRegistry.create") as mock_create,
|
||||||
):
|
):
|
||||||
mock_display = Mock()
|
mock_display = Mock()
|
||||||
mock_display.init = Mock()
|
mock_display.init = Mock()
|
||||||
@@ -155,12 +157,13 @@ class TestRunPipelineMode:
|
|||||||
def test_run_pipeline_mode_fetches_poetry_for_poetry_source(self):
|
def test_run_pipeline_mode_fetches_poetry_for_poetry_source(self):
|
||||||
"""run_pipeline_mode() fetches poetry for poetry preset."""
|
"""run_pipeline_mode() fetches poetry for poetry preset."""
|
||||||
with (
|
with (
|
||||||
patch("engine.app.load_cache", return_value=None),
|
patch("engine.app.pipeline_runner.load_cache", return_value=None),
|
||||||
patch(
|
patch(
|
||||||
"engine.app.fetch_poetry", return_value=(["poem"], None, None)
|
"engine.app.pipeline_runner.fetch_poetry",
|
||||||
|
return_value=(["poem"], None, None),
|
||||||
) as mock_fetch_poetry,
|
) as mock_fetch_poetry,
|
||||||
patch("engine.app.fetch_all") as mock_fetch_all,
|
patch("engine.app.pipeline_runner.fetch_all") as mock_fetch_all,
|
||||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
patch("engine.app.pipeline_runner.DisplayRegistry.create") as mock_create,
|
||||||
):
|
):
|
||||||
mock_display = Mock()
|
mock_display = Mock()
|
||||||
mock_display.init = Mock()
|
mock_display.init = Mock()
|
||||||
@@ -183,9 +186,9 @@ class TestRunPipelineMode:
|
|||||||
def test_run_pipeline_mode_discovers_effect_plugins(self):
|
def test_run_pipeline_mode_discovers_effect_plugins(self):
|
||||||
"""run_pipeline_mode() discovers available effect plugins."""
|
"""run_pipeline_mode() discovers available effect plugins."""
|
||||||
with (
|
with (
|
||||||
patch("engine.app.load_cache", return_value=["item"]),
|
patch("engine.app.pipeline_runner.load_cache", return_value=["item"]),
|
||||||
patch("engine.app.effects_plugins") as mock_effects,
|
patch("engine.effects.plugins.discover_plugins") as mock_discover,
|
||||||
patch("engine.app.DisplayRegistry.create") as mock_create,
|
patch("engine.app.pipeline_runner.DisplayRegistry.create") as mock_create,
|
||||||
):
|
):
|
||||||
mock_display = Mock()
|
mock_display = Mock()
|
||||||
mock_display.init = Mock()
|
mock_display.init = Mock()
|
||||||
@@ -202,4 +205,4 @@ class TestRunPipelineMode:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# Verify effects_plugins.discover_plugins was called
|
# Verify effects_plugins.discover_plugins was called
|
||||||
mock_effects.discover_plugins.assert_called_once()
|
mock_discover.assert_called_once()
|
||||||
|
|||||||
@@ -11,14 +11,7 @@ import pytest
|
|||||||
from engine.data_sources.sources import SourceItem
|
from engine.data_sources.sources import SourceItem
|
||||||
from engine.pipeline.adapters import FontStage, ViewportFilterStage
|
from engine.pipeline.adapters import FontStage, ViewportFilterStage
|
||||||
from engine.pipeline.core import PipelineContext
|
from engine.pipeline.core import PipelineContext
|
||||||
|
from engine.pipeline.params import PipelineParams
|
||||||
|
|
||||||
class MockParams:
|
|
||||||
"""Mock parameters object for testing."""
|
|
||||||
|
|
||||||
def __init__(self, viewport_width: int = 80, viewport_height: int = 24):
|
|
||||||
self.viewport_width = viewport_width
|
|
||||||
self.viewport_height = viewport_height
|
|
||||||
|
|
||||||
|
|
||||||
class TestViewportFilterPerformance:
|
class TestViewportFilterPerformance:
|
||||||
@@ -38,12 +31,12 @@ class TestViewportFilterPerformance:
|
|||||||
|
|
||||||
stage = ViewportFilterStage()
|
stage = ViewportFilterStage()
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = MockParams(viewport_height=24)
|
ctx.params = PipelineParams(viewport_height=24)
|
||||||
|
|
||||||
result = benchmark(stage.process, test_items, ctx)
|
result = benchmark(stage.process, test_items, ctx)
|
||||||
|
|
||||||
# Verify result is correct
|
# Verify result is correct - viewport filter takes first N items
|
||||||
assert len(result) <= 5
|
assert len(result) <= 24 # viewport height
|
||||||
assert len(result) > 0
|
assert len(result) > 0
|
||||||
|
|
||||||
@pytest.mark.benchmark
|
@pytest.mark.benchmark
|
||||||
@@ -61,7 +54,7 @@ class TestViewportFilterPerformance:
|
|||||||
|
|
||||||
font_stage = FontStage()
|
font_stage = FontStage()
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = MockParams()
|
ctx.params = PipelineParams()
|
||||||
|
|
||||||
result = benchmark(font_stage.process, filtered_items, ctx)
|
result = benchmark(font_stage.process, filtered_items, ctx)
|
||||||
|
|
||||||
@@ -75,8 +68,8 @@ class TestViewportFilterPerformance:
|
|||||||
|
|
||||||
With 1438 items and 24-line viewport:
|
With 1438 items and 24-line viewport:
|
||||||
- Without filter: FontStage renders all 1438 items
|
- Without filter: FontStage renders all 1438 items
|
||||||
- With filter: FontStage renders ~3 items (layout-based)
|
- With filter: FontStage renders ~4 items (height-based)
|
||||||
- Expected improvement: 1438 / 3 ≈ 479x
|
- Expected improvement: 1438 / 4 ≈ 360x
|
||||||
"""
|
"""
|
||||||
test_items = [
|
test_items = [
|
||||||
SourceItem(f"Headline {i}", "source", str(i)) for i in range(1438)
|
SourceItem(f"Headline {i}", "source", str(i)) for i in range(1438)
|
||||||
@@ -84,15 +77,15 @@ class TestViewportFilterPerformance:
|
|||||||
|
|
||||||
stage = ViewportFilterStage()
|
stage = ViewportFilterStage()
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = MockParams(viewport_height=24)
|
ctx.params = PipelineParams(viewport_height=24)
|
||||||
|
|
||||||
filtered = stage.process(test_items, ctx)
|
filtered = stage.process(test_items, ctx)
|
||||||
improvement_factor = len(test_items) / len(filtered)
|
improvement_factor = len(test_items) / len(filtered)
|
||||||
|
|
||||||
# Verify we get expected ~479x improvement (better than old ~288x)
|
# Verify we get significant improvement (height-based filtering)
|
||||||
assert 400 < improvement_factor < 600
|
assert 300 < improvement_factor < 500
|
||||||
# Verify filtered count is reasonable (layout-based is more precise)
|
# Verify filtered count is ~4 (24 viewport / 6 rows per item)
|
||||||
assert 2 <= len(filtered) <= 5
|
assert len(filtered) == 4
|
||||||
|
|
||||||
|
|
||||||
class TestPipelinePerformanceWithRealData:
|
class TestPipelinePerformanceWithRealData:
|
||||||
@@ -109,7 +102,7 @@ class TestPipelinePerformanceWithRealData:
|
|||||||
font_stage = FontStage()
|
font_stage = FontStage()
|
||||||
|
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = MockParams(viewport_height=24)
|
ctx.params = PipelineParams(viewport_height=24)
|
||||||
|
|
||||||
# Filter should reduce items quickly
|
# Filter should reduce items quickly
|
||||||
filtered = filter_stage.process(large_items, ctx)
|
filtered = filter_stage.process(large_items, ctx)
|
||||||
@@ -129,14 +122,14 @@ class TestPipelinePerformanceWithRealData:
|
|||||||
|
|
||||||
# Test different viewport heights
|
# Test different viewport heights
|
||||||
test_cases = [
|
test_cases = [
|
||||||
(12, 3), # 12px height -> ~3 items
|
(12, 12), # 12px height -> 12 items
|
||||||
(24, 5), # 24px height -> ~5 items
|
(24, 24), # 24px height -> 24 items
|
||||||
(48, 9), # 48px height -> ~9 items
|
(48, 48), # 48px height -> 48 items
|
||||||
]
|
]
|
||||||
|
|
||||||
for viewport_height, expected_max_items in test_cases:
|
for viewport_height, expected_max_items in test_cases:
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = MockParams(viewport_height=viewport_height)
|
ctx.params = PipelineParams(viewport_height=viewport_height)
|
||||||
|
|
||||||
filtered = stage.process(large_items, ctx)
|
filtered = stage.process(large_items, ctx)
|
||||||
|
|
||||||
@@ -159,14 +152,14 @@ class TestPerformanceRegressions:
|
|||||||
|
|
||||||
stage = ViewportFilterStage()
|
stage = ViewportFilterStage()
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = MockParams()
|
ctx.params = PipelineParams()
|
||||||
|
|
||||||
filtered = stage.process(large_items, ctx)
|
filtered = stage.process(large_items, ctx)
|
||||||
|
|
||||||
# Should NOT have all items (regression detection)
|
# Should NOT have all items (regression detection)
|
||||||
assert len(filtered) != len(large_items)
|
assert len(filtered) != len(large_items)
|
||||||
# Should have drastically fewer items
|
# With height-based filtering, ~4 items fit in 24-row viewport (6 rows/item)
|
||||||
assert len(filtered) < 10
|
assert len(filtered) == 4
|
||||||
|
|
||||||
def test_font_stage_doesnt_hang_with_filter(self):
|
def test_font_stage_doesnt_hang_with_filter(self):
|
||||||
"""Regression test: FontStage shouldn't hang when receiving filtered data.
|
"""Regression test: FontStage shouldn't hang when receiving filtered data.
|
||||||
@@ -182,7 +175,7 @@ class TestPerformanceRegressions:
|
|||||||
|
|
||||||
font_stage = FontStage()
|
font_stage = FontStage()
|
||||||
ctx = PipelineContext()
|
ctx = PipelineContext()
|
||||||
ctx.params = MockParams()
|
ctx.params = PipelineParams()
|
||||||
|
|
||||||
# Should complete instantly (not hang)
|
# Should complete instantly (not hang)
|
||||||
result = font_stage.process(filtered_items, ctx)
|
result = font_stage.process(filtered_items, ctx)
|
||||||
|
|||||||
@@ -1307,4 +1307,470 @@ class TestInletOutletTypeValidation:
|
|||||||
pipeline.build()
|
pipeline.build()
|
||||||
|
|
||||||
assert "display" in str(exc_info.value).lower()
|
assert "display" in str(exc_info.value).lower()
|
||||||
assert "TEXT_BUFFER" in str(exc_info.value)
|
|
||||||
|
|
||||||
|
class TestPipelineMutation:
|
||||||
|
"""Tests for Pipeline Mutation API - dynamic stage modification."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Set up test fixtures."""
|
||||||
|
StageRegistry._discovered = False
|
||||||
|
StageRegistry._categories.clear()
|
||||||
|
StageRegistry._instances.clear()
|
||||||
|
discover_stages()
|
||||||
|
|
||||||
|
def _create_mock_stage(
|
||||||
|
self,
|
||||||
|
name: str = "test",
|
||||||
|
category: str = "test",
|
||||||
|
capabilities: set | None = None,
|
||||||
|
dependencies: set | None = None,
|
||||||
|
):
|
||||||
|
"""Helper to create a mock stage."""
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
mock = MagicMock(spec=Stage)
|
||||||
|
mock.name = name
|
||||||
|
mock.category = category
|
||||||
|
mock.stage_type = category
|
||||||
|
mock.render_order = 0
|
||||||
|
mock.is_overlay = False
|
||||||
|
mock.inlet_types = {DataType.ANY}
|
||||||
|
mock.outlet_types = {DataType.TEXT_BUFFER}
|
||||||
|
mock.capabilities = capabilities or {f"{category}.{name}"}
|
||||||
|
mock.dependencies = dependencies or set()
|
||||||
|
mock.process = lambda data, ctx: data
|
||||||
|
mock.init = MagicMock(return_value=True)
|
||||||
|
mock.cleanup = MagicMock()
|
||||||
|
mock.is_enabled = MagicMock(return_value=True)
|
||||||
|
mock.set_enabled = MagicMock()
|
||||||
|
mock._enabled = True
|
||||||
|
return mock
|
||||||
|
|
||||||
|
def test_add_stage_initializes_when_pipeline_initialized(self):
|
||||||
|
"""add_stage() initializes stage when pipeline already initialized."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
mock_stage = self._create_mock_stage("test")
|
||||||
|
pipeline.build()
|
||||||
|
pipeline._initialized = True
|
||||||
|
|
||||||
|
pipeline.add_stage("test", mock_stage, initialize=True)
|
||||||
|
|
||||||
|
mock_stage.init.assert_called_once()
|
||||||
|
|
||||||
|
def test_add_stage_skips_initialize_when_pipeline_not_initialized(self):
|
||||||
|
"""add_stage() skips initialization when pipeline not built."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
mock_stage = self._create_mock_stage("test")
|
||||||
|
|
||||||
|
pipeline.add_stage("test", mock_stage, initialize=False)
|
||||||
|
|
||||||
|
mock_stage.init.assert_not_called()
|
||||||
|
|
||||||
|
def test_remove_stage_returns_removed_stage(self):
|
||||||
|
"""remove_stage() returns the removed stage."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
mock_stage = self._create_mock_stage("test")
|
||||||
|
pipeline.add_stage("test", mock_stage, initialize=False)
|
||||||
|
|
||||||
|
removed = pipeline.remove_stage("test", cleanup=False)
|
||||||
|
|
||||||
|
assert removed is mock_stage
|
||||||
|
assert "test" not in pipeline.stages
|
||||||
|
|
||||||
|
def test_remove_stage_calls_cleanup_when_requested(self):
|
||||||
|
"""remove_stage() calls cleanup when cleanup=True."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
mock_stage = self._create_mock_stage("test")
|
||||||
|
pipeline.add_stage("test", mock_stage, initialize=False)
|
||||||
|
|
||||||
|
pipeline.remove_stage("test", cleanup=True)
|
||||||
|
|
||||||
|
mock_stage.cleanup.assert_called_once()
|
||||||
|
|
||||||
|
def test_remove_stage_skips_cleanup_when_requested(self):
|
||||||
|
"""remove_stage() skips cleanup when cleanup=False."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
mock_stage = self._create_mock_stage("test")
|
||||||
|
pipeline.add_stage("test", mock_stage, initialize=False)
|
||||||
|
|
||||||
|
pipeline.remove_stage("test", cleanup=False)
|
||||||
|
|
||||||
|
mock_stage.cleanup.assert_not_called()
|
||||||
|
|
||||||
|
def test_remove_nonexistent_stage_returns_none(self):
|
||||||
|
"""remove_stage() returns None for nonexistent stage."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
|
||||||
|
result = pipeline.remove_stage("nonexistent", cleanup=False)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_replace_stage_preserves_state(self):
|
||||||
|
"""replace_stage() copies _enabled from old to new stage."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
old_stage = self._create_mock_stage("test")
|
||||||
|
old_stage._enabled = False
|
||||||
|
|
||||||
|
new_stage = self._create_mock_stage("test")
|
||||||
|
|
||||||
|
pipeline.add_stage("test", old_stage, initialize=False)
|
||||||
|
pipeline.replace_stage("test", new_stage, preserve_state=True)
|
||||||
|
|
||||||
|
assert new_stage._enabled is False
|
||||||
|
old_stage.cleanup.assert_called_once()
|
||||||
|
new_stage.init.assert_called_once()
|
||||||
|
|
||||||
|
def test_replace_stage_without_preserving_state(self):
|
||||||
|
"""replace_stage() without preserve_state doesn't copy state."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
old_stage = self._create_mock_stage("test")
|
||||||
|
old_stage._enabled = False
|
||||||
|
|
||||||
|
new_stage = self._create_mock_stage("test")
|
||||||
|
new_stage._enabled = True
|
||||||
|
|
||||||
|
pipeline.add_stage("test", old_stage, initialize=False)
|
||||||
|
pipeline.replace_stage("test", new_stage, preserve_state=False)
|
||||||
|
|
||||||
|
assert new_stage._enabled is True
|
||||||
|
|
||||||
|
def test_replace_nonexistent_stage_returns_none(self):
|
||||||
|
"""replace_stage() returns None for nonexistent stage."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
mock_stage = self._create_mock_stage("test")
|
||||||
|
|
||||||
|
result = pipeline.replace_stage("nonexistent", mock_stage)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_swap_stages_swaps_stages(self):
|
||||||
|
"""swap_stages() swaps two stages."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
stage_a = self._create_mock_stage("stage_a", "a")
|
||||||
|
stage_b = self._create_mock_stage("stage_b", "b")
|
||||||
|
|
||||||
|
pipeline.add_stage("a", stage_a, initialize=False)
|
||||||
|
pipeline.add_stage("b", stage_b, initialize=False)
|
||||||
|
|
||||||
|
result = pipeline.swap_stages("a", "b")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert pipeline.stages["a"].name == "stage_b"
|
||||||
|
assert pipeline.stages["b"].name == "stage_a"
|
||||||
|
|
||||||
|
def test_swap_stages_fails_for_nonexistent(self):
|
||||||
|
"""swap_stages() fails if either stage doesn't exist."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
stage = self._create_mock_stage("test")
|
||||||
|
|
||||||
|
pipeline.add_stage("test", stage, initialize=False)
|
||||||
|
|
||||||
|
result = pipeline.swap_stages("test", "nonexistent")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_move_stage_after(self):
|
||||||
|
"""move_stage() moves stage after another."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
stage_a = self._create_mock_stage("a")
|
||||||
|
stage_b = self._create_mock_stage("b")
|
||||||
|
stage_c = self._create_mock_stage("c")
|
||||||
|
|
||||||
|
pipeline.add_stage("a", stage_a, initialize=False)
|
||||||
|
pipeline.add_stage("b", stage_b, initialize=False)
|
||||||
|
pipeline.add_stage("c", stage_c, initialize=False)
|
||||||
|
pipeline.build()
|
||||||
|
|
||||||
|
result = pipeline.move_stage("a", after="c")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
idx_a = pipeline.execution_order.index("a")
|
||||||
|
idx_c = pipeline.execution_order.index("c")
|
||||||
|
assert idx_a > idx_c
|
||||||
|
|
||||||
|
def test_move_stage_before(self):
|
||||||
|
"""move_stage() moves stage before another."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
stage_a = self._create_mock_stage("a")
|
||||||
|
stage_b = self._create_mock_stage("b")
|
||||||
|
stage_c = self._create_mock_stage("c")
|
||||||
|
|
||||||
|
pipeline.add_stage("a", stage_a, initialize=False)
|
||||||
|
pipeline.add_stage("b", stage_b, initialize=False)
|
||||||
|
pipeline.add_stage("c", stage_c, initialize=False)
|
||||||
|
pipeline.build()
|
||||||
|
|
||||||
|
result = pipeline.move_stage("c", before="a")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
idx_a = pipeline.execution_order.index("a")
|
||||||
|
idx_c = pipeline.execution_order.index("c")
|
||||||
|
assert idx_c < idx_a
|
||||||
|
|
||||||
|
def test_move_stage_fails_for_nonexistent(self):
|
||||||
|
"""move_stage() fails if stage doesn't exist."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
stage = self._create_mock_stage("test")
|
||||||
|
|
||||||
|
pipeline.add_stage("test", stage, initialize=False)
|
||||||
|
pipeline.build()
|
||||||
|
|
||||||
|
result = pipeline.move_stage("nonexistent", after="test")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_move_stage_fails_when_not_initialized(self):
|
||||||
|
"""move_stage() fails if pipeline not built."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
stage = self._create_mock_stage("test")
|
||||||
|
|
||||||
|
pipeline.add_stage("test", stage, initialize=False)
|
||||||
|
|
||||||
|
result = pipeline.move_stage("test", after="other")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_enable_stage(self):
|
||||||
|
"""enable_stage() enables a stage."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
stage = self._create_mock_stage("test")
|
||||||
|
|
||||||
|
pipeline.add_stage("test", stage, initialize=False)
|
||||||
|
|
||||||
|
result = pipeline.enable_stage("test")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
stage.set_enabled.assert_called_with(True)
|
||||||
|
|
||||||
|
def test_enable_nonexistent_stage_returns_false(self):
|
||||||
|
"""enable_stage() returns False for nonexistent stage."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
|
||||||
|
result = pipeline.enable_stage("nonexistent")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_disable_stage(self):
|
||||||
|
"""disable_stage() disables a stage."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
stage = self._create_mock_stage("test")
|
||||||
|
|
||||||
|
pipeline.add_stage("test", stage, initialize=False)
|
||||||
|
|
||||||
|
result = pipeline.disable_stage("test")
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
stage.set_enabled.assert_called_with(False)
|
||||||
|
|
||||||
|
def test_disable_nonexistent_stage_returns_false(self):
|
||||||
|
"""disable_stage() returns False for nonexistent stage."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
|
||||||
|
result = pipeline.disable_stage("nonexistent")
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_get_stage_info_returns_correct_info(self):
|
||||||
|
"""get_stage_info() returns correct stage information."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
stage = self._create_mock_stage(
|
||||||
|
"test_stage",
|
||||||
|
"effect",
|
||||||
|
capabilities={"effect.test"},
|
||||||
|
dependencies={"source"},
|
||||||
|
)
|
||||||
|
stage.render_order = 5
|
||||||
|
stage.is_overlay = False
|
||||||
|
stage.optional = True
|
||||||
|
|
||||||
|
pipeline.add_stage("test", stage, initialize=False)
|
||||||
|
|
||||||
|
info = pipeline.get_stage_info("test")
|
||||||
|
|
||||||
|
assert info is not None
|
||||||
|
assert info["name"] == "test" # Dict key, not stage.name
|
||||||
|
assert info["category"] == "effect"
|
||||||
|
assert info["stage_type"] == "effect"
|
||||||
|
assert info["enabled"] is True
|
||||||
|
assert info["optional"] is True
|
||||||
|
assert info["capabilities"] == ["effect.test"]
|
||||||
|
assert info["dependencies"] == ["source"]
|
||||||
|
assert info["render_order"] == 5
|
||||||
|
assert info["is_overlay"] is False
|
||||||
|
|
||||||
|
def test_get_stage_info_returns_none_for_nonexistent(self):
|
||||||
|
"""get_stage_info() returns None for nonexistent stage."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
|
||||||
|
info = pipeline.get_stage_info("nonexistent")
|
||||||
|
|
||||||
|
assert info is None
|
||||||
|
|
||||||
|
def test_get_pipeline_info_returns_complete_info(self):
|
||||||
|
"""get_pipeline_info() returns complete pipeline state."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
stage1 = self._create_mock_stage("stage1")
|
||||||
|
stage2 = self._create_mock_stage("stage2")
|
||||||
|
|
||||||
|
pipeline.add_stage("s1", stage1, initialize=False)
|
||||||
|
pipeline.add_stage("s2", stage2, initialize=False)
|
||||||
|
pipeline.build()
|
||||||
|
|
||||||
|
info = pipeline.get_pipeline_info()
|
||||||
|
|
||||||
|
assert "stages" in info
|
||||||
|
assert "execution_order" in info
|
||||||
|
assert info["initialized"] is True
|
||||||
|
assert info["stage_count"] == 2
|
||||||
|
assert "s1" in info["stages"]
|
||||||
|
assert "s2" in info["stages"]
|
||||||
|
|
||||||
|
def test_rebuild_after_mutation(self):
|
||||||
|
"""_rebuild() updates execution order after mutation."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
source = self._create_mock_stage(
|
||||||
|
"source", "source", capabilities={"source"}, dependencies=set()
|
||||||
|
)
|
||||||
|
effect = self._create_mock_stage(
|
||||||
|
"effect", "effect", capabilities={"effect"}, dependencies={"source"}
|
||||||
|
)
|
||||||
|
display = self._create_mock_stage(
|
||||||
|
"display", "display", capabilities={"display"}, dependencies={"effect"}
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline.add_stage("source", source, initialize=False)
|
||||||
|
pipeline.add_stage("effect", effect, initialize=False)
|
||||||
|
pipeline.add_stage("display", display, initialize=False)
|
||||||
|
pipeline.build()
|
||||||
|
|
||||||
|
assert pipeline.execution_order == ["source", "effect", "display"]
|
||||||
|
|
||||||
|
pipeline.remove_stage("effect", cleanup=False)
|
||||||
|
|
||||||
|
pipeline._rebuild()
|
||||||
|
|
||||||
|
assert "effect" not in pipeline.execution_order
|
||||||
|
assert "source" in pipeline.execution_order
|
||||||
|
assert "display" in pipeline.execution_order
|
||||||
|
|
||||||
|
def test_add_stage_after_build(self):
|
||||||
|
"""add_stage() can add stage after build with initialization."""
|
||||||
|
pipeline = Pipeline()
|
||||||
|
source = self._create_mock_stage(
|
||||||
|
"source", "source", capabilities={"source"}, dependencies=set()
|
||||||
|
)
|
||||||
|
display = self._create_mock_stage(
|
||||||
|
"display", "display", capabilities={"display"}, dependencies={"source"}
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline.add_stage("source", source, initialize=False)
|
||||||
|
pipeline.add_stage("display", display, initialize=False)
|
||||||
|
pipeline.build()
|
||||||
|
|
||||||
|
new_stage = self._create_mock_stage(
|
||||||
|
"effect", "effect", capabilities={"effect"}, dependencies={"source"}
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline.add_stage("effect", new_stage, initialize=True)
|
||||||
|
|
||||||
|
assert "effect" in pipeline.stages
|
||||||
|
new_stage.init.assert_called_once()
|
||||||
|
|
||||||
|
def test_mutation_preserves_execution_for_remaining_stages(self):
|
||||||
|
"""Removing a stage doesn't break execution of remaining stages."""
|
||||||
|
from engine.pipeline.core import DataType
|
||||||
|
|
||||||
|
call_log = []
|
||||||
|
|
||||||
|
class TestSource(Stage):
|
||||||
|
name = "source"
|
||||||
|
category = "source"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self):
|
||||||
|
return {DataType.NONE}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self):
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self):
|
||||||
|
return {"source"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self):
|
||||||
|
return set()
|
||||||
|
|
||||||
|
def process(self, data, ctx):
|
||||||
|
call_log.append("source")
|
||||||
|
return ["item"]
|
||||||
|
|
||||||
|
class TestEffect(Stage):
|
||||||
|
name = "effect"
|
||||||
|
category = "effect"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self):
|
||||||
|
return {DataType.SOURCE_ITEMS}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self):
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self):
|
||||||
|
return {"effect"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self):
|
||||||
|
return {"source"}
|
||||||
|
|
||||||
|
def process(self, data, ctx):
|
||||||
|
call_log.append("effect")
|
||||||
|
return data
|
||||||
|
|
||||||
|
class TestDisplay(Stage):
|
||||||
|
name = "display"
|
||||||
|
category = "display"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inlet_types(self):
|
||||||
|
return {DataType.TEXT_BUFFER}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outlet_types(self):
|
||||||
|
return {DataType.NONE}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def capabilities(self):
|
||||||
|
return {"display"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dependencies(self):
|
||||||
|
return {"effect"}
|
||||||
|
|
||||||
|
def process(self, data, ctx):
|
||||||
|
call_log.append("display")
|
||||||
|
return data
|
||||||
|
|
||||||
|
pipeline = Pipeline()
|
||||||
|
pipeline.add_stage("source", TestSource(), initialize=False)
|
||||||
|
pipeline.add_stage("effect", TestEffect(), initialize=False)
|
||||||
|
pipeline.add_stage("display", TestDisplay(), initialize=False)
|
||||||
|
pipeline.build()
|
||||||
|
pipeline.initialize()
|
||||||
|
|
||||||
|
result = pipeline.execute(None)
|
||||||
|
assert result.success
|
||||||
|
assert call_log == ["source", "effect", "display"]
|
||||||
|
|
||||||
|
call_log.clear()
|
||||||
|
pipeline.remove_stage("effect", cleanup=True)
|
||||||
|
|
||||||
|
pipeline._rebuild()
|
||||||
|
|
||||||
|
result = pipeline.execute(None)
|
||||||
|
assert result.success
|
||||||
|
assert call_log == ["source", "display"]
|
||||||
|
|||||||
224
tests/test_streaming.py
Normal file
224
tests/test_streaming.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
"""
|
||||||
|
Tests for streaming protocol utilities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
from engine.display.streaming import (
|
||||||
|
FrameDiff,
|
||||||
|
MessageType,
|
||||||
|
apply_diff,
|
||||||
|
compress_frame,
|
||||||
|
compute_diff,
|
||||||
|
decode_binary_message,
|
||||||
|
decode_diff_message,
|
||||||
|
decode_rle,
|
||||||
|
decompress_frame,
|
||||||
|
encode_binary_message,
|
||||||
|
encode_diff_message,
|
||||||
|
encode_rle,
|
||||||
|
should_use_diff,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFrameDiff:
|
||||||
|
"""Tests for FrameDiff computation."""
|
||||||
|
|
||||||
|
def test_compute_diff_all_changed(self):
|
||||||
|
"""compute_diff detects all changed lines."""
|
||||||
|
old = ["a", "b", "c"]
|
||||||
|
new = ["x", "y", "z"]
|
||||||
|
|
||||||
|
diff = compute_diff(old, new)
|
||||||
|
|
||||||
|
assert len(diff.changed_lines) == 3
|
||||||
|
assert diff.width == 1
|
||||||
|
assert diff.height == 3
|
||||||
|
|
||||||
|
def test_compute_diff_no_changes(self):
|
||||||
|
"""compute_diff returns empty for identical buffers."""
|
||||||
|
old = ["a", "b", "c"]
|
||||||
|
new = ["a", "b", "c"]
|
||||||
|
|
||||||
|
diff = compute_diff(old, new)
|
||||||
|
|
||||||
|
assert len(diff.changed_lines) == 0
|
||||||
|
|
||||||
|
def test_compute_diff_partial_changes(self):
|
||||||
|
"""compute_diff detects partial changes."""
|
||||||
|
old = ["a", "b", "c"]
|
||||||
|
new = ["a", "x", "c"]
|
||||||
|
|
||||||
|
diff = compute_diff(old, new)
|
||||||
|
|
||||||
|
assert len(diff.changed_lines) == 1
|
||||||
|
assert diff.changed_lines[0] == (1, "x")
|
||||||
|
|
||||||
|
def test_compute_diff_new_lines(self):
|
||||||
|
"""compute_diff detects new lines added."""
|
||||||
|
old = ["a", "b"]
|
||||||
|
new = ["a", "b", "c"]
|
||||||
|
|
||||||
|
diff = compute_diff(old, new)
|
||||||
|
|
||||||
|
assert len(diff.changed_lines) == 1
|
||||||
|
assert diff.changed_lines[0] == (2, "c")
|
||||||
|
|
||||||
|
def test_compute_diff_empty_old(self):
|
||||||
|
"""compute_diff handles empty old buffer."""
|
||||||
|
old = []
|
||||||
|
new = ["a", "b", "c"]
|
||||||
|
|
||||||
|
diff = compute_diff(old, new)
|
||||||
|
|
||||||
|
assert len(diff.changed_lines) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestRLE:
|
||||||
|
"""Tests for run-length encoding."""
|
||||||
|
|
||||||
|
def test_encode_rle_no_repeats(self):
|
||||||
|
"""encode_rle handles no repeated lines."""
|
||||||
|
lines = [(0, "a"), (1, "b"), (2, "c")]
|
||||||
|
|
||||||
|
encoded = encode_rle(lines)
|
||||||
|
|
||||||
|
assert len(encoded) == 3
|
||||||
|
assert encoded[0] == (0, "a", 1)
|
||||||
|
assert encoded[1] == (1, "b", 1)
|
||||||
|
assert encoded[2] == (2, "c", 1)
|
||||||
|
|
||||||
|
def test_encode_rle_with_repeats(self):
|
||||||
|
"""encode_rle compresses repeated lines."""
|
||||||
|
lines = [(0, "a"), (1, "a"), (2, "a"), (3, "b")]
|
||||||
|
|
||||||
|
encoded = encode_rle(lines)
|
||||||
|
|
||||||
|
assert len(encoded) == 2
|
||||||
|
assert encoded[0] == (0, "a", 3)
|
||||||
|
assert encoded[1] == (3, "b", 1)
|
||||||
|
|
||||||
|
def test_decode_rle(self):
|
||||||
|
"""decode_rle reconstructs original lines."""
|
||||||
|
encoded = [(0, "a", 3), (3, "b", 1)]
|
||||||
|
|
||||||
|
decoded = decode_rle(encoded)
|
||||||
|
|
||||||
|
assert decoded == [(0, "a"), (1, "a"), (2, "a"), (3, "b")]
|
||||||
|
|
||||||
|
def test_encode_decode_roundtrip(self):
|
||||||
|
"""encode/decode is lossless."""
|
||||||
|
original = [(i, f"line{i % 3}") for i in range(10)]
|
||||||
|
encoded = encode_rle(original)
|
||||||
|
decoded = decode_rle(encoded)
|
||||||
|
|
||||||
|
assert decoded == original
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompression:
|
||||||
|
"""Tests for frame compression."""
|
||||||
|
|
||||||
|
def test_compress_decompress(self):
|
||||||
|
"""compress_frame is lossless."""
|
||||||
|
buffer = [f"Line {i:02d}" for i in range(24)]
|
||||||
|
|
||||||
|
compressed = compress_frame(buffer)
|
||||||
|
decompressed = decompress_frame(compressed, 24)
|
||||||
|
|
||||||
|
assert decompressed == buffer
|
||||||
|
|
||||||
|
def test_compress_empty(self):
|
||||||
|
"""compress_frame handles empty buffer."""
|
||||||
|
compressed = compress_frame([])
|
||||||
|
decompressed = decompress_frame(compressed, 0)
|
||||||
|
|
||||||
|
assert decompressed == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestBinaryProtocol:
|
||||||
|
"""Tests for binary message encoding."""
|
||||||
|
|
||||||
|
def test_encode_decode_message(self):
|
||||||
|
"""encode_binary_message is lossless."""
|
||||||
|
payload = b"test payload"
|
||||||
|
|
||||||
|
encoded = encode_binary_message(MessageType.FULL_FRAME, 80, 24, payload)
|
||||||
|
msg_type, width, height, decoded_payload = decode_binary_message(encoded)
|
||||||
|
|
||||||
|
assert msg_type == MessageType.FULL_FRAME
|
||||||
|
assert width == 80
|
||||||
|
assert height == 24
|
||||||
|
assert decoded_payload == payload
|
||||||
|
|
||||||
|
def test_encode_decode_all_types(self):
|
||||||
|
"""All message types encode correctly."""
|
||||||
|
for msg_type in MessageType:
|
||||||
|
payload = b"test"
|
||||||
|
encoded = encode_binary_message(msg_type, 80, 24, payload)
|
||||||
|
decoded_type, _, _, _ = decode_binary_message(encoded)
|
||||||
|
assert decoded_type == msg_type
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiffProtocol:
|
||||||
|
"""Tests for diff message encoding."""
|
||||||
|
|
||||||
|
def test_encode_decode_diff(self):
|
||||||
|
"""encode_diff_message is lossless."""
|
||||||
|
diff = FrameDiff(width=80, height=24, changed_lines=[(0, "a"), (5, "b")])
|
||||||
|
|
||||||
|
payload = encode_diff_message(diff)
|
||||||
|
decoded = decode_diff_message(payload)
|
||||||
|
|
||||||
|
assert decoded == diff.changed_lines
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyDiff:
|
||||||
|
"""Tests for applying diffs."""
|
||||||
|
|
||||||
|
def test_apply_diff(self):
|
||||||
|
"""apply_diff reconstructs new buffer."""
|
||||||
|
old_buffer = ["a", "b", "c", "d"]
|
||||||
|
diff = FrameDiff(width=1, height=4, changed_lines=[(1, "x"), (2, "y")])
|
||||||
|
|
||||||
|
new_buffer = apply_diff(old_buffer, diff)
|
||||||
|
|
||||||
|
assert new_buffer == ["a", "x", "y", "d"]
|
||||||
|
|
||||||
|
def test_apply_diff_new_lines(self):
|
||||||
|
"""apply_diff handles new lines."""
|
||||||
|
old_buffer = ["a", "b"]
|
||||||
|
diff = FrameDiff(width=1, height=4, changed_lines=[(2, "c"), (3, "d")])
|
||||||
|
|
||||||
|
new_buffer = apply_diff(old_buffer, diff)
|
||||||
|
|
||||||
|
assert new_buffer == ["a", "b", "c", "d"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestShouldUseDiff:
|
||||||
|
"""Tests for diff threshold decision."""
|
||||||
|
|
||||||
|
def test_uses_diff_when_small_changes(self):
|
||||||
|
"""should_use_diff returns True when few changes."""
|
||||||
|
old = ["a"] * 100
|
||||||
|
new = ["a"] * 95 + ["b"] * 5
|
||||||
|
|
||||||
|
assert should_use_diff(old, new, threshold=0.3) is True
|
||||||
|
|
||||||
|
def test_uses_full_when_many_changes(self):
|
||||||
|
"""should_use_diff returns False when many changes."""
|
||||||
|
old = ["a"] * 100
|
||||||
|
new = ["b"] * 100
|
||||||
|
|
||||||
|
assert should_use_diff(old, new, threshold=0.3) is False
|
||||||
|
|
||||||
|
def test_uses_diff_at_threshold(self):
|
||||||
|
"""should_use_diff handles threshold boundary."""
|
||||||
|
old = ["a"] * 100
|
||||||
|
new = ["a"] * 70 + ["b"] * 30
|
||||||
|
|
||||||
|
result = should_use_diff(old, new, threshold=0.3)
|
||||||
|
assert result is True or result is False # At boundary
|
||||||
|
|
||||||
|
def test_returns_false_for_empty(self):
|
||||||
|
"""should_use_diff returns False for empty buffers."""
|
||||||
|
assert should_use_diff([], ["a", "b"]) is False
|
||||||
|
assert should_use_diff(["a", "b"], []) is False
|
||||||
@@ -110,10 +110,9 @@ class TestViewportFilterStage:
|
|||||||
filtered = stage.process(test_items, ctx)
|
filtered = stage.process(test_items, ctx)
|
||||||
improvement_factor = len(test_items) / len(filtered)
|
improvement_factor = len(test_items) / len(filtered)
|
||||||
|
|
||||||
# Verify we get at least 400x improvement (better than old ~288x)
|
# Verify we get significant improvement (360x with 4 items vs 1438)
|
||||||
assert improvement_factor > 400
|
assert improvement_factor > 300
|
||||||
# Verify we get the expected ~479x improvement
|
assert 300 < improvement_factor < 500
|
||||||
assert 400 < improvement_factor < 600
|
|
||||||
|
|
||||||
|
|
||||||
class TestViewportFilterIntegration:
|
class TestViewportFilterIntegration:
|
||||||
|
|||||||
@@ -160,3 +160,236 @@ class TestWebSocketDisplayUnavailable:
|
|||||||
"""show does nothing when websockets unavailable."""
|
"""show does nothing when websockets unavailable."""
|
||||||
display = WebSocketDisplay()
|
display = WebSocketDisplay()
|
||||||
display.show(["line1", "line2"])
|
display.show(["line1", "line2"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketUIPanelIntegration:
|
||||||
|
"""Tests for WebSocket-UIPanel integration for remote control."""
|
||||||
|
|
||||||
|
def test_set_controller_stores_controller(self):
|
||||||
|
"""set_controller stores the controller reference."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
mock_controller = MagicMock()
|
||||||
|
display.set_controller(mock_controller)
|
||||||
|
assert display._controller is mock_controller
|
||||||
|
|
||||||
|
def test_set_command_callback_stores_callback(self):
|
||||||
|
"""set_command_callback stores the callback."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
callback = MagicMock()
|
||||||
|
display.set_command_callback(callback)
|
||||||
|
assert display._command_callback is callback
|
||||||
|
|
||||||
|
def test_get_state_snapshot_returns_none_without_controller(self):
|
||||||
|
"""_get_state_snapshot returns None when no controller is set."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
assert display._get_state_snapshot() is None
|
||||||
|
|
||||||
|
def test_get_state_snapshot_returns_controller_state(self):
|
||||||
|
"""_get_state_snapshot returns state from controller."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
|
||||||
|
# Create mock controller with expected attributes
|
||||||
|
mock_controller = MagicMock()
|
||||||
|
mock_controller.stages = {
|
||||||
|
"test_stage": MagicMock(
|
||||||
|
enabled=True, params={"intensity": 0.5}, selected=False
|
||||||
|
)
|
||||||
|
}
|
||||||
|
mock_controller._current_preset = "demo"
|
||||||
|
mock_controller._presets = ["demo", "test"]
|
||||||
|
mock_controller.selected_stage = "test_stage"
|
||||||
|
|
||||||
|
display.set_controller(mock_controller)
|
||||||
|
state = display._get_state_snapshot()
|
||||||
|
|
||||||
|
assert state is not None
|
||||||
|
assert "stages" in state
|
||||||
|
assert "test_stage" in state["stages"]
|
||||||
|
assert state["stages"]["test_stage"]["enabled"] is True
|
||||||
|
assert state["stages"]["test_stage"]["params"] == {"intensity": 0.5}
|
||||||
|
assert state["preset"] == "demo"
|
||||||
|
assert state["presets"] == ["demo", "test"]
|
||||||
|
assert state["selected_stage"] == "test_stage"
|
||||||
|
|
||||||
|
def test_get_state_snapshot_handles_missing_attributes(self):
|
||||||
|
"""_get_state_snapshot handles controller without all attributes."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
|
||||||
|
# Create mock controller without stages attribute using spec
|
||||||
|
# This prevents MagicMock from auto-creating the attribute
|
||||||
|
mock_controller = MagicMock(spec=[]) # Empty spec means no attributes
|
||||||
|
|
||||||
|
display.set_controller(mock_controller)
|
||||||
|
state = display._get_state_snapshot()
|
||||||
|
|
||||||
|
assert state == {}
|
||||||
|
|
||||||
|
def test_broadcast_state_sends_to_clients(self):
|
||||||
|
"""broadcast_state sends state update to all connected clients."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
|
||||||
|
# Mock client with send method
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client.send = MagicMock()
|
||||||
|
display._clients.add(mock_client)
|
||||||
|
|
||||||
|
test_state = {"test": "state"}
|
||||||
|
display.broadcast_state(test_state)
|
||||||
|
|
||||||
|
# Verify send was called with JSON containing state
|
||||||
|
mock_client.send.assert_called_once()
|
||||||
|
call_args = mock_client.send.call_args[0][0]
|
||||||
|
assert '"type": "state"' in call_args
|
||||||
|
assert '"test"' in call_args
|
||||||
|
|
||||||
|
def test_broadcast_state_noop_when_no_clients(self):
|
||||||
|
"""broadcast_state does nothing when no clients connected."""
|
||||||
|
with patch("engine.display.backends.websocket.websockets", MagicMock()):
|
||||||
|
display = WebSocketDisplay()
|
||||||
|
display._clients.clear()
|
||||||
|
|
||||||
|
# Should not raise error
|
||||||
|
display.broadcast_state({"test": "state"})
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSocketHTTPServerPath:
|
||||||
|
"""Tests for WebSocket HTTP server client directory path calculation."""
|
||||||
|
|
||||||
|
def test_client_dir_path_calculation(self):
|
||||||
|
"""Client directory path is correctly calculated from websocket.py location."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Use the actual websocket.py file location, not the test file
|
||||||
|
websocket_module = __import__(
|
||||||
|
"engine.display.backends.websocket", fromlist=["WebSocketDisplay"]
|
||||||
|
)
|
||||||
|
websocket_file = websocket_module.__file__
|
||||||
|
parts = websocket_file.split(os.sep)
|
||||||
|
|
||||||
|
if "engine" in parts:
|
||||||
|
engine_idx = parts.index("engine")
|
||||||
|
project_root = os.sep.join(parts[:engine_idx])
|
||||||
|
client_dir = os.path.join(project_root, "client")
|
||||||
|
else:
|
||||||
|
# Fallback calculation (shouldn't happen in normal test runs)
|
||||||
|
client_dir = os.path.join(
|
||||||
|
os.path.dirname(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(websocket_file)))
|
||||||
|
),
|
||||||
|
"client",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the client directory exists and contains expected files
|
||||||
|
assert os.path.exists(client_dir), f"Client directory not found: {client_dir}"
|
||||||
|
assert "index.html" in os.listdir(client_dir), (
|
||||||
|
"index.html not found in client directory"
|
||||||
|
)
|
||||||
|
assert "editor.html" in os.listdir(client_dir), (
|
||||||
|
"editor.html not found in client directory"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the path is correct (should be .../Mainline/client)
|
||||||
|
assert client_dir.endswith("client"), (
|
||||||
|
f"Client dir should end with 'client': {client_dir}"
|
||||||
|
)
|
||||||
|
assert "Mainline" in client_dir, (
|
||||||
|
f"Client dir should contain 'Mainline': {client_dir}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_http_server_directory_serves_client_files(self):
|
||||||
|
"""HTTP server directory correctly serves client files."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Use the actual websocket.py file location, not the test file
|
||||||
|
websocket_module = __import__(
|
||||||
|
"engine.display.backends.websocket", fromlist=["WebSocketDisplay"]
|
||||||
|
)
|
||||||
|
websocket_file = websocket_module.__file__
|
||||||
|
parts = websocket_file.split(os.sep)
|
||||||
|
|
||||||
|
if "engine" in parts:
|
||||||
|
engine_idx = parts.index("engine")
|
||||||
|
project_root = os.sep.join(parts[:engine_idx])
|
||||||
|
client_dir = os.path.join(project_root, "client")
|
||||||
|
else:
|
||||||
|
client_dir = os.path.join(
|
||||||
|
os.path.dirname(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(websocket_file)))
|
||||||
|
),
|
||||||
|
"client",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the handler would be able to serve files from this directory
|
||||||
|
# We can't actually instantiate the handler without a valid request,
|
||||||
|
# but we can verify the directory is accessible
|
||||||
|
assert os.access(client_dir, os.R_OK), (
|
||||||
|
f"Client directory not readable: {client_dir}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify key files exist
|
||||||
|
index_path = os.path.join(client_dir, "index.html")
|
||||||
|
editor_path = os.path.join(client_dir, "editor.html")
|
||||||
|
|
||||||
|
assert os.path.exists(index_path), f"index.html not found at: {index_path}"
|
||||||
|
assert os.path.exists(editor_path), f"editor.html not found at: {editor_path}"
|
||||||
|
|
||||||
|
# Verify files are readable
|
||||||
|
assert os.access(index_path, os.R_OK), "index.html not readable"
|
||||||
|
assert os.access(editor_path, os.R_OK), "editor.html not readable"
|
||||||
|
|
||||||
|
def test_old_buggy_path_does_not_find_client_directory(self):
|
||||||
|
"""The old buggy path (3 dirname calls) should NOT find the client directory.
|
||||||
|
|
||||||
|
This test verifies that the old buggy behavior would have failed.
|
||||||
|
The old code used:
|
||||||
|
client_dir = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "client"
|
||||||
|
)
|
||||||
|
|
||||||
|
This would resolve to: .../engine/client (which doesn't exist)
|
||||||
|
Instead of: .../Mainline/client (which does exist)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Use the actual websocket.py file location
|
||||||
|
websocket_module = __import__(
|
||||||
|
"engine.display.backends.websocket", fromlist=["WebSocketDisplay"]
|
||||||
|
)
|
||||||
|
websocket_file = websocket_module.__file__
|
||||||
|
|
||||||
|
# OLD BUGGY CODE: 3 dirname calls
|
||||||
|
old_buggy_client_dir = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(websocket_file))), "client"
|
||||||
|
)
|
||||||
|
|
||||||
|
# This path should NOT exist (it's the buggy path)
|
||||||
|
assert not os.path.exists(old_buggy_client_dir), (
|
||||||
|
f"Old buggy path should not exist: {old_buggy_client_dir}\n"
|
||||||
|
f"If this assertion fails, the bug may have been fixed elsewhere or "
|
||||||
|
f"the test needs updating."
|
||||||
|
)
|
||||||
|
|
||||||
|
# The buggy path should be .../engine/client, not .../Mainline/client
|
||||||
|
assert old_buggy_client_dir.endswith("engine/client"), (
|
||||||
|
f"Old buggy path should end with 'engine/client': {old_buggy_client_dir}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify that going up one more level (4 dirname calls) finds the correct path
|
||||||
|
correct_client_dir = os.path.join(
|
||||||
|
os.path.dirname(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.dirname(websocket_file)))
|
||||||
|
),
|
||||||
|
"client",
|
||||||
|
)
|
||||||
|
assert os.path.exists(correct_client_dir), (
|
||||||
|
f"Correct path should exist: {correct_client_dir}"
|
||||||
|
)
|
||||||
|
assert "index.html" in os.listdir(correct_client_dir), (
|
||||||
|
f"index.html should exist in correct path: {correct_client_dir}"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user