forked from genewildish/Mainline
- 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
210 lines
7.1 KiB
Python
210 lines
7.1 KiB
Python
"""Adapter for camera stage."""
|
|
|
|
import time
|
|
from typing import Any
|
|
|
|
from engine.pipeline.core import DataType, PipelineContext, Stage
|
|
|
|
|
|
class CameraClockStage(Stage):
|
|
"""Per-frame clock stage that updates camera state.
|
|
|
|
This stage runs once per frame and updates the camera's internal state
|
|
(position, time). It makes camera_y/camera_x available to subsequent
|
|
stages via the pipeline context.
|
|
|
|
Unlike other stages, this is a pure clock stage and doesn't process
|
|
data - it just updates camera state and passes data through unchanged.
|
|
"""
|
|
|
|
def __init__(self, camera, name: str = "camera-clock"):
|
|
self._camera = camera
|
|
self.name = name
|
|
self.category = "camera"
|
|
self.optional = False
|
|
self._last_frame_time: float | None = None
|
|
|
|
@property
|
|
def stage_type(self) -> str:
|
|
return "camera"
|
|
|
|
@property
|
|
def capabilities(self) -> set[str]:
|
|
# Provides camera state info only
|
|
# NOTE: Do NOT provide "source" as it conflicts with viewport_filter's "source.filtered"
|
|
return {"camera.state"}
|
|
|
|
@property
|
|
def dependencies(self) -> set[str]:
|
|
# Clock stage - no dependencies (updates every frame regardless of data flow)
|
|
return set()
|
|
|
|
@property
|
|
def inlet_types(self) -> set:
|
|
# Accept any data type - this is a pass-through stage
|
|
return {DataType.ANY}
|
|
|
|
@property
|
|
def outlet_types(self) -> set:
|
|
# Pass through whatever was received
|
|
return {DataType.ANY}
|
|
|
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
"""Update camera state and pass data through.
|
|
|
|
This stage updates the camera's internal state (position, time) and
|
|
makes the updated camera_y/camera_x available to subsequent stages
|
|
via the pipeline context.
|
|
|
|
The data is passed through unchanged - this stage only updates
|
|
camera state, it doesn't transform the data.
|
|
"""
|
|
if data is None:
|
|
return data
|
|
|
|
current_time = time.perf_counter()
|
|
dt = 0.0
|
|
if self._last_frame_time is not None:
|
|
dt = current_time - self._last_frame_time
|
|
self._camera.update(dt)
|
|
self._last_frame_time = current_time
|
|
|
|
# Update context with current camera position
|
|
ctx.set_state("camera_y", self._camera.y)
|
|
ctx.set_state("camera_x", self._camera.x)
|
|
|
|
# Pass data through unchanged
|
|
return data
|
|
|
|
|
|
class CameraStage(Stage):
|
|
"""Adapter wrapping Camera as a Stage.
|
|
|
|
This stage applies camera viewport transformation to the rendered buffer.
|
|
Camera state updates are handled by CameraClockStage.
|
|
"""
|
|
|
|
def __init__(self, camera, name: str = "vertical"):
|
|
self._camera = camera
|
|
self.name = name
|
|
self.category = "camera"
|
|
self.optional = True
|
|
self._last_frame_time: float | None = None
|
|
|
|
def save_state(self) -> dict[str, Any]:
|
|
"""Save camera state for restoration after pipeline rebuild.
|
|
|
|
Returns:
|
|
Dictionary containing camera state that can be restored
|
|
"""
|
|
state = {
|
|
"x": self._camera.x,
|
|
"y": self._camera.y,
|
|
"mode": self._camera.mode.value
|
|
if hasattr(self._camera.mode, "value")
|
|
else self._camera.mode,
|
|
"speed": self._camera.speed,
|
|
"zoom": self._camera.zoom,
|
|
"canvas_width": self._camera.canvas_width,
|
|
"canvas_height": self._camera.canvas_height,
|
|
"_x_float": getattr(self._camera, "_x_float", 0.0),
|
|
"_y_float": getattr(self._camera, "_y_float", 0.0),
|
|
"_time": getattr(self._camera, "_time", 0.0),
|
|
}
|
|
# Save radial camera state if present
|
|
if hasattr(self._camera, "_r_float"):
|
|
state["_r_float"] = self._camera._r_float
|
|
if hasattr(self._camera, "_theta_float"):
|
|
state["_theta_float"] = self._camera._theta_float
|
|
if hasattr(self._camera, "_radial_input"):
|
|
state["_radial_input"] = self._camera._radial_input
|
|
return state
|
|
|
|
def restore_state(self, state: dict[str, Any]) -> None:
|
|
"""Restore camera state from saved state.
|
|
|
|
Args:
|
|
state: Dictionary containing camera state from save_state()
|
|
"""
|
|
from engine.camera import CameraMode
|
|
|
|
self._camera.x = state.get("x", 0)
|
|
self._camera.y = state.get("y", 0)
|
|
|
|
# Restore mode - handle both enum value and direct enum
|
|
mode_value = state.get("mode", 0)
|
|
if isinstance(mode_value, int):
|
|
self._camera.mode = CameraMode(mode_value)
|
|
else:
|
|
self._camera.mode = mode_value
|
|
|
|
self._camera.speed = state.get("speed", 1.0)
|
|
self._camera.zoom = state.get("zoom", 1.0)
|
|
self._camera.canvas_width = state.get("canvas_width", 200)
|
|
self._camera.canvas_height = state.get("canvas_height", 200)
|
|
|
|
# Restore internal state
|
|
if hasattr(self._camera, "_x_float"):
|
|
self._camera._x_float = state.get("_x_float", 0.0)
|
|
if hasattr(self._camera, "_y_float"):
|
|
self._camera._y_float = state.get("_y_float", 0.0)
|
|
if hasattr(self._camera, "_time"):
|
|
self._camera._time = state.get("_time", 0.0)
|
|
|
|
# Restore radial camera state if present
|
|
if hasattr(self._camera, "_r_float"):
|
|
self._camera._r_float = state.get("_r_float", 0.0)
|
|
if hasattr(self._camera, "_theta_float"):
|
|
self._camera._theta_float = state.get("_theta_float", 0.0)
|
|
if hasattr(self._camera, "_radial_input"):
|
|
self._camera._radial_input = state.get("_radial_input", 0.0)
|
|
|
|
@property
|
|
def stage_type(self) -> str:
|
|
return "camera"
|
|
|
|
@property
|
|
def capabilities(self) -> set[str]:
|
|
return {"camera"}
|
|
|
|
@property
|
|
def dependencies(self) -> set[str]:
|
|
return {"render.output"}
|
|
|
|
@property
|
|
def inlet_types(self) -> set:
|
|
return {DataType.TEXT_BUFFER}
|
|
|
|
@property
|
|
def outlet_types(self) -> set:
|
|
return {DataType.TEXT_BUFFER}
|
|
|
|
def process(self, data: Any, ctx: PipelineContext) -> Any:
|
|
"""Apply camera transformation to items."""
|
|
if data is None:
|
|
return data
|
|
|
|
# Camera state is updated by CameraClockStage
|
|
# We only apply the viewport transformation here
|
|
|
|
if hasattr(self._camera, "apply"):
|
|
viewport_width = ctx.params.viewport_width if ctx.params else 80
|
|
viewport_height = ctx.params.viewport_height if ctx.params else 24
|
|
|
|
# Use filtered camera position if available (from ViewportFilterStage)
|
|
# This handles the case where the buffer has been filtered and starts at row 0
|
|
filtered_camera_y = ctx.get("camera_y", self._camera.y)
|
|
|
|
# Temporarily adjust camera position for filtering
|
|
original_y = self._camera.y
|
|
self._camera.y = filtered_camera_y
|
|
|
|
try:
|
|
result = self._camera.apply(data, viewport_width, viewport_height)
|
|
finally:
|
|
# Restore original camera position
|
|
self._camera.y = original_y
|
|
|
|
return result
|
|
return data
|