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