- 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
223 lines
7.0 KiB
Python
223 lines
7.0 KiB
Python
#!/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()
|