feat: Complete pipeline hot-rebuild implementation with acceptance tests
- Implements pipeline hot-rebuild with state preservation (issue #43) - Adds auto-injection of MVP stages for missing capabilities - Adds radial camera mode for polar coordinate scanning - Adds afterimage and motionblur effects using framebuffer history - Adds comprehensive acceptance tests for camera modes and pipeline rebuild - Updates presets.toml with new effect configurations Related to: #35 (Pipeline Mutation API epic) Closes: #43, #44, #45
This commit is contained in:
@@ -4,7 +4,7 @@ This module provides adapters that wrap existing components
|
||||
(EffectPlugin, Display, DataSource, Camera) as Stage implementations.
|
||||
"""
|
||||
|
||||
from .camera import CameraStage
|
||||
from .camera import CameraClockStage, CameraStage
|
||||
from .data_source import DataSourceStage, PassthroughStage, SourceItemsToBufferStage
|
||||
from .display import DisplayStage
|
||||
from .effect_plugin import EffectPluginStage
|
||||
@@ -30,6 +30,7 @@ __all__ = [
|
||||
"PassthroughStage",
|
||||
"SourceItemsToBufferStage",
|
||||
"CameraStage",
|
||||
"CameraClockStage",
|
||||
"ViewportFilterStage",
|
||||
"FontStage",
|
||||
"ImageToTextStage",
|
||||
|
||||
@@ -6,8 +6,83 @@ from typing import Any
|
||||
from engine.pipeline.core import DataType, PipelineContext, Stage
|
||||
|
||||
|
||||
class CameraClockStage(Stage):
|
||||
"""Per-frame clock stage that updates camera state.
|
||||
|
||||
This stage runs once per frame and updates the camera's internal state
|
||||
(position, time). It makes camera_y/camera_x available to subsequent
|
||||
stages via the pipeline context.
|
||||
|
||||
Unlike other stages, this is a pure clock stage and doesn't process
|
||||
data - it just updates camera state and passes data through unchanged.
|
||||
"""
|
||||
|
||||
def __init__(self, camera, name: str = "camera-clock"):
|
||||
self._camera = camera
|
||||
self.name = name
|
||||
self.category = "camera"
|
||||
self.optional = False
|
||||
self._last_frame_time: float | None = None
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "camera"
|
||||
|
||||
@property
|
||||
def capabilities(self) -> set[str]:
|
||||
# Provides camera state info only
|
||||
# NOTE: Do NOT provide "source" as it conflicts with viewport_filter's "source.filtered"
|
||||
return {"camera.state"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
# Clock stage - no dependencies (updates every frame regardless of data flow)
|
||||
return set()
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
# Accept any data type - this is a pass-through stage
|
||||
return {DataType.ANY}
|
||||
|
||||
@property
|
||||
def outlet_types(self) -> set:
|
||||
# Pass through whatever was received
|
||||
return {DataType.ANY}
|
||||
|
||||
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
||||
"""Update camera state and pass data through.
|
||||
|
||||
This stage updates the camera's internal state (position, time) and
|
||||
makes the updated camera_y/camera_x available to subsequent stages
|
||||
via the pipeline context.
|
||||
|
||||
The data is passed through unchanged - this stage only updates
|
||||
camera state, it doesn't transform the data.
|
||||
"""
|
||||
if data is None:
|
||||
return data
|
||||
|
||||
current_time = time.perf_counter()
|
||||
dt = 0.0
|
||||
if self._last_frame_time is not None:
|
||||
dt = current_time - self._last_frame_time
|
||||
self._camera.update(dt)
|
||||
self._last_frame_time = current_time
|
||||
|
||||
# Update context with current camera position
|
||||
ctx.set_state("camera_y", self._camera.y)
|
||||
ctx.set_state("camera_x", self._camera.x)
|
||||
|
||||
# Pass data through unchanged
|
||||
return data
|
||||
|
||||
|
||||
class CameraStage(Stage):
|
||||
"""Adapter wrapping Camera as a Stage."""
|
||||
"""Adapter wrapping Camera as a Stage.
|
||||
|
||||
This stage applies camera viewport transformation to the rendered buffer.
|
||||
Camera state updates are handled by CameraClockStage.
|
||||
"""
|
||||
|
||||
def __init__(self, camera, name: str = "vertical"):
|
||||
self._camera = camera
|
||||
@@ -22,7 +97,7 @@ class CameraStage(Stage):
|
||||
Returns:
|
||||
Dictionary containing camera state that can be restored
|
||||
"""
|
||||
return {
|
||||
state = {
|
||||
"x": self._camera.x,
|
||||
"y": self._camera.y,
|
||||
"mode": self._camera.mode.value
|
||||
@@ -36,6 +111,14 @@ class CameraStage(Stage):
|
||||
"_y_float": getattr(self._camera, "_y_float", 0.0),
|
||||
"_time": getattr(self._camera, "_time", 0.0),
|
||||
}
|
||||
# Save radial camera state if present
|
||||
if hasattr(self._camera, "_r_float"):
|
||||
state["_r_float"] = self._camera._r_float
|
||||
if hasattr(self._camera, "_theta_float"):
|
||||
state["_theta_float"] = self._camera._theta_float
|
||||
if hasattr(self._camera, "_radial_input"):
|
||||
state["_radial_input"] = self._camera._radial_input
|
||||
return state
|
||||
|
||||
def restore_state(self, state: dict[str, Any]) -> None:
|
||||
"""Restore camera state from saved state.
|
||||
@@ -68,6 +151,14 @@ class CameraStage(Stage):
|
||||
if hasattr(self._camera, "_time"):
|
||||
self._camera._time = state.get("_time", 0.0)
|
||||
|
||||
# Restore radial camera state if present
|
||||
if hasattr(self._camera, "_r_float"):
|
||||
self._camera._r_float = state.get("_r_float", 0.0)
|
||||
if hasattr(self._camera, "_theta_float"):
|
||||
self._camera._theta_float = state.get("_theta_float", 0.0)
|
||||
if hasattr(self._camera, "_radial_input"):
|
||||
self._camera._radial_input = state.get("_radial_input", 0.0)
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
return "camera"
|
||||
@@ -93,18 +184,26 @@ class CameraStage(Stage):
|
||||
if data is None:
|
||||
return data
|
||||
|
||||
current_time = time.perf_counter()
|
||||
dt = 0.0
|
||||
if self._last_frame_time is not None:
|
||||
dt = current_time - self._last_frame_time
|
||||
self._camera.update(dt)
|
||||
self._last_frame_time = current_time
|
||||
|
||||
ctx.set_state("camera_y", self._camera.y)
|
||||
ctx.set_state("camera_x", self._camera.x)
|
||||
# Camera state is updated by CameraClockStage
|
||||
# We only apply the viewport transformation here
|
||||
|
||||
if hasattr(self._camera, "apply"):
|
||||
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
||||
return self._camera.apply(data, viewport_width, viewport_height)
|
||||
|
||||
# Use filtered camera position if available (from ViewportFilterStage)
|
||||
# This handles the case where the buffer has been filtered and starts at row 0
|
||||
filtered_camera_y = ctx.get("camera_y", self._camera.y)
|
||||
|
||||
# Temporarily adjust camera position for filtering
|
||||
original_y = self._camera.y
|
||||
self._camera.y = filtered_camera_y
|
||||
|
||||
try:
|
||||
result = self._camera.apply(data, viewport_width, viewport_height)
|
||||
finally:
|
||||
# Restore original camera position
|
||||
self._camera.y = original_y
|
||||
|
||||
return result
|
||||
return data
|
||||
|
||||
@@ -6,13 +6,22 @@ from engine.pipeline.core import PipelineContext, Stage
|
||||
|
||||
|
||||
class EffectPluginStage(Stage):
|
||||
"""Adapter wrapping EffectPlugin as a Stage."""
|
||||
"""Adapter wrapping EffectPlugin as a Stage.
|
||||
|
||||
def __init__(self, effect_plugin, name: str = "effect"):
|
||||
Supports capability-based dependencies through the dependencies parameter.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
effect_plugin,
|
||||
name: str = "effect",
|
||||
dependencies: set[str] | None = None,
|
||||
):
|
||||
self._effect = effect_plugin
|
||||
self.name = name
|
||||
self.category = "effect"
|
||||
self.optional = False
|
||||
self._dependencies = dependencies or set()
|
||||
|
||||
@property
|
||||
def stage_type(self) -> str:
|
||||
@@ -49,7 +58,7 @@ class EffectPluginStage(Stage):
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return set()
|
||||
return self._dependencies
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
|
||||
@@ -49,7 +49,9 @@ class ViewportFilterStage(Stage):
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source"}
|
||||
# Always requires camera.state for viewport filtering
|
||||
# CameraUpdateStage provides this (auto-injected if missing)
|
||||
return {"source", "camera.state"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
@@ -95,9 +97,13 @@ class ViewportFilterStage(Stage):
|
||||
|
||||
# Find start index (first item that intersects with visible range)
|
||||
start_idx = 0
|
||||
start_item_y = 0 # Y position where the first visible item starts
|
||||
for i, total_h in enumerate(cumulative_heights):
|
||||
if total_h > start_y:
|
||||
start_idx = i
|
||||
# Calculate the Y position of the start of this item
|
||||
if i > 0:
|
||||
start_item_y = cumulative_heights[i - 1]
|
||||
break
|
||||
|
||||
# Find end index (first item that extends beyond visible range)
|
||||
@@ -107,6 +113,16 @@ class ViewportFilterStage(Stage):
|
||||
end_idx = i + 1
|
||||
break
|
||||
|
||||
# Adjust camera_y for the filtered buffer
|
||||
# The filtered buffer starts at row 0, but the camera position
|
||||
# needs to be relative to where the first visible item starts
|
||||
filtered_camera_y = camera_y - start_item_y
|
||||
|
||||
# Update context with the filtered camera position
|
||||
# This ensures CameraStage can correctly slice the filtered buffer
|
||||
ctx.set_state("camera_y", filtered_camera_y)
|
||||
ctx.set_state("camera_x", ctx.get("camera_x", 0)) # Keep camera_x unchanged
|
||||
|
||||
# Return visible items
|
||||
return data[start_idx:end_idx]
|
||||
|
||||
@@ -127,9 +143,16 @@ class FontStage(Stage):
|
||||
def capabilities(self) -> set[str]:
|
||||
return {"render.output"}
|
||||
|
||||
@property
|
||||
def stage_dependencies(self) -> set[str]:
|
||||
# Must connect to viewport_filter stage to get filtered source
|
||||
return {"viewport_filter"}
|
||||
|
||||
@property
|
||||
def dependencies(self) -> set[str]:
|
||||
return {"source"}
|
||||
# Depend on source.filtered (provided by viewport_filter)
|
||||
# This ensures we get the filtered/processed source, not raw source
|
||||
return {"source.filtered"}
|
||||
|
||||
@property
|
||||
def inlet_types(self) -> set:
|
||||
@@ -147,6 +170,11 @@ class FontStage(Stage):
|
||||
if not isinstance(data, list):
|
||||
return [str(data)]
|
||||
|
||||
import os
|
||||
|
||||
if os.environ.get("DEBUG_CAMERA"):
|
||||
print(f"FontStage: input items={len(data)}")
|
||||
|
||||
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
||||
|
||||
result = []
|
||||
|
||||
Reference in New Issue
Block a user