forked from genewildish/Mainline
## Summary Fixed critical performance issue where demo/poetry presets would hang for 10+ seconds due to FontStage rendering all 1438+ headline items instead of just the visible ~5 items. ## Changes ### Core Fix: ViewportFilterStage - New pipeline stage that filters items to only those fitting in the viewport - Reduces 1438 items → ~5 items (288x reduction) before FontStage - Prevents expensive PIL font rendering operations on items that won't be displayed - Located: engine/pipeline/adapters.py:348-403 ### Pipeline Integration - Updated app.py to add ViewportFilterStage before FontStage for headlines/poetry sources - Ensures correct data flow: source → viewport_filter → font → camera → effects → display - ViewportFilterStage depends on 'source' capability, providing pass-through filtering ### Display Protocol Enhancement - Added is_quit_requested() and clear_quit_request() method signatures to Display protocol - Documented as optional methods for backends supporting keyboard input - Already implemented by pygame backend, now formally part of protocol ### Debug Infrastructure - Added MAINLINE_DEBUG_DATAFLOW environment variable logging throughout pipeline - Logs stage input/output types and data sizes to stderr (when flag enabled) - Verified working: 1438 → 5 item reduction shown in debug output ### Performance Testing - Added pytest-benchmark (v5.2.3) as dev dependency for statistical benchmarking - Created comprehensive performance regression tests (tests/test_performance_regression.py) - Tests verify: - ViewportFilterStage filters 2000 items efficiently (<1ms) - FontStage processes filtered items quickly (<50ms) - 288x performance improvement ratio maintained - Pipeline doesn't hang with large datasets - All 523 tests passing, including 7 new performance tests ## Performance Impact **Before:** FontStage renders all 1438 items per frame → 10+ second hang **After:** FontStage renders ~5 items per frame → sub-second execution Real-world impact: Demo preset now responsive and usable with news sources. ## Testing - Unit tests: 523 passed, 16 skipped - Regression tests: Catch performance degradation with large datasets - E2E verification: Debug logging confirms correct pipeline flow - Benchmark suite: Statistical performance tracking enabled
263 lines
8.2 KiB
Python
263 lines
8.2 KiB
Python
"""
|
|
Application orchestrator — pipeline mode entry point.
|
|
"""
|
|
|
|
import sys
|
|
import time
|
|
|
|
import effects_plugins
|
|
from engine import config
|
|
from engine.display import DisplayRegistry
|
|
from engine.effects import PerformanceMonitor, get_registry, set_monitor
|
|
from engine.fetch import fetch_all, fetch_poetry, load_cache
|
|
from engine.pipeline import (
|
|
Pipeline,
|
|
PipelineConfig,
|
|
get_preset,
|
|
list_presets,
|
|
)
|
|
from engine.pipeline.adapters import (
|
|
SourceItemsToBufferStage,
|
|
create_stage_from_display,
|
|
create_stage_from_effect,
|
|
)
|
|
|
|
|
|
def main():
|
|
"""Main entry point - all modes now use presets."""
|
|
if config.PIPELINE_DIAGRAM:
|
|
try:
|
|
from engine.pipeline import generate_pipeline_diagram
|
|
except ImportError:
|
|
print("Error: pipeline diagram not available")
|
|
return
|
|
print(generate_pipeline_diagram())
|
|
return
|
|
|
|
preset_name = None
|
|
|
|
if config.PRESET:
|
|
preset_name = config.PRESET
|
|
elif config.PIPELINE_MODE:
|
|
preset_name = config.PIPELINE_PRESET
|
|
else:
|
|
preset_name = "demo"
|
|
|
|
available = list_presets()
|
|
if preset_name not in available:
|
|
print(f"Error: Unknown preset '{preset_name}'")
|
|
print(f"Available presets: {', '.join(available)}")
|
|
sys.exit(1)
|
|
|
|
run_pipeline_mode(preset_name)
|
|
|
|
|
|
def run_pipeline_mode(preset_name: str = "demo"):
|
|
"""Run using the new unified pipeline architecture."""
|
|
print(" \033[1;38;5;46mPIPELINE MODE\033[0m")
|
|
print(" \033[38;5;245mUsing unified pipeline architecture\033[0m")
|
|
|
|
effects_plugins.discover_plugins()
|
|
|
|
monitor = PerformanceMonitor()
|
|
set_monitor(monitor)
|
|
|
|
preset = get_preset(preset_name)
|
|
if not preset:
|
|
print(f" \033[38;5;196mUnknown preset: {preset_name}\033[0m")
|
|
sys.exit(1)
|
|
|
|
print(f" \033[38;5;245mPreset: {preset.name} - {preset.description}\033[0m")
|
|
|
|
params = preset.to_params()
|
|
params.viewport_width = 80
|
|
params.viewport_height = 24
|
|
|
|
pipeline = Pipeline(
|
|
config=PipelineConfig(
|
|
source=preset.source,
|
|
display=preset.display,
|
|
camera=preset.camera,
|
|
effects=preset.effects,
|
|
)
|
|
)
|
|
|
|
print(" \033[38;5;245mFetching content...\033[0m")
|
|
|
|
# Handle special sources that don't need traditional fetching
|
|
introspection_source = None
|
|
if preset.source == "pipeline-inspect":
|
|
items = []
|
|
print(" \033[38;5;245mUsing pipeline introspection source\033[0m")
|
|
elif preset.source == "empty":
|
|
items = []
|
|
print(" \033[38;5;245mUsing empty source (no content)\033[0m")
|
|
else:
|
|
cached = load_cache()
|
|
if cached:
|
|
items = cached
|
|
elif preset.source == "poetry":
|
|
items, _, _ = fetch_poetry()
|
|
else:
|
|
items, _, _ = fetch_all()
|
|
|
|
if not items:
|
|
print(" \033[38;5;196mNo content available\033[0m")
|
|
sys.exit(1)
|
|
|
|
print(f" \033[38;5;82mLoaded {len(items)} items\033[0m")
|
|
|
|
# CLI --display flag takes priority over preset
|
|
# Check if --display was explicitly provided
|
|
display_name = preset.display
|
|
if "--display" in sys.argv:
|
|
idx = sys.argv.index("--display")
|
|
if idx + 1 < len(sys.argv):
|
|
display_name = sys.argv[idx + 1]
|
|
|
|
display = DisplayRegistry.create(display_name)
|
|
if not display:
|
|
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
|
|
sys.exit(1)
|
|
|
|
display.init(80, 24)
|
|
|
|
effect_registry = get_registry()
|
|
|
|
# Create source stage based on preset source type
|
|
if preset.source == "pipeline-inspect":
|
|
from engine.data_sources.pipeline_introspection import (
|
|
PipelineIntrospectionSource,
|
|
)
|
|
from engine.pipeline.adapters import DataSourceStage
|
|
|
|
introspection_source = PipelineIntrospectionSource(
|
|
pipeline=None, # Will be set after pipeline.build()
|
|
viewport_width=80,
|
|
viewport_height=24,
|
|
)
|
|
pipeline.add_stage(
|
|
"source", DataSourceStage(introspection_source, name="pipeline-inspect")
|
|
)
|
|
elif preset.source == "empty":
|
|
from engine.data_sources.sources import EmptyDataSource
|
|
from engine.pipeline.adapters import DataSourceStage
|
|
|
|
empty_source = EmptyDataSource(width=80, height=24)
|
|
pipeline.add_stage("source", DataSourceStage(empty_source, name="empty"))
|
|
else:
|
|
from engine.data_sources.sources import ListDataSource
|
|
from engine.pipeline.adapters import DataSourceStage
|
|
|
|
list_source = ListDataSource(items, name=preset.source)
|
|
pipeline.add_stage("source", DataSourceStage(list_source, name=preset.source))
|
|
|
|
# Add FontStage for headlines/poetry (default for demo)
|
|
if preset.source in ["headlines", "poetry"]:
|
|
from engine.pipeline.adapters import FontStage, ViewportFilterStage
|
|
|
|
# Add viewport filter to prevent rendering all items
|
|
pipeline.add_stage(
|
|
"viewport_filter", ViewportFilterStage(name="viewport-filter")
|
|
)
|
|
pipeline.add_stage("font", FontStage(name="font"))
|
|
else:
|
|
# Fallback to simple conversion for other sources
|
|
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
|
|
|
|
# Add camera stage if specified in preset
|
|
if preset.camera:
|
|
from engine.camera import Camera
|
|
from engine.pipeline.adapters import CameraStage
|
|
|
|
camera = None
|
|
if preset.camera == "vertical":
|
|
camera = Camera.vertical()
|
|
elif preset.camera == "horizontal":
|
|
camera = Camera.horizontal()
|
|
elif preset.camera == "omni":
|
|
camera = Camera.omni()
|
|
elif preset.camera == "floating":
|
|
camera = Camera.floating()
|
|
elif preset.camera == "bounce":
|
|
camera = Camera.bounce()
|
|
|
|
if camera:
|
|
pipeline.add_stage("camera", CameraStage(camera, name=preset.camera))
|
|
|
|
for effect_name in preset.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, display_name))
|
|
|
|
pipeline.build()
|
|
|
|
# For pipeline-inspect, set the pipeline after build to avoid circular dependency
|
|
if introspection_source is not None:
|
|
introspection_source.set_pipeline(pipeline)
|
|
|
|
if not pipeline.initialize():
|
|
print(" \033[38;5;196mFailed to initialize pipeline\033[0m")
|
|
sys.exit(1)
|
|
|
|
print(" \033[38;5;82mStarting pipeline...\033[0m")
|
|
print(" \033[38;5;245mPress Ctrl+C to exit\033[0m\n")
|
|
|
|
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)
|
|
|
|
current_width = 80
|
|
current_height = 24
|
|
|
|
if hasattr(display, "get_dimensions"):
|
|
current_width, current_height = display.get_dimensions()
|
|
params.viewport_width = current_width
|
|
params.viewport_height = current_height
|
|
|
|
try:
|
|
frame = 0
|
|
while True:
|
|
params.frame_number = frame
|
|
ctx.params = params
|
|
|
|
result = pipeline.execute(items)
|
|
if result.success:
|
|
display.show(result.data, border=params.border)
|
|
|
|
if hasattr(display, "is_quit_requested") and display.is_quit_requested():
|
|
if hasattr(display, "clear_quit_request"):
|
|
display.clear_quit_request()
|
|
raise KeyboardInterrupt()
|
|
|
|
if hasattr(display, "get_dimensions"):
|
|
new_w, new_h = display.get_dimensions()
|
|
if new_w != current_width or new_h != current_height:
|
|
current_width, current_height = new_w, new_h
|
|
params.viewport_width = current_width
|
|
params.viewport_height = current_height
|
|
|
|
time.sleep(1 / 60)
|
|
frame += 1
|
|
|
|
except KeyboardInterrupt:
|
|
pipeline.cleanup()
|
|
display.cleanup()
|
|
print("\n \033[38;5;245mPipeline stopped\033[0m")
|
|
return
|
|
|
|
pipeline.cleanup()
|
|
display.cleanup()
|
|
print("\n \033[38;5;245mPipeline stopped\033[0m")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|