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

222
scripts/demo_hot_rebuild.py Normal file
View File

@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""
Demo script for testing pipeline hot-rebuild and state preservation.
Usage:
python scripts/demo_hot_rebuild.py
python scripts/demo_hot_rebuild.py --viewport 40x15
This script:
1. Creates a small viewport (40x15) for easier capture
2. Uses NullDisplay with recording enabled
3. Runs the pipeline for N frames (capturing initial state)
4. Triggers a "hot-rebuild" (e.g., toggling an effect stage)
5. Runs the pipeline for M more frames
6. Verifies state preservation by comparing frames before/after rebuild
7. Prints visual comparison to stdout
"""
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from engine.display import DisplayRegistry
from engine.effects import get_registry
from engine.fetch import load_cache
from engine.pipeline import Pipeline, PipelineConfig, PipelineContext
from engine.pipeline.adapters import (
EffectPluginStage,
FontStage,
SourceItemsToBufferStage,
ViewportFilterStage,
create_stage_from_display,
create_stage_from_effect,
)
from engine.pipeline.params import PipelineParams
def run_demo(viewport_width: int = 40, viewport_height: int = 15):
"""Run the hot-rebuild demo."""
print(f"\n{'=' * 60}")
print(f"Pipeline Hot-Rebuild Demo")
print(f"Viewport: {viewport_width}x{viewport_height}")
print(f"{'=' * 60}\n")
import engine.effects.plugins as effects_plugins
effects_plugins.discover_plugins()
print("[1/6] Loading source items...")
items = load_cache()
if not items:
print(" ERROR: No fixture cache available")
sys.exit(1)
print(f" Loaded {len(items)} items")
print("[2/6] Creating NullDisplay with recording...")
display = DisplayRegistry.create("null")
display.init(viewport_width, viewport_height)
display.start_recording()
print(" Recording started")
print("[3/6] Building pipeline...")
params = PipelineParams()
params.viewport_width = viewport_width
params.viewport_height = viewport_height
config = PipelineConfig(
source="fixture",
display="null",
camera="scroll",
effects=["noise", "fade"],
)
pipeline = Pipeline(config=config, context=PipelineContext())
from engine.data_sources.sources import ListDataSource
from engine.pipeline.adapters import DataSourceStage
list_source = ListDataSource(items, name="fixture")
pipeline.add_stage("source", DataSourceStage(list_source, name="fixture"))
pipeline.add_stage("viewport_filter", ViewportFilterStage(name="viewport-filter"))
pipeline.add_stage("font", FontStage(name="font"))
effect_registry = get_registry()
for effect_name in config.effects:
effect = effect_registry.get(effect_name)
if effect:
pipeline.add_stage(
f"effect_{effect_name}",
create_stage_from_effect(effect, effect_name),
)
pipeline.add_stage("display", create_stage_from_display(display, "null"))
pipeline.build()
if not pipeline.initialize():
print(" ERROR: Failed to initialize pipeline")
sys.exit(1)
print(" Pipeline built and initialized")
ctx = pipeline.context
ctx.params = params
ctx.set("display", display)
ctx.set("items", items)
ctx.set("pipeline", pipeline)
ctx.set("pipeline_order", pipeline.execution_order)
ctx.set("camera_y", 0)
print("[4/6] Running pipeline for 10 frames (before rebuild)...")
frames_before = []
for frame in range(10):
params.frame_number = frame
ctx.params = params
result = pipeline.execute(items)
if result.success:
frames_before.append(display._last_buffer)
print(f" Captured {len(frames_before)} frames")
print("[5/6] Triggering hot-rebuild (toggling 'fade' effect)...")
fade_stage = pipeline.get_stage("effect_fade")
if fade_stage and isinstance(fade_stage, EffectPluginStage):
new_enabled = not fade_stage.is_enabled()
fade_stage.set_enabled(new_enabled)
fade_stage._effect.config.enabled = new_enabled
print(f" Fade effect enabled: {new_enabled}")
else:
print(" WARNING: Could not find fade effect stage")
print("[6/6] Running pipeline for 10 more frames (after rebuild)...")
frames_after = []
for frame in range(10, 20):
params.frame_number = frame
ctx.params = params
result = pipeline.execute(items)
if result.success:
frames_after.append(display._last_buffer)
print(f" Captured {len(frames_after)} frames")
display.stop_recording()
print("\n" + "=" * 60)
print("RESULTS")
print("=" * 60)
print("\n[State Preservation Check]")
if frames_before and frames_after:
last_before = frames_before[-1]
first_after = frames_after[0]
if last_before == first_after:
print(" PASS: Buffer state preserved across rebuild")
else:
print(" INFO: Buffer changed after rebuild (expected - effect toggled)")
print("\n[Frame Continuity Check]")
recorded_frames = display.get_frames()
print(f" Total recorded frames: {len(recorded_frames)}")
print(f" Frames before rebuild: {len(frames_before)}")
print(f" Frames after rebuild: {len(frames_after)}")
if len(recorded_frames) == 20:
print(" PASS: All frames recorded")
else:
print(" WARNING: Frame count mismatch")
print("\n[Visual Comparison - First frame before vs after rebuild]")
print("\n--- Before rebuild (frame 9) ---")
for i, line in enumerate(frames_before[0][:viewport_height]):
print(f"{i:2}: {line}")
print("\n--- After rebuild (frame 10) ---")
for i, line in enumerate(frames_after[0][:viewport_height]):
print(f"{i:2}: {line}")
print("\n[Recording Save/Load Test]")
test_file = Path("/tmp/test_recording.json")
display.save_recording(test_file)
print(f" Saved recording to: {test_file}")
display2 = DisplayRegistry.create("null")
display2.init(viewport_width, viewport_height)
display2.load_recording(test_file)
loaded_frames = display2.get_frames()
print(f" Loaded {len(loaded_frames)} frames from file")
if len(loaded_frames) == len(recorded_frames):
print(" PASS: Recording save/load works correctly")
else:
print(" WARNING: Frame count mismatch after load")
test_file.unlink(missing_ok=True)
pipeline.cleanup()
display.cleanup()
print("\n" + "=" * 60)
print("Demo complete!")
print("=" * 60 + "\n")
def main():
viewport_width = 40
viewport_height = 15
if "--viewport" in sys.argv:
idx = sys.argv.index("--viewport")
if idx + 1 < len(sys.argv):
vp = sys.argv[idx + 1]
try:
viewport_width, viewport_height = map(int, vp.split("x"))
except ValueError:
print("Error: Invalid viewport format. Use WxH (e.g., 40x15)")
sys.exit(1)
run_demo(viewport_width, viewport_height)
if __name__ == "__main__":
main()