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:
222
scripts/demo_hot_rebuild.py
Normal file
222
scripts/demo_hot_rebuild.py
Normal 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()
|
||||
Reference in New Issue
Block a user