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:
2026-03-19 03:34:06 -07:00
parent 0eb5f1d5ff
commit 238bac1bb2
30 changed files with 3438 additions and 378 deletions

View File

@@ -1,12 +1,12 @@
"""
Frame buffer stage - stores previous frames for temporal effects.
Provides:
- frame_history: list of previous buffers (most recent first)
- intensity_history: list of corresponding intensity maps
- current_intensity: intensity map for current frame
Provides (per-instance, using instance name):
- framebuffer.{name}.history: list of previous buffers (most recent first)
- framebuffer.{name}.intensity_history: list of corresponding intensity maps
- framebuffer.{name}.current_intensity: intensity map for current frame
Capability: "framebuffer.history"
Capability: "framebuffer.history.{name}"
"""
import threading
@@ -22,21 +22,32 @@ class FrameBufferConfig:
"""Configuration for FrameBufferStage."""
history_depth: int = 2 # Number of previous frames to keep
name: str = "default" # Unique instance name for capability and context keys
class FrameBufferStage(Stage):
"""Stores frame history and computes intensity maps."""
"""Stores frame history and computes intensity maps.
Supports multiple instances with unique capabilities and context keys.
"""
name = "framebuffer"
category = "effect" # It's an effect that enriches context with frame history
def __init__(self, config: FrameBufferConfig | None = None, history_depth: int = 2):
self.config = config or FrameBufferConfig(history_depth=history_depth)
def __init__(
self,
config: FrameBufferConfig | None = None,
history_depth: int = 2,
name: str = "default",
):
self.config = config or FrameBufferConfig(
history_depth=history_depth, name=name
)
self._lock = threading.Lock()
@property
def capabilities(self) -> set[str]:
return {"framebuffer.history"}
return {f"framebuffer.history.{self.config.name}"}
@property
def dependencies(self) -> set[str]:
@@ -53,8 +64,9 @@ class FrameBufferStage(Stage):
def init(self, ctx: PipelineContext) -> bool:
"""Initialize framebuffer state in context."""
ctx.set("frame_history", [])
ctx.set("intensity_history", [])
prefix = f"framebuffer.{self.config.name}"
ctx.set(f"{prefix}.history", [])
ctx.set(f"{prefix}.intensity_history", [])
return True
def process(self, data: Any, ctx: PipelineContext) -> Any:
@@ -70,16 +82,18 @@ class FrameBufferStage(Stage):
if not isinstance(data, list):
return data
prefix = f"framebuffer.{self.config.name}"
# Compute intensity map for current buffer (per-row, length = buffer rows)
intensity_map = self._compute_buffer_intensity(data, len(data))
# Store in context
ctx.set("current_intensity", intensity_map)
ctx.set(f"{prefix}.current_intensity", intensity_map)
with self._lock:
# Get existing histories
history = ctx.get("frame_history", [])
intensity_hist = ctx.get("intensity_history", [])
history = ctx.get(f"{prefix}.history", [])
intensity_hist = ctx.get(f"{prefix}.intensity_history", [])
# Prepend current frame to history
history.insert(0, data.copy())
@@ -87,8 +101,8 @@ class FrameBufferStage(Stage):
# Trim to configured depth
max_depth = self.config.history_depth
ctx.set("frame_history", history[:max_depth])
ctx.set("intensity_history", intensity_hist[:max_depth])
ctx.set(f"{prefix}.history", history[:max_depth])
ctx.set(f"{prefix}.intensity_history", intensity_hist[:max_depth])
return data
@@ -137,7 +151,8 @@ class FrameBufferStage(Stage):
"""Get frame from history by index (0 = current, 1 = previous, etc)."""
if ctx is None:
return None
history = ctx.get("frame_history", [])
prefix = f"framebuffer.{self.config.name}"
history = ctx.get(f"{prefix}.history", [])
if 0 <= index < len(history):
return history[index]
return None
@@ -148,7 +163,8 @@ class FrameBufferStage(Stage):
"""Get intensity map from history by index."""
if ctx is None:
return None
intensity_hist = ctx.get("intensity_history", [])
prefix = f"framebuffer.{self.config.name}"
intensity_hist = ctx.get(f"{prefix}.intensity_history", [])
if 0 <= index < len(intensity_hist):
return intensity_hist[index]
return None