forked from genewildish/Mainline
This commit implements the Sideline/Mainline split with a clean plugin architecture: ## Core Changes ### Sideline Framework (New Directory) - Created directory containing the reusable pipeline framework - Moved pipeline core, controllers, adapters, and registry to - Moved display system to - Moved effects system to - Created plugin system with security and compatibility management in - Created preset pack system with ASCII art encoding in - Added default font (Corptic) to - Added terminal ANSI constants to ### Mainline Application (Updated) - Created for Mainline stage component registration - Updated to register Mainline stages at startup - Updated as a compatibility shim re-exporting from sideline ### Terminology Consistency - : Base class for all pipeline components (sources, effects, displays, cameras) - : Base class for distributable plugin packages (was ) - : Base class for visual effects (was ) - Backward compatibility aliases maintained for existing code ## Key Features - Plugin discovery via entry points and explicit registration - Security permissions system for plugins - Compatibility management with semantic version constraints - Preset pack system for distributable configurations - Default font bundled with Sideline (Corptic.otf) ## Testing - Updated tests to register Mainline stages before discovery - All StageRegistry tests passing Note: This is a major refactoring that separates the framework (Sideline) from the application (Mainline), enabling Sideline to be used by other applications.
220 lines
7.5 KiB
Python
220 lines
7.5 KiB
Python
"""Adapter for camera stage."""
|
|
|
|
import time
|
|
from typing import Any
|
|
|
|
from sideline.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 sideline.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", "camera.state"}
|
|
|
|
@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
|