Compare commits

..

45 Commits

Author SHA1 Message Date
ef98add0c5 feat(integration): Complete feature rewrite with pipeline architecture, effects system, and display improvements
Major changes:
- Pipeline architecture with capability-based dependency resolution
- Effects plugin system with performance monitoring
- Display abstraction with multiple backends (terminal, null, websocket)
- Camera system for viewport scrolling
- Sensor framework for real-time input
- Command-and-control system via ntfy
- WebSocket display backend for browser clients
- Comprehensive test suite and documentation

Issue #48: ADR for preset scripting language included

This commit consolidates 110 individual commits into a single
feature integration that can be reviewed and tested before
further refinement.
2026-03-20 04:41:44 -07:00
42aa6f16cc Merge pull request 'feat/figment: periodic SVG glyph overlays with CLI flag' (#34) from feat/figment into main
Reviewed-on: genewildish/Mainline#34
2026-03-19 20:42:10 +00:00
a25b80d4a6 feat: Enable and configure figment mode via new CLI flags, update documentation, and improve Cairo library detection on macOS. 2026-03-19 13:41:40 -07:00
3a1aa975d1 docs: update README for figment mode
- Add figment mode to intro paragraph
- New Figment Mode section: enabling, assets, trigger protocol, deps
- Architecture table updated with engine/figment_render.py,
  figment_trigger.py, and effects_plugins/ directory breakdown
- Dev setup: separate uv sync --extras entries (mic vs figment)
- Testing section: mentions figment test coverage and Cairo skip
- Roadmap: adds figment follow-up items (CLI flag, intensity wiring,
  ntfy trigger)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:39:58 -07:00
d5e5f39404 test: add pytest.importorskip for cairosvg-dependent tests
Gracefully skips figment tests when system Cairo library is unavailable
instead of crashing with opaque OSError during test collection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
2bfd3a01da style: apply ruff lint fixes and formatting to figment modules
Fixes: unused imports, import sorting, unused variable, overly broad
exception type in test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
4cf316c280 feat(figment): integrate figment overlay into scroll loop
Wire render_figment_overlay() into stream() between the effects chain
and the ntfy message overlay, with optional cairosvg import guard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
79d271c42b feat(figment): add render_figment_overlay() to layers.py
Implements phase-aware (REVEAL/HOLD/DISSOLVE) ANSI cursor-positioning overlay
renderer for figment glyphs, with deterministic shuffle seeding and gradient
coloring via _color_codes_to_ansi(). Includes 6 TDD tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
525af4bc46 feat(figment): add FigmentEffect plugin with state machine and timer
Implements the core figment plugin: timer-driven SVG selection, REVEAL →
HOLD → DISSOLVE state machine, trigger API, and get_figment_state() for
overlay rendering. process() is a deliberate no-op; scroll.py will call
get_figment_state() instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
085f150cb0 feat(figment): add SVG to half-block rasterization pipeline
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
0b6e2fae74 feat(figment): add trigger protocol and command types
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
6864ad84c6 feat(figment): add test fixture SVG and FIGMENT_TRIGGER event type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
acb42ea140 build: add cairosvg optional dependency for figment mode
Also adds DYLD_LIBRARY_PATH to mise.toml for macOS Cairo discovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
7014a9d5cd docs: add figment mode TDD implementation plan
8-task plan covering SVG rasterization, overlay rendering,
FigmentEffect plugin, trigger protocol, and scroll loop integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
2cc8dbfc02 docs: address spec review feedback for figment mode
- Rename FigmentPlugin to FigmentEffect (discovery convention)
- Define FigmentState dataclass and FigmentPhase enum
- Clarify chain exclusion (no-op process, not in chain order)
- Add isinstance() downcast for type-safe scroll.py access
- Use FigmentAction enum instead of string literals
- Add Error Handling section (missing deps, empty dir, resize)
- Add Goals, Out of Scope sections
- Split tests per module (test_figment_render, test_figment_trigger)
- Add FIGMENT_TRIGGER to modified files (events.py)
- Document timing formula (progress += FRAME_DT / phase_duration)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
f1d5162488 docs: add figment mode design spec
Hybrid plugin + overlay architecture for periodic SVG glyph display
with theme-aware coloring and extensible input abstraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 13:38:00 -07:00
9f61226779 Merge pull request 'docs/update-readme' (#33) from docs/update-readme into main
Reviewed-on: genewildish/Mainline#33
2026-03-19 06:30:18 +00:00
9415e18679 docs: use david's most recent README from feature/vector_display branch 2026-03-18 23:29:02 -07:00
0819f8d160 Merge pull request 'refactor: Make EffectPlugin an abstract base class, update effects to inherit from it, and improve plugin discovery.' (#32) from chore/plugin-migration into main
Reviewed-on: genewildish/Mainline#32
2026-03-19 06:15:41 +00:00
edd1416407 refactor: Make EffectPlugin an abstract base class, update effects to inherit from it, and improve plugin discovery. 2026-03-18 23:06:04 -07:00
ac9b47f668 Merge pull request 'fix: use theme-aware msg_gradient for ntfy messages' (#31) from feat/color-pick into main
Reviewed-on: genewildish/Mainline#31
2026-03-16 10:29:46 +00:00
b149825bcb Merge branch 'main' into feat/color-pick 2026-03-16 10:29:28 +00:00
1b29e91f9d fix: use theme-aware msg_gradient for ntfy messages
Replace lr_gradient_opposite() with msg_gradient() in render_message_overlay().
Messages now render in complementary colors matching the active theme:
- Green theme → magenta messages
- Orange theme → blue messages
- Purple theme → yellow messages
2026-03-16 03:25:52 -07:00
001158214c Merge pull request 'feat/color-pick' (#30) from feat/color-pick into main
Reviewed-on: genewildish/Mainline#30
2026-03-16 10:13:47 +00:00
31f5d9f171 Merge origin/main into feat/color-pick
Resolved conflicts in tests/test_config.py by keeping TestActiveTheme tests.
Main branch has new architecture components (controller, events, layers, effects).
Color scheme feature preserved and compatible.
2026-03-16 03:13:13 -07:00
bc20a35ea9 refactor: Fix import ordering and code formatting with ruff
Organize imports in render.py and scroll.py to meet ruff style requirements.
Add blank lines for code formatting compliance.
2026-03-16 03:01:22 -07:00
d4d0344a12 feat: add pick_color_theme() UI and integration 2026-03-16 02:58:18 -07:00
84cb16d463 feat: update scroll.py to use theme message gradient
Add msg_gradient() helper function to render.py that applies message
(ntfy) gradient using the active theme's message_gradient property.
This replaces hardcoded magenta gradient in scroll.py with a theme-aware
approach that uses complementary colors from the active theme.

- Add msg_gradient(rows, offset) helper in render.py with fallback to
  default magenta gradient when no theme is active
- Update scroll.py imports to use msg_gradient instead of
  lr_gradient_opposite
- Replace lr_gradient_opposite() call with msg_gradient() in message
  overlay rendering
- Add 6 comprehensive tests for msg_gradient covering theme usage,
  fallback behavior, and edge cases

All tests pass (121 passed), no regressions detected.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-16 02:55:17 -07:00
d67423fe4c feat: update lr_gradient to use config.ACTIVE_THEME
- Remove hardcoded GRAD_COLS and MSG_GRAD_COLS module constants
- Add _default_green_gradient() and _default_magenta_gradient() fallback functions
- Add _color_codes_to_ansi() to convert integer color codes from themes to ANSI escape strings
- Update lr_gradient() signature: cols parameter (was grad_cols)
- lr_gradient() now pulls colors from config.ACTIVE_THEME when available
- Falls back to default green gradient when no theme is active
- Existing calls with explicit cols parameter continue to work
- Add comprehensive tests for new functionality

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-16 02:53:22 -07:00
ebe7b04ba5 feat: add ACTIVE_THEME global and set_active_theme() to config 2026-03-16 02:50:36 -07:00
abc4483859 feat: create Theme class and registry with finalized color gradients 2026-03-16 02:49:15 -07:00
d9422b1fec docs: add color scheme implementation plan
Comprehensive plan with 6 chunks, each containing bite-sized TDD tasks:
- Chunk 1: Theme class and registry
- Chunk 2: Config integration
- Chunk 3: Render pipeline
- Chunk 4: Message gradient integration
- Chunk 5: Color picker UI
- Chunk 6: Integration and validation

Each step includes exact code, test commands, and expected output.
2026-03-16 02:47:25 -07:00
6daea90b0a docs: add color scheme feature documentation to README
- Update opening description to mention selectable color gradients
- Add new 'Color Schemes' section with picker usage instructions
- Document three available themes (Green, Orange, Purple)
- Clarify that boot UI uses hardcoded green, not theme colors
2026-03-16 02:44:59 -07:00
9d9172ef0d docs: add terminal resize handling clarification 2026-03-16 02:42:59 -07:00
667bef2685 docs: revise color scheme design spec to address review feedback
- Clarify boot messages use hardcoded green, not theme gradients
- Finalize all gradient ANSI color codes (no TBD)
- Add initialization guarantee for ACTIVE_THEME
- Resolve circular import risk with data-only themes.py
- Update theme ID naming to match menu labels
- Expand test strategy and mock approach
2026-03-16 02:42:19 -07:00
f085042dee docs: add color scheme switcher design spec 2026-03-16 02:40:32 -07:00
8b696c96ce Merge pull request 'feat/code-scroll' (#29) from feat/code-scroll into main
Reviewed-on: genewildish/Mainline#29
2026-03-16 09:16:53 +00:00
72d21459ca Merge branch 'main' into feat/code-scroll 2026-03-16 09:16:36 +00:00
58dbbbdba7 Merge pull request 'plugin-based effects architecture, daemon mode with command-and-control (C&C), and display abstraction' (#25) from david/Mainline:effects_plugins into main
Reviewed-on: genewildish/Mainline#25
2026-03-16 09:13:22 +00:00
7ff78c66ed Merge pull request 'refactor to improve testability and modularization of the mainline terminal application' (#24) from david/Mainline:testability_modularization into main
Reviewed-on: genewildish/Mainline#24
2026-03-16 09:11:13 +00:00
2229ccdea4 feat: introduce a 'code' mode to display source code lines, add new font assets, and include dedicated tests for code fetching. 2026-03-16 02:09:56 -07:00
f13e89f823 docs: add code-scroll mode design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 01:48:18 -07:00
4228400c43 feat(daemon): add display abstraction and daemon mode with C&C 2026-03-15 19:17:23 -07:00
05cc475858 feat(cmdline): add command-line interface for mainline control 2026-03-15 19:17:10 -07:00
cfd7e8931e feat(effects): add plugin architecture with performance monitoring 2026-03-15 19:17:10 -07:00
58 changed files with 197 additions and 10631 deletions

2
.gitignore vendored
View File

@@ -13,5 +13,3 @@ coverage.xml
*.dot
*.png
test-reports/
.opencode/
tests/comparison_output/

36
TODO.md
View File

@@ -19,42 +19,6 @@
- [x] Enumerate all effect plugin parameters automatically for UI control (intensity, decay, etc.)
- [ ] Implement pipeline hot-rebuild when stage toggles or params change, preserving camera and display state [#43](https://git.notsosm.art/david/Mainline/issues/43)
## Test Suite Cleanup & Feature Implementation
### Phase 1: Test Suite Cleanup (In Progress)
- [x] Port figment feature to modern pipeline architecture
- [x] Create `engine/effects/plugins/figment.py` (full port)
- [x] Add `figment.py` to `engine/effects/plugins/`
- [x] Copy SVG files to `figments/` directory
- [x] Update `pyproject.toml` with figment extra
- [x] Add `test-figment` preset to `presets.toml`
- [x] Update pipeline adapters for overlay effects
- [x] Clean up `test_adapters.py` (removed 18 mock-only tests)
- [x] Verify all tests pass (652 passing, 20 skipped, 58% coverage)
- [ ] Review remaining mock-heavy tests in `test_pipeline.py`
- [ ] Review `test_effects.py` for implementation detail tests
- [ ] Identify additional tests to remove/consolidate
- [ ] Target: ~600 tests total
### Phase 2: Acceptance Test Expansion (Planned)
- [ ] Create `test_message_overlay.py` for message rendering
- [ ] Create `test_firehose.py` for firehose rendering
- [ ] Create `test_pipeline_order.py` for execution order verification
- [ ] Expand `test_figment_effect.py` for animation phases
- [ ] Target: 10-15 new acceptance tests
### Phase 3: Post-Branch Features (Planned)
- [ ] Port message overlay system from `upstream_layers.py`
- [ ] Port firehose rendering from `upstream_layers.py`
- [ ] Create `MessageOverlayStage` for pipeline integration
- [ ] Verify figment renders in correct order (effects → figment → messages → display)
### Phase 4: Visual Quality Improvements (Planned)
- [ ] Compare upstream vs current pipeline output
- [ ] Implement easing functions for figment animations
- [ ] Add animated gradient shifts
- [ ] Improve strobe effect patterns
- [ ] Use introspection to match visual style
## Gitea Issues Tracking
- [#37](https://git.notsosm.art/david/Mainline/issues/37): Refactor app.py and adapter.py for better maintainability
- [#35](https://git.notsosm.art/david/Mainline/issues/35): Epic: Pipeline Mutation API for Stage Hot-Swapping

View File

@@ -1,158 +0,0 @@
# Visual Output Comparison: Upstream/Main vs Sideline
## Summary
A comprehensive comparison of visual output between `upstream/main` and the sideline branch (`feature/capability-based-deps`) reveals fundamental architectural differences in how content is rendered and displayed.
## Captured Outputs
### Sideline (Pipeline Architecture)
- **File**: `output/sideline_demo.json`
- **Format**: Plain text lines without ANSI cursor positioning
- **Content**: Readable headlines with gradient colors applied
### Upstream/Main (Monolithic Architecture)
- **File**: `output/upstream_demo.json`
- **Format**: Lines with explicit ANSI cursor positioning codes
- **Content**: Cursor positioning codes + block characters + ANSI colors
## Key Architectural Differences
### 1. Buffer Content Structure
**Sideline Pipeline:**
```python
# Each line is plain text with ANSI colors
buffer = [
"The Download: OpenAI is building...",
"OpenAI is throwing everything...",
# ... more lines
]
```
**Upstream Monolithic:**
```python
# Each line includes cursor positioning
buffer = [
"\033[10;1H \033[2;38;5;238mユ\033[0m \033[2;38;5;37mモ\033[0m ...",
"\033[11;1H\033[K", # Clear line 11
# ... more lines with positioning
]
```
### 2. Rendering Approach
**Sideline (Pipeline Architecture):**
- Stages produce plain text buffers
- Display backend handles cursor positioning
- `TerminalDisplay.show()` prepends `\033[H\033[J` (home + clear)
- Lines are appended sequentially
**Upstream (Monolithic Architecture):**
- `render_ticker_zone()` produces buffers with explicit positioning
- Each line includes `\033[{row};1H` to position cursor
- Display backend writes buffer directly to stdout
- Lines are positioned explicitly in the buffer
### 3. Content Rendering
**Sideline:**
- Headlines rendered as plain text
- Gradient colors applied via ANSI codes
- Ticker effect via camera/viewport filtering
**Upstream:**
- Headlines rendered as block characters (▀, ▄, █, etc.)
- Japanese katakana glyphs used for glitch effect
- Explicit row positioning for each line
## Visual Output Analysis
### Sideline Frame 0 (First 5 lines):
```
Line 0: 'The Download: OpenAI is building a fully automated researcher...'
Line 1: 'OpenAI is throwing everything into building a fully automated...'
Line 2: 'Mind-altering substances are (still) falling short in clinical...'
Line 3: 'The Download: Quantum computing for health...'
Line 4: 'Can quantum computers now solve health care problems...'
```
### Upstream Frame 0 (First 5 lines):
```
Line 0: ''
Line 1: '\x1b[2;1H\x1b[K'
Line 2: '\x1b[3;1H\x1b[K'
Line 3: '\x1b[4;1H\x1b[2;38;5;238m \x1b[0m \x1b[2;38;5;238mリ\x1b[0m ...'
Line 4: '\x1b[5;1H\x1b[K'
```
## Implications for Visual Comparison
### Challenges with Direct Comparison
1. **Different buffer formats**: Plain text vs. positioned ANSI codes
2. **Different rendering pipelines**: Pipeline stages vs. monolithic functions
3. **Different content generation**: Headlines vs. block characters
### Approaches for Visual Verification
#### Option 1: Render and Compare Terminal Output
- Run both branches with `TerminalDisplay`
- Capture terminal output (not buffer)
- Compare visual rendering
- **Challenge**: Requires actual terminal rendering
#### Option 2: Normalize Buffers for Comparison
- Convert upstream positioned buffers to plain text
- Strip ANSI cursor positioning codes
- Compare normalized content
- **Challenge**: Loses positioning information
#### Option 3: Functional Equivalence Testing
- Verify features work the same way
- Test message overlay rendering
- Test effect application
- **Challenge**: Doesn't verify exact visual match
## Recommendations
### For Exact Visual Match
1. **Update sideline to match upstream architecture**:
- Change `MessageOverlayStage` to return positioned buffers
- Update terminal display to handle positioned buffers
- This requires significant refactoring
2. **Accept architectural differences**:
- The sideline pipeline architecture is fundamentally different
- Visual differences are expected and acceptable
- Focus on functional equivalence
### For Functional Verification
1. **Test message overlay rendering**:
- Verify message appears in correct position
- Verify gradient colors are applied
- Verify metadata bar is displayed
2. **Test effect rendering**:
- Verify glitch effect applies block characters
- Verify firehose effect renders correctly
- Verify figment effect integrates properly
3. **Test pipeline execution**:
- Verify stage execution order
- Verify capability resolution
- Verify dependency injection
## Conclusion
The visual output comparison reveals that `sideline` and `upstream/main` use fundamentally different rendering architectures:
- **Upstream**: Explicit cursor positioning in buffer, monolithic rendering
- **Sideline**: Plain text buffer, display handles positioning, pipeline rendering
These differences are **architectural**, not bugs. The sideline branch has successfully adapted the upstream features to a new pipeline architecture.
### Next Steps
1. ✅ Document architectural differences (this file)
2. ⏳ Create functional tests for visual verification
3. ⏳ Update Gitea issue #50 with findings
4. ⏳ Consider whether to adapt sideline to match upstream rendering style

View File

@@ -1,106 +0,0 @@
# Mainline bash completion script
#
# To install:
# source /path/to/completion/mainline-completion.bash
#
# Or add to ~/.bashrc:
# source /path/to/completion/mainline-completion.bash
_mainline_completion() {
local cur prev words cword
_init_completion || return
# Get current word and previous word
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
# Completion options based on previous word
case "${prev}" in
--display)
# Display backends
COMPREPLY=($(compgen -W "terminal null replay websocket pygame moderngl" -- "${cur}"))
return
;;
--pipeline-source)
# Available sources
COMPREPLY=($(compgen -W "headlines poetry empty fixture pipeline-inspect" -- "${cur}"))
return
;;
--pipeline-effects)
# Available effects (comma-separated)
local effects="afterimage border crop fade firehose glitch hud motionblur noise tint"
COMPREPLY=($(compgen -W "${effects}" -- "${cur}"))
return
;;
--pipeline-camera)
# Camera modes
COMPREPLY=($(compgen -W "feed scroll horizontal omni floating bounce radial" -- "${cur}"))
return
;;
--pipeline-border)
# Border modes
COMPREPLY=($(compgen -W "off simple ui" -- "${cur}"))
return
;;
--pipeline-display)
# Display backends (same as --display)
COMPREPLY=($(compgen -W "terminal null replay websocket pygame moderngl" -- "${cur}"))
return
;;
--theme)
# Theme colors
COMPREPLY=($(compgen -W "green orange purple blue red" -- "${cur}"))
return
;;
--viewport)
# Viewport size suggestions
COMPREPLY=($(compgen -W "80x24 100x30 120x40 60x20" -- "${cur}"))
return
;;
--preset)
# Presets (would need to query available presets)
COMPREPLY=($(compgen -W "demo demo-base demo-pygame demo-camera-showcase poetry headlines empty test-basic test-border test-scroll-camera" -- "${cur}"))
return
;;
--positioning)
# Positioning modes
COMPREPLY=($(compgen -W "absolute relative mixed" -- "${cur}"))
return
;;
esac
# Flag completion (start with --)
if [[ "${cur}" == -* ]]; then
COMPREPLY=($(compgen -W "
--display
--pipeline-source
--pipeline-effects
--pipeline-camera
--pipeline-display
--pipeline-ui
--pipeline-border
--viewport
--preset
--theme
--positioning
--websocket
--websocket-port
--allow-unsafe
--help
" -- "${cur}"))
return
fi
}
complete -F _mainline_completion mainline.py
complete -F _mainline_completion python\ -m\ engine.app
complete -F _mainline_completion python\ -m\ mainline

View File

@@ -1,81 +0,0 @@
# Fish completion script for Mainline
#
# To install:
# source /path/to/completion/mainline-completion.fish
#
# Or copy to ~/.config/fish/completions/mainline.fish
# Define display backends
set -l display_backends terminal null replay websocket pygame moderngl
# Define sources
set -l sources headlines poetry empty fixture pipeline-inspect
# Define effects
set -l effects afterimage border crop fade firehose glitch hud motionblur noise tint
# Define camera modes
set -l cameras feed scroll horizontal omni floating bounce radial
# Define border modes
set -l borders off simple ui
# Define themes
set -l themes green orange purple blue red
# Define presets
set -l presets demo demo-base demo-pygame demo-camera-showcase poetry headlines empty test-basic test-border test-scroll-camera test-figment test-message-overlay
# Main completion function
function __mainline_complete
set -l cmd (commandline -po)
set -l token (commandline -t)
# Complete display backends
complete -c mainline.py -n '__fish_seen_argument --display' -a "$display_backends" -d 'Display backend'
# Complete sources
complete -c mainline.py -n '__fish_seen_argument --pipeline-source' -a "$sources" -d 'Data source'
# Complete effects
complete -c mainline.py -n '__fish_seen_argument --pipeline-effects' -a "$effects" -d 'Effect plugin'
# Complete camera modes
complete -c mainline.py -n '__fish_seen_argument --pipeline-camera' -a "$cameras" -d 'Camera mode'
# Complete display backends (pipeline)
complete -c mainline.py -n '__fish_seen_argument --pipeline-display' -a "$display_backends" -d 'Display backend'
# Complete border modes
complete -c mainline.py -n '__fish_seen_argument --pipeline-border' -a "$borders" -d 'Border mode'
# Complete themes
complete -c mainline.py -n '__fish_seen_argument --theme' -a "$themes" -d 'Color theme'
# Complete presets
complete -c mainline.py -n '__fish_seen_argument --preset' -a "$presets" -d 'Preset name'
# Complete viewport sizes
complete -c mainline.py -n '__fish_seen_argument --viewport' -a '80x24 100x30 120x40 60x20' -d 'Viewport size (WxH)'
# Complete flag options
complete -c mainline.py -n 'not __fish_seen_argument --display' -l display -d 'Display backend' -a "$display_backends"
complete -c mainline.py -n 'not __fish_seen_argument --preset' -l preset -d 'Preset to use' -a "$presets"
complete -c mainline.py -n 'not __fish_seen_argument --viewport' -l viewport -d 'Viewport size (WxH)' -a '80x24 100x30 120x40 60x20'
complete -c mainline.py -n 'not __fish_seen_argument --theme' -l theme -d 'Color theme' -a "$themes"
complete -c mainline.py -l websocket -d 'Enable WebSocket server'
complete -c mainline.py -n 'not __fish_seen_argument --websocket-port' -l websocket-port -d 'WebSocket port' -a '8765'
complete -c mainline.py -l allow-unsafe -d 'Allow unsafe pipeline configuration'
complete -c mainline.py -n 'not __fish_seen_argument --help' -l help -d 'Show help'
# Pipeline-specific flags
complete -c mainline.py -n 'not __fish_seen_argument --pipeline-source' -l pipeline-source -d 'Data source' -a "$sources"
complete -c mainline.py -n 'not __fish_seen_argument --pipeline-effects' -l pipeline-effects -d 'Effect plugins (comma-separated)' -a "$effects"
complete -c mainline.py -n 'not __fish_seen_argument --pipeline-camera' -l pipeline-camera -d 'Camera mode' -a "$cameras"
complete -c mainline.py -n 'not __fish_seen_argument --pipeline-display' -l pipeline-display -d 'Display backend' -a "$display_backends"
complete -c mainline.py -l pipeline-ui -d 'Enable UI panel'
complete -c mainline.py -n 'not __fish_seen_argument --pipeline-border' -l pipeline-border -d 'Border mode' -a "$borders"
end
# Register the completion function
__mainline_complete

View File

@@ -1,48 +0,0 @@
#compdef mainline.py
# Mainline zsh completion script
#
# To install:
# source /path/to/completion/mainline-completion.zsh
#
# Or add to ~/.zshrc:
# source /path/to/completion/mainline-completion.zsh
# Define completion function
_mainline() {
local -a commands
local curcontext="$curcontext" state line
typeset -A opt_args
_arguments -C \
'(-h --help)'{-h,--help}'[Show help]' \
'--display=[Display backend]:backend:(terminal null replay websocket pygame moderngl)' \
'--preset=[Preset to use]:preset:(demo demo-base demo-pygame demo-camera-showcase poetry headlines empty test-basic test-border test-scroll-camera test-figment test-message-overlay)' \
'--viewport=[Viewport size]:size:(80x24 100x30 120x40 60x20)' \
'--theme=[Color theme]:theme:(green orange purple blue red)' \
'--websocket[Enable WebSocket server]' \
'--websocket-port=[WebSocket port]:port:' \
'--allow-unsafe[Allow unsafe pipeline configuration]' \
'(-)*: :{_files}' \
&& ret=0
# Handle --pipeline-* arguments
if [[ -n ${words[*]} ]]; then
_arguments -C \
'--pipeline-source=[Data source]:source:(headlines poetry empty fixture pipeline-inspect)' \
'--pipeline-effects=[Effect plugins]:effects:(afterimage border crop fade firehose glitch hud motionblur noise tint)' \
'--pipeline-camera=[Camera mode]:camera:(feed scroll horizontal omni floating bounce radial)' \
'--pipeline-display=[Display backend]:backend:(terminal null replay websocket pygame moderngl)' \
'--pipeline-ui[Enable UI panel]' \
'--pipeline-border=[Border mode]:mode:(off simple ui)' \
'--viewport=[Viewport size]:size:(80x24 100x30 120x40 60x20)' \
&& ret=0
fi
return ret
}
# Register completion function
compdef _mainline mainline.py
compdef _mainline "python -m engine.app"
compdef _mainline "python -m mainline"

View File

@@ -1,303 +0,0 @@
# ANSI Positioning Approaches Analysis
## Current Positioning Methods in Mainline
### 1. Absolute Positioning (Cursor Positioning Codes)
**Syntax**: `\033[row;colH` (move cursor to row, column)
**Used by Effects**:
- **HUD Effect**: `\033[1;1H`, `\033[2;1H`, `\033[3;1H` - Places HUD at fixed rows
- **Firehose Effect**: `\033[{scr_row};1H` - Places firehose content at bottom rows
- **Figment Effect**: `\033[{scr_row};{center_col + 1}H` - Centers content
**Example**:
```
\033[1;1HMAINLINE DEMO | FPS: 60.0 | 16.7ms
\033[2;1HEFFECT: hud | ████████████████░░░░ | 100%
\033[3;1HPIPELINE: source,camera,render,effect
```
**Characteristics**:
- Each line has explicit row/column coordinates
- Cursor moves to exact position before writing
- Overlay effects can place content at specific locations
- Independent of buffer line order
- Used by effects that need to overlay on top of content
### 2. Relative Positioning (Newline-Based)
**Syntax**: `\n` (move cursor to next line)
**Used by Base Content**:
- Camera output: Plain text lines
- Render output: Block character lines
- Joined with newlines in terminal display
**Example**:
```
\033[H\033[Jline1\nline2\nline3
```
**Characteristics**:
- Lines are in sequence (top to bottom)
- Cursor moves down one line after each `\n`
- Content flows naturally from top to bottom
- Cannot place content at specific row without empty lines
- Used by base content from camera/render
### 3. Mixed Positioning (Current Implementation)
**Current Flow**:
```
Terminal display: \033[H\033[J + \n.join(buffer)
Buffer structure: [line1, line2, \033[1;1HHUD line, ...]
```
**Behavior**:
1. `\033[H\033[J` - Move to (1,1), clear screen
2. `line1\n` - Write line1, move to line2
3. `line2\n` - Write line2, move to line3
4. `\033[1;1H` - Move back to (1,1)
5. Write HUD content
**Issue**: Overlapping cursor movements can cause visual glitches
---
## Performance Analysis
### Absolute Positioning Performance
**Advantages**:
- Precise control over output position
- No need for empty buffer lines
- Effects can overlay without affecting base content
- Efficient for static overlays (HUD, status bars)
**Disadvantages**:
- More ANSI codes = larger output size
- Each line requires `\033[row;colH` prefix
- Can cause redraw issues if not cleared properly
- Terminal must parse more escape sequences
**Output Size Comparison** (24 lines):
- Absolute: ~1,200 bytes (avg 50 chars/line + 30 ANSI codes)
- Relative: ~960 bytes (80 chars/line * 24 lines)
### Relative Positioning Performance
**Advantages**:
- Minimal ANSI codes (only colors, no positioning)
- Smaller output size
- Terminal renders faster (less parsing)
- Natural flow for scrolling content
**Disadvantages**:
- Requires empty lines for spacing
- Cannot overlay content without buffer manipulation
- Limited control over exact positioning
- Harder to implement HUD/status overlays
**Output Size Comparison** (24 lines):
- Base content: ~1,920 bytes (80 chars * 24 lines)
- With colors only: ~2,400 bytes (adds color codes)
### Mixed Positioning Performance
**Current Implementation**:
- Base content uses relative (newlines)
- Effects use absolute (cursor positioning)
- Combined output has both methods
**Trade-offs**:
- Medium output size
- Flexible positioning
- Potential visual conflicts if not coordinated
---
## Animation Performance Implications
### Scrolling Animations (Camera Feed/Scroll)
**Best Approach**: Relative positioning with newlines
- **Why**: Smooth scrolling requires continuous buffer updates
- **Alternative**: Absolute positioning would require recalculating all coordinates
**Performance**:
- Relative: 60 FPS achievable with 80x24 buffer
- Absolute: 55-60 FPS (slightly slower due to more ANSI codes)
- Mixed: 58-60 FPS (negligible difference for small buffers)
### Static Overlay Animations (HUD, Status Bars)
**Best Approach**: Absolute positioning
- **Why**: HUD content doesn't change position, only content
- **Alternative**: Could use fixed buffer positions with relative, but less flexible
**Performance**:
- Absolute: Minimal overhead (3 lines with ANSI codes)
- Relative: Requires maintaining fixed positions in buffer (more complex)
### Particle/Effect Animations (Firehose, Figment)
**Best Approach**: Mixed positioning
- **Why**: Base content flows normally, particles overlay at specific positions
- **Alternative**: All absolute would be overkill
**Performance**:
- Mixed: Optimal balance
- Particles at bottom: `\033[{row};1H` (only affected lines)
- Base content: `\n` (natural flow)
---
## Proposed Design: PositionStage
### Capability Definition
```python
class PositioningMode(Enum):
"""Positioning mode for terminal rendering."""
ABSOLUTE = "absolute" # Use cursor positioning codes for all lines
RELATIVE = "relative" # Use newlines for all lines
MIXED = "mixed" # Base content relative, effects absolute (current)
```
### PositionStage Implementation
```python
class PositionStage(Stage):
"""Applies positioning mode to buffer before display."""
def __init__(self, mode: PositioningMode = PositioningMode.RELATIVE):
self.mode = mode
self.name = f"position-{mode.value}"
self.category = "position"
@property
def capabilities(self) -> set[str]:
return {"position.output"}
@property
def dependencies(self) -> set[str]:
return {"render.output"} # Needs content before positioning
def process(self, data: Any, ctx: PipelineContext) -> Any:
if self.mode == PositioningMode.ABSOLUTE:
return self._to_absolute(data, ctx)
elif self.mode == PositioningMode.RELATIVE:
return self._to_relative(data, ctx)
else: # MIXED
return data # No transformation needed
def _to_absolute(self, data: list[str], ctx: PipelineContext) -> list[str]:
"""Convert buffer to absolute positioning (all lines have cursor codes)."""
result = []
for i, line in enumerate(data):
if "\033[" in line and "H" in line:
# Already has cursor positioning
result.append(line)
else:
# Add cursor positioning for this line
result.append(f"\033[{i + 1};1H{line}")
return result
def _to_relative(self, data: list[str], ctx: PipelineContext) -> list[str]:
"""Convert buffer to relative positioning (use newlines)."""
# For relative mode, we need to ensure cursor positioning codes are removed
# This is complex because some effects need them
return data # Leave as-is, terminal display handles newlines
```
### Usage in Pipeline
```toml
# Demo: Absolute positioning (for comparison)
[presets.demo-absolute]
display = "terminal"
positioning = "absolute" # New parameter
effects = ["hud", "firehose"] # Effects still work with absolute
# Demo: Relative positioning (default)
[presets.demo-relative]
display = "terminal"
positioning = "relative" # New parameter
effects = ["hud", "firehose"] # Effects must adapt
```
### Terminal Display Integration
```python
def show(self, buffer: list[str], border: bool = False, mode: PositioningMode = None) -> None:
# Apply border if requested
if border and border != BorderMode.OFF:
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
# Apply positioning based on mode
if mode == PositioningMode.ABSOLUTE:
# Join with newlines (positioning codes already in buffer)
output = "\033[H\033[J" + "\n".join(buffer)
elif mode == PositioningMode.RELATIVE:
# Join with newlines
output = "\033[H\033,J" + "\n".join(buffer)
else: # MIXED
# Current implementation
output = "\033[H\033[J" + "\n".join(buffer)
sys.stdout.buffer.write(output.encode())
sys.stdout.flush()
```
---
## Recommendations
### For Different Animation Types
1. **Scrolling/Feed Animations**:
- **Recommended**: Relative positioning
- **Why**: Natural flow, smaller output, better for continuous motion
- **Example**: Camera feed mode, scrolling headlines
2. **Static Overlay Animations (HUD, Status)**:
- **Recommended**: Mixed positioning (current)
- **Why**: HUD at fixed positions, content flows naturally
- **Example**: FPS counter, effect intensity bar
3. **Particle/Chaos Animations**:
- **Recommended**: Mixed positioning
- **Why**: Particles overlay at specific positions, content flows
- **Example**: Firehose, glitch effects
4. **Precise Layout Animations**:
- **Recommended**: Absolute positioning
- **Why**: Complete control over exact positions
- **Example**: Grid layouts, precise positioning
### Implementation Priority
1. **Phase 1**: Document current behavior (done)
2. **Phase 2**: Create PositionStage with configurable mode
3. **Phase 3**: Update terminal display to respect positioning mode
4. **Phase 4**: Create presets for different positioning modes
5. **Phase 5**: Performance testing and optimization
### Key Considerations
- **Backward Compatibility**: Keep mixed positioning as default
- **Performance**: Relative is ~20% faster for large buffers
- **Flexibility**: Absolute allows precise control but increases output size
- **Simplicity**: Mixed provides best balance for typical use cases
---
## Next Steps
1. Implement `PositioningMode` enum
2. Create `PositionStage` class with mode configuration
3. Update terminal display to accept positioning mode parameter
4. Create test presets for each positioning mode
5. Performance benchmark each approach
6. Document best practices for choosing positioning mode

View File

@@ -254,23 +254,7 @@ def run_pipeline_mode_direct():
# Create display using validated display name
display_name = result.config.display or "terminal" # Default to terminal if empty
# Warn if display was auto-selected (not explicitly specified)
if not display_name:
print(
" \033[38;5;226mWarning: No --pipeline-display specified, using default: terminal\033[0m"
)
print(
" \033[38;5;245mTip: Use --pipeline-display null for headless mode (useful for testing)\033[0m"
)
display = DisplayRegistry.create(display_name)
# Set positioning mode
if "--positioning" in sys.argv:
idx = sys.argv.index("--positioning")
if idx + 1 < len(sys.argv):
params.positioning = sys.argv[idx + 1]
if not display:
print(f" \033[38;5;196mFailed to create display: {display_name}\033[0m")
sys.exit(1)

View File

@@ -12,7 +12,6 @@ from engine.fetch import fetch_all, fetch_all_fast, fetch_poetry, load_cache, sa
from engine.pipeline import Pipeline, PipelineConfig, PipelineContext, get_preset
from engine.pipeline.adapters import (
EffectPluginStage,
MessageOverlayStage,
SourceItemsToBufferStage,
create_stage_from_display,
create_stage_from_effect,
@@ -139,16 +138,6 @@ def run_pipeline_mode(preset_name: str = "demo"):
print("Error: Invalid viewport format. Use WxH (e.g., 40x15)")
sys.exit(1)
# Set positioning mode from command line or config
if "--positioning" in sys.argv:
idx = sys.argv.index("--positioning")
if idx + 1 < len(sys.argv):
params.positioning = sys.argv[idx + 1]
else:
from engine import config as app_config
params.positioning = app_config.get_config().positioning
pipeline = Pipeline(config=preset.to_config())
print(" \033[38;5;245mFetching content...\033[0m")
@@ -199,19 +188,10 @@ def run_pipeline_mode(preset_name: str = "demo"):
# CLI --display flag takes priority over preset
# Check if --display was explicitly provided
display_name = preset.display
display_explicitly_specified = "--display" in sys.argv
if display_explicitly_specified:
if "--display" in sys.argv:
idx = sys.argv.index("--display")
if idx + 1 < len(sys.argv):
display_name = sys.argv[idx + 1]
else:
# Warn user that display is falling back to preset default
print(
f" \033[38;5;226mWarning: No --display specified, using preset default: {display_name}\033[0m"
)
print(
" \033[38;5;245mTip: Use --display null for headless mode (useful for testing/capture)\033[0m"
)
display = DisplayRegistry.create(display_name)
if not display and not display_name.startswith("multi"):
@@ -331,24 +311,6 @@ def run_pipeline_mode(preset_name: str = "demo"):
f"effect_{effect_name}", create_stage_from_effect(effect, effect_name)
)
# Add message overlay stage if enabled
if getattr(preset, "enable_message_overlay", False):
from engine import config as engine_config
from engine.pipeline.adapters import MessageOverlayConfig
overlay_config = MessageOverlayConfig(
enabled=True,
display_secs=engine_config.MESSAGE_DISPLAY_SECS
if hasattr(engine_config, "MESSAGE_DISPLAY_SECS")
else 30,
topic_url=engine_config.NTFY_TOPIC
if hasattr(engine_config, "NTFY_TOPIC")
else None,
)
pipeline.add_stage(
"message_overlay", MessageOverlayStage(config=overlay_config)
)
pipeline.add_stage("display", create_stage_from_display(display, display_name))
pipeline.build()
@@ -663,24 +625,6 @@ def run_pipeline_mode(preset_name: str = "demo"):
create_stage_from_effect(effect, effect_name),
)
# Add message overlay stage if enabled
if getattr(new_preset, "enable_message_overlay", False):
from engine import config as engine_config
from engine.pipeline.adapters import MessageOverlayConfig
overlay_config = MessageOverlayConfig(
enabled=True,
display_secs=engine_config.MESSAGE_DISPLAY_SECS
if hasattr(engine_config, "MESSAGE_DISPLAY_SECS")
else 30,
topic_url=engine_config.NTFY_TOPIC
if hasattr(engine_config, "NTFY_TOPIC")
else None,
)
pipeline.add_stage(
"message_overlay", MessageOverlayStage(config=overlay_config)
)
# Add display (respect CLI override)
display_name = new_preset.display
if "--display" in sys.argv:
@@ -880,17 +824,7 @@ def run_pipeline_mode(preset_name: str = "demo"):
show_border = (
params.border if isinstance(params.border, bool) else False
)
# Pass positioning mode if display supports it
positioning = getattr(params, "positioning", "mixed")
if (
hasattr(display, "show")
and "positioning" in display.show.__code__.co_varnames
):
display.show(
result.data, border=show_border, positioning=positioning
)
else:
display.show(result.data, border=show_border)
display.show(result.data, border=show_border)
if hasattr(display, "is_quit_requested") and display.is_quit_requested():
if hasattr(display, "clear_quit_request"):

View File

@@ -130,10 +130,8 @@ class Config:
script_fonts: dict[str, str] = field(default_factory=_get_platform_font_paths)
display: str = "pygame"
positioning: str = "mixed"
websocket: bool = False
websocket_port: int = 8765
theme: str = "green"
@classmethod
def from_args(cls, argv: list[str] | None = None) -> "Config":
@@ -175,10 +173,8 @@ class Config:
kata_glyphs="ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ",
script_fonts=_get_platform_font_paths(),
display=_arg_value("--display", argv) or "terminal",
positioning=_arg_value("--positioning", argv) or "mixed",
websocket="--websocket" in argv,
websocket_port=_arg_int("--websocket-port", 8765, argv),
theme=_arg_value("--theme", argv) or "green",
)
@@ -250,40 +246,6 @@ DEMO = "--demo" in sys.argv
DEMO_EFFECT_DURATION = 5.0 # seconds per effect
PIPELINE_DEMO = "--pipeline-demo" in sys.argv
# ─── THEME MANAGEMENT ─────────────────────────────────────────
ACTIVE_THEME = None
def set_active_theme(theme_id: str = "green"):
"""Set the active theme by ID.
Args:
theme_id: Theme identifier from theme registry (e.g., "green", "orange", "purple")
Raises:
KeyError: If theme_id is not in the theme registry
Side Effects:
Sets the ACTIVE_THEME global variable
"""
global ACTIVE_THEME
from engine import themes
ACTIVE_THEME = themes.get_theme(theme_id)
# Initialize theme on module load (lazy to avoid circular dependency)
def _init_theme():
theme_id = _arg_value("--theme", sys.argv) or "green"
try:
set_active_theme(theme_id)
except KeyError:
pass # Theme not found, keep None
_init_theme()
# ─── PIPELINE MODE (new unified architecture) ─────────────
PIPELINE_MODE = "--pipeline" in sys.argv
PIPELINE_PRESET = _arg_value("--pipeline-preset", sys.argv) or "demo"
@@ -294,9 +256,6 @@ PRESET = _arg_value("--preset", sys.argv)
# ─── PIPELINE DIAGRAM ────────────────────────────────────
PIPELINE_DIAGRAM = "--pipeline-diagram" in sys.argv
# ─── THEME ──────────────────────────────────────────────────
THEME = _arg_value("--theme", sys.argv) or "green"
def set_font_selection(font_path=None, font_index=None):
"""Set runtime primary font selection."""

View File

@@ -1,656 +0,0 @@
"""
Animation Report Display Backend
Captures frames from pipeline stages and generates an interactive HTML report
showing before/after states for each transformative stage.
"""
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
from engine.display.streaming import compute_diff
@dataclass
class CapturedFrame:
"""A captured frame with metadata."""
stage: str
buffer: list[str]
timestamp: float
frame_number: int
diff_from_previous: dict[str, Any] | None = None
@dataclass
class StageCapture:
"""Captures frames for a single pipeline stage."""
name: str
frames: list[CapturedFrame] = field(default_factory=list)
start_time: float = field(default_factory=time.time)
end_time: float = 0.0
def add_frame(
self,
buffer: list[str],
frame_number: int,
previous_buffer: list[str] | None = None,
) -> None:
"""Add a captured frame."""
timestamp = time.time()
diff = None
if previous_buffer is not None:
diff_data = compute_diff(previous_buffer, buffer)
diff = {
"changed_lines": len(diff_data.changed_lines),
"total_lines": len(buffer),
"width": diff_data.width,
"height": diff_data.height,
}
frame = CapturedFrame(
stage=self.name,
buffer=list(buffer),
timestamp=timestamp,
frame_number=frame_number,
diff_from_previous=diff,
)
self.frames.append(frame)
def finish(self) -> None:
"""Mark capture as finished."""
self.end_time = time.time()
class AnimationReportDisplay:
"""
Display backend that captures frames for animation report generation.
Instead of rendering to terminal, this display captures the buffer at each
stage and stores it for later HTML report generation.
"""
width: int = 80
height: int = 24
def __init__(self, output_dir: str = "./reports"):
"""
Initialize the animation report display.
Args:
output_dir: Directory where reports will be saved
"""
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self._stages: dict[str, StageCapture] = {}
self._current_stage: str = ""
self._previous_buffer: list[str] | None = None
self._frame_number: int = 0
self._total_frames: int = 0
self._start_time: float = 0.0
def init(self, width: int, height: int, reuse: bool = False) -> None:
"""Initialize display with dimensions."""
self.width = width
self.height = height
self._start_time = time.time()
def show(self, buffer: list[str], border: bool = False) -> None:
"""
Capture a frame for the current stage.
Args:
buffer: The frame buffer to capture
border: Border flag (ignored)
"""
if not self._current_stage:
# If no stage is set, use a default name
self._current_stage = "final"
if self._current_stage not in self._stages:
self._stages[self._current_stage] = StageCapture(self._current_stage)
stage = self._stages[self._current_stage]
stage.add_frame(buffer, self._frame_number, self._previous_buffer)
self._previous_buffer = list(buffer)
self._frame_number += 1
self._total_frames += 1
def start_stage(self, stage_name: str) -> None:
"""
Start capturing frames for a new stage.
Args:
stage_name: Name of the stage (e.g., "noise", "fade", "firehose")
"""
if self._current_stage and self._current_stage in self._stages:
# Finish previous stage
self._stages[self._current_stage].finish()
self._current_stage = stage_name
self._previous_buffer = None # Reset for new stage
def clear(self) -> None:
"""Clear the display (no-op for report display)."""
pass
def cleanup(self) -> None:
"""Cleanup resources."""
# Finish current stage
if self._current_stage and self._current_stage in self._stages:
self._stages[self._current_stage].finish()
def get_dimensions(self) -> tuple[int, int]:
"""Get current dimensions."""
return (self.width, self.height)
def get_stages(self) -> dict[str, StageCapture]:
"""Get all captured stages."""
return self._stages
def generate_report(self, title: str = "Animation Report") -> Path:
"""
Generate an HTML report with captured frames and animations.
Args:
title: Title of the report
Returns:
Path to the generated HTML file
"""
report_path = self.output_dir / f"animation_report_{int(time.time())}.html"
html_content = self._build_html(title)
report_path.write_text(html_content)
return report_path
def _build_html(self, title: str) -> str:
"""Build the HTML content for the report."""
# Collect all frames across stages
all_frames = []
for stage_name, stage in self._stages.items():
for frame in stage.frames:
all_frames.append(frame)
# Sort frames by timestamp
all_frames.sort(key=lambda f: f.timestamp)
# Build stage sections
stages_html = ""
for stage_name, stage in self._stages.items():
stages_html += self._build_stage_section(stage_name, stage)
# Build full HTML
html = f"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
* {{
box-sizing: border-box;
margin: 0;
padding: 0;
}}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #1a1a2e;
color: #eee;
padding: 20px;
line-height: 1.6;
}}
.container {{
max-width: 1400px;
margin: 0 auto;
}}
.header {{
background: linear-gradient(135deg, #16213e 0%, #1a1a2e 100%);
padding: 30px;
border-radius: 12px;
margin-bottom: 30px;
text-align: center;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}}
.header h1 {{
font-size: 2.5em;
margin-bottom: 10px;
background: linear-gradient(90deg, #00d4ff, #00ff88);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}}
.header .meta {{
color: #888;
font-size: 0.9em;
}}
.stats-grid {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
margin: 20px 0;
}}
.stat-card {{
background: #16213e;
padding: 15px;
border-radius: 8px;
text-align: center;
}}
.stat-value {{
font-size: 1.8em;
font-weight: bold;
color: #00ff88;
}}
.stat-label {{
color: #888;
font-size: 0.85em;
margin-top: 5px;
}}
.stage-section {{
background: #16213e;
border-radius: 12px;
margin-bottom: 25px;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
}}
.stage-header {{
background: #1f2a48;
padding: 15px 20px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
user-select: none;
}}
.stage-header:hover {{
background: #253252;
}}
.stage-name {{
font-weight: bold;
font-size: 1.1em;
color: #00d4ff;
}}
.stage-info {{
color: #888;
font-size: 0.9em;
}}
.stage-content {{
padding: 20px;
}}
.frames-container {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
}}
.frame-card {{
background: #0f0f1a;
border-radius: 8px;
overflow: hidden;
border: 1px solid #333;
transition: transform 0.2s, box-shadow 0.2s;
}}
.frame-card:hover {{
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0,212,255,0.2);
}}
.frame-header {{
background: #1a1a2e;
padding: 10px 15px;
font-size: 0.85em;
color: #888;
border-bottom: 1px solid #333;
display: flex;
justify-content: space-between;
}}
.frame-number {{
color: #00ff88;
}}
.frame-diff {{
color: #ff6b6b;
}}
.frame-content {{
padding: 10px;
font-family: 'Fira Code', 'Consolas', 'Monaco', monospace;
font-size: 11px;
line-height: 1.3;
white-space: pre;
overflow-x: auto;
max-height: 200px;
overflow-y: auto;
}}
.timeline-section {{
background: #16213e;
border-radius: 12px;
padding: 20px;
margin-bottom: 25px;
}}
.timeline-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}}
.timeline-title {{
font-weight: bold;
color: #00d4ff;
}}
.timeline-controls {{
display: flex;
gap: 10px;
}}
.timeline-controls button {{
background: #1f2a48;
border: 1px solid #333;
color: #eee;
padding: 8px 15px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}}
.timeline-controls button:hover {{
background: #253252;
border-color: #00d4ff;
}}
.timeline-controls button.active {{
background: #00d4ff;
color: #000;
}}
.timeline-canvas {{
width: 100%;
height: 100px;
background: #0f0f1a;
border-radius: 8px;
position: relative;
overflow: hidden;
}}
.timeline-track {{
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 4px;
background: #333;
transform: translateY(-50%);
}}
.timeline-marker {{
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background: #00d4ff;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
}}
.timeline-marker:hover {{
transform: translate(-50%, -50%) scale(1.3);
box-shadow: 0 0 10px #00d4ff;
}}
.timeline-marker.stage-{{stage_name}} {{
background: var(--stage-color, #00d4ff);
}}
.comparison-view {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 20px;
}}
.comparison-panel {{
background: #0f0f1a;
border-radius: 8px;
padding: 15px;
border: 1px solid #333;
}}
.comparison-panel h4 {{
color: #888;
margin-bottom: 10px;
font-size: 0.9em;
}}
.comparison-content {{
font-family: 'Fira Code', 'Consolas', 'Monaco', monospace;
font-size: 11px;
line-height: 1.3;
white-space: pre;
}}
.diff-added {{
background: rgba(0, 255, 136, 0.2);
}}
.diff-removed {{
background: rgba(255, 107, 107, 0.2);
}}
@keyframes pulse {{
0%, 100% {{ opacity: 1; }}
50% {{ opacity: 0.7; }}
}}
.animating {{
animation: pulse 1s infinite;
}}
.footer {{
text-align: center;
color: #666;
padding: 20px;
font-size: 0.9em;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎬 {title}</h1>
<div class="meta">
Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} |
Total Frames: {self._total_frames} |
Duration: {time.time() - self._start_time:.2f}s
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{len(self._stages)}</div>
<div class="stat-label">Pipeline Stages</div>
</div>
<div class="stat-card">
<div class="stat-value">{self._total_frames}</div>
<div class="stat-label">Total Frames</div>
</div>
<div class="stat-card">
<div class="stat-value">{time.time() - self._start_time:.2f}s</div>
<div class="stat-label">Capture Duration</div>
</div>
<div class="stat-card">
<div class="stat-value">{self.width}x{self.height}</div>
<div class="stat-label">Resolution</div>
</div>
</div>
</div>
<div class="timeline-section">
<div class="timeline-header">
<div class="timeline-title">Timeline</div>
<div class="timeline-controls">
<button onclick="playAnimation()">▶ Play</button>
<button onclick="pauseAnimation()">⏸ Pause</button>
<button onclick="stepForward()">⏭ Step</button>
</div>
</div>
<div class="timeline-canvas" id="timeline">
<div class="timeline-track"></div>
<!-- Timeline markers will be added by JavaScript -->
</div>
</div>
{stages_html}
<div class="footer">
<p>Animation Report generated by Mainline</p>
<p>Use the timeline controls above to play/pause the animation</p>
</div>
</div>
<script>
// Animation state
let currentFrame = 0;
let isPlaying = false;
let animationInterval = null;
const totalFrames = {len(all_frames)};
// Stage colors for timeline markers
const stageColors = {{
{self._build_stage_colors()}
}};
// Initialize timeline
function initTimeline() {{
const timeline = document.getElementById('timeline');
const track = timeline.querySelector('.timeline-track');
{self._build_timeline_markers(all_frames)}
}}
function playAnimation() {{
if (isPlaying) return;
isPlaying = true;
animationInterval = setInterval(() => {{
currentFrame = (currentFrame + 1) % totalFrames;
updateFrameDisplay();
}}, 100);
}}
function pauseAnimation() {{
isPlaying = false;
if (animationInterval) {{
clearInterval(animationInterval);
animationInterval = null;
}}
}}
function stepForward() {{
currentFrame = (currentFrame + 1) % totalFrames;
updateFrameDisplay();
}}
function updateFrameDisplay() {{
// Highlight current frame in timeline
const markers = document.querySelectorAll('.timeline-marker');
markers.forEach((marker, index) => {{
if (index === currentFrame) {{
marker.style.transform = 'translate(-50%, -50%) scale(1.5)';
marker.style.boxShadow = '0 0 15px #00ff88';
}} else {{
marker.style.transform = 'translate(-50%, -50%) scale(1)';
marker.style.boxShadow = 'none';
}}
}});
}}
// Initialize on page load
document.addEventListener('DOMContentLoaded', initTimeline);
</script>
</body>
</html>
"""
return html
def _build_stage_section(self, stage_name: str, stage: StageCapture) -> str:
"""Build HTML for a single stage section."""
frames_html = ""
for i, frame in enumerate(stage.frames):
diff_info = ""
if frame.diff_from_previous:
changed = frame.diff_from_previous.get("changed_lines", 0)
total = frame.diff_from_previous.get("total_lines", 0)
diff_info = f'<span class="frame-diff"{changed}/{total}</span>'
frames_html += f"""
<div class="frame-card">
<div class="frame-header">
<span>Frame <span class="frame-number">{frame.frame_number}</span></span>
{diff_info}
</div>
<div class="frame-content">{self._escape_html("".join(frame.buffer))}</div>
</div>
"""
return f"""
<div class="stage-section">
<div class="stage-header" onclick="this.nextElementSibling.classList.toggle('hidden')">
<span class="stage-name">{stage_name}</span>
<span class="stage-info">{len(stage.frames)} frames</span>
</div>
<div class="stage-content">
<div class="frames-container">
{frames_html}
</div>
</div>
</div>
"""
def _build_timeline(self, all_frames: list[CapturedFrame]) -> str:
"""Build timeline HTML."""
if not all_frames:
return ""
markers_html = ""
for i, frame in enumerate(all_frames):
left_percent = (i / len(all_frames)) * 100
markers_html += f'<div class="timeline-marker" style="left: {left_percent}%" data-frame="{i}"></div>'
return markers_html
def _build_stage_colors(self) -> str:
"""Build stage color mapping for JavaScript."""
colors = [
"#00d4ff",
"#00ff88",
"#ff6b6b",
"#ffd93d",
"#a855f7",
"#ec4899",
"#14b8a6",
"#f97316",
"#8b5cf6",
"#06b6d4",
]
color_map = ""
for i, stage_name in enumerate(self._stages.keys()):
color = colors[i % len(colors)]
color_map += f' "{stage_name}": "{color}",\n'
return color_map.rstrip(",\n")
def _build_timeline_markers(self, all_frames: list[CapturedFrame]) -> str:
"""Build timeline markers in JavaScript."""
if not all_frames:
return ""
markers_js = ""
for i, frame in enumerate(all_frames):
left_percent = (i / len(all_frames)) * 100
stage_color = f"stageColors['{frame.stage}']"
markers_js += f"""
const marker{i} = document.createElement('div');
marker{i}.className = 'timeline-marker stage-{{frame.stage}}';
marker{i}.style.left = '{left_percent}%';
marker{i}.style.setProperty('--stage-color', {stage_color});
marker{i}.onclick = () => {{
currentFrame = {i};
updateFrameDisplay();
}};
timeline.appendChild(marker{i});
"""
return markers_js
def _escape_html(self, text: str) -> str:
"""Escape HTML special characters."""
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&#39;")
)

View File

@@ -83,16 +83,7 @@ class TerminalDisplay:
return self._cached_dimensions
def show(
self, buffer: list[str], border: bool = False, positioning: str = "mixed"
) -> None:
"""Display buffer with optional border and positioning mode.
Args:
buffer: List of lines to display
border: Whether to apply border
positioning: Positioning mode - "mixed" (default), "absolute", or "relative"
"""
def show(self, buffer: list[str], border: bool = False) -> None:
import sys
from engine.display import get_monitor, render_border
@@ -118,27 +109,8 @@ class TerminalDisplay:
if border and border != BorderMode.OFF:
buffer = render_border(buffer, self.width, self.height, fps, frame_time)
# Apply positioning based on mode
if positioning == "absolute":
# All lines should have cursor positioning codes
# Join with newlines (cursor codes already in buffer)
output = "\033[H\033[J" + "\n".join(buffer)
elif positioning == "relative":
# Remove cursor positioning codes (except colors) and join with newlines
import re
cleaned_buffer = []
for line in buffer:
# Remove cursor positioning codes but keep color codes
# Pattern: \033[row;colH or \033[row;col;...H
cleaned = re.sub(r"\033\[[0-9;]*H", "", line)
cleaned_buffer.append(cleaned)
output = "\033[H\033[J" + "\n".join(cleaned_buffer)
else: # mixed (default)
# Current behavior: join with newlines
# Effects that need absolute positioning have their own cursor codes
output = "\033[H\033[J" + "\n".join(buffer)
# Write buffer with cursor home + erase down to avoid flicker
output = "\033[H\033[J" + "".join(buffer)
sys.stdout.buffer.write(output.encode())
sys.stdout.flush()

View File

@@ -1,332 +0,0 @@
"""
Figment overlay effect for modern pipeline architecture.
Provides periodic SVG glyph overlays with reveal/hold/dissolve animation phases.
Integrates directly with the pipeline's effect system without legacy dependencies.
"""
import random
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path
from engine import config
from engine.effects.types import EffectConfig, EffectContext, EffectPlugin
from engine.figment_render import rasterize_svg
from engine.figment_trigger import FigmentAction, FigmentCommand, FigmentTrigger
from engine.terminal import RST
from engine.themes import THEME_REGISTRY
class FigmentPhase(Enum):
"""Animation phases for figment overlay."""
REVEAL = auto()
HOLD = auto()
DISSOLVE = auto()
@dataclass
class FigmentState:
"""State of a figment overlay at a given frame."""
phase: FigmentPhase
progress: float
rows: list[str]
gradient: list[int]
center_row: int
center_col: int
def _color_codes_to_ansi(gradient: list[int]) -> list[str]:
"""Convert gradient list to ANSI color codes.
Args:
gradient: List of 256-color palette codes
Returns:
List of ANSI escape code strings
"""
codes = []
for color in gradient:
if isinstance(color, int):
codes.append(f"\033[38;5;{color}m")
else:
# Fallback to green
codes.append("\033[38;5;46m")
return codes if codes else ["\033[38;5;46m"]
def render_figment_overlay(figment_state: FigmentState, w: int, h: int) -> list[str]:
"""Render figment overlay as ANSI cursor-positioning commands.
Args:
figment_state: FigmentState with phase, progress, rows, gradient, centering.
w: terminal width
h: terminal height
Returns:
List of ANSI strings to append to display buffer.
"""
rows = figment_state.rows
if not rows:
return []
phase = figment_state.phase
progress = figment_state.progress
gradient = figment_state.gradient
center_row = figment_state.center_row
center_col = figment_state.center_col
cols = _color_codes_to_ansi(gradient)
# Build a list of non-space cell positions
cell_positions = []
for r_idx, row in enumerate(rows):
for c_idx, ch in enumerate(row):
if ch != " ":
cell_positions.append((r_idx, c_idx))
n_cells = len(cell_positions)
if n_cells == 0:
return []
# Use a deterministic seed so the reveal/dissolve pattern is stable per-figment
rng = random.Random(hash(tuple(rows[0][:10])) if rows[0] else 42)
shuffled = list(cell_positions)
rng.shuffle(shuffled)
# Phase-dependent visibility
if phase == FigmentPhase.REVEAL:
visible_count = int(n_cells * progress)
visible = set(shuffled[:visible_count])
elif phase == FigmentPhase.HOLD:
visible = set(cell_positions)
# Strobe: dim some cells periodically
if int(progress * 20) % 3 == 0:
dim_count = int(n_cells * 0.3)
visible -= set(shuffled[:dim_count])
elif phase == FigmentPhase.DISSOLVE:
remaining_count = int(n_cells * (1.0 - progress))
visible = set(shuffled[:remaining_count])
else:
visible = set(cell_positions)
# Build overlay commands
overlay: list[str] = []
n_cols = len(cols)
max_x = max((len(r.rstrip()) for r in rows if r.strip()), default=1)
for r_idx, row in enumerate(rows):
scr_row = center_row + r_idx + 1 # 1-indexed
if scr_row < 1 or scr_row > h:
continue
line_buf: list[str] = []
has_content = False
for c_idx, ch in enumerate(row):
scr_col = center_col + c_idx + 1
if scr_col < 1 or scr_col > w:
continue
if ch != " " and (r_idx, c_idx) in visible:
# Apply gradient color
shifted = (c_idx / max(max_x - 1, 1)) % 1.0
idx = min(round(shifted * (n_cols - 1)), n_cols - 1)
line_buf.append(f"{cols[idx]}{ch}{RST}")
has_content = True
else:
line_buf.append(" ")
if has_content:
line_str = "".join(line_buf).rstrip()
if line_str.strip():
overlay.append(f"\033[{scr_row};{center_col + 1}H{line_str}{RST}")
return overlay
class FigmentEffect(EffectPlugin):
"""Figment overlay effect for pipeline architecture.
Provides periodic SVG overlays with reveal/hold/dissolve animation.
"""
name = "figment"
config = EffectConfig(
enabled=True,
intensity=1.0,
params={
"interval_secs": 60,
"display_secs": 4.5,
"figment_dir": "figments",
},
)
supports_partial_updates = False
is_overlay = True # Figment is an overlay effect that composes on top of the buffer
def __init__(
self,
figment_dir: str | None = None,
triggers: list[FigmentTrigger] | None = None,
):
self.config = EffectConfig(
enabled=True,
intensity=1.0,
params={
"interval_secs": 60,
"display_secs": 4.5,
"figment_dir": figment_dir or "figments",
},
)
self._triggers = triggers or []
self._phase: FigmentPhase | None = None
self._progress: float = 0.0
self._rows: list[str] = []
self._gradient: list[int] = []
self._center_row: int = 0
self._center_col: int = 0
self._timer: float = 0.0
self._last_svg: str | None = None
self._svg_files: list[str] = []
self._scan_svgs()
def _scan_svgs(self) -> None:
"""Scan figment directory for SVG files."""
figment_dir = Path(self.config.params["figment_dir"])
if figment_dir.is_dir():
self._svg_files = sorted(str(p) for p in figment_dir.glob("*.svg"))
def process(self, buf: list[str], ctx: EffectContext) -> list[str]:
"""Add figment overlay to buffer."""
if not self.config.enabled:
return buf
# Get figment state using frame number from context
figment_state = self.get_figment_state(
ctx.frame_number, ctx.terminal_width, ctx.terminal_height
)
if figment_state:
# Render overlay and append to buffer
overlay = render_figment_overlay(
figment_state, ctx.terminal_width, ctx.terminal_height
)
buf = buf + overlay
return buf
def configure(self, config: EffectConfig) -> None:
"""Configure the effect."""
# Preserve figment_dir if the new config doesn't supply one
figment_dir = config.params.get(
"figment_dir", self.config.params.get("figment_dir", "figments")
)
self.config = config
if "figment_dir" not in self.config.params:
self.config.params["figment_dir"] = figment_dir
self._scan_svgs()
def trigger(self, w: int, h: int) -> None:
"""Manually trigger a figment display."""
if not self._svg_files:
return
# Pick a random SVG, avoid repeating
candidates = [s for s in self._svg_files if s != self._last_svg]
if not candidates:
candidates = self._svg_files
svg_path = random.choice(candidates)
self._last_svg = svg_path
# Rasterize
try:
self._rows = rasterize_svg(svg_path, w, h)
except Exception:
return
# Pick random theme gradient
theme_key = random.choice(list(THEME_REGISTRY.keys()))
self._gradient = THEME_REGISTRY[theme_key].main_gradient
# Center in viewport
figment_h = len(self._rows)
figment_w = max((len(r) for r in self._rows), default=0)
self._center_row = max(0, (h - figment_h) // 2)
self._center_col = max(0, (w - figment_w) // 2)
# Start reveal phase
self._phase = FigmentPhase.REVEAL
self._progress = 0.0
def get_figment_state(
self, frame_number: int, w: int, h: int
) -> FigmentState | None:
"""Tick the state machine and return current state, or None if idle."""
if not self.config.enabled:
return None
# Poll triggers
for trig in self._triggers:
cmd = trig.poll()
if cmd is not None:
self._handle_command(cmd, w, h)
# Tick timer when idle
if self._phase is None:
self._timer += config.FRAME_DT
interval = self.config.params.get("interval_secs", 60)
if self._timer >= interval:
self._timer = 0.0
self.trigger(w, h)
# Tick animation — snapshot current phase/progress, then advance
if self._phase is not None:
# Capture the state at the start of this frame
current_phase = self._phase
current_progress = self._progress
# Advance for next frame
display_secs = self.config.params.get("display_secs", 4.5)
phase_duration = display_secs / 3.0
self._progress += config.FRAME_DT / phase_duration
if self._progress >= 1.0:
self._progress = 0.0
if self._phase == FigmentPhase.REVEAL:
self._phase = FigmentPhase.HOLD
elif self._phase == FigmentPhase.HOLD:
self._phase = FigmentPhase.DISSOLVE
elif self._phase == FigmentPhase.DISSOLVE:
self._phase = None
return FigmentState(
phase=current_phase,
progress=current_progress,
rows=self._rows,
gradient=self._gradient,
center_row=self._center_row,
center_col=self._center_col,
)
return None
def _handle_command(self, cmd: FigmentCommand, w: int, h: int) -> None:
"""Handle a figment command."""
if cmd.action == FigmentAction.TRIGGER:
self.trigger(w, h)
elif cmd.action == FigmentAction.SET_INTENSITY and isinstance(
cmd.value, (int, float)
):
self.config.intensity = float(cmd.value)
elif cmd.action == FigmentAction.SET_INTERVAL and isinstance(
cmd.value, (int, float)
):
self.config.params["interval_secs"] = float(cmd.value)
elif cmd.action == FigmentAction.SET_COLOR and isinstance(cmd.value, str):
if cmd.value in THEME_REGISTRY:
self._gradient = THEME_REGISTRY[cmd.value].main_gradient
elif cmd.action == FigmentAction.STOP:
self._phase = None
self._progress = 0.0

View File

@@ -1,90 +0,0 @@
"""
SVG to half-block terminal art rasterization.
Pipeline: SVG -> cairosvg -> PIL -> greyscale threshold -> half-block encode.
Follows the same pixel-pair approach as engine/render.py for OTF fonts.
"""
from __future__ import annotations
import os
import sys
from io import BytesIO
# cairocffi (used by cairosvg) calls dlopen() to find the Cairo C library.
# On macOS with Homebrew, Cairo lives in /opt/homebrew/lib (Apple Silicon) or
# /usr/local/lib (Intel), which are not in dyld's default search path.
# Setting DYLD_LIBRARY_PATH before the import directs dlopen() to those paths.
if sys.platform == "darwin" and not os.environ.get("DYLD_LIBRARY_PATH"):
for _brew_lib in ("/opt/homebrew/lib", "/usr/local/lib"):
if os.path.exists(os.path.join(_brew_lib, "libcairo.2.dylib")):
os.environ["DYLD_LIBRARY_PATH"] = _brew_lib
break
import cairosvg
from PIL import Image
_cache: dict[tuple[str, int, int], list[str]] = {}
def rasterize_svg(svg_path: str, width: int, height: int) -> list[str]:
"""Convert SVG file to list of half-block terminal rows (uncolored).
Args:
svg_path: Path to SVG file.
width: Target terminal width in columns.
height: Target terminal height in rows.
Returns:
List of strings, one per terminal row, containing block characters.
"""
cache_key = (svg_path, width, height)
if cache_key in _cache:
return _cache[cache_key]
# SVG -> PNG in memory
png_bytes = cairosvg.svg2png(
url=svg_path,
output_width=width,
output_height=height * 2, # 2 pixel rows per terminal row
)
# PNG -> greyscale PIL image
# Composite RGBA onto white background so transparent areas become white (255)
# and drawn pixels retain their luminance values.
img_rgba = Image.open(BytesIO(png_bytes)).convert("RGBA")
img_rgba = img_rgba.resize((width, height * 2), Image.Resampling.LANCZOS)
background = Image.new("RGBA", img_rgba.size, (255, 255, 255, 255))
background.paste(img_rgba, mask=img_rgba.split()[3])
img = background.convert("L")
data = img.tobytes()
pix_w = width
pix_h = height * 2
# White (255) = empty space, dark (< threshold) = filled pixel
threshold = 128
# Half-block encode: walk pixel pairs
rows: list[str] = []
for y in range(0, pix_h, 2):
row: list[str] = []
for x in range(pix_w):
top = data[y * pix_w + x] < threshold
bot = data[(y + 1) * pix_w + x] < threshold if y + 1 < pix_h else False
if top and bot:
row.append("")
elif top:
row.append("")
elif bot:
row.append("")
else:
row.append(" ")
rows.append("".join(row))
_cache[cache_key] = rows
return rows
def clear_cache() -> None:
"""Clear the rasterization cache (e.g., on terminal resize)."""
_cache.clear()

View File

@@ -1,36 +0,0 @@
"""
Figment trigger protocol and command types.
Defines the extensible input abstraction for triggering figment displays
from any control surface (ntfy, MQTT, serial, etc.).
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import Protocol
class FigmentAction(Enum):
TRIGGER = "trigger"
SET_INTENSITY = "set_intensity"
SET_INTERVAL = "set_interval"
SET_COLOR = "set_color"
STOP = "stop"
@dataclass
class FigmentCommand:
action: FigmentAction
value: float | str | None = None
class FigmentTrigger(Protocol):
"""Protocol for figment trigger sources.
Any input source (ntfy, MQTT, serial) can implement this
to trigger and control figment displays.
"""
def poll(self) -> FigmentCommand | None: ...

File diff suppressed because one or more lines are too long

View File

@@ -15,12 +15,6 @@ from .factory import (
create_stage_from_font,
create_stage_from_source,
)
from .message_overlay import MessageOverlayConfig, MessageOverlayStage
from .positioning import (
PositioningMode,
PositionStage,
create_position_stage,
)
from .transform import (
CanvasStage,
FontStage,
@@ -41,15 +35,10 @@ __all__ = [
"FontStage",
"ImageToTextStage",
"CanvasStage",
"MessageOverlayStage",
"MessageOverlayConfig",
"PositionStage",
"PositioningMode",
# Factory functions
"create_stage_from_display",
"create_stage_from_effect",
"create_stage_from_source",
"create_stage_from_camera",
"create_stage_from_font",
"create_position_stage",
]

View File

@@ -179,7 +179,7 @@ class CameraStage(Stage):
@property
def dependencies(self) -> set[str]:
return {"render.output", "camera.state"}
return {"render.output"}
@property
def inlet_types(self) -> set:

View File

@@ -8,7 +8,7 @@ from engine.pipeline.core import PipelineContext, Stage
class DisplayStage(Stage):
"""Adapter wrapping Display as a Stage."""
def __init__(self, display, name: str = "terminal", positioning: str = "mixed"):
def __init__(self, display, name: str = "terminal"):
self._display = display
self.name = name
self.category = "display"
@@ -16,7 +16,6 @@ class DisplayStage(Stage):
self._initialized = False
self._init_width = 80
self._init_height = 24
self._positioning = positioning
def save_state(self) -> dict[str, Any]:
"""Save display state for restoration after pipeline rebuild.
@@ -54,8 +53,7 @@ class DisplayStage(Stage):
@property
def dependencies(self) -> set[str]:
# Display needs rendered content and camera transformation
return {"render.output", "camera"}
return {"render.output"} # Display needs rendered content
@property
def inlet_types(self) -> set:
@@ -88,20 +86,7 @@ class DisplayStage(Stage):
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Output data to display."""
if data is not None:
# Check if positioning mode is specified in context params
positioning = self._positioning
if ctx and ctx.params and hasattr(ctx.params, "positioning"):
positioning = ctx.params.positioning
# Pass positioning to display if supported
if (
hasattr(self._display, "show")
and "positioning" in self._display.show.__code__.co_varnames
):
self._display.show(data, positioning=positioning)
else:
# Fallback for displays that don't support positioning parameter
self._display.show(data)
self._display.show(data)
return data
def cleanup(self) -> None:

View File

@@ -27,9 +27,9 @@ class EffectPluginStage(Stage):
def stage_type(self) -> str:
"""Return stage_type based on effect name.
Overlay effects have stage_type "overlay".
HUD effects are overlays.
"""
if self.is_overlay:
if self.name == "hud":
return "overlay"
return self.category
@@ -37,26 +37,19 @@ class EffectPluginStage(Stage):
def render_order(self) -> int:
"""Return render_order based on effect type.
Overlay effects have high render_order to appear on top.
HUD effects have high render_order to appear on top.
"""
if self.is_overlay:
if self.name == "hud":
return 100 # High order for overlays
return 0
@property
def is_overlay(self) -> bool:
"""Return True for overlay effects.
"""Return True for HUD effects.
Overlay effects compose on top of the buffer
HUD is an overlay - it composes on top of the buffer
rather than transforming it for the next stage.
"""
# Check if the effect has an is_overlay attribute that is explicitly True
# (not just any truthy value from a mock object)
if hasattr(self._effect, "is_overlay"):
effect_overlay = self._effect.is_overlay
# Only return True if it's explicitly set to True
if effect_overlay is True:
return True
return self.name == "hud"
@property

View File

@@ -1,165 +0,0 @@
"""
Frame Capture Stage Adapter
Wraps pipeline stages to capture frames for animation report generation.
"""
from typing import Any
from engine.display.backends.animation_report import AnimationReportDisplay
from engine.pipeline.core import PipelineContext, Stage
class FrameCaptureStage(Stage):
"""
Wrapper stage that captures frames before and after a wrapped stage.
This allows generating animation reports showing how each stage
transforms the data.
"""
def __init__(
self,
wrapped_stage: Stage,
display: AnimationReportDisplay,
name: str | None = None,
):
"""
Initialize frame capture stage.
Args:
wrapped_stage: The stage to wrap and capture frames from
display: The animation report display to send frames to
name: Optional name for this capture stage
"""
self._wrapped_stage = wrapped_stage
self._display = display
self.name = name or f"capture_{wrapped_stage.name}"
self.category = wrapped_stage.category
self.optional = wrapped_stage.optional
# Capture state
self._captured_input = False
self._captured_output = False
@property
def stage_type(self) -> str:
return self._wrapped_stage.stage_type
@property
def capabilities(self) -> set[str]:
return self._wrapped_stage.capabilities
@property
def dependencies(self) -> set[str]:
return self._wrapped_stage.dependencies
@property
def inlet_types(self) -> set:
return self._wrapped_stage.inlet_types
@property
def outlet_types(self) -> set:
return self._wrapped_stage.outlet_types
def init(self, ctx: PipelineContext) -> bool:
"""Initialize the wrapped stage."""
return self._wrapped_stage.init(ctx)
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""
Process data through wrapped stage and capture frames.
Args:
data: Input data (typically a text buffer)
ctx: Pipeline context
Returns:
Output data from wrapped stage
"""
# Capture input frame (before stage processing)
if isinstance(data, list) and all(isinstance(line, str) for line in data):
self._display.start_stage(f"{self._wrapped_stage.name}_input")
self._display.show(data)
self._captured_input = True
# Process through wrapped stage
result = self._wrapped_stage.process(data, ctx)
# Capture output frame (after stage processing)
if isinstance(result, list) and all(isinstance(line, str) for line in result):
self._display.start_stage(f"{self._wrapped_stage.name}_output")
self._display.show(result)
self._captured_output = True
return result
def cleanup(self) -> None:
"""Cleanup the wrapped stage."""
self._wrapped_stage.cleanup()
class FrameCaptureController:
"""
Controller for managing frame capture across the pipeline.
This class provides an easy way to enable frame capture for
specific stages or the entire pipeline.
"""
def __init__(self, display: AnimationReportDisplay):
"""
Initialize frame capture controller.
Args:
display: The animation report display to use for capture
"""
self._display = display
self._captured_stages: list[FrameCaptureStage] = []
def wrap_stage(self, stage: Stage, name: str | None = None) -> FrameCaptureStage:
"""
Wrap a stage with frame capture.
Args:
stage: The stage to wrap
name: Optional name for the capture stage
Returns:
Wrapped stage that captures frames
"""
capture_stage = FrameCaptureStage(stage, self._display, name)
self._captured_stages.append(capture_stage)
return capture_stage
def wrap_stages(self, stages: dict[str, Stage]) -> dict[str, Stage]:
"""
Wrap multiple stages with frame capture.
Args:
stages: Dictionary of stage names to stages
Returns:
Dictionary of stage names to wrapped stages
"""
wrapped = {}
for name, stage in stages.items():
wrapped[name] = self.wrap_stage(stage, name)
return wrapped
def get_captured_stages(self) -> list[FrameCaptureStage]:
"""Get list of all captured stages."""
return self._captured_stages
def generate_report(self, title: str = "Pipeline Animation Report") -> str:
"""
Generate the animation report.
Args:
title: Title for the report
Returns:
Path to the generated HTML file
"""
report_path = self._display.generate_report(title)
return str(report_path)

View File

@@ -1,185 +0,0 @@
"""
Message overlay stage - Renders ntfy messages as an overlay on the buffer.
This stage provides message overlay capability for displaying ntfy.sh messages
as a centered panel with pink/magenta gradient, matching upstream/main aesthetics.
"""
import re
import time
from dataclasses import dataclass
from datetime import datetime
from engine import config
from engine.effects.legacy import vis_trunc
from engine.pipeline.core import DataType, PipelineContext, Stage
from engine.render.blocks import big_wrap
from engine.render.gradient import msg_gradient
@dataclass
class MessageOverlayConfig:
"""Configuration for MessageOverlayStage."""
enabled: bool = True
display_secs: int = 30 # How long to display messages
topic_url: str | None = None # Ntfy topic URL (None = use config default)
class MessageOverlayStage(Stage):
"""Stage that renders ntfy message overlay on the buffer.
Provides:
- message.overlay capability (optional)
- Renders centered panel with pink/magenta gradient
- Shows title, body, timestamp, and remaining time
"""
name = "message_overlay"
category = "overlay"
def __init__(
self, config: MessageOverlayConfig | None = None, name: str = "message_overlay"
):
self.config = config or MessageOverlayConfig()
self._ntfy_poller = None
self._msg_cache = (None, None) # (cache_key, rendered_rows)
@property
def capabilities(self) -> set[str]:
"""Provides message overlay capability."""
return {"message.overlay"} if self.config.enabled else set()
@property
def dependencies(self) -> set[str]:
"""Needs rendered buffer and camera transformation to overlay onto."""
return {"render.output", "camera"}
@property
def inlet_types(self) -> set:
return {DataType.TEXT_BUFFER}
@property
def outlet_types(self) -> set:
return {DataType.TEXT_BUFFER}
def init(self, ctx: PipelineContext) -> bool:
"""Initialize ntfy poller if topic URL is configured."""
if not self.config.enabled:
return True
# Get or create ntfy poller
topic_url = self.config.topic_url or config.NTFY_TOPIC
if topic_url:
from engine.ntfy import NtfyPoller
self._ntfy_poller = NtfyPoller(
topic_url=topic_url,
reconnect_delay=getattr(config, "NTFY_RECONNECT_DELAY", 5),
display_secs=self.config.display_secs,
)
self._ntfy_poller.start()
ctx.set("ntfy_poller", self._ntfy_poller)
return True
def process(self, data: list[str], ctx: PipelineContext) -> list[str]:
"""Render message overlay on the buffer."""
if not self.config.enabled or not data:
return data
# Get active message from poller
msg = None
if self._ntfy_poller:
msg = self._ntfy_poller.get_active_message()
if msg is None:
return data
# Render overlay
w = ctx.terminal_width if hasattr(ctx, "terminal_width") else 80
h = ctx.terminal_height if hasattr(ctx, "terminal_height") else 24
overlay, self._msg_cache = self._render_message_overlay(
msg, w, h, self._msg_cache
)
# Composite overlay onto buffer
result = list(data)
for line in overlay:
# Overlay uses ANSI cursor positioning, just append
result.append(line)
return result
def _render_message_overlay(
self,
msg: tuple[str, str, float] | None,
w: int,
h: int,
msg_cache: tuple,
) -> tuple[list[str], tuple]:
"""Render ntfy message overlay.
Args:
msg: (title, body, timestamp) or None
w: terminal width
h: terminal height
msg_cache: (cache_key, rendered_rows) for caching
Returns:
(list of ANSI strings, updated cache)
"""
overlay = []
if msg is None:
return overlay, msg_cache
m_title, m_body, m_ts = msg
display_text = m_body or m_title or "(empty)"
display_text = re.sub(r"\s+", " ", display_text.upper())
cache_key = (display_text, w)
if msg_cache[0] != cache_key:
msg_rows = big_wrap(display_text, w - 4)
msg_cache = (cache_key, msg_rows)
else:
msg_rows = msg_cache[1]
msg_rows = msg_gradient(msg_rows, (time.monotonic() * config.GRAD_SPEED) % 1.0)
elapsed_s = int(time.monotonic() - m_ts)
remaining = max(0, self.config.display_secs - elapsed_s)
ts_str = datetime.now().strftime("%H:%M:%S")
panel_h = len(msg_rows) + 2
panel_top = max(0, (h - panel_h) // 2)
row_idx = 0
for mr in msg_rows:
ln = vis_trunc(mr, w)
overlay.append(f"\033[{panel_top + row_idx + 1};1H {ln}\033[0m\033[K")
row_idx += 1
meta_parts = []
if m_title and m_title != m_body:
meta_parts.append(m_title)
meta_parts.append(f"ntfy \u00b7 {ts_str} \u00b7 {remaining}s")
meta = (
" " + " \u00b7 ".join(meta_parts)
if len(meta_parts) > 1
else " " + meta_parts[0]
)
overlay.append(
f"\033[{panel_top + row_idx + 1};1H\033[38;5;245m{meta}\033[0m\033[K"
)
row_idx += 1
bar = "\u2500" * (w - 4)
overlay.append(
f"\033[{panel_top + row_idx + 1};1H \033[2;38;5;37m{bar}\033[0m\033[K"
)
return overlay, msg_cache
def cleanup(self) -> None:
"""Cleanup resources."""
pass

View File

@@ -1,185 +0,0 @@
"""PositionStage - Configurable positioning mode for terminal rendering.
This module provides positioning stages that allow choosing between
different ANSI positioning approaches:
- ABSOLUTE: Use cursor positioning codes (\\033[row;colH) for all lines
- RELATIVE: Use newlines for all lines
- MIXED: Base content uses newlines, effects use cursor positioning (default)
"""
from enum import Enum
from typing import Any
from engine.pipeline.core import DataType, PipelineContext, Stage
class PositioningMode(Enum):
"""Positioning mode for terminal rendering."""
ABSOLUTE = "absolute" # All lines have cursor positioning codes
RELATIVE = "relative" # Lines use newlines (no cursor codes)
MIXED = "mixed" # Mixed: newlines for base, cursor codes for overlays (default)
class PositionStage(Stage):
"""Applies positioning mode to buffer before display.
This stage allows configuring how lines are positioned in the terminal:
- ABSOLUTE: Each line has \\033[row;colH prefix (precise control)
- RELATIVE: Lines are joined with \\n (natural flow)
- MIXED: Leaves buffer as-is (effects add their own positioning)
"""
def __init__(
self, mode: PositioningMode = PositioningMode.RELATIVE, name: str = "position"
):
self.mode = mode
self.name = name
self.category = "position"
self._mode_str = mode.value
def save_state(self) -> dict[str, Any]:
"""Save positioning mode for restoration."""
return {"mode": self.mode.value}
def restore_state(self, state: dict[str, Any]) -> None:
"""Restore positioning mode from saved state."""
mode_value = state.get("mode", "relative")
self.mode = PositioningMode(mode_value)
@property
def capabilities(self) -> set[str]:
return {"position.output"}
@property
def dependencies(self) -> set[str]:
# Position stage typically runs after render but before effects
# Effects may add their own positioning codes
return {"render.output"}
@property
def inlet_types(self) -> set:
return {DataType.TEXT_BUFFER}
@property
def outlet_types(self) -> set:
return {DataType.TEXT_BUFFER}
def init(self, ctx: PipelineContext) -> bool:
"""Initialize the positioning stage."""
return True
def process(self, data: Any, ctx: PipelineContext) -> Any:
"""Apply positioning mode to the buffer.
Args:
data: List of strings (buffer lines)
ctx: Pipeline context
Returns:
Buffer with applied positioning mode
"""
if data is None:
return data
if not isinstance(data, list):
return data
if self.mode == PositioningMode.ABSOLUTE:
return self._to_absolute(data, ctx)
elif self.mode == PositioningMode.RELATIVE:
return self._to_relative(data, ctx)
else: # MIXED
return data # No transformation
def _to_absolute(self, data: list[str], ctx: PipelineContext) -> list[str]:
"""Convert buffer to absolute positioning (all lines have cursor codes).
This mode prefixes each line with \\033[row;colH to move cursor
to the exact position before writing the line.
Args:
data: List of buffer lines
ctx: Pipeline context (provides terminal dimensions)
Returns:
Buffer with cursor positioning codes for each line
"""
result = []
viewport_height = ctx.params.viewport_height if ctx.params else 24
for i, line in enumerate(data):
if i >= viewport_height:
break # Don't exceed viewport
# Check if line already has cursor positioning
if "\033[" in line and "H" in line:
# Already has cursor positioning - leave as-is
result.append(line)
else:
# Add cursor positioning for this line
# Row is 1-indexed
result.append(f"\033[{i + 1};1H{line}")
return result
def _to_relative(self, data: list[str], ctx: PipelineContext) -> list[str]:
"""Convert buffer to relative positioning (use newlines).
This mode removes explicit cursor positioning codes from lines
(except for effects that specifically add them).
Note: Effects like HUD add their own cursor positioning codes,
so we can't simply remove all of them. We rely on the terminal
display to join lines with newlines.
Args:
data: List of buffer lines
ctx: Pipeline context (unused)
Returns:
Buffer with minimal cursor positioning (only for overlays)
"""
# For relative mode, we leave the buffer as-is
# The terminal display handles joining with newlines
# Effects that need absolute positioning will add their own codes
# Filter out lines that would cause double-positioning
result = []
for i, line in enumerate(data):
# Check if this line looks like base content (no cursor code at start)
# vs an effect line (has cursor code at start)
if line.startswith("\033[") and "H" in line[:20]:
# This is an effect with positioning - keep it
result.append(line)
else:
# Base content - strip any inline cursor codes (rare)
# but keep color codes
result.append(line)
return result
def cleanup(self) -> None:
"""Clean up positioning stage."""
pass
# Convenience function to create positioning stage
def create_position_stage(
mode: str = "relative", name: str = "position"
) -> PositionStage:
"""Create a positioning stage with the specified mode.
Args:
mode: Positioning mode ("absolute", "relative", or "mixed")
name: Name for the stage
Returns:
PositionStage instance
"""
try:
positioning_mode = PositioningMode(mode)
except ValueError:
positioning_mode = PositioningMode.RELATIVE
return PositionStage(mode=positioning_mode, name=name)

View File

@@ -474,10 +474,9 @@ class Pipeline:
not self._find_stage_with_capability("display.output")
and "display" not in self._stages
):
display_name = self.config.display or "terminal"
display = DisplayRegistry.create(display_name)
display = DisplayRegistry.create("terminal")
if display:
self.add_stage("display", DisplayStage(display, name=display_name))
self.add_stage("display", DisplayStage(display, name="terminal"))
injected.append("display")
# Rebuild pipeline if stages were injected

View File

@@ -1,206 +0,0 @@
"""Graph-based pipeline configuration and orchestration.
This module provides a graph abstraction for defining pipelines as nodes
and connections, replacing the verbose XYZStage naming convention.
Usage:
# Declarative (TOML-like)
graph = Graph.from_dict({
"nodes": {
"source": "headlines",
"camera": {"type": "camera", "mode": "scroll"},
"display": {"type": "terminal", "positioning": "mixed"}
},
"connections": ["source -> camera -> display"]
})
# Imperative
graph = Graph()
graph.node("source", "headlines")
graph.node("camera", type="camera", mode="scroll")
graph.connect("source", "camera", "display")
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union
from enum import Enum
class NodeType(Enum):
"""Types of pipeline nodes."""
SOURCE = "source"
RENDER = "render"
CAMERA = "camera"
EFFECT = "effect"
OVERLAY = "overlay"
POSITION = "position"
DISPLAY = "display"
CUSTOM = "custom"
@dataclass
class Node:
"""A node in the pipeline graph."""
name: str
type: NodeType
config: Dict[str, Any] = field(default_factory=dict)
enabled: bool = True
optional: bool = False
def __repr__(self) -> str:
return f"Node({self.name}, type={self.type.value})"
@dataclass
class Connection:
"""A connection between two nodes."""
source: str
target: str
data_type: Optional[str] = None # Optional data type constraint
@dataclass
class Graph:
"""Pipeline graph representation."""
nodes: Dict[str, Node] = field(default_factory=dict)
connections: List[Connection] = field(default_factory=list)
def node(self, name: str, node_type: Union[NodeType, str], **config) -> "Graph":
"""Add a node to the graph."""
if isinstance(node_type, str):
# Try to parse as NodeType
try:
node_type = NodeType(node_type)
except ValueError:
node_type = NodeType.CUSTOM
self.nodes[name] = Node(name=name, type=node_type, config=config)
return self
def connect(
self, source: str, target: str, data_type: Optional[str] = None
) -> "Graph":
"""Add a connection between nodes."""
if source not in self.nodes:
raise ValueError(f"Source node '{source}' not found")
if target not in self.nodes:
raise ValueError(f"Target node '{target}' not found")
self.connections.append(Connection(source, target, data_type))
return self
def chain(self, *names: str) -> "Graph":
"""Connect nodes in a chain."""
for i in range(len(names) - 1):
self.connect(names[i], names[i + 1])
return self
def from_dict(self, data: Dict[str, Any]) -> "Graph":
"""Load graph from dictionary (TOML-compatible)."""
# Parse nodes
nodes_data = data.get("nodes", {})
for name, node_info in nodes_data.items():
if isinstance(node_info, str):
# Simple format: "source": "headlines"
self.node(name, NodeType.SOURCE, source=node_info)
elif isinstance(node_info, dict):
# Full format: {"type": "camera", "mode": "scroll"}
node_type = node_info.get("type", "custom")
config = {k: v for k, v in node_info.items() if k != "type"}
self.node(name, node_type, **config)
# Parse connections
connections_data = data.get("connections", [])
for conn in connections_data:
if isinstance(conn, str):
# Parse "source -> target" format
parts = conn.split("->")
if len(parts) == 2:
self.connect(parts[0].strip(), parts[1].strip())
elif isinstance(conn, dict):
# Parse dict format: {"source": "a", "target": "b"}
self.connect(conn["source"], conn["target"])
return self
def to_dict(self) -> Dict[str, Any]:
"""Convert graph to dictionary."""
return {
"nodes": {
name: {"type": node.type.value, **node.config}
for name, node in self.nodes.items()
},
"connections": [
{"source": conn.source, "target": conn.target}
for conn in self.connections
],
}
def validate(self) -> List[str]:
"""Validate graph structure and return list of errors."""
errors = []
# Check for disconnected nodes
connected_nodes = set()
for conn in self.connections:
connected_nodes.add(conn.source)
connected_nodes.add(conn.target)
for node_name in self.nodes:
if node_name not in connected_nodes:
errors.append(f"Node '{node_name}' is not connected")
# Check for cycles (simplified)
visited = set()
temp = set()
def has_cycle(node_name: str) -> bool:
if node_name in temp:
return True
if node_name in visited:
return False
temp.add(node_name)
for conn in self.connections:
if conn.source == node_name:
if has_cycle(conn.target):
return True
temp.remove(node_name)
visited.add(node_name)
return False
for node_name in self.nodes:
if has_cycle(node_name):
errors.append(f"Cycle detected involving node '{node_name}'")
break
return errors
def __repr__(self) -> str:
nodes_str = ", ".join(str(n) for n in self.nodes.values())
return f"Graph(nodes=[{nodes_str}])"
# Factory functions for common node types
def source(name: str, source_type: str, **config) -> Node:
"""Create a source node."""
return Node(name, NodeType.SOURCE, {"source": source_type, **config})
def camera(name: str, mode: str = "scroll", **config) -> Node:
"""Create a camera node."""
return Node(name, NodeType.CAMERA, {"mode": mode, **config})
def display(name: str, backend: str = "terminal", **config) -> Node:
"""Create a display node."""
return Node(name, NodeType.DISPLAY, {"backend": backend, **config})
def effect(name: str, effect_name: str, **config) -> Node:
"""Create an effect node."""
return Node(name, NodeType.EFFECT, {"effect": effect_name, **config})

View File

@@ -1,161 +0,0 @@
"""Adapter to convert Graph to Pipeline stages.
This module bridges the new graph-based abstraction with the existing
Stage-based pipeline system for backward compatibility.
"""
from typing import Dict, Any, List, Optional
from engine.pipeline.graph import Graph, NodeType
from engine.pipeline.controller import Pipeline, PipelineConfig
from engine.pipeline.core import PipelineContext
from engine.pipeline.params import PipelineParams
from engine.pipeline.adapters import (
CameraStage,
DataSourceStage,
DisplayStage,
EffectPluginStage,
FontStage,
MessageOverlayStage,
PositionStage,
ViewportFilterStage,
create_stage_from_display,
create_stage_from_effect,
)
from engine.pipeline.adapters.positioning import PositioningMode
from engine.display import DisplayRegistry
from engine.effects import get_registry
from engine.data_sources.sources import EmptyDataSource, HeadlinesDataSource
from engine.camera import Camera
class GraphAdapter:
"""Converts Graph to Pipeline with existing Stage classes."""
def __init__(self, graph: Graph):
self.graph = graph
self.pipeline: Optional[Pipeline] = None
self.context: Optional[PipelineContext] = None
def build_pipeline(
self, viewport_width: int = 80, viewport_height: int = 24
) -> Pipeline:
"""Build a Pipeline from the Graph."""
# Create pipeline context
self.context = PipelineContext()
self.context.terminal_width = viewport_width
self.context.terminal_height = viewport_height
# Create params
params = PipelineParams(
viewport_width=viewport_width,
viewport_height=viewport_height,
)
self.context.params = params
# Create pipeline config
config = PipelineConfig()
# Create pipeline
self.pipeline = Pipeline(config=config, context=self.context)
# Map graph nodes to pipeline stages
self._map_nodes_to_stages()
# Build pipeline
self.pipeline.build()
return self.pipeline
def _map_nodes_to_stages(self) -> None:
"""Map graph nodes to pipeline stages."""
for name, node in self.graph.nodes.items():
if not node.enabled:
continue
stage = self._create_stage_from_node(name, node)
if stage:
self.pipeline.add_stage(name, stage)
def _create_stage_from_node(self, name: str, node) -> Optional:
"""Create a pipeline stage from a graph node."""
stage = None
if node.type == NodeType.SOURCE:
source_type = node.config.get("source", "headlines")
if source_type == "headlines":
source = HeadlinesDataSource()
elif source_type == "empty":
source = EmptyDataSource(
width=self.context.terminal_width,
height=self.context.terminal_height,
)
else:
source = EmptyDataSource(
width=self.context.terminal_width,
height=self.context.terminal_height,
)
stage = DataSourceStage(source, name=name)
elif node.type == NodeType.CAMERA:
mode = node.config.get("mode", "scroll")
speed = node.config.get("speed", 1.0)
# Map mode string to Camera factory method
mode_lower = mode.lower()
if hasattr(Camera, mode_lower):
camera_factory = getattr(Camera, mode_lower)
camera = camera_factory(speed=speed)
else:
# Fallback to scroll mode
camera = Camera.scroll(speed=speed)
stage = CameraStage(camera, name=name)
elif node.type == NodeType.DISPLAY:
backend = node.config.get("backend", "terminal")
positioning = node.config.get("positioning", "mixed")
display = DisplayRegistry.create(backend)
if display:
stage = DisplayStage(display, name=name, positioning=positioning)
elif node.type == NodeType.EFFECT:
effect_name = node.config.get("effect", "")
intensity = node.config.get("intensity", 1.0)
effect = get_registry().get(effect_name)
if effect:
# Set effect intensity (modifies global effect state)
effect.config.intensity = intensity
# Effects typically depend on rendered output
dependencies = {"render.output"}
stage = EffectPluginStage(effect, name=name, dependencies=dependencies)
elif node.type == NodeType.RENDER:
stage = FontStage(name=name)
elif node.type == NodeType.OVERLAY:
stage = MessageOverlayStage(name=name)
elif node.type == NodeType.POSITION:
mode_str = node.config.get("mode", "mixed")
try:
mode = PositioningMode(mode_str)
except ValueError:
mode = PositioningMode.MIXED
stage = PositionStage(mode=mode, name=name)
return stage
def graph_to_pipeline(
graph: Graph, viewport_width: int = 80, viewport_height: int = 24
) -> Pipeline:
"""Convert a Graph to a Pipeline."""
adapter = GraphAdapter(graph)
return adapter.build_pipeline(viewport_width, viewport_height)
def dict_to_pipeline(
data: Dict[str, Any], viewport_width: int = 80, viewport_height: int = 24
) -> Pipeline:
"""Convert a dictionary to a Pipeline."""
graph = Graph().from_dict(data)
return graph_to_pipeline(graph, viewport_width, viewport_height)

View File

@@ -29,7 +29,6 @@ class PipelineParams:
# Display config
display: str = "terminal"
border: bool | BorderMode = False
positioning: str = "mixed" # Positioning mode: "absolute", "relative", "mixed"
# Camera config
camera_mode: str = "vertical"
@@ -85,7 +84,6 @@ class PipelineParams:
return {
"source": self.source,
"display": self.display,
"positioning": self.positioning,
"camera_mode": self.camera_mode,
"camera_speed": self.camera_speed,
"effect_order": self.effect_order,

View File

@@ -59,8 +59,6 @@ class PipelinePreset:
viewport_height: int = 24 # Viewport height in rows
source_items: list[dict[str, Any]] | None = None # For ListDataSource
enable_metrics: bool = True # Enable performance metrics collection
enable_message_overlay: bool = False # Enable ntfy message overlay
positioning: str = "mixed" # Positioning mode: "absolute", "relative", "mixed"
def to_params(self) -> PipelineParams:
"""Convert to PipelineParams (runtime configuration)."""
@@ -69,7 +67,6 @@ class PipelinePreset:
params = PipelineParams()
params.source = self.source
params.display = self.display
params.positioning = self.positioning
params.border = (
self.border
if isinstance(self.border, bool)
@@ -116,39 +113,17 @@ class PipelinePreset:
viewport_height=data.get("viewport_height", 24),
source_items=data.get("source_items"),
enable_metrics=data.get("enable_metrics", True),
enable_message_overlay=data.get("enable_message_overlay", False),
positioning=data.get("positioning", "mixed"),
)
# Built-in presets
# Upstream-default preset: Matches the default upstream Mainline operation
UPSTREAM_PRESET = PipelinePreset(
name="upstream-default",
description="Upstream default operation (terminal display, legacy behavior)",
source="headlines",
display="terminal",
camera="scroll",
effects=["noise", "fade", "glitch", "firehose"],
enable_message_overlay=False,
positioning="mixed",
)
# Demo preset: Showcases hotswappable effects and sensors
# This preset demonstrates the sideline features:
# - Hotswappable effects via effect plugins
# - Sensor integration (oscillator LFO for modulation)
# - Mixed positioning mode
# - Message overlay with ntfy integration
DEMO_PRESET = PipelinePreset(
name="demo",
description="Demo: Hotswappable effects, LFO sensor modulation, mixed positioning",
description="Demo mode with effect cycling and camera modes",
source="headlines",
display="pygame",
camera="scroll",
effects=["noise", "fade", "glitch", "firehose", "hud"],
enable_message_overlay=True,
positioning="mixed",
effects=["noise", "fade", "glitch", "firehose"],
)
UI_PRESET = PipelinePreset(
@@ -159,7 +134,6 @@ UI_PRESET = PipelinePreset(
camera="scroll",
effects=["noise", "fade", "glitch"],
border=BorderMode.UI,
enable_message_overlay=True,
)
POETRY_PRESET = PipelinePreset(
@@ -196,7 +170,6 @@ FIREHOSE_PRESET = PipelinePreset(
display="pygame",
camera="scroll",
effects=["noise", "fade", "glitch", "firehose"],
enable_message_overlay=True,
)
FIXTURE_PRESET = PipelinePreset(
@@ -223,7 +196,6 @@ def _build_presets() -> dict[str, PipelinePreset]:
# Add built-in presets as fallback (if not in YAML)
builtins = {
"demo": DEMO_PRESET,
"upstream-default": UPSTREAM_PRESET,
"poetry": POETRY_PRESET,
"pipeline": PIPELINE_VIZ_PRESET,
"websocket": WEBSOCKET_PRESET,

View File

@@ -80,57 +80,3 @@ def lr_gradient_opposite(rows, offset=0.0):
List of lines with complementary gradient coloring applied
"""
return lr_gradient(rows, offset, MSG_GRAD_COLS)
def msg_gradient(rows, offset):
"""Apply message (ntfy) gradient using theme complementary colors.
Returns colored rows using ACTIVE_THEME.message_gradient if available,
falling back to default magenta if no theme is set.
Args:
rows: List of text strings to colorize
offset: Gradient offset (0.0-1.0) for animation
Returns:
List of rows with ANSI color codes applied
"""
from engine import config
# Check if theme is set and use it
if config.ACTIVE_THEME:
cols = _color_codes_to_ansi(config.ACTIVE_THEME.message_gradient)
else:
# Fallback to default magenta gradient
cols = MSG_GRAD_COLS
return lr_gradient(rows, offset, cols)
def _color_codes_to_ansi(color_codes):
"""Convert a list of 256-color codes to ANSI escape code strings.
Pattern: first 2 are bold, middle 8 are normal, last 2 are dim.
Args:
color_codes: List of 12 integers (256-color palette codes)
Returns:
List of ANSI escape code strings
"""
if not color_codes or len(color_codes) != 12:
# Fallback to default green if invalid
return GRAD_COLS
result = []
for i, code in enumerate(color_codes):
if i < 2:
# Bold for first 2 (bright leading edge)
result.append(f"\033[1;38;5;{code}m")
elif i < 10:
# Normal for middle 8
result.append(f"\033[38;5;{code}m")
else:
# Dim for last 2 (dark trailing edge)
result.append(f"\033[2;38;5;{code}m")
return result

View File

@@ -1,60 +0,0 @@
"""
Theme definitions with color gradients for terminal rendering.
This module is data-only and does not import config or render
to prevent circular dependencies.
"""
class Theme:
"""Represents a color theme with two gradients."""
def __init__(self, name, main_gradient, message_gradient):
"""Initialize a theme with name and color gradients.
Args:
name: Theme identifier string
main_gradient: List of 12 ANSI 256-color codes for main gradient
message_gradient: List of 12 ANSI 256-color codes for message gradient
"""
self.name = name
self.main_gradient = main_gradient
self.message_gradient = message_gradient
# ─── GRADIENT DEFINITIONS ─────────────────────────────────────────────────
# Each gradient is 12 ANSI 256-color codes in sequence
# Format: [light...] → [medium...] → [dark...] → [black]
_GREEN_MAIN = [231, 195, 123, 118, 82, 46, 40, 34, 28, 22, 22, 235]
_GREEN_MSG = [231, 225, 219, 213, 207, 201, 165, 161, 125, 89, 89, 235]
_ORANGE_MAIN = [231, 215, 209, 208, 202, 166, 130, 94, 58, 94, 94, 235]
_ORANGE_MSG = [231, 195, 33, 27, 21, 21, 21, 18, 18, 18, 18, 235]
_PURPLE_MAIN = [231, 225, 177, 171, 165, 135, 129, 93, 57, 57, 57, 235]
_PURPLE_MSG = [231, 226, 226, 220, 220, 184, 184, 178, 178, 172, 172, 235]
# ─── THEME REGISTRY ───────────────────────────────────────────────────────
THEME_REGISTRY = {
"green": Theme("green", _GREEN_MAIN, _GREEN_MSG),
"orange": Theme("orange", _ORANGE_MAIN, _ORANGE_MSG),
"purple": Theme("purple", _PURPLE_MAIN, _PURPLE_MSG),
}
def get_theme(theme_id):
"""Retrieve a theme by ID.
Args:
theme_id: Theme identifier string
Returns:
Theme object matching the ID
Raises:
KeyError: If theme_id is not in registry
"""
return THEME_REGISTRY[theme_id]

View File

@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 577.362 577.362"
xml:space="preserve">
<g>
<g id="Layer_2_21_">
<path d="M547.301,156.98c-23.113-2.132-181.832-24.174-314.358,5.718c-37.848-16.734-57.337-21.019-85.269-31.078
c-12.47-4.494-28.209-7.277-41.301-9.458c-26.01-4.322-45.89,1.253-54.697,31.346C36.94,203.846,19.201,253.293,0,311.386
c15.118-0.842,40.487-8.836,40.487-8.836l48.214-7.966l-9.964,66.938l57.777-19.526v57.776l66.938-29.883l19.125,49.41
c0,0,44.647-34.081,57.375-49.41c28.076,83.634,104.595,105.981,175.71,70.122c21.42-10.806,39.914-46.637,48.129-65.255
c23.926-54.229,11.6-93.712-5.891-137.155c20.254-9.562,34.061-13.464,66.344-30.628
C582.365,197.764,585.951,161.904,547.301,156.98z M63.352,196.119c11.924-8.396,18.599,0.889,34.511-10.308
c6.971-5.183,4.581-18.924-4.542-21.908c-3.997-1.31-6.722-2.897-12.049-5.192c-7.449-2.984-0.851-20.082,7.325-18.676
c15.443,2.572,24.575,3.012,32.159,12.125c8.702,10.452,9.008,37.074,4.991,45.843c-9.553,20.885-35.257,19.087-53.923,17.241
C57.624,214.097,56.744,201.034,63.352,196.119z M284.073,346.938c-51.915,6.685-102.921,0.794-142.462-42.313
c-25.331-27.616-57.231-46.187-88.654-68.611c28.84-11.121,64.49-5.078,84.781,25.704
c45.383,68.841,106.344,71.279,176.887,56.247c24.127-5.145,52.9-8.052,76.807-2.983c26.297,5.574,29.279,31.24,12.039,48.118
c-18.227,19.775-39.045-0.794-29.482-6.378c7.967-4.38,12.643-10.997,10.482-19.259c-6.197-9.668-21.707-2.975-31.586-1.425
C324.953,340.437,312.023,343.344,284.073,346.938z M472.188,381.049c-24.176,34.31-54.775,55.969-100.789,47.602
c-27.846-5.059-61.41-30.179-53.789-65.14c34.061,41.836,95.625,35.859,114.75,1.195c16.533-29.969-4.141-62.5-23.793-66.852
c-30.676-6.779-69.891-0.134-101.381,4.408c-58.58,8.444-104.48,7.812-152.579-43.844c-26.067-27.99,15.376-53.493-7.736-107.282
c44.351,8.578,72.121,22.711,89.247,79.292c11.293,37.294,59.096,61.325,110.762,53.387
c38.031-5.842,81.912-22.873,119.703-31.853C499.66,299.786,498.293,343.984,472.188,381.049z M288.195,243.568
c31.805-12.135,64.67-9.151,94.362,0C350.475,273.26,301.467,268.479,288.195,243.568z M528.979,198.959
c-35.459,17.337-60.961,25.102-98.809,37.055c-5.146,1.626-13.895,1.042-18.438-2.17c-47.803-33.813-114.846-27.425-142.338-6.292
c-18.522-11.456-21.038-42.582,8.406-49.304c83.834-19.125,179.45-13.646,248.788,0.793
C540.529,183.42,538.674,194.876,528.979,198.959z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

View File

@@ -1,60 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 559.731 559.731"
xml:space="preserve">
<g>
<g id="Layer_2_36_">
<path d="M295.414,162.367l-15.061-39.302l-14.918,39.34c5.049-0.507,10.165-0.774,15.339-0.774
C285.718,161.621,290.595,161.898,295.414,162.367z"/>
<path d="M522.103,244.126c-20.062-0.631-36.71,12.67-55.787,21.937c-25.111,12.192-17.548-7.526-17.548-7.526l56.419-107.186
c-31.346-31.967-127.869-68.324-127.869-68.324l-38.968,85.957L280.774,27.249L221.295,168.84l-38.9-85.804
c0,0-96.533,36.356-127.87,68.324l56.418,107.186c0,0,7.564,19.718-17.547,7.525c-19.077-9.266-35.726-22.567-55.788-21.936
C17.547,244.767,0,275.481,0,305.565c0,30.084,7.525,68.955,39.493,68.955c31.967,0,47.64-16.926,58.924-23.188
c11.284-6.273,20.062,1.252,14.105,12.536S49.524,465.412,49.524,465.412s57.041,40.115,130.375,67.071l33.22-84.083
c-49.601-24.91-83.796-76.127-83.796-135.31c0-61.372,36.758-114.214,89.352-137.986c1.511-0.688,3.002-1.406,4.542-2.037
c9.964-4.112,20.483-7.095,31.384-9.008l25.732-67.836l25.943,67.731c10.576,1.807,20.779,4.657,30.495,8.53
c1.176,0.468,2.391,0.88,3.557,1.377c53.99,23.18,91.925,76.844,91.925,139.229c0,59.795-34.913,111.441-85.346,136.056
l32.924,83.337c73.335-26.956,130.375-67.071,130.375-67.071s-57.04-90.26-62.998-101.544
c-5.957-11.284,2.821-18.81,14.105-12.536c11.283,6.272,26.956,23.188,58.924,23.188s39.493-38.861,39.493-68.955
C559.712,275.472,542.165,244.757,522.103,244.126z"/>
<path d="M256.131,173.478c-1.836,0.325-3.682,0.612-5.499,1.004c-8.912,1.932-17.518,4.676-25.723,8.205
c-4.045,1.74-7.995,3.634-11.839,5.728c-44.159,24.078-74.195,70.925-74.195,124.667c0,55.146,31.681,102.931,77.743,126.396
c19.297,9.831,41.052,15.491,64.146,15.491c22.481,0,43.682-5.393,62.596-14.745c46.895-23.18,79.302-71.394,79.302-127.152
c0-54.851-31.336-102.434-77.007-126.043c-3.557-1.836-7.172-3.576-10.892-5.116c-7.86-3.242-16.056-5.814-24.547-7.622
c-1.808-0.382-3.652-0.622-5.479-0.937c-1.807-0.306-3.614-0.593-5.44-0.832c-6.082-0.793-12.24-1.348-18.532-1.348
c-6.541,0-12.919,0.602-19.221,1.463C259.736,172.895,257.929,173.163,256.131,173.478z M280.783,196.084
c10.433,0,20.493,1.501,30.132,4.074c8.559,2.285,16.754,5.441,24.423,9.496c37.093,19.641,62.443,58.608,62.443,103.418
c0,43.155-23.543,80.832-58.408,101.114c-17.251,10.04-37.227,15.883-58.59,15.883c-22.127,0-42.753-6.282-60.416-16.992
c-33.842-20.531-56.581-57.614-56.581-100.005c0-44.064,24.499-82.486,60.578-102.434c14.889-8.233,31.776-13.196,49.715-14.22
C276.309,196.294,278.518,196.084,280.783,196.084z"/>
<path d="M236.997,354.764c-6.694,0-12.145,5.45-12.145,12.145v4.398c0,6.694,5.441,12.145,12.145,12.145h16.457
c-1.683-11.743-0.717-22.376,0.268-28.688H236.997z"/>
<path d="M327.458,383.452c5.001,0,9.295-3.041,11.15-7.373c0.641-1.473,0.994-3.079,0.994-4.771v-4.398
c0-1.874-0.507-3.605-1.271-5.192c-1.961-4.074-6.054-6.952-10.873-6.952h-17.882c2.592,8.415,3.5,18.303,1.683,28.688H327.458z"
/>
<path d="M173.339,313.082c0,36.949,18.752,69.596,47.239,88.94c14.516,9.859,31.566,16.237,49.945,17.978
c-7.879-8.176-12.527-17.633-15.089-26.985h-18.437c-6.407,0-12.116-2.85-16.084-7.277c-3.461-3.844-5.623-8.874-5.623-14.43
v-4.398c0-5.938,2.41-11.322,6.283-15.243c3.939-3.987,9.39-6.464,15.424-6.464h18.809h49.974h21.697
c3.863,0,7.449,1.1,10.595,2.888c6.579,3.729,11.093,10.72,11.093,18.819v4.398c0,7.765-4.131,14.535-10.279,18.379
c-3.328,2.075-7.22,3.328-11.428,3.328h-18.676c-3.088,9.056-8.463,18.227-16.791,26.909c17.27-1.798,33.296-7.756,47.162-16.772
c29.48-19.173,49.056-52.355,49.056-90.069c0-39.216-21.19-73.498-52.661-92.259c-16.064-9.572-34.75-15.176-54.765-15.176
c-20.798,0-40.172,6.043-56.638,16.313C193.698,240.942,173.339,274.64,173.339,313.082z M306.287,274.583
c4.513-9.027,15.156-14.64,27.778-14.64c0.775,0,1.502,0.201,2.257,0.249c11.026,0.622,21.22,5.499,27.53,13.598l2.238,2.888
l-2.19,2.926c-6.789,9.036-16.667,14.688-26.89,15.597c-0.956,0.086-1.912,0.19-2.878,0.19c-11.284,0-21.362-5.89-27.664-16.16
l-1.387-2.257L306.287,274.583z M268.353,311.484l1.271,3.691c1.501,4.398,6.206,13.493,11.159,13.493
c4.915,0,9.649-9.372,11.055-13.646l1.138-3.48l3.653,0.201c9.658,0.517,12.594-1.454,13.244-2.065
c0.392-0.363,0.641-0.794,0.641-1.722c0-2.639,2.142-4.781,4.781-4.781c2.639,0,4.781,2.143,4.781,4.781
c0,3.414-1.253,6.417-3.624,8.664c-3.396,3.223-8.731,4.666-16.84,4.781c-2.534,5.852-8.635,16.839-18.838,16.839
c-10.06,0-16.19-10.595-18.81-16.428c-5.756,0.315-13.368-0.249-18.216-4.514c-2.716-2.391-4.16-5.623-4.16-9.343
c0-2.639,2.142-4.781,4.781-4.781s4.781,2.143,4.781,4.781c0,0.976,0.258,1.597,0.908,2.171c2.2,1.932,8.004,2.696,14.42,1.855
L268.353,311.484z M257.9,273.789l2.238,2.878l-2.19,2.916c-7.411,9.888-18.532,15.788-29.758,15.788
c-1.875,0-3.701-0.22-5.499-0.535c-9.018-1.598-16.916-7.058-22.166-15.625l-1.396-2.266l1.186-2.372
c3.94-7.87,12.546-13.148,23.055-14.363c1.54-0.182,3.127-0.277,4.733-0.277C240.028,259.942,251.168,265.116,257.9,273.789z"/>
<path d="M301.468,383.452c2.228-10.596,1.08-20.636-1.961-28.688h-36.06c-0.918,5.489-2.171,16.591-0.191,28.688
c0.517,3.146,1.272,6.359,2.295,9.562c2.763,8.664,7.563,17.231,15.73,24.088c8.443-7.707,13.941-15.94,17.26-24.088
C299.86,389.801,300.808,386.607,301.468,383.452z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -1,110 +0,0 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg fill="#000000" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="800px" height="800px" viewBox="0 0 589.748 589.748"
xml:space="preserve">
<g>
<g id="Layer_2_2_">
<path d="M498.658,267.846c-9.219-9.744-20.59-14.382-33.211-15.491c-13.914-1.234-26.719,3.098-37.514,12.278
c-4.82,4.093-15.416,2.763-16.916-5.413c-0.795-4.303-0.096-7.602,2.305-11.246c3.854-5.862,6.98-12.202,10.422-18.331
c3.73-6.646,7.508-13.263,11.16-19.947c5.26-9.61,10.375-19.307,15.672-28.898c3.76-6.799,7.785-13.445,11.486-20.273
c0.459-0.851,0.104-3.031-0.594-3.48c-7.898-5.106-15.777-10.28-23.982-14.86c-7.602-4.236-15.502-7.975-23.447-11.542
c-8.348-3.739-16.889-7.076-25.418-10.404c-0.879-0.344-2.869,0.191-3.299,0.928c-5.26,9.008-10.346,18.111-15.443,27.215
c-4.006,7.153-7.918,14.363-11.924,21.516c-2.381,4.255-4.877,8.434-7.297,12.661c-3.193,5.575-6.215,11.255-9.609,16.715
c-1.234,1.989-0.363,2.467,1.07,3.232c5.25,2.812,11.016,5.001,15.586,8.673c7.736,6.225,15.109,13.034,21.879,20.301
c4.629,4.963,8.598,10.796,11.725,16.82c3.824,7.373,6.865,15.233,9.477,23.132c2.094,6.34,4.006,13.024,4.283,19.632
c0.441,10.317,1.473,20.837-1.291,31.04c-2.352,8.645-4.484,17.423-7.764,25.723c-2.41,6.101-6.445,11.58-9.879,17.27
c-6.225,10.309-14.354,18.943-24.115,25.925c-6.428,4.599-13.207,8.701-20.035,13.157c14.621,26.584,29.396,53.436,44.266,80.459
c4.762-1.788,9.256-3.375,13.664-5.154c7.412-2.974,14.918-5.766,22.129-9.189c6.082-2.888,11.857-6.464,17.662-9.906
c7.41-4.399,14.734-8.932,22.012-13.541c0.604-0.382,1.043-2.056,0.717-2.706c-1.768-3.5-3.748-6.904-5.766-10.271
c-4.246-7.085-8.635-14.095-12.812-21.219c-3.5-5.967-6.752-12.077-10.166-18.083c-3.711-6.512-7.525-12.957-11.207-19.488
c-2.611-4.638-4.887-9.477-7.65-14.019c-2.008-3.299-3.91-6.292-3.768-10.528c0.152-4.6,2.18-7.583,5.824-9.668
c3.613-2.056,7.391-1.864,10.814,0.546c2.945,2.074,5.412,5.077,8.615,6.492c5.527,2.438,11.408,4.122,17.232,5.834
c7.602,2.228,15.328,0.927,22.586-1.062c7.268-1.989,14.258-5.394,19.861-10.806c2.85-2.754,5.939-5.441,8.09-8.712
c4.285-6.493,7.432-13.426,8.885-21.324c1.51-8.195,0.688-16.065-1.645-23.61C508.957,280.516,504.404,273.927,498.658,267.846z"
/>
<path d="M183.983,301.85c0.421-46.885,24.174-79.417,64.69-100.846c-1.817-3.471-3.461-6.761-5.24-9.983
c-3.423-6.177-6.99-12.278-10.375-18.475c-5.518-10.117-10.882-20.32-16.438-30.418c-3.577-6.502-7.574-12.766-10.987-19.345
c-1.454-2.802-2.802-3.137-5.613-2.142c-12.642,4.466-25.016,9.543-36.979,15.606c-11.915,6.043-23.418,12.728-34.32,20.492
c-1.778,1.262-1.96,2.104-1.004,3.777c2.792,4.848,5.537,9.725,8.271,14.611c4.973,8.874,9.955,17.739,14.86,26.632
c3.242,5.871,6.282,11.857,9.572,17.7c5.843,10.375,12.02,20.579,17.643,31.078c2.448,4.571,2.247,10.604-2.639,14.009
c-5.011,3.491-9.486,3.596-14.22-0.115c-6.311-4.953-13.167-8.424-20.913-10.509c-11.59-3.127-22.711-1.894-33.564,2.802
c-2.18,0.946-4.112,2.429-6.244,3.48c-6.216,3.079-10.815,7.994-14.755,13.455c-4.447,6.168-7.076,13.158-8.683,20.655
c-1.73,8.071-1.052,16.008,1.167,23.677c2.878,9.955,8.807,18.149,16.677,24.996c5.613,4.887,12.192,8.339,19.096,9.975
c6.666,1.577,13.933,1.367,20.866,0.898c7.621-0.507,14.621-3.528,20.817-8.176c5.699-4.274,11.16-9.209,18.905-3.558
c3.242,2.362,5.431,10.375,3.414,13.751c-7.937,13.272-15.816,26.584-23.524,39.99c-4.169,7.249-7.851,14.774-11.915,22.09
c-4.456,8.013-9.151,15.902-13.646,23.896c-2.362,4.207-2.094,4.724,2.142,7.277c4.8,2.878,9.505,5.947,14.373,8.711
c8.09,4.6,16.18,9.237,24.48,13.436c5.556,2.812,11.427,5.011,17.241,7.286c5.393,2.113,10.892,3.969,16.524,6.006
c14.908-27.119,29.653-53.942,44.322-80.631C207.775,381.381,183.563,349.012,183.983,301.85z"/>
<path d="M283.979,220.368c-36.777,4.839-64.327,32.302-72.245,60.99c55.348,0,110.629,0,166.129,0
C364.667,233.545,324.189,215.08,283.979,220.368z"/>
<path d="M381.019,300.482c-9.82,0-19.201,0-28.889,0c0.727,9.562-3.203,28.143-13.1,40.028
c-9.926,11.915-22.529,18.207-37.658,19.68c-16.983,1.645-32.694-1.692-45.546-13.464c-13.655-12.498-20.129-27.119-18.81-46.244
c-9.763,0-18.972,0-29.223,0c-0.239,38.25,14.688,62.089,45.719,78.986c29.863,16.266,60.559,15.242,88.883-3.433
C369.066,358.45,382.291,329.17,381.019,300.482z"/>
<path d="M260.656,176.715c3.242,5.948,6.474,11.886,9.477,17.404c6.541-0.88,12.622-2.458,18.675-2.343
c9.313,0.182,18.59,1.559,27.893,2.314c0.957,0.077,2.486-0.296,2.869-0.975c2.486-4.332,4.695-8.817,7.057-13.215
c2.238-4.169,4.543-8.3,6.752-12.316c-12.719-24.203-25.389-48.319-38.451-73.172c-0.822,1.482-1.358,2.381-1.836,3.309
c-1.96,3.825-3.854,7.688-5.862,11.484c-2.438,4.628-4.954,9.218-7.459,13.818c-2.228,4.083-4.456,8.157-6.722,12.221
c-2.381,4.274-4.858,8.501-7.201,12.804c-2.381,4.361-4.418,8.932-7.028,13.148c-2.611,4.208-2.917,7.526-0.249,11.762
C259.336,174.171,259.967,175.462,260.656,176.715z"/>
<path d="M272.991,331.341c10.949,8.501,29.424,10.643,42.047,1.157c10.566-7.938,16.734-22.453,13.721-32.016
c-22.807,0-45.632,0-68.41,0C257.127,310.045,263.008,323.595,272.991,331.341z"/>
<path d="M322.248,413.836c-1.281-2.447-2.811-3.356-6.119-2.515c-5.699,1.444-11.676,2.133-17.566,2.381
c-10.175,0.431-20.388,0.479-30.486-2.696c-2.62,6.034-5.125,11.8-7.688,17.69c22.96,8.894,45.729,8.894,68.889,0.899
c-0.049-0.794,0.105-1.492-0.145-1.999C326.886,422.987,324.638,418.379,322.248,413.836z"/>
<path d="M541.498,355.343c10.613-15.654,15.863-33.345,15.586-52.556c-0.43-30.237-12.9-55.721-36.088-73.708
c-12.527-9.715-25.887-16.065-39.914-18.972c0.469-0.794,0.928-1.597,1.377-2.4c2.295-4.15,4.514-8.338,6.74-12.527
c1.914-3.605,3.836-7.21,5.795-10.796c1.482-2.716,3.014-5.403,4.543-8.09c2.295-4.036,4.59-8.081,6.76-12.183
c4.189-7.908,3.031-18.59-2.744-25.398c-2.781-3.28-5.785-5.25-7.773-6.56l-0.871-0.583l-4.465-3.213
c-3.883-2.812-7.908-5.709-12.184-8.491c-7.707-5.011-14.793-9.343-21.668-13.244c-4.17-2.362-8.387-4.236-12.105-5.891
l-3.08-1.377c-1.988-0.909-3.969-1.846-5.957-2.773c-5.633-2.658-11.455-5.402-17.795-7.707c-7.422-2.697-14.861-5.001-22.07-7.22
c-3.672-1.138-7.354-2.276-11.008-3.462c-2.236-0.727-5.66-1.683-9.609-1.683c-5.375,0-15.367,1.855-21.832,14.248
c-1.338,2.562-2.658,5.125-3.977,7.698L311.625,30.59L294.708,0l-16.639,30.743l-36.873,68.124
c-1.884-3.232-3.749-6.474-5.575-9.735c-4.523-8.07-12.125-12.699-20.865-12.699c-2.305,0-4.657,0.334-7,1.004
c-4.208,1.195-9.113,2.601-14.038,4.293l-5.747,1.941c-6.866,2.305-13.961,4.686-21.057,7.641
c-12.393,5.154-23.543,9.916-34.616,15.902c-9.333,5.049-17.968,10.815-26.316,16.39l-5.106,3.404
c-3.796,2.515-7.172,5.25-10.146,7.669c-1.176,0.947-2.343,1.903-3.519,2.821l-12.852,10.002l7.832,14.287l26.479,48.291
c-14.86,2.993-28.745,9.763-41.463,20.225c-21.994,18.102-33.938,42.773-34.53,71.355c-0.526,25.293,8.186,48.195,25.178,66.249
c14.248,15.128,31.049,24.538,50.107,28.086c-2.936,5.288-5.872,10.575-8.798,15.863c-1.3,2.362-2.562,4.733-3.834,7.115
c-1.625,3.05-3.251,6.11-4.963,9.112c-1.214,2.133-2.524,4.218-3.834,6.293c-1.281,2.046-2.563,4.102-3.796,6.187
c-5.891,10.012-1.568,21.649,6.015,27.119c7.851,5.671,15.73,11.303,23.677,16.858c12.451,8.702,25.408,15.864,38.508,21.286
l4.676,1.941c7.468,3.117,15.195,6.331,23.227,9.123c7.631,2.648,15.3,4.915,22.711,7.104c3.137,0.928,6.264,1.855,9.391,2.812
l9.955,4.657c3.892,32.751,35.324,58.283,73.526,58.283c38.508,0,70.112-25.943,73.592-59.058l10.49-3.51l4.715-1.683
l10.107-3.118c2.018-0.593,4.035-1.214,6.062-1.778c4.973-1.367,10.117-2.821,15.396-4.743
c7.889-2.878,16.352-6.368,26.641-10.949c6.588-2.936,12.938-6.206,18.877-9.696c8.883-5.23,17.566-10.662,25.789-16.142
c5.184-3.452,9.707-7.172,14.076-10.776l1.463-1.205c8.492-6.962,9.18-19.153,4.936-26.909c-2.229-4.073-4.562-8.09-6.895-12.097
l-2.42-4.159l-3.271-5.651c-3.107-5.374-6.225-10.748-9.295-16.142c-1.156-2.037-2.303-4.073-3.441-6.12
c6.961-1.301,13.637-3.404,19.957-6.292C517.552,382.251,531.093,370.69,541.498,355.343z M463.82,378.465
c-4.809,0-9.734-0.411-14.764-1.167c3.461,6.254,6.396,11.552,9.332,16.84c3.232,5.823,6.436,11.656,9.727,17.441
c4.168,7.325,8.404,14.612,12.621,21.908c3.051,5.278,6.168,10.519,9.096,15.864c0.41,0.746,0.268,2.496-0.287,2.955
c-4.562,3.748-9.094,7.573-14,10.844c-8.148,5.422-16.457,10.604-24.891,15.567c-5.471,3.223-11.16,6.12-16.965,8.702
c-8.357,3.729-16.811,7.296-25.408,10.433c-6.617,2.409-13.512,4.035-20.281,6.024c-4.82,1.415-9.629,2.83-14.85,4.37
c-2.736-4.753-5.49-9.371-8.072-14.066c-2.477-4.504-4.732-9.123-7.172-13.646c-4.34-8.033-8.807-16.008-13.109-24.069
c-1.598-2.993-2.133-3.997-3.576-3.997c-0.871,0-2.076,0.363-4.045,0.87c-8.148,2.104-16.324,3.873-24.309,5.661
c22.223,7.659,38.221,28.735,38.221,53.607c0,31.326-25.35,56.725-56.609,56.725c-31.27,0-56.61-25.398-56.61-56.725
c0-24.566,15.606-45.422,37.409-53.312c-7.516-2.065-15.472-4.341-23.572-6.54c-0.918-0.249-1.721-0.584-2.448-0.584
c-1.301,0-2.362,0.546-3.366,2.592c-4.581,9.267-9.744,18.217-14.697,27.301c-3.911,7.182-7.86,14.325-11.791,21.497
c-0.804,1.463-1.645,2.897-2.812,4.972c-10.49-3.203-21.076-6.11-31.422-9.696c-9.094-3.155-17.949-6.99-26.852-10.671
c-12.345-5.106-23.925-11.638-34.865-19.288c-7.86-5.498-15.664-11.083-23.438-16.696c-0.478-0.344-0.947-1.529-0.717-1.912
c2.515-4.274,5.288-8.396,7.746-12.699c3.098-5.422,5.909-10.997,8.931-16.467c5.919-10.729,11.896-21.42,17.834-32.14
c1.979-3.576,3.892-7.2,6.264-11.58c-4.848,0.736-9.562,1.109-14.143,1.109c-20.952,0-39.082-7.755-54.085-23.687
c-13.78-14.63-20.406-32.607-19.986-52.737c0.478-23.074,9.811-42.38,27.559-56.992c13.952-11.484,29.663-17.643,47.354-17.643
c4.523,0,9.17,0.401,13.952,1.224c-14.028-25.589-27.75-50.615-41.692-76.06c4.112-3.204,8.1-6.723,12.479-9.63
c9.85-6.521,19.594-13.311,29.959-18.915c10.585-5.718,21.745-10.433,32.866-15.07c8.367-3.481,17.06-6.197,25.646-9.142
c4.303-1.472,8.683-2.744,13.053-3.987c0.641-0.182,1.233-0.277,1.788-0.277c1.721,0,3.05,0.908,4.179,2.926
c5.393,9.62,11.092,19.067,16.629,28.611c2.018,3.481,3.901,7.048,6.11,11.054c17.853-32.981,35.41-65.426,53.206-98.312
c18.322,33.134,36.348,65.732,54.65,98.819c2.467-4.485,4.828-8.597,7.018-12.804c4.553-8.74,8.98-17.538,13.531-26.268
c1.463-2.812,2.773-3.968,4.867-3.968c1.014,0,2.219,0.268,3.711,0.755c10.814,3.5,21.773,6.588,32.445,10.461
c7.65,2.773,14.938,6.531,22.367,9.916c4.59,2.085,9.285,4.007,13.654,6.483c7.029,3.988,13.914,8.243,20.684,12.651
c5.471,3.557,10.682,7.487,15.998,11.265c1.77,1.252,3.777,2.314,5.145,3.92c0.756,0.889,0.977,3.031,0.432,4.074
c-3.576,6.751-7.498,13.32-11.18,20.024c-4.236,7.717-8.252,15.558-12.508,23.266c-2.246,4.064-4.895,7.898-7.182,11.943
c-3.309,5.862-6.445,11.819-10.012,18.389c4.973-0.947,9.803-1.406,14.498-1.406c17.174,0,32.502,6.13,46.254,16.802
c18.951,14.707,28.352,35.065,28.688,58.866c0.209,14.803-3.74,28.927-12.299,41.559c-8.309,12.26-19.039,21.602-32.379,27.693
C483.902,376.6,474.101,378.465,463.82,378.465z"/>
<path d="M261.746,512.598c0,18.102,14.669,32.818,32.704,32.818c18.034,0,32.704-14.726,32.704-32.818
c0-18.092-14.67-32.818-32.704-32.818C276.415,479.779,261.746,494.506,261.746,512.598z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -19,8 +19,7 @@ format = "uv run ruff format engine/ mainline.py"
# Run
# =====================
mainline = "uv run mainline.py"
run = { run = "uv run mainline.py", depends = ["sync-all"] }
run = "uv run mainline.py"
run-pygame = { run = "uv run mainline.py --display pygame", depends = ["sync-all"] }
run-terminal = { run = "uv run mainline.py --display terminal", depends = ["sync-all"] }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -40,31 +40,10 @@ camera_speed = 0.5
viewport_width = 80
viewport_height = 24
[presets.test-figment]
description = "Test: Figment overlay effect"
source = "empty"
display = "terminal"
camera = "feed"
effects = ["figment"]
viewport_width = 80
viewport_height = 24
# ============================================
# DEMO PRESETS (for demonstration and exploration)
# ============================================
[presets.upstream-default]
description = "Upstream default operation (terminal display, legacy behavior)"
source = "headlines"
display = "terminal"
camera = "scroll"
effects = ["noise", "fade", "glitch", "firehose"]
camera_speed = 1.0
viewport_width = 80
viewport_height = 24
enable_message_overlay = false
positioning = "mixed"
[presets.demo-base]
description = "Demo: Base preset for effect hot-swapping"
source = "headlines"
@@ -74,20 +53,16 @@ effects = [] # Demo script will add/remove effects dynamically
camera_speed = 0.1
viewport_width = 80
viewport_height = 24
enable_message_overlay = true
positioning = "mixed"
[presets.demo-pygame]
description = "Demo: Pygame display version"
source = "headlines"
display = "pygame"
camera = "feed"
effects = ["noise", "fade", "glitch", "firehose"] # Default effects
effects = [] # Demo script will add/remove effects dynamically
camera_speed = 0.1
viewport_width = 80
viewport_height = 24
enable_message_overlay = true
positioning = "mixed"
[presets.demo-camera-showcase]
description = "Demo: Camera mode showcase"
@@ -98,20 +73,6 @@ effects = [] # Demo script will cycle through camera modes
camera_speed = 0.5
viewport_width = 80
viewport_height = 24
enable_message_overlay = true
positioning = "mixed"
[presets.test-message-overlay]
description = "Test: Message overlay with ntfy integration"
source = "headlines"
display = "terminal"
camera = "feed"
effects = ["hud"]
camera_speed = 0.1
viewport_width = 80
viewport_height = 24
enable_message_overlay = true
positioning = "mixed"
# ============================================
# SENSOR CONFIGURATION

View File

@@ -40,9 +40,6 @@ pygame = [
browser = [
"playwright>=1.40.0",
]
figment = [
"cairosvg>=2.7.0",
]
dev = [
"pytest>=8.0.0",
"pytest-benchmark>=4.0.0",
@@ -65,7 +62,6 @@ dev = [
"pytest-cov>=4.1.0",
"pytest-mock>=3.12.0",
"ruff>=0.1.0",
"tomli>=2.0.0",
]
[tool.pytest.ini_options]

View File

@@ -1,201 +0,0 @@
#!/usr/bin/env python3
"""
Capture output utility for Mainline.
This script captures the output of a Mainline pipeline using NullDisplay
and saves it to a JSON file for comparison with other branches.
"""
import argparse
import json
import time
from pathlib import Path
from engine.display import DisplayRegistry
from engine.pipeline import Pipeline, PipelineConfig, PipelineContext
from engine.pipeline.adapters import create_stage_from_display
from engine.pipeline.presets import get_preset
def capture_pipeline_output(
preset_name: str,
output_file: str,
frames: int = 60,
width: int = 80,
height: int = 24,
):
"""Capture pipeline output for a given preset.
Args:
preset_name: Name of preset to use
output_file: Path to save captured output
frames: Number of frames to capture
width: Terminal width
height: Terminal height
"""
print(f"Capturing output for preset '{preset_name}'...")
# Get preset
preset = get_preset(preset_name)
if not preset:
print(f"Error: Preset '{preset_name}' not found")
return False
# Create NullDisplay with recording
display = DisplayRegistry.create("null")
display.init(width, height)
display.start_recording()
# Build pipeline
config = PipelineConfig(
source=preset.source,
display="null", # Use null display
camera=preset.camera,
effects=preset.effects,
enable_metrics=False,
)
# Create pipeline context with params
from engine.pipeline.params import PipelineParams
params = PipelineParams(
source=preset.source,
display="null",
camera_mode=preset.camera,
effect_order=preset.effects,
viewport_width=preset.viewport_width,
viewport_height=preset.viewport_height,
camera_speed=preset.camera_speed,
)
ctx = PipelineContext()
ctx.params = params
pipeline = Pipeline(config=config, context=ctx)
# Add stages based on preset
from engine.data_sources.sources import HeadlinesDataSource
from engine.pipeline.adapters import DataSourceStage
# Add source stage
source = HeadlinesDataSource()
pipeline.add_stage("source", DataSourceStage(source, name="headlines"))
# Add message overlay if enabled
if getattr(preset, "enable_message_overlay", False):
from engine import config as engine_config
from engine.pipeline.adapters import MessageOverlayConfig, MessageOverlayStage
overlay_config = MessageOverlayConfig(
enabled=True,
display_secs=getattr(engine_config, "MESSAGE_DISPLAY_SECS", 30),
topic_url=getattr(engine_config, "NTFY_TOPIC", None),
)
pipeline.add_stage(
"message_overlay", MessageOverlayStage(config=overlay_config)
)
# Add display stage
pipeline.add_stage("display", create_stage_from_display(display, "null"))
# Build and initialize
pipeline.build()
if not pipeline.initialize():
print("Error: Failed to initialize pipeline")
return False
# Capture frames
print(f"Capturing {frames} frames...")
start_time = time.time()
for frame in range(frames):
try:
pipeline.execute([])
if frame % 10 == 0:
print(f" Frame {frame}/{frames}")
except Exception as e:
print(f"Error on frame {frame}: {e}")
break
elapsed = time.time() - start_time
print(f"Captured {frame + 1} frames in {elapsed:.2f}s")
# Get captured frames
captured_frames = display.get_frames()
print(f"Retrieved {len(captured_frames)} frames from display")
# Save to JSON
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
recording_data = {
"version": 1,
"preset": preset_name,
"display": "null",
"width": width,
"height": height,
"frame_count": len(captured_frames),
"frames": [
{
"frame_number": i,
"buffer": frame,
"width": width,
"height": height,
}
for i, frame in enumerate(captured_frames)
],
}
with open(output_path, "w") as f:
json.dump(recording_data, f, indent=2)
print(f"Saved recording to {output_path}")
return True
def main():
parser = argparse.ArgumentParser(description="Capture Mainline pipeline output")
parser.add_argument(
"--preset",
default="demo",
help="Preset name to use (default: demo)",
)
parser.add_argument(
"--output",
default="output/capture.json",
help="Output file path (default: output/capture.json)",
)
parser.add_argument(
"--frames",
type=int,
default=60,
help="Number of frames to capture (default: 60)",
)
parser.add_argument(
"--width",
type=int,
default=80,
help="Terminal width (default: 80)",
)
parser.add_argument(
"--height",
type=int,
default=24,
help="Terminal height (default: 24)",
)
args = parser.parse_args()
success = capture_pipeline_output(
preset_name=args.preset,
output_file=args.output,
frames=args.frames,
width=args.width,
height=args.height,
)
return 0 if success else 1
if __name__ == "__main__":
exit(main())

View File

@@ -1,186 +0,0 @@
#!/usr/bin/env python3
"""
Capture output from upstream/main branch.
This script captures the output of upstream/main Mainline using NullDisplay
and saves it to a JSON file for comparison with sideline branch.
"""
import argparse
import json
import sys
from pathlib import Path
# Add upstream/main to path
sys.path.insert(0, "/tmp/upstream_mainline")
def capture_upstream_output(
output_file: str,
frames: int = 60,
width: int = 80,
height: int = 24,
):
"""Capture upstream/main output.
Args:
output_file: Path to save captured output
frames: Number of frames to capture
width: Terminal width
height: Terminal height
"""
print(f"Capturing upstream/main output...")
try:
# Import upstream modules
from engine import config, themes
from engine.display import NullDisplay
from engine.fetch import fetch_all, load_cache
from engine.scroll import stream
from engine.ntfy import NtfyPoller
from engine.mic import MicMonitor
except ImportError as e:
print(f"Error importing upstream modules: {e}")
print("Make sure upstream/main is in the Python path")
return False
# Create a custom NullDisplay that captures frames
class CapturingNullDisplay:
def __init__(self, width, height, max_frames):
self.width = width
self.height = height
self.max_frames = max_frames
self.frame_count = 0
self.frames = []
def init(self, width: int, height: int) -> None:
self.width = width
self.height = height
def show(self, buffer: list[str], border: bool = False) -> None:
if self.frame_count < self.max_frames:
self.frames.append(list(buffer))
self.frame_count += 1
if self.frame_count >= self.max_frames:
raise StopIteration("Frame limit reached")
def clear(self) -> None:
pass
def cleanup(self) -> None:
pass
def get_frames(self):
return self.frames
display = CapturingNullDisplay(width, height, frames)
# Load items (use cached headlines)
items = load_cache()
if not items:
print("No cached items found, fetching...")
result = fetch_all()
if isinstance(result, tuple):
items, linked, failed = result
else:
items = result
if not items:
print("Error: No items available")
return False
print(f"Loaded {len(items)} items")
# Create ntfy poller and mic monitor (upstream uses these)
ntfy_poller = NtfyPoller(config.NTFY_TOPIC, reconnect_delay=5, display_secs=30)
mic_monitor = MicMonitor()
# Run stream for specified number of frames
print(f"Capturing {frames} frames...")
try:
# Run the stream
stream(
items=items,
ntfy_poller=ntfy_poller,
mic_monitor=mic_monitor,
display=display,
)
except StopIteration:
print("Frame limit reached")
except Exception as e:
print(f"Error during capture: {e}")
# Continue to save what we have
# Get captured frames
captured_frames = display.get_frames()
print(f"Retrieved {len(captured_frames)} frames from display")
# Save to JSON
output_path = Path(output_file)
output_path.parent.mkdir(parents=True, exist_ok=True)
recording_data = {
"version": 1,
"preset": "upstream_demo",
"display": "null",
"width": width,
"height": height,
"frame_count": len(captured_frames),
"frames": [
{
"frame_number": i,
"buffer": frame,
"width": width,
"height": height,
}
for i, frame in enumerate(captured_frames)
],
}
with open(output_path, "w") as f:
json.dump(recording_data, f, indent=2)
print(f"Saved recording to {output_path}")
return True
def main():
parser = argparse.ArgumentParser(description="Capture upstream/main output")
parser.add_argument(
"--output",
default="output/upstream_demo.json",
help="Output file path (default: output/upstream_demo.json)",
)
parser.add_argument(
"--frames",
type=int,
default=60,
help="Number of frames to capture (default: 60)",
)
parser.add_argument(
"--width",
type=int,
default=80,
help="Terminal width (default: 80)",
)
parser.add_argument(
"--height",
type=int,
default=24,
help="Terminal height (default: 24)",
)
args = parser.parse_args()
success = capture_upstream_output(
output_file=args.output,
frames=args.frames,
width=args.width,
height=args.height,
)
return 0 if success else 1
if __name__ == "__main__":
exit(main())

View File

@@ -1,144 +0,0 @@
"""Capture frames from upstream Mainline for comparison testing.
This script should be run on the upstream/main branch to capture frames
that will later be compared with sideline branch output.
Usage:
# On upstream/main branch
python scripts/capture_upstream_comparison.py --preset demo
# This will create tests/comparison_output/demo_upstream.json
"""
import argparse
import json
import sys
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent))
def load_preset(preset_name: str) -> dict:
"""Load a preset from presets.toml."""
import tomli
# Try user presets first
user_presets = Path.home() / ".config" / "mainline" / "presets.toml"
local_presets = Path("presets.toml")
built_in_presets = Path(__file__).parent.parent / "presets.toml"
for preset_file in [user_presets, local_presets, built_in_presets]:
if preset_file.exists():
with open(preset_file, "rb") as f:
config = tomli.load(f)
if "presets" in config and preset_name in config["presets"]:
return config["presets"][preset_name]
raise ValueError(f"Preset '{preset_name}' not found")
def capture_upstream_frames(
preset_name: str,
frame_count: int = 30,
output_dir: Path = Path("tests/comparison_output"),
) -> Path:
"""Capture frames from upstream pipeline.
Note: This is a simplified version that mimics upstream behavior.
For actual upstream comparison, you may need to:
1. Checkout upstream/main branch
2. Run this script
3. Copy the output file
4. Checkout your branch
5. Run comparison
"""
output_dir.mkdir(parents=True, exist_ok=True)
# Load preset
preset = load_preset(preset_name)
# For upstream, we need to use the old monolithic rendering approach
# This is a simplified placeholder - actual implementation depends on
# the specific upstream architecture
print(f"Capturing {frame_count} frames from upstream preset '{preset_name}'")
print("Note: This script should be run on upstream/main branch")
print(f" for accurate comparison with sideline branch")
# Placeholder: In a real implementation, this would:
# 1. Import upstream-specific modules
# 2. Create pipeline using upstream architecture
# 3. Capture frames
# 4. Save to JSON
# For now, create a placeholder file with instructions
placeholder_data = {
"preset": preset_name,
"config": preset,
"note": "This is a placeholder file.",
"instructions": [
"1. Checkout upstream/main branch: git checkout main",
"2. Run frame capture: python scripts/capture_upstream_comparison.py --preset <name>",
"3. Copy output file to sideline branch",
"4. Checkout sideline branch: git checkout feature/capability-based-deps",
"5. Run comparison: python tests/run_comparison.py --preset <name>",
],
"frames": [], # Empty until properly captured
}
output_file = output_dir / f"{preset_name}_upstream.json"
with open(output_file, "w") as f:
json.dump(placeholder_data, f, indent=2)
print(f"\nPlaceholder file created: {output_file}")
print("\nTo capture actual upstream frames:")
print("1. Ensure you are on upstream/main branch")
print("2. This script needs to be adapted to use upstream-specific rendering")
print("3. The captured frames will be used for comparison with sideline")
return output_file
def main():
"""Main entry point."""
parser = argparse.ArgumentParser(
description="Capture frames from upstream Mainline for comparison"
)
parser.add_argument(
"--preset",
"-p",
required=True,
help="Preset name to capture",
)
parser.add_argument(
"--frames",
"-f",
type=int,
default=30,
help="Number of frames to capture",
)
parser.add_argument(
"--output-dir",
"-o",
type=Path,
default=Path("tests/comparison_output"),
help="Output directory",
)
args = parser.parse_args()
try:
output_file = capture_upstream_frames(
preset_name=args.preset,
frame_count=args.frames,
output_dir=args.output_dir,
)
print(f"\nCapture complete: {output_file}")
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,220 +0,0 @@
#!/usr/bin/env python3
"""
Compare captured outputs from different branches or configurations.
This script loads two captured recordings and compares them frame-by-frame,
reporting any differences found.
"""
import argparse
import difflib
import json
from pathlib import Path
def load_recording(file_path: str) -> dict:
"""Load a recording from a JSON file."""
with open(file_path, "r") as f:
return json.load(f)
def compare_frame_buffers(buf1: list[str], buf2: list[str]) -> tuple[int, list[str]]:
"""Compare two frame buffers and return differences.
Returns:
tuple: (difference_count, list of difference descriptions)
"""
differences = []
# Check dimensions
if len(buf1) != len(buf2):
differences.append(f"Height mismatch: {len(buf1)} vs {len(buf2)}")
# Check each line
max_lines = max(len(buf1), len(buf2))
for i in range(max_lines):
if i >= len(buf1):
differences.append(f"Line {i}: Missing in first buffer")
continue
if i >= len(buf2):
differences.append(f"Line {i}: Missing in second buffer")
continue
line1 = buf1[i]
line2 = buf2[i]
if line1 != line2:
# Find the specific differences in the line
if len(line1) != len(line2):
differences.append(
f"Line {i}: Length mismatch ({len(line1)} vs {len(line2)})"
)
# Show a snippet of the difference
max_len = max(len(line1), len(line2))
snippet1 = line1[:50] + "..." if len(line1) > 50 else line1
snippet2 = line2[:50] + "..." if len(line2) > 50 else line2
differences.append(f"Line {i}: '{snippet1}' != '{snippet2}'")
return len(differences), differences
def compare_recordings(
recording1: dict, recording2: dict, max_frames: int = None
) -> dict:
"""Compare two recordings frame-by-frame.
Returns:
dict: Comparison results with summary and detailed differences
"""
results = {
"summary": {},
"frames": [],
"total_differences": 0,
"frames_with_differences": 0,
}
# Compare metadata
results["summary"]["recording1"] = {
"preset": recording1.get("preset", "unknown"),
"frame_count": recording1.get("frame_count", 0),
"width": recording1.get("width", 0),
"height": recording1.get("height", 0),
}
results["summary"]["recording2"] = {
"preset": recording2.get("preset", "unknown"),
"frame_count": recording2.get("frame_count", 0),
"width": recording2.get("width", 0),
"height": recording2.get("height", 0),
}
# Compare frames
frames1 = recording1.get("frames", [])
frames2 = recording2.get("frames", [])
num_frames = min(len(frames1), len(frames2))
if max_frames:
num_frames = min(num_frames, max_frames)
print(f"Comparing {num_frames} frames...")
for frame_idx in range(num_frames):
frame1 = frames1[frame_idx]
frame2 = frames2[frame_idx]
buf1 = frame1.get("buffer", [])
buf2 = frame2.get("buffer", [])
diff_count, differences = compare_frame_buffers(buf1, buf2)
if diff_count > 0:
results["total_differences"] += diff_count
results["frames_with_differences"] += 1
results["frames"].append(
{
"frame_number": frame_idx,
"differences": differences,
"diff_count": diff_count,
}
)
if frame_idx < 5: # Only print first 5 frames with differences
print(f"\nFrame {frame_idx} ({diff_count} differences):")
for diff in differences[:5]: # Limit to 5 differences per frame
print(f" - {diff}")
# Summary
results["summary"]["total_frames_compared"] = num_frames
results["summary"]["frames_with_differences"] = results["frames_with_differences"]
results["summary"]["total_differences"] = results["total_differences"]
results["summary"]["match_percentage"] = (
(1 - results["frames_with_differences"] / num_frames) * 100
if num_frames > 0
else 0
)
return results
def print_comparison_summary(results: dict):
"""Print a summary of the comparison results."""
print("\n" + "=" * 80)
print("COMPARISON SUMMARY")
print("=" * 80)
r1 = results["summary"]["recording1"]
r2 = results["summary"]["recording2"]
print(f"\nRecording 1: {r1['preset']}")
print(
f" Frames: {r1['frame_count']}, Width: {r1['width']}, Height: {r1['height']}"
)
print(f"\nRecording 2: {r2['preset']}")
print(
f" Frames: {r2['frame_count']}, Width: {r2['width']}, Height: {r2['height']}"
)
print(f"\nComparison:")
print(f" Frames compared: {results['summary']['total_frames_compared']}")
print(f" Frames with differences: {results['summary']['frames_with_differences']}")
print(f" Total differences: {results['summary']['total_differences']}")
print(f" Match percentage: {results['summary']['match_percentage']:.2f}%")
if results["summary"]["match_percentage"] == 100:
print("\n✓ Recordings match perfectly!")
else:
print("\n⚠ Recordings have differences.")
def main():
parser = argparse.ArgumentParser(
description="Compare captured outputs from different branches"
)
parser.add_argument(
"recording1",
help="First recording file (JSON)",
)
parser.add_argument(
"recording2",
help="Second recording file (JSON)",
)
parser.add_argument(
"--max-frames",
type=int,
help="Maximum number of frames to compare",
)
parser.add_argument(
"--output",
"-o",
help="Output file for detailed comparison results (JSON)",
)
args = parser.parse_args()
# Load recordings
print(f"Loading {args.recording1}...")
recording1 = load_recording(args.recording1)
print(f"Loading {args.recording2}...")
recording2 = load_recording(args.recording2)
# Compare
results = compare_recordings(recording1, recording2, args.max_frames)
# Print summary
print_comparison_summary(results)
# Save detailed results if requested
if args.output:
output_path = Path(args.output)
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
json.dump(results, f, indent=2)
print(f"\nDetailed results saved to {args.output}")
return 0
if __name__ == "__main__":
exit(main())

View File

@@ -1,151 +0,0 @@
#!/usr/bin/env python3
"""
Pygame Demo: Effects with LFO Modulation
This demo shows how to use LFO (Low Frequency Oscillator) to modulate
effect intensities over time, creating smooth animated changes.
Effects modulated:
- noise: Random noise intensity
- fade: Fade effect intensity
- tint: Color tint intensity
- glitch: Glitch effect intensity
The LFO uses a sine wave to oscillate intensity between 0.0 and 1.0.
"""
import sys
import time
from dataclasses import dataclass
from typing import Any
from engine import config
from engine.display import DisplayRegistry
from engine.effects import get_registry
from engine.pipeline import Pipeline, PipelineConfig, PipelineContext, list_presets
from engine.pipeline.params import PipelineParams
from engine.pipeline.preset_loader import load_presets
from engine.sensors.oscillator import OscillatorSensor
from engine.sources import FEEDS
@dataclass
class LFOEffectConfig:
"""Configuration for LFO-modulated effect."""
name: str
frequency: float # LFO frequency in Hz
phase_offset: float # Phase offset (0.0 to 1.0)
min_intensity: float = 0.0
max_intensity: float = 1.0
class LFOEffectDemo:
"""Demo controller that modulates effect intensities using LFO."""
def __init__(self, pipeline: Pipeline):
self.pipeline = pipeline
self.effects = [
LFOEffectConfig("noise", frequency=0.5, phase_offset=0.0),
LFOEffectConfig("fade", frequency=0.3, phase_offset=0.33),
LFOEffectConfig("tint", frequency=0.4, phase_offset=0.66),
LFOEffectConfig("glitch", frequency=0.6, phase_offset=0.9),
]
self.start_time = time.time()
self.frame_count = 0
def update(self):
"""Update effect intensities based on LFO."""
elapsed = time.time() - self.start_time
self.frame_count += 1
for effect_cfg in self.effects:
# Calculate LFO value using sine wave
angle = (
(elapsed * effect_cfg.frequency + effect_cfg.phase_offset) * 2 * 3.14159
)
lfo_value = 0.5 + 0.5 * (angle.__sin__())
# Scale to intensity range
intensity = effect_cfg.min_intensity + lfo_value * (
effect_cfg.max_intensity - effect_cfg.min_intensity
)
# Update effect intensity in pipeline
self.pipeline.set_effect_intensity(effect_cfg.name, intensity)
def run(self, duration: float = 30.0):
"""Run the demo for specified duration."""
print(f"\n{'=' * 60}")
print("LFO EFFECT MODULATION DEMO")
print(f"{'=' * 60}")
print("\nEffects being modulated:")
for effect in self.effects:
print(f" - {effect.name}: {effect.frequency}Hz")
print(f"\nPress Ctrl+C to stop")
print(f"{'=' * 60}\n")
start = time.time()
try:
while time.time() - start < duration:
self.update()
time.sleep(0.016) # ~60 FPS
except KeyboardInterrupt:
print("\n\nDemo stopped by user")
finally:
print(f"\nTotal frames rendered: {self.frame_count}")
def main():
"""Main entry point for the LFO demo."""
# Configuration
effect_names = ["noise", "fade", "tint", "glitch"]
# Get pipeline config from preset
preset_name = "demo-pygame"
presets = load_presets()
preset = presets["presets"].get(preset_name)
if not preset:
print(f"Error: Preset '{preset_name}' not found")
print(f"Available presets: {list(presets['presets'].keys())}")
sys.exit(1)
# Create pipeline context
ctx = PipelineContext()
ctx.terminal_width = preset.get("viewport_width", 80)
ctx.terminal_height = preset.get("viewport_height", 24)
# Create params
params = PipelineParams(
source=preset.get("source", "headlines"),
display="pygame", # Force pygame display
camera_mode=preset.get("camera", "feed"),
effect_order=effect_names, # Enable our effects
viewport_width=preset.get("viewport_width", 80),
viewport_height=preset.get("viewport_height", 24),
)
ctx.params = params
# Create pipeline config
pipeline_config = PipelineConfig(
source=preset.get("source", "headlines"),
display="pygame",
camera=preset.get("camera", "feed"),
effects=effect_names,
)
# Create pipeline
pipeline = Pipeline(config=pipeline_config, context=ctx)
# Build pipeline
pipeline.build()
# Create demo controller
demo = LFOEffectDemo(pipeline)
# Run demo
demo.run(duration=30.0)
if __name__ == "__main__":
main()

View File

@@ -1,489 +0,0 @@
"""Frame capture utilities for upstream vs sideline comparison.
This module provides functions to capture frames from both upstream and sideline
implementations for visual comparison and performance analysis.
"""
import json
import time
from pathlib import Path
from typing import Any, Dict, List, Tuple
import tomli
from engine.pipeline import Pipeline, PipelineConfig, PipelineContext
from engine.pipeline.params import PipelineParams
def load_comparison_preset(preset_name: str) -> Any:
"""Load a comparison preset from comparison_presets.toml.
Args:
preset_name: Name of the preset to load
Returns:
Preset configuration dictionary
"""
presets_file = Path("tests/comparison_presets.toml")
if not presets_file.exists():
raise FileNotFoundError(f"Comparison presets file not found: {presets_file}")
with open(presets_file, "rb") as f:
config = tomli.load(f)
presets = config.get("presets", {})
full_name = (
f"presets.{preset_name}"
if not preset_name.startswith("presets.")
else preset_name
)
simple_name = (
preset_name.replace("presets.", "")
if preset_name.startswith("presets.")
else preset_name
)
if full_name in presets:
return presets[full_name]
elif simple_name in presets:
return presets[simple_name]
else:
raise ValueError(
f"Preset '{preset_name}' not found in {presets_file}. Available: {list(presets.keys())}"
)
def capture_frames(
preset_name: str,
frame_count: int = 30,
output_dir: Path = Path("tests/comparison_output"),
) -> Dict[str, Any]:
"""Capture frames from sideline pipeline using a preset.
Args:
preset_name: Name of preset to use
frame_count: Number of frames to capture
output_dir: Directory to save captured frames
Returns:
Dictionary with captured frames and metadata
"""
from engine.pipeline.presets import get_preset
output_dir.mkdir(parents=True, exist_ok=True)
# Load preset - try comparison presets first, then built-in presets
try:
preset = load_comparison_preset(preset_name)
# Convert dict to object-like access
from types import SimpleNamespace
preset = SimpleNamespace(**preset)
except (FileNotFoundError, ValueError):
# Fall back to built-in presets
preset = get_preset(preset_name)
if not preset:
raise ValueError(
f"Preset '{preset_name}' not found in comparison or built-in presets"
)
# Create pipeline config from preset
config = PipelineConfig(
source=preset.source,
display="null", # Always use null display for capture
camera=preset.camera,
effects=preset.effects,
)
# Create pipeline
ctx = PipelineContext()
ctx.terminal_width = preset.viewport_width
ctx.terminal_height = preset.viewport_height
pipeline = Pipeline(config=config, context=ctx)
# Create params
params = PipelineParams(
viewport_width=preset.viewport_width,
viewport_height=preset.viewport_height,
)
ctx.params = params
# Add stages based on source type (similar to pipeline_runner)
from engine.display import DisplayRegistry
from engine.pipeline.adapters import create_stage_from_display
from engine.data_sources.sources import EmptyDataSource
from engine.pipeline.adapters import DataSourceStage
# Add source stage
if preset.source == "empty":
source_stage = DataSourceStage(
EmptyDataSource(width=preset.viewport_width, height=preset.viewport_height),
name="empty",
)
else:
# For headlines/poetry, use the actual source
from engine.data_sources.sources import HeadlinesDataSource, PoetryDataSource
if preset.source == "headlines":
source_stage = DataSourceStage(HeadlinesDataSource(), name="headlines")
elif preset.source == "poetry":
source_stage = DataSourceStage(PoetryDataSource(), name="poetry")
else:
# Fallback to empty
source_stage = DataSourceStage(
EmptyDataSource(
width=preset.viewport_width, height=preset.viewport_height
),
name="empty",
)
pipeline.add_stage("source", source_stage)
# Add font stage for headlines/poetry (with viewport filter)
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")
)
# Add font stage for block character rendering
pipeline.add_stage("font", FontStage(name="font"))
else:
# Fallback to simple conversion for empty/other sources
from engine.pipeline.adapters import SourceItemsToBufferStage
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
# Add camera stage
from engine.camera import Camera
from engine.pipeline.adapters import CameraStage, CameraClockStage
# Create camera based on preset
if preset.camera == "feed":
camera = Camera.feed()
elif preset.camera == "scroll":
camera = Camera.scroll(speed=0.1)
elif preset.camera == "horizontal":
camera = Camera.horizontal(speed=0.1)
else:
camera = Camera.feed()
camera.set_canvas_size(preset.viewport_width, preset.viewport_height * 2)
# Add camera update (for animation)
pipeline.add_stage("camera_update", CameraClockStage(camera, name="camera-clock"))
# Add camera stage
pipeline.add_stage("camera", CameraStage(camera, name=preset.camera))
# Add effects
if preset.effects:
from engine.effects.registry import EffectRegistry
from engine.pipeline.adapters import create_stage_from_effect
effect_registry = EffectRegistry()
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),
)
# Add message overlay stage if enabled (BEFORE display)
if getattr(preset, "enable_message_overlay", False):
from engine.pipeline.adapters import MessageOverlayConfig, MessageOverlayStage
overlay_config = MessageOverlayConfig(
enabled=True,
display_secs=30,
)
pipeline.add_stage(
"message_overlay", MessageOverlayStage(config=overlay_config)
)
# Add null display stage (LAST)
null_display = DisplayRegistry.create("null")
if null_display:
pipeline.add_stage("display", create_stage_from_display(null_display, "null"))
# Build pipeline
pipeline.build()
# Enable recording on null display if available
display_stage = pipeline._stages.get("display")
if display_stage and hasattr(display_stage, "_display"):
backend = display_stage._display
if hasattr(backend, "start_recording"):
backend.start_recording()
# Capture frames
frames = []
start_time = time.time()
for i in range(frame_count):
frame_start = time.time()
stage_result = pipeline.execute()
frame_time = time.time() - frame_start
# Get frames from display recording
display_stage = pipeline._stages.get("display")
if display_stage and hasattr(display_stage, "_display"):
backend = display_stage._display
if hasattr(backend, "get_recorded_data"):
recorded_frames = backend.get_recorded_data()
# Add render_time_ms to each frame
for frame in recorded_frames:
frame["render_time_ms"] = frame_time * 1000
frames = recorded_frames
# Fallback: create empty frames if no recording
if not frames:
for i in range(frame_count):
frames.append(
{
"frame_number": i,
"buffer": [],
"width": preset.viewport_width,
"height": preset.viewport_height,
"render_time_ms": frame_time * 1000,
}
)
# Stop recording on null display
display_stage = pipeline._stages.get("display")
if display_stage and hasattr(display_stage, "_display"):
backend = display_stage._display
if hasattr(backend, "stop_recording"):
backend.stop_recording()
total_time = time.time() - start_time
# Save captured data
output_file = output_dir / f"{preset_name}_sideline.json"
captured_data = {
"preset": preset_name,
"config": {
"source": preset.source,
"camera": preset.camera,
"effects": preset.effects,
"viewport_width": preset.viewport_width,
"viewport_height": preset.viewport_height,
"enable_message_overlay": getattr(preset, "enable_message_overlay", False),
},
"capture_stats": {
"frame_count": frame_count,
"total_time_ms": total_time * 1000,
"avg_frame_time_ms": (total_time * 1000) / frame_count,
"fps": frame_count / total_time if total_time > 0 else 0,
},
"frames": frames,
}
with open(output_file, "w") as f:
json.dump(captured_data, f, indent=2)
return captured_data
def compare_captured_outputs(
sideline_file: Path,
upstream_file: Path,
output_dir: Path = Path("tests/comparison_output"),
) -> Dict[str, Any]:
"""Compare captured outputs from sideline and upstream.
Args:
sideline_file: Path to sideline captured output
upstream_file: Path to upstream captured output
output_dir: Directory to save comparison results
Returns:
Dictionary with comparison results
"""
output_dir.mkdir(parents=True, exist_ok=True)
# Load captured data
with open(sideline_file) as f:
sideline_data = json.load(f)
with open(upstream_file) as f:
upstream_data = json.load(f)
# Compare configurations
config_diff = {}
for key in [
"source",
"camera",
"effects",
"viewport_width",
"viewport_height",
"enable_message_overlay",
]:
sideline_val = sideline_data["config"].get(key)
upstream_val = upstream_data["config"].get(key)
if sideline_val != upstream_val:
config_diff[key] = {"sideline": sideline_val, "upstream": upstream_val}
# Compare frame counts
sideline_frames = len(sideline_data["frames"])
upstream_frames = len(upstream_data["frames"])
frame_count_match = sideline_frames == upstream_frames
# Compare individual frames
frame_comparisons = []
total_diff = 0
max_diff = 0
identical_frames = 0
min_frames = min(sideline_frames, upstream_frames)
for i in range(min_frames):
sideline_frame = sideline_data["frames"][i]
upstream_frame = upstream_data["frames"][i]
sideline_buffer = sideline_frame["buffer"]
upstream_buffer = upstream_frame["buffer"]
# Compare buffers line by line
line_diffs = []
frame_diff = 0
max_lines = max(len(sideline_buffer), len(upstream_buffer))
for line_idx in range(max_lines):
sideline_line = (
sideline_buffer[line_idx] if line_idx < len(sideline_buffer) else ""
)
upstream_line = (
upstream_buffer[line_idx] if line_idx < len(upstream_buffer) else ""
)
if sideline_line != upstream_line:
line_diffs.append(
{
"line": line_idx,
"sideline": sideline_line,
"upstream": upstream_line,
}
)
frame_diff += 1
if frame_diff == 0:
identical_frames += 1
total_diff += frame_diff
max_diff = max(max_diff, frame_diff)
frame_comparisons.append(
{
"frame_number": i,
"differences": frame_diff,
"line_diffs": line_diffs[
:5
], # Only store first 5 differences per frame
"render_time_diff_ms": sideline_frame.get("render_time_ms", 0)
- upstream_frame.get("render_time_ms", 0),
}
)
# Calculate statistics
stats = {
"total_frames_compared": min_frames,
"identical_frames": identical_frames,
"frames_with_differences": min_frames - identical_frames,
"total_differences": total_diff,
"max_differences_per_frame": max_diff,
"avg_differences_per_frame": total_diff / min_frames if min_frames > 0 else 0,
"match_percentage": (identical_frames / min_frames * 100)
if min_frames > 0
else 0,
}
# Compare performance stats
sideline_stats = sideline_data.get("capture_stats", {})
upstream_stats = upstream_data.get("capture_stats", {})
performance_comparison = {
"sideline": {
"total_time_ms": sideline_stats.get("total_time_ms", 0),
"avg_frame_time_ms": sideline_stats.get("avg_frame_time_ms", 0),
"fps": sideline_stats.get("fps", 0),
},
"upstream": {
"total_time_ms": upstream_stats.get("total_time_ms", 0),
"avg_frame_time_ms": upstream_stats.get("avg_frame_time_ms", 0),
"fps": upstream_stats.get("fps", 0),
},
"diff": {
"total_time_ms": sideline_stats.get("total_time_ms", 0)
- upstream_stats.get("total_time_ms", 0),
"avg_frame_time_ms": sideline_stats.get("avg_frame_time_ms", 0)
- upstream_stats.get("avg_frame_time_ms", 0),
"fps": sideline_stats.get("fps", 0) - upstream_stats.get("fps", 0),
},
}
# Build comparison result
result = {
"preset": sideline_data["preset"],
"config_diff": config_diff,
"frame_count_match": frame_count_match,
"stats": stats,
"performance_comparison": performance_comparison,
"frame_comparisons": frame_comparisons,
"sideline_file": str(sideline_file),
"upstream_file": str(upstream_file),
}
# Save comparison result
output_file = output_dir / f"{sideline_data['preset']}_comparison.json"
with open(output_file, "w") as f:
json.dump(result, f, indent=2)
return result
def generate_html_report(
comparison_results: List[Dict[str, Any]],
output_dir: Path = Path("tests/comparison_output"),
) -> Path:
"""Generate HTML report from comparison results using acceptance_report.py.
Args:
comparison_results: List of comparison results
output_dir: Directory to save HTML report
Returns:
Path to generated HTML report
"""
from tests.acceptance_report import save_index_report
output_dir.mkdir(parents=True, exist_ok=True)
# Generate index report with links to all comparison results
reports = []
for result in comparison_results:
reports.append(
{
"test_name": f"comparison-{result['preset']}",
"status": "PASS" if result.get("status") == "success" else "FAIL",
"frame_count": result["stats"]["total_frames_compared"],
"duration_ms": result["performance_comparison"]["sideline"][
"total_time_ms"
],
}
)
# Save index report
index_file = save_index_report(reports, str(output_dir))
# Also save a summary JSON file for programmatic access
summary_file = output_dir / "comparison_summary.json"
with open(summary_file, "w") as f:
json.dump(
{
"timestamp": __import__("datetime").datetime.now().isoformat(),
"results": comparison_results,
},
f,
indent=2,
)
return Path(index_file)

View File

@@ -1,253 +0,0 @@
# Comparison Presets for Upstream vs Sideline Testing
# These presets are designed to test various pipeline configurations
# to ensure visual equivalence and performance parity
# ============================================
# CORE PIPELINE TESTS (Basic functionality)
# ============================================
[presets.comparison-basic]
description = "Comparison: Basic pipeline, no effects"
source = "headlines"
display = "null"
camera = "feed"
effects = []
viewport_width = 80
viewport_height = 24
enable_message_overlay = false
frame_count = 30
[presets.comparison-with-message-overlay]
description = "Comparison: Basic pipeline with message overlay"
source = "headlines"
display = "null"
camera = "feed"
effects = []
viewport_width = 80
viewport_height = 24
enable_message_overlay = true
frame_count = 30
# ============================================
# EFFECT TESTS (Various effect combinations)
# ============================================
[presets.comparison-single-effect]
description = "Comparison: Single effect (border)"
source = "headlines"
display = "null"
camera = "feed"
effects = ["border"]
viewport_width = 80
viewport_height = 24
enable_message_overlay = false
frame_count = 30
[presets.comparison-multiple-effects]
description = "Comparison: Multiple effects chain"
source = "headlines"
display = "null"
camera = "feed"
effects = ["border", "tint", "hud"]
viewport_width = 80
viewport_height = 24
enable_message_overlay = false
frame_count = 30
[presets.comparison-all-effects]
description = "Comparison: All available effects"
source = "headlines"
display = "null"
camera = "feed"
effects = ["border", "tint", "hud", "fade", "noise", "glitch"]
viewport_width = 80
viewport_height = 24
enable_message_overlay = false
frame_count = 30
# ============================================
# CAMERA MODE TESTS (Different viewport behaviors)
# ============================================
[presets.comparison-camera-feed]
description = "Comparison: Feed camera mode"
source = "headlines"
display = "null"
camera = "feed"
effects = []
viewport_width = 80
viewport_height = 24
enable_message_overlay = false
frame_count = 30
[presets.comparison-camera-scroll]
description = "Comparison: Scroll camera mode"
source = "headlines"
display = "null"
camera = "scroll"
effects = []
viewport_width = 80
viewport_height = 24
enable_message_overlay = false
frame_count = 30
camera_speed = 0.5
[presets.comparison-camera-horizontal]
description = "Comparison: Horizontal camera mode"
source = "headlines"
display = "null"
camera = "horizontal"
effects = []
viewport_width = 80
viewport_height = 24
enable_message_overlay = false
frame_count = 30
# ============================================
# SOURCE TESTS (Different data sources)
# ============================================
[presets.comparison-source-headlines]
description = "Comparison: Headlines source"
source = "headlines"
display = "null"
camera = "feed"
effects = []
viewport_width = 80
viewport_height = 24
enable_message_overlay = false
frame_count = 30
[presets.comparison-source-poetry]
description = "Comparison: Poetry source"
source = "poetry"
display = "null"
camera = "feed"
effects = []
viewport_width = 80
viewport_height = 24
enable_message_overlay = false
frame_count = 30
[presets.comparison-source-empty]
description = "Comparison: Empty source (blank canvas)"
source = "empty"
display = "null"
camera = "feed"
effects = []
viewport_width = 80
viewport_height = 24
enable_message_overlay = false
frame_count = 30
# ============================================
# DIMENSION TESTS (Different viewport sizes)
# ============================================
[presets.comparison-small-viewport]
description = "Comparison: Small viewport"
source = "headlines"
display = "null"
camera = "feed"
effects = []
viewport_width = 60
viewport_height = 20
enable_message_overlay = false
frame_count = 30
[presets.comparison-large-viewport]
description = "Comparison: Large viewport"
source = "headlines"
display = "null"
camera = "feed"
effects = []
viewport_width = 120
viewport_height = 40
enable_message_overlay = false
frame_count = 30
[presets.comparison-wide-viewport]
description = "Comparison: Wide viewport"
source = "headlines"
display = "null"
camera = "feed"
effects = []
viewport_width = 160
viewport_height = 24
enable_message_overlay = false
frame_count = 30
# ============================================
# COMPREHENSIVE TESTS (Combined scenarios)
# ============================================
[presets.comparison-comprehensive-1]
description = "Comparison: Headlines + Effects + Message Overlay"
source = "headlines"
display = "null"
camera = "feed"
effects = ["border", "tint"]
viewport_width = 80
viewport_height = 24
enable_message_overlay = true
frame_count = 30
[presets.comparison-comprehensive-2]
description = "Comparison: Poetry + Camera Scroll + Effects"
source = "poetry"
display = "null"
camera = "scroll"
effects = ["fade", "noise"]
viewport_width = 80
viewport_height = 24
enable_message_overlay = false
frame_count = 30
camera_speed = 0.3
[presets.comparison-comprehensive-3]
description = "Comparison: Headlines + Horizontal Camera + All Effects"
source = "headlines"
display = "null"
camera = "horizontal"
effects = ["border", "tint", "hud", "fade"]
viewport_width = 100
viewport_height = 30
enable_message_overlay = true
frame_count = 30
# ============================================
# REGRESSION TESTS (Specific edge cases)
# ============================================
[presets.comparison-regression-empty-message]
description = "Regression: Empty message overlay"
source = "empty"
display = "null"
camera = "feed"
effects = []
viewport_width = 80
viewport_height = 24
enable_message_overlay = true
frame_count = 30
[presets.comparison-regression-narrow-viewport]
description = "Regression: Very narrow viewport with long text"
source = "headlines"
display = "null"
camera = "feed"
effects = []
viewport_width = 40
viewport_height = 24
enable_message_overlay = false
frame_count = 30
[presets.comparison-regression-tall-viewport]
description = "Regression: Tall viewport with few items"
source = "empty"
display = "null"
camera = "feed"
effects = []
viewport_width = 80
viewport_height = 60
enable_message_overlay = false
frame_count = 30

View File

@@ -1,243 +0,0 @@
"""Main comparison runner for upstream vs sideline testing.
This script runs comparisons between upstream and sideline implementations
using multiple presets and generates HTML reports.
"""
import argparse
import json
import sys
from pathlib import Path
from tests.comparison_capture import (
capture_frames,
compare_captured_outputs,
generate_html_report,
)
def load_comparison_presets() -> list[str]:
"""Load list of comparison presets from config file.
Returns:
List of preset names
"""
import tomli
config_file = Path("tests/comparison_presets.toml")
if not config_file.exists():
raise FileNotFoundError(f"Comparison presets not found: {config_file}")
with open(config_file, "rb") as f:
config = tomli.load(f)
presets = list(config.get("presets", {}).keys())
# Strip "presets." prefix if present
return [p.replace("presets.", "") for p in presets]
def run_comparison_for_preset(
preset_name: str,
sideline_only: bool = False,
upstream_file: Path | None = None,
) -> dict:
"""Run comparison for a single preset.
Args:
preset_name: Name of preset to test
sideline_only: If True, only capture sideline frames
upstream_file: Path to upstream captured output (if not None, use this instead of capturing)
Returns:
Comparison result dict
"""
print(f" Running preset: {preset_name}")
# Capture sideline frames
sideline_data = capture_frames(preset_name, frame_count=30)
sideline_file = Path(f"tests/comparison_output/{preset_name}_sideline.json")
if sideline_only:
return {
"preset": preset_name,
"status": "sideline_only",
"sideline_file": str(sideline_file),
}
# Use provided upstream file or look for it
if upstream_file:
upstream_path = upstream_file
else:
upstream_path = Path(f"tests/comparison_output/{preset_name}_upstream.json")
if not upstream_path.exists():
print(f" Warning: Upstream file not found: {upstream_path}")
return {
"preset": preset_name,
"status": "missing_upstream",
"sideline_file": str(sideline_file),
"upstream_file": str(upstream_path),
}
# Compare outputs
try:
comparison_result = compare_captured_outputs(
sideline_file=sideline_file,
upstream_file=upstream_path,
)
comparison_result["status"] = "success"
return comparison_result
except Exception as e:
print(f" Error comparing outputs: {e}")
return {
"preset": preset_name,
"status": "error",
"error": str(e),
"sideline_file": str(sideline_file),
"upstream_file": str(upstream_path),
}
def main():
"""Main entry point for comparison runner."""
parser = argparse.ArgumentParser(
description="Run comparison tests between upstream and sideline implementations"
)
parser.add_argument(
"--preset",
"-p",
help="Run specific preset (can be specified multiple times)",
action="append",
dest="presets",
)
parser.add_argument(
"--all",
"-a",
help="Run all comparison presets",
action="store_true",
)
parser.add_argument(
"--sideline-only",
"-s",
help="Only capture sideline frames (no comparison)",
action="store_true",
)
parser.add_argument(
"--upstream-file",
"-u",
help="Path to upstream captured output file",
type=Path,
)
parser.add_argument(
"--output-dir",
"-o",
help="Output directory for captured frames and reports",
type=Path,
default=Path("tests/comparison_output"),
)
parser.add_argument(
"--no-report",
help="Skip HTML report generation",
action="store_true",
)
args = parser.parse_args()
# Determine which presets to run
if args.presets:
presets_to_run = args.presets
elif args.all:
presets_to_run = load_comparison_presets()
else:
print("Error: Either --preset or --all must be specified")
print(f"Available presets: {', '.join(load_comparison_presets())}")
sys.exit(1)
print(f"Running comparison for {len(presets_to_run)} preset(s)")
print(f"Output directory: {args.output_dir}")
print()
# Run comparisons
results = []
for preset_name in presets_to_run:
try:
result = run_comparison_for_preset(
preset_name,
sideline_only=args.sideline_only,
upstream_file=args.upstream_file,
)
results.append(result)
if result["status"] == "success":
match_pct = result["stats"]["match_percentage"]
print(f" ✓ Match: {match_pct:.1f}%")
elif result["status"] == "missing_upstream":
print(f" ⚠ Missing upstream file")
elif result["status"] == "error":
print(f" ✗ Error: {result['error']}")
else:
print(f" ✓ Captured sideline only")
except Exception as e:
print(f" ✗ Failed: {e}")
results.append(
{
"preset": preset_name,
"status": "failed",
"error": str(e),
}
)
# Generate HTML report
if not args.no_report and not args.sideline_only:
successful_results = [r for r in results if r.get("status") == "success"]
if successful_results:
print(f"\nGenerating HTML report...")
report_file = generate_html_report(successful_results, args.output_dir)
print(f" Report saved to: {report_file}")
# Also save summary JSON
summary_file = args.output_dir / "comparison_summary.json"
with open(summary_file, "w") as f:
json.dump(
{
"timestamp": __import__("datetime").datetime.now().isoformat(),
"presets_tested": [r["preset"] for r in results],
"results": results,
},
f,
indent=2,
)
print(f" Summary saved to: {summary_file}")
else:
print(f"\nNote: No successful comparisons to report.")
print(f" Capture files saved in {args.output_dir}")
print(f" Run comparison when upstream files are available.")
# Print summary
print("\n" + "=" * 60)
print("SUMMARY")
print("=" * 60)
status_counts = {}
for result in results:
status = result.get("status", "unknown")
status_counts[status] = status_counts.get(status, 0) + 1
for status, count in sorted(status_counts.items()):
print(f" {status}: {count}")
if "success" in status_counts:
successful_results = [r for r in results if r.get("status") == "success"]
avg_match = sum(
r["stats"]["match_percentage"] for r in successful_results
) / len(successful_results)
print(f"\n Average match rate: {avg_match:.1f}%")
# Exit with error code if any failures
if any(r.get("status") in ["error", "failed"] for r in results):
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,16 +1,17 @@
"""
Tests for engine/pipeline/adapters.py - Stage adapters for the pipeline.
Tests Stage adapters that bridge existing components to the Stage interface.
Focuses on behavior testing rather than mock interactions.
Tests Stage adapters that bridge existing components to the Stage interface:
- DataSourceStage: Wraps DataSource objects
- DisplayStage: Wraps Display backends
- PassthroughStage: Simple pass-through stage for pre-rendered data
- SourceItemsToBufferStage: Converts SourceItem objects to text buffers
- EffectPluginStage: Wraps effect plugins
"""
from unittest.mock import MagicMock
from engine.data_sources.sources import SourceItem
from engine.display.backends.null import NullDisplay
from engine.effects.plugins import discover_plugins
from engine.effects.registry import get_registry
from engine.pipeline.adapters import (
DataSourceStage,
DisplayStage,
@@ -24,14 +25,28 @@ from engine.pipeline.core import PipelineContext
class TestDataSourceStage:
"""Test DataSourceStage adapter."""
def test_datasource_stage_properties(self):
"""DataSourceStage has correct name, category, and capabilities."""
def test_datasource_stage_name(self):
"""DataSourceStage stores name correctly."""
mock_source = MagicMock()
stage = DataSourceStage(mock_source, name="headlines")
assert stage.name == "headlines"
def test_datasource_stage_category(self):
"""DataSourceStage has 'source' category."""
mock_source = MagicMock()
stage = DataSourceStage(mock_source, name="headlines")
assert stage.category == "source"
def test_datasource_stage_capabilities(self):
"""DataSourceStage advertises source capability."""
mock_source = MagicMock()
stage = DataSourceStage(mock_source, name="headlines")
assert "source.headlines" in stage.capabilities
def test_datasource_stage_dependencies(self):
"""DataSourceStage has no dependencies."""
mock_source = MagicMock()
stage = DataSourceStage(mock_source, name="headlines")
assert stage.dependencies == set()
def test_datasource_stage_process_calls_get_items(self):
@@ -49,7 +64,7 @@ class TestDataSourceStage:
assert result == mock_items
mock_source.get_items.assert_called_once()
def test_datasource_stage_process_fallback(self):
def test_datasource_stage_process_fallback_returns_data(self):
"""DataSourceStage.process() returns data if no get_items method."""
mock_source = MagicMock(spec=[]) # No get_items method
stage = DataSourceStage(mock_source, name="headlines")
@@ -61,68 +76,124 @@ class TestDataSourceStage:
class TestDisplayStage:
"""Test DisplayStage adapter using NullDisplay for real behavior."""
def test_display_stage_properties(self):
"""DisplayStage has correct name, category, and capabilities."""
display = NullDisplay()
stage = DisplayStage(display, name="terminal")
"""Test DisplayStage adapter."""
def test_display_stage_name(self):
"""DisplayStage stores name correctly."""
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
assert stage.name == "terminal"
def test_display_stage_category(self):
"""DisplayStage has 'display' category."""
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
assert stage.category == "display"
def test_display_stage_capabilities(self):
"""DisplayStage advertises display capability."""
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
assert "display.output" in stage.capabilities
def test_display_stage_dependencies(self):
"""DisplayStage depends on render.output."""
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
assert "render.output" in stage.dependencies
def test_display_stage_init_and_process(self):
"""DisplayStage initializes display and processes buffer."""
from engine.pipeline.params import PipelineParams
display = NullDisplay()
stage = DisplayStage(display, name="terminal")
def test_display_stage_init(self):
"""DisplayStage.init() calls display.init() with dimensions."""
mock_display = MagicMock()
mock_display.init.return_value = True
stage = DisplayStage(mock_display, name="terminal")
ctx = PipelineContext()
ctx.params = PipelineParams()
ctx.params.viewport_width = 80
ctx.params.viewport_height = 24
ctx.params = MagicMock()
ctx.params.viewport_width = 100
ctx.params.viewport_height = 30
# Initialize
result = stage.init(ctx)
assert result is True
mock_display.init.assert_called_once_with(100, 30, reuse=False)
# Process buffer
buffer = ["Line 1", "Line 2", "Line 3"]
output = stage.process(buffer, ctx)
assert output == buffer
def test_display_stage_init_uses_defaults(self):
"""DisplayStage.init() uses defaults when params missing."""
mock_display = MagicMock()
mock_display.init.return_value = True
stage = DisplayStage(mock_display, name="terminal")
# Verify display captured the buffer
assert display._last_buffer == buffer
ctx = PipelineContext()
ctx.params = None
def test_display_stage_skips_none_data(self):
result = stage.init(ctx)
assert result is True
mock_display.init.assert_called_once_with(80, 24, reuse=False)
def test_display_stage_process_calls_show(self):
"""DisplayStage.process() calls display.show() with data."""
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
test_buffer = [[["A", "red"] for _ in range(80)] for _ in range(24)]
ctx = PipelineContext()
result = stage.process(test_buffer, ctx)
assert result == test_buffer
mock_display.show.assert_called_once_with(test_buffer)
def test_display_stage_process_skips_none_data(self):
"""DisplayStage.process() skips show() if data is None."""
display = NullDisplay()
stage = DisplayStage(display, name="terminal")
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
ctx = PipelineContext()
result = stage.process(None, ctx)
assert result is None
assert display._last_buffer is None
mock_display.show.assert_not_called()
def test_display_stage_cleanup(self):
"""DisplayStage.cleanup() calls display.cleanup()."""
mock_display = MagicMock()
stage = DisplayStage(mock_display, name="terminal")
stage.cleanup()
mock_display.cleanup.assert_called_once()
class TestPassthroughStage:
"""Test PassthroughStage adapter."""
def test_passthrough_stage_properties(self):
"""PassthroughStage has correct properties."""
def test_passthrough_stage_name(self):
"""PassthroughStage stores name correctly."""
stage = PassthroughStage(name="test")
assert stage.name == "test"
def test_passthrough_stage_category(self):
"""PassthroughStage has 'render' category."""
stage = PassthroughStage()
assert stage.category == "render"
def test_passthrough_stage_is_optional(self):
"""PassthroughStage is optional."""
stage = PassthroughStage()
assert stage.optional is True
def test_passthrough_stage_capabilities(self):
"""PassthroughStage advertises render output capability."""
stage = PassthroughStage()
assert "render.output" in stage.capabilities
def test_passthrough_stage_dependencies(self):
"""PassthroughStage depends on source."""
stage = PassthroughStage()
assert "source" in stage.dependencies
def test_passthrough_stage_process_unchanged(self):
def test_passthrough_stage_process_returns_data_unchanged(self):
"""PassthroughStage.process() returns data unchanged."""
stage = PassthroughStage()
ctx = PipelineContext()
@@ -139,17 +210,32 @@ class TestPassthroughStage:
class TestSourceItemsToBufferStage:
"""Test SourceItemsToBufferStage adapter."""
def test_source_items_to_buffer_stage_properties(self):
"""SourceItemsToBufferStage has correct properties."""
def test_source_items_to_buffer_stage_name(self):
"""SourceItemsToBufferStage stores name correctly."""
stage = SourceItemsToBufferStage(name="custom-name")
assert stage.name == "custom-name"
def test_source_items_to_buffer_stage_category(self):
"""SourceItemsToBufferStage has 'render' category."""
stage = SourceItemsToBufferStage()
assert stage.category == "render"
def test_source_items_to_buffer_stage_is_optional(self):
"""SourceItemsToBufferStage is optional."""
stage = SourceItemsToBufferStage()
assert stage.optional is True
def test_source_items_to_buffer_stage_capabilities(self):
"""SourceItemsToBufferStage advertises render output capability."""
stage = SourceItemsToBufferStage()
assert "render.output" in stage.capabilities
def test_source_items_to_buffer_stage_dependencies(self):
"""SourceItemsToBufferStage depends on source."""
stage = SourceItemsToBufferStage()
assert "source" in stage.dependencies
def test_source_items_to_buffer_stage_process_single_line(self):
def test_source_items_to_buffer_stage_process_single_line_item(self):
"""SourceItemsToBufferStage converts single-line SourceItem."""
stage = SourceItemsToBufferStage()
ctx = PipelineContext()
@@ -161,10 +247,10 @@ class TestSourceItemsToBufferStage:
assert isinstance(result, list)
assert len(result) >= 1
# Result should be lines of text
assert all(isinstance(line, str) for line in result)
assert "Single line content" in result[0]
def test_source_items_to_buffer_stage_process_multiline(self):
def test_source_items_to_buffer_stage_process_multiline_item(self):
"""SourceItemsToBufferStage splits multiline SourceItem content."""
stage = SourceItemsToBufferStage()
ctx = PipelineContext()
@@ -197,76 +283,63 @@ class TestSourceItemsToBufferStage:
class TestEffectPluginStage:
"""Test EffectPluginStage adapter with real effect plugins."""
"""Test EffectPluginStage adapter."""
def test_effect_plugin_stage_properties(self):
"""EffectPluginStage has correct properties for real effects."""
discover_plugins()
registry = get_registry()
effect = registry.get("noise")
def test_effect_plugin_stage_name(self):
"""EffectPluginStage stores name correctly."""
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="blur")
assert stage.name == "blur"
stage = EffectPluginStage(effect, name="noise")
assert stage.name == "noise"
def test_effect_plugin_stage_category(self):
"""EffectPluginStage has 'effect' category."""
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="blur")
assert stage.category == "effect"
def test_effect_plugin_stage_is_not_optional(self):
"""EffectPluginStage is required when configured."""
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="blur")
assert stage.optional is False
assert "effect.noise" in stage.capabilities
def test_effect_plugin_stage_capabilities(self):
"""EffectPluginStage advertises effect capability with name."""
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="blur")
assert "effect.blur" in stage.capabilities
def test_effect_plugin_stage_dependencies(self):
"""EffectPluginStage has no static dependencies."""
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="blur")
# EffectPluginStage has empty dependencies - they are resolved dynamically
assert stage.dependencies == set()
def test_effect_plugin_stage_stage_type(self):
"""EffectPluginStage.stage_type returns effect for non-HUD."""
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="blur")
assert stage.stage_type == "effect"
def test_effect_plugin_stage_hud_special_handling(self):
"""EffectPluginStage has special handling for HUD effect."""
discover_plugins()
registry = get_registry()
hud_effect = registry.get("hud")
stage = EffectPluginStage(hud_effect, name="hud")
mock_effect = MagicMock()
stage = EffectPluginStage(mock_effect, name="hud")
assert stage.stage_type == "overlay"
assert stage.is_overlay is True
assert stage.render_order == 100
def test_effect_plugin_stage_process_real_effect(self):
"""EffectPluginStage.process() calls real effect.process()."""
from engine.pipeline.params import PipelineParams
def test_effect_plugin_stage_process(self):
"""EffectPluginStage.process() calls effect.process()."""
mock_effect = MagicMock()
mock_effect.process.return_value = "processed_data"
discover_plugins()
registry = get_registry()
effect = registry.get("noise")
stage = EffectPluginStage(effect, name="noise")
stage = EffectPluginStage(mock_effect, name="blur")
ctx = PipelineContext()
ctx.params = PipelineParams()
ctx.params.viewport_width = 80
ctx.params.viewport_height = 24
ctx.params.frame_number = 0
test_buffer = "test_buffer"
test_buffer = ["Line 1", "Line 2", "Line 3"]
result = stage.process(test_buffer, ctx)
# Should return a list (possibly modified buffer)
assert isinstance(result, list)
# Noise effect should preserve line count
assert len(result) == len(test_buffer)
def test_effect_plugin_stage_process_with_real_figment(self):
"""EffectPluginStage processes figment effect correctly."""
from engine.pipeline.params import PipelineParams
discover_plugins()
registry = get_registry()
figment = registry.get("figment")
stage = EffectPluginStage(figment, name="figment")
ctx = PipelineContext()
ctx.params = PipelineParams()
ctx.params.viewport_width = 80
ctx.params.viewport_height = 24
ctx.params.frame_number = 0
test_buffer = ["Line 1", "Line 2", "Line 3"]
result = stage.process(test_buffer, ctx)
# Figment is an overlay effect
assert stage.is_overlay is True
assert stage.stage_type == "overlay"
# Result should be a list
assert isinstance(result, list)
assert result == "processed_data"
mock_effect.process.assert_called_once()

View File

@@ -1,285 +0,0 @@
"""
Unit tests for engine.canvas.Canvas.
Tests the core 2D rendering surface without any display dependencies.
"""
from engine.canvas import Canvas, CanvasRegion
class TestCanvasRegion:
"""Tests for CanvasRegion dataclass."""
def test_is_valid_positive_dimensions(self):
"""Positive width and height returns True."""
region = CanvasRegion(0, 0, 10, 5)
assert region.is_valid() is True
def test_is_valid_zero_width(self):
"""Zero width returns False."""
region = CanvasRegion(0, 0, 0, 5)
assert region.is_valid() is False
def test_is_valid_zero_height(self):
"""Zero height returns False."""
region = CanvasRegion(0, 0, 10, 0)
assert region.is_valid() is False
def test_is_valid_negative_dimensions(self):
"""Negative dimensions return False."""
region = CanvasRegion(0, 0, -1, 5)
assert region.is_valid() is False
def test_rows_computes_correct_set(self):
"""rows() returns set of row indices in region."""
region = CanvasRegion(2, 3, 4, 2)
assert region.rows() == {3, 4}
class TestCanvas:
"""Tests for Canvas class."""
def test_init_default_dimensions(self):
"""Default width=80, height=24."""
canvas = Canvas()
assert canvas.width == 80
assert canvas.height == 24
assert len(canvas._grid) == 24
assert len(canvas._grid[0]) == 80
def test_init_custom_dimensions(self):
"""Custom dimensions are set correctly."""
canvas = Canvas(100, 50)
assert canvas.width == 100
assert canvas.height == 50
def test_clear_empties_grid(self):
"""clear() resets all cells to spaces."""
canvas = Canvas(5, 3)
canvas.put_text(0, 0, "Hello")
canvas.clear()
region = canvas.get_region(0, 0, 5, 3)
assert all(all(cell == " " for cell in row) for row in region)
def test_clear_marks_entire_canvas_dirty(self):
"""clear() marks entire canvas as dirty."""
canvas = Canvas(10, 5)
canvas.clear()
dirty = canvas.get_dirty_regions()
assert len(dirty) == 1
assert dirty[0].x == 0 and dirty[0].y == 0
assert dirty[0].width == 10 and dirty[0].height == 5
def test_put_text_single_char(self):
"""put_text writes a single character at position."""
canvas = Canvas(10, 5)
canvas.put_text(3, 2, "X")
assert canvas._grid[2][3] == "X"
def test_put_text_multiple_chars(self):
"""put_text writes multiple characters in a row."""
canvas = Canvas(10, 5)
canvas.put_text(2, 1, "ABC")
assert canvas._grid[1][2] == "A"
assert canvas._grid[1][3] == "B"
assert canvas._grid[1][4] == "C"
def test_put_text_ignores_overflow_right(self):
"""Characters beyond width are ignored."""
canvas = Canvas(5, 5)
canvas.put_text(3, 0, "XYZ")
assert canvas._grid[0][3] == "X"
assert canvas._grid[0][4] == "Y"
# Z would be at index 5, which is out of bounds
def test_put_text_ignores_overflow_bottom(self):
"""Rows beyond height are ignored."""
canvas = Canvas(5, 3)
canvas.put_text(0, 5, "test")
# Row 5 doesn't exist, nothing should be written
assert all(cell == " " for row in canvas._grid for cell in row)
def test_put_text_marks_dirty_region(self):
"""put_text marks the written area as dirty."""
canvas = Canvas(10, 5)
canvas.put_text(2, 1, "Hello")
dirty = canvas.get_dirty_regions()
assert len(dirty) == 1
assert dirty[0].x == 2 and dirty[0].y == 1
assert dirty[0].width == 5 and dirty[0].height == 1
def test_put_text_empty_string_no_dirty(self):
"""Empty string does not create dirty region."""
canvas = Canvas(10, 5)
canvas.put_text(0, 0, "")
assert not canvas.is_dirty()
def test_put_region_single_cell(self):
"""put_region writes a single cell correctly."""
canvas = Canvas(5, 5)
content = [["X"]]
canvas.put_region(2, 2, content)
assert canvas._grid[2][2] == "X"
def test_put_region_multiple_rows(self):
"""put_region writes multiple rows correctly."""
canvas = Canvas(10, 10)
content = [["A", "B"], ["C", "D"]]
canvas.put_region(1, 1, content)
assert canvas._grid[1][1] == "A"
assert canvas._grid[1][2] == "B"
assert canvas._grid[2][1] == "C"
assert canvas._grid[2][2] == "D"
def test_put_region_partial_out_of_bounds(self):
"""put_region clips content that extends beyond canvas bounds."""
canvas = Canvas(5, 5)
content = [["A", "B", "C"], ["D", "E", "F"]]
canvas.put_region(4, 4, content)
# Only cell (4,4) should be within bounds
assert canvas._grid[4][4] == "A"
# Others are out of bounds
assert canvas._grid[4][5] == " " if 5 < 5 else True # index 5 doesn't exist
assert canvas._grid[5][4] == " " if 5 < 5 else True # row 5 doesn't exist
def test_put_region_marks_dirty(self):
"""put_region marks dirty region covering written area (clipped)."""
canvas = Canvas(10, 10)
content = [["A", "B", "C"], ["D", "E", "F"]]
canvas.put_region(2, 2, content)
dirty = canvas.get_dirty_regions()
assert len(dirty) == 1
assert dirty[0].x == 2 and dirty[0].y == 2
assert dirty[0].width == 3 and dirty[0].height == 2
def test_fill_rectangle(self):
"""fill() fills a rectangular region with character."""
canvas = Canvas(10, 10)
canvas.fill(2, 2, 3, 2, "*")
for y in range(2, 4):
for x in range(2, 5):
assert canvas._grid[y][x] == "*"
def test_fill_entire_canvas(self):
"""fill() can fill entire canvas."""
canvas = Canvas(5, 3)
canvas.fill(0, 0, 5, 3, "#")
for row in canvas._grid:
assert all(cell == "#" for cell in row)
def test_fill_empty_region_no_dirty(self):
"""fill with zero dimensions does not mark dirty."""
canvas = Canvas(10, 10)
canvas.fill(0, 0, 0, 5, "X")
assert not canvas.is_dirty()
def test_fill_clips_to_bounds(self):
"""fill clips to canvas boundaries."""
canvas = Canvas(5, 5)
canvas.fill(3, 3, 5, 5, "X")
# Should only fill within bounds: (3,3) to (4,4)
assert canvas._grid[3][3] == "X"
assert canvas._grid[3][4] == "X"
assert canvas._grid[4][3] == "X"
assert canvas._grid[4][4] == "X"
# Out of bounds should remain spaces
assert canvas._grid[5] if 5 < 5 else True # row 5 doesn't exist
def test_get_region_extracts_subgrid(self):
"""get_region returns correct rectangular subgrid."""
canvas = Canvas(10, 10)
for y in range(10):
for x in range(10):
canvas._grid[y][x] = chr(ord("A") + (x % 26))
region = canvas.get_region(2, 3, 4, 2)
assert len(region) == 2
assert len(region[0]) == 4
assert region[0][0] == "C" # (2,3) = 'C'
assert region[1][2] == "E" # (4,4) = 'E'
def test_get_region_out_of_bounds_returns_spaces(self):
"""get_region pads out-of-bounds areas with spaces."""
canvas = Canvas(5, 5)
canvas.put_text(0, 0, "HELLO")
# Region overlapping right edge: cols 3-4 inside, col5+ outside
region = canvas.get_region(3, 0, 5, 2)
assert region[0][0] == "L"
assert region[0][1] == "O"
assert region[0][2] == " " # col5 out of bounds
assert all(cell == " " for cell in region[1])
def test_get_region_flat_returns_lines(self):
"""get_region_flat returns list of joined strings."""
canvas = Canvas(10, 5)
canvas.put_text(0, 0, "FIRST")
canvas.put_text(0, 1, "SECOND")
flat = canvas.get_region_flat(0, 0, 6, 2)
assert flat == ["FIRST ", "SECOND"]
def test_mark_dirty_manual(self):
"""mark_dirty() can be called manually to mark arbitrary region."""
canvas = Canvas(10, 10)
canvas.mark_dirty(5, 5, 3, 2)
dirty = canvas.get_dirty_regions()
assert len(dirty) == 1
assert dirty[0] == CanvasRegion(5, 5, 3, 2)
def test_get_dirty_rows_union(self):
"""get_dirty_rows() returns union of all dirty row indices."""
canvas = Canvas(10, 10)
canvas.put_text(0, 0, "A") # row 0
canvas.put_text(0, 2, "B") # row 2
canvas.mark_dirty(0, 1, 1, 1) # row 1
rows = canvas.get_dirty_rows()
assert rows == {0, 1, 2}
def test_is_dirty_after_operations(self):
"""is_dirty() returns True after any modifying operation."""
canvas = Canvas(10, 10)
assert not canvas.is_dirty()
canvas.put_text(0, 0, "X")
assert canvas.is_dirty()
_ = canvas.get_dirty_regions() # resets
assert not canvas.is_dirty()
def test_resize_same_size_no_change(self):
"""resize with same dimensions does nothing."""
canvas = Canvas(10, 5)
canvas.put_text(0, 0, "TEST")
canvas.resize(10, 5)
assert canvas._grid[0][0] == "T"
def test_resize_larger_preserves_content(self):
"""resize to larger canvas preserves existing content."""
canvas = Canvas(5, 3)
canvas.put_text(1, 1, "AB")
canvas.resize(10, 6)
assert canvas.width == 10
assert canvas.height == 6
assert canvas._grid[1][1] == "A"
assert canvas._grid[1][2] == "B"
# New area should be spaces
assert canvas._grid[0][0] == " "
def test_resize_smaller_truncates(self):
"""resize to smaller canvas drops content outside new bounds."""
canvas = Canvas(10, 5)
canvas.put_text(8, 4, "XYZ")
canvas.resize(5, 3)
assert canvas.width == 5
assert canvas.height == 3
# Content at (8,4) should be lost
# But content within new bounds should remain
canvas2 = Canvas(10, 5)
canvas2.put_text(2, 2, "HI")
canvas2.resize(5, 3)
assert canvas2._grid[2][2] == "H"
def test_resize_does_not_auto_mark_dirty(self):
"""resize() does not automatically mark dirty (caller responsibility)."""
canvas = Canvas(10, 10)
canvas.put_text(0, 0, "A")
_ = canvas.get_dirty_regions() # reset
canvas.resize(5, 5)
# Resize doesn't mark dirty - this is current implementation
assert not canvas.is_dirty()

View File

@@ -1,341 +0,0 @@
"""Comparison framework tests for upstream vs sideline pipeline.
These tests verify that the comparison framework works correctly
and can be used for regression testing.
"""
import json
import tempfile
from pathlib import Path
import pytest
from tests.comparison_capture import capture_frames, compare_captured_outputs
class TestComparisonCapture:
"""Tests for frame capture functionality."""
def test_capture_basic_preset(self):
"""Test capturing frames from a basic preset."""
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
# Capture frames
result = capture_frames(
preset_name="comparison-basic",
frame_count=10,
output_dir=output_dir,
)
# Verify result structure
assert "preset" in result
assert "config" in result
assert "frames" in result
assert "capture_stats" in result
# Verify frame count
assert len(result["frames"]) == 10
# Verify frame structure
frame = result["frames"][0]
assert "frame_number" in frame
assert "buffer" in frame
assert "width" in frame
assert "height" in frame
def test_capture_with_message_overlay(self):
"""Test capturing frames with message overlay enabled."""
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
result = capture_frames(
preset_name="comparison-with-message-overlay",
frame_count=5,
output_dir=output_dir,
)
# Verify message overlay is enabled in config
assert result["config"]["enable_message_overlay"] is True
def test_capture_multiple_presets(self):
"""Test capturing frames from multiple presets."""
presets = ["comparison-basic", "comparison-single-effect"]
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
for preset in presets:
result = capture_frames(
preset_name=preset,
frame_count=5,
output_dir=output_dir,
)
assert result["preset"] == preset
class TestComparisonAnalysis:
"""Tests for comparison analysis functionality."""
def test_compare_identical_outputs(self):
"""Test comparing identical outputs shows 100% match."""
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
# Create two identical captured outputs
sideline_file = output_dir / "test_sideline.json"
upstream_file = output_dir / "test_upstream.json"
test_data = {
"preset": "test",
"config": {"viewport_width": 80, "viewport_height": 24},
"frames": [
{
"frame_number": 0,
"buffer": ["Line 1", "Line 2", "Line 3"],
"width": 80,
"height": 24,
"render_time_ms": 10.0,
}
],
"capture_stats": {
"frame_count": 1,
"total_time_ms": 10.0,
"avg_frame_time_ms": 10.0,
"fps": 100.0,
},
}
with open(sideline_file, "w") as f:
json.dump(test_data, f)
with open(upstream_file, "w") as f:
json.dump(test_data, f)
# Compare
result = compare_captured_outputs(
sideline_file=sideline_file,
upstream_file=upstream_file,
)
# Should have 100% match
assert result["stats"]["match_percentage"] == 100.0
assert result["stats"]["identical_frames"] == 1
assert result["stats"]["total_differences"] == 0
def test_compare_different_outputs(self):
"""Test comparing different outputs detects differences."""
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
sideline_file = output_dir / "test_sideline.json"
upstream_file = output_dir / "test_upstream.json"
# Create different outputs
sideline_data = {
"preset": "test",
"config": {"viewport_width": 80, "viewport_height": 24},
"frames": [
{
"frame_number": 0,
"buffer": ["Sideline Line 1", "Line 2"],
"width": 80,
"height": 24,
"render_time_ms": 10.0,
}
],
"capture_stats": {
"frame_count": 1,
"total_time_ms": 10.0,
"avg_frame_time_ms": 10.0,
"fps": 100.0,
},
}
upstream_data = {
"preset": "test",
"config": {"viewport_width": 80, "viewport_height": 24},
"frames": [
{
"frame_number": 0,
"buffer": ["Upstream Line 1", "Line 2"],
"width": 80,
"height": 24,
"render_time_ms": 12.0,
}
],
"capture_stats": {
"frame_count": 1,
"total_time_ms": 12.0,
"avg_frame_time_ms": 12.0,
"fps": 83.33,
},
}
with open(sideline_file, "w") as f:
json.dump(sideline_data, f)
with open(upstream_file, "w") as f:
json.dump(upstream_data, f)
# Compare
result = compare_captured_outputs(
sideline_file=sideline_file,
upstream_file=upstream_file,
)
# Should detect differences
assert result["stats"]["match_percentage"] < 100.0
assert result["stats"]["total_differences"] > 0
assert len(result["frame_comparisons"][0]["line_diffs"]) > 0
def test_performance_comparison(self):
"""Test that performance metrics are compared correctly."""
with tempfile.TemporaryDirectory() as tmpdir:
output_dir = Path(tmpdir)
sideline_file = output_dir / "test_sideline.json"
upstream_file = output_dir / "test_upstream.json"
sideline_data = {
"preset": "test",
"config": {"viewport_width": 80, "viewport_height": 24},
"frames": [
{
"frame_number": 0,
"buffer": [],
"width": 80,
"height": 24,
"render_time_ms": 10.0,
}
],
"capture_stats": {
"frame_count": 1,
"total_time_ms": 10.0,
"avg_frame_time_ms": 10.0,
"fps": 100.0,
},
}
upstream_data = {
"preset": "test",
"config": {"viewport_width": 80, "viewport_height": 24},
"frames": [
{
"frame_number": 0,
"buffer": [],
"width": 80,
"height": 24,
"render_time_ms": 12.0,
}
],
"capture_stats": {
"frame_count": 1,
"total_time_ms": 12.0,
"avg_frame_time_ms": 12.0,
"fps": 83.33,
},
}
with open(sideline_file, "w") as f:
json.dump(sideline_data, f)
with open(upstream_file, "w") as f:
json.dump(upstream_data, f)
result = compare_captured_outputs(
sideline_file=sideline_file,
upstream_file=upstream_file,
)
# Verify performance comparison
perf = result["performance_comparison"]
assert "sideline" in perf
assert "upstream" in perf
assert "diff" in perf
assert (
perf["sideline"]["fps"] > perf["upstream"]["fps"]
) # Sideline is faster in this example
class TestComparisonPresets:
"""Tests for comparison preset configuration."""
def test_comparison_presets_exist(self):
"""Test that comparison presets file exists and is valid."""
presets_file = Path("tests/comparison_presets.toml")
assert presets_file.exists(), "Comparison presets file should exist"
def test_preset_structure(self):
"""Test that presets have required fields."""
import tomli
with open("tests/comparison_presets.toml", "rb") as f:
config = tomli.load(f)
presets = config.get("presets", {})
assert len(presets) > 0, "Should have at least one preset"
for preset_name, preset_config in presets.items():
# Each preset should have required fields
assert "source" in preset_config, f"{preset_name} should have 'source'"
assert "display" in preset_config, f"{preset_name} should have 'display'"
assert "camera" in preset_config, f"{preset_name} should have 'camera'"
assert "viewport_width" in preset_config, (
f"{preset_name} should have 'viewport_width'"
)
assert "viewport_height" in preset_config, (
f"{preset_name} should have 'viewport_height'"
)
assert "frame_count" in preset_config, (
f"{preset_name} should have 'frame_count'"
)
def test_preset_variety(self):
"""Test that presets cover different scenarios."""
import tomli
with open("tests/comparison_presets.toml", "rb") as f:
config = tomli.load(f)
presets = config.get("presets", {})
# Should have presets for different categories
categories = {
"basic": 0,
"effect": 0,
"camera": 0,
"source": 0,
"viewport": 0,
"comprehensive": 0,
"regression": 0,
}
for preset_name in presets.keys():
name_lower = preset_name.lower()
if "basic" in name_lower:
categories["basic"] += 1
elif (
"effect" in name_lower or "border" in name_lower or "tint" in name_lower
):
categories["effect"] += 1
elif "camera" in name_lower:
categories["camera"] += 1
elif "source" in name_lower:
categories["source"] += 1
elif (
"viewport" in name_lower
or "small" in name_lower
or "large" in name_lower
):
categories["viewport"] += 1
elif "comprehensive" in name_lower:
categories["comprehensive"] += 1
elif "regression" in name_lower:
categories["regression"] += 1
# Verify we have variety
assert categories["basic"] > 0, "Should have at least one basic preset"
assert categories["effect"] > 0, "Should have at least one effect preset"
assert categories["camera"] > 0, "Should have at least one camera preset"
assert categories["source"] > 0, "Should have at least one source preset"

View File

@@ -1,103 +0,0 @@
"""
Tests for the FigmentOverlayEffect plugin.
"""
import pytest
from engine.effects.plugins import discover_plugins
from engine.effects.registry import get_registry
from engine.effects.types import EffectConfig, create_effect_context
from engine.pipeline.adapters import EffectPluginStage
class TestFigmentEffect:
"""Tests for FigmentOverlayEffect."""
def setup_method(self):
"""Discover plugins before each test."""
discover_plugins()
def test_figment_plugin_discovered(self):
"""Figment plugin is discovered and registered."""
registry = get_registry()
figment = registry.get("figment")
assert figment is not None
assert figment.name == "figment"
def test_figment_plugin_enabled_by_default(self):
"""Figment plugin is enabled by default."""
registry = get_registry()
figment = registry.get("figment")
assert figment.config.enabled is True
def test_figment_renders_overlay(self):
"""Figment renders SVG overlay after interval."""
registry = get_registry()
figment = registry.get("figment")
# Configure with short interval for testing
config = EffectConfig(
enabled=True,
intensity=1.0,
params={
"interval_secs": 0.1, # 100ms
"display_secs": 1.0,
"figment_dir": "figments",
},
)
figment.configure(config)
# Create test buffer
buf = [" " * 80 for _ in range(24)]
# Create context
ctx = create_effect_context(
terminal_width=80,
terminal_height=24,
frame_number=0,
)
# Process frames until figment renders
for i in range(20):
result = figment.process(buf, ctx)
if len(result) > len(buf):
# Figment rendered overlay
assert len(result) > len(buf)
# Check that overlay lines contain ANSI escape codes
overlay_lines = result[len(buf) :]
assert len(overlay_lines) > 0
# First overlay line should contain cursor positioning
assert "\x1b[" in overlay_lines[0]
assert "H" in overlay_lines[0]
return
ctx.frame_number += 1
pytest.fail("Figment did not render in 20 frames")
def test_figment_stage_capabilities(self):
"""EffectPluginStage wraps figment correctly."""
registry = get_registry()
figment = registry.get("figment")
stage = EffectPluginStage(figment, name="figment")
assert "effect.figment" in stage.capabilities
def test_figment_configure_preserves_params(self):
"""Figment configuration preserves figment_dir."""
registry = get_registry()
figment = registry.get("figment")
# Configure without figment_dir
config = EffectConfig(
enabled=True,
intensity=1.0,
params={
"interval_secs": 30.0,
"display_secs": 3.0,
},
)
figment.configure(config)
# figment_dir should be preserved
assert "figment_dir" in figment.config.params
assert figment.config.params["figment_dir"] == "figments"

View File

@@ -1,79 +0,0 @@
"""
Integration tests for figment effect in the pipeline.
"""
from engine.effects.plugins import discover_plugins
from engine.effects.registry import get_registry
from engine.pipeline import Pipeline, PipelineConfig, get_preset
from engine.pipeline.adapters import (
EffectPluginStage,
SourceItemsToBufferStage,
create_stage_from_display,
)
from engine.pipeline.controller import PipelineRunner
class TestFigmentPipeline:
"""Tests for figment effect in pipeline integration."""
def setup_method(self):
"""Discover plugins before each test."""
discover_plugins()
def test_figment_in_pipeline(self):
"""Figment effect can be added to pipeline."""
registry = get_registry()
figment = registry.get("figment")
pipeline = Pipeline(
config=PipelineConfig(
source="empty",
display="null",
camera="feed",
effects=["figment"],
)
)
# Add source stage
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"))
# Add render stage
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
# Add figment effect stage
pipeline.add_stage("effect_figment", EffectPluginStage(figment, name="figment"))
# Add display stage
from engine.display import DisplayRegistry
display = DisplayRegistry.create("null")
display.init(0, 0)
pipeline.add_stage("display", create_stage_from_display(display, "null"))
# Build and initialize pipeline
pipeline.build()
assert pipeline.initialize()
# Use PipelineRunner to step through frames
runner = PipelineRunner(pipeline)
runner.start()
# Run pipeline for a few frames
for i in range(10):
runner.step()
# Result might be None for null display, but that's okay
# Verify pipeline ran without errors
assert pipeline.context.params.frame_number == 10
def test_figment_preset(self):
"""Figment preset is properly configured."""
preset = get_preset("test-figment")
assert preset is not None
assert preset.source == "empty"
assert preset.display == "terminal"
assert "figment" in preset.effects

View File

@@ -1,104 +0,0 @@
"""
Tests to verify figment rendering in the pipeline.
"""
from engine.effects.plugins import discover_plugins
from engine.effects.registry import get_registry
from engine.effects.types import EffectConfig
from engine.pipeline import Pipeline, PipelineConfig
from engine.pipeline.adapters import (
EffectPluginStage,
SourceItemsToBufferStage,
create_stage_from_display,
)
from engine.pipeline.controller import PipelineRunner
def test_figment_renders_in_pipeline():
"""Verify figment renders overlay in pipeline."""
# Discover plugins
discover_plugins()
# Get figment plugin
registry = get_registry()
figment = registry.get("figment")
# Configure with short interval for testing
config = EffectConfig(
enabled=True,
intensity=1.0,
params={
"interval_secs": 0.1, # 100ms
"display_secs": 1.0,
"figment_dir": "figments",
},
)
figment.configure(config)
# Create pipeline
pipeline = Pipeline(
config=PipelineConfig(
source="empty",
display="null",
camera="feed",
effects=["figment"],
)
)
# Add source stage
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"))
# Add render stage
pipeline.add_stage("render", SourceItemsToBufferStage(name="items-to-buffer"))
# Add figment effect stage
pipeline.add_stage("effect_figment", EffectPluginStage(figment, name="figment"))
# Add display stage
from engine.display import DisplayRegistry
display = DisplayRegistry.create("null")
display.init(0, 0)
pipeline.add_stage("display", create_stage_from_display(display, "null"))
# Build and initialize pipeline
pipeline.build()
assert pipeline.initialize()
# Use PipelineRunner to step through frames
runner = PipelineRunner(pipeline)
runner.start()
# Run pipeline until figment renders (or timeout)
figment_rendered = False
for i in range(30):
runner.step()
# Check if figment rendered by inspecting the display's internal buffer
# The null display stores the last rendered buffer
if hasattr(display, "_last_buffer") and display._last_buffer:
buffer = display._last_buffer
# Check if buffer contains ANSI escape codes (indicating figment overlay)
# Figment adds overlay lines at the end of the buffer
for line in buffer:
if "\x1b[" in line:
figment_rendered = True
print(f"Figment rendered at frame {i}")
# Print first few lines containing escape codes
for j, line in enumerate(buffer[:10]):
if "\x1b[" in line:
print(f"Line {j}: {repr(line[:80])}")
break
if figment_rendered:
break
assert figment_rendered, "Figment did not render in 30 frames"
if __name__ == "__main__":
test_figment_renders_in_pipeline()
print("Test passed!")

View File

@@ -1,125 +0,0 @@
"""Tests for FirehoseEffect plugin."""
import pytest
from engine.effects.plugins.firehose import FirehoseEffect
from engine.effects.types import EffectContext
@pytest.fixture(autouse=True)
def patch_config(monkeypatch):
"""Patch config globals for firehose tests."""
import engine.config as config
monkeypatch.setattr(config, "FIREHOSE", False)
monkeypatch.setattr(config, "FIREHOSE_H", 12)
monkeypatch.setattr(config, "MODE", "news")
monkeypatch.setattr(config, "GLITCH", "░▒▓█▌▐╌╍╎╏┃┆┇┊┋")
monkeypatch.setattr(config, "KATA", "ハミヒーウシナモニサワツオリアホテマケメエカキムユラセネスタヌヘ")
def test_firehose_disabled_returns_input():
"""Firehose disabled returns input buffer unchanged."""
effect = FirehoseEffect()
effect.configure(effect.config)
buf = ["line1", "line2"]
ctx = EffectContext(
terminal_width=80,
terminal_height=24,
scroll_cam=0,
ticker_height=0,
items=[("Title", "Source", "2025-01-01T00:00:00")],
)
import engine.config as config
config.FIREHOSE = False
result = effect.process(buf, ctx)
assert result == buf
def test_firehose_enabled_adds_lines():
"""Firehose enabled adds FIREHOSE_H lines to output."""
effect = FirehoseEffect()
effect.configure(effect.config)
buf = ["line1"]
ctx = EffectContext(
terminal_width=80,
terminal_height=24,
scroll_cam=0,
ticker_height=0,
items=[("Title", "Source", "2025-01-01T00:00:00")] * 10,
)
import engine.config as config
config.FIREHOSE = True
config.FIREHOSE_H = 3
result = effect.process(buf, ctx)
assert len(result) == 4
assert any("\033[" in line for line in result[1:])
def test_firehose_respects_terminal_width():
"""Firehose lines are truncated to terminal width."""
effect = FirehoseEffect()
effect.configure(effect.config)
ctx = EffectContext(
terminal_width=40,
terminal_height=24,
scroll_cam=0,
ticker_height=0,
items=[("A" * 100, "Source", "2025-01-01T00:00:00")],
)
import engine.config as config
config.FIREHOSE = True
config.FIREHOSE_H = 2
result = effect.process([], ctx)
firehose_lines = [line for line in result if "\033[" in line]
for line in firehose_lines:
# Strip all ANSI escape sequences (CSI sequences ending with letter)
import re
plain = re.sub(r"\x1b\[[^a-zA-Z]*[a-zA-Z]", "", line)
# Extract content after position code
content = plain.split("H", 1)[1] if "H" in plain else plain
assert len(content) <= 40
def test_firehose_zero_height_noop():
"""Firehose with zero height returns buffer unchanged."""
effect = FirehoseEffect()
effect.configure(effect.config)
buf = ["line1"]
ctx = EffectContext(
terminal_width=80,
terminal_height=24,
scroll_cam=0,
ticker_height=0,
items=[("Title", "Source", "2025-01-01T00:00:00")],
)
import engine.config as config
config.FIREHOSE = True
config.FIREHOSE_H = 0
result = effect.process(buf, ctx)
assert result == buf
def test_firehose_with_no_items():
"""Firehose with no content items returns buffer unchanged."""
effect = FirehoseEffect()
effect.configure(effect.config)
buf = ["line1"]
ctx = EffectContext(
terminal_width=80,
terminal_height=24,
scroll_cam=0,
ticker_height=0,
items=[],
)
import engine.config as config
config.FIREHOSE = True
config.FIREHOSE_H = 3
result = effect.process(buf, ctx)
assert result == buf

View File

@@ -1,118 +0,0 @@
"""Tests for pipeline execution order verification."""
from unittest.mock import MagicMock
import pytest
from engine.pipeline import Pipeline, Stage, discover_stages
from engine.pipeline.core import DataType
@pytest.fixture(autouse=True)
def reset_registry():
"""Reset stage registry before each test."""
from engine.pipeline.registry import StageRegistry
StageRegistry._discovered = False
StageRegistry._categories.clear()
StageRegistry._instances.clear()
discover_stages()
yield
StageRegistry._discovered = False
StageRegistry._categories.clear()
StageRegistry._instances.clear()
def _create_mock_stage(name: str, category: str, capabilities: set, dependencies: set):
"""Helper to create a mock stage."""
mock = MagicMock(spec=Stage)
mock.name = name
mock.category = category
mock.stage_type = category
mock.render_order = 0
mock.is_overlay = False
mock.inlet_types = {DataType.ANY}
mock.outlet_types = {DataType.TEXT_BUFFER}
mock.capabilities = capabilities
mock.dependencies = dependencies
mock.process = lambda data, ctx: data
mock.init = MagicMock(return_value=True)
mock.cleanup = MagicMock()
mock.is_enabled = MagicMock(return_value=True)
mock.set_enabled = MagicMock()
mock._enabled = True
return mock
def test_pipeline_execution_order_linear():
"""Verify stages execute in linear order based on dependencies."""
pipeline = Pipeline()
pipeline.build(auto_inject=False)
source = _create_mock_stage("source", "source", {"source"}, set())
render = _create_mock_stage("render", "render", {"render"}, {"source"})
effect = _create_mock_stage("effect", "effect", {"effect"}, {"render"})
display = _create_mock_stage("display", "display", {"display"}, {"effect"})
pipeline.add_stage("source", source, initialize=False)
pipeline.add_stage("render", render, initialize=False)
pipeline.add_stage("effect", effect, initialize=False)
pipeline.add_stage("display", display, initialize=False)
pipeline._rebuild()
assert pipeline.execution_order == [
"source",
"render",
"effect",
"display",
]
def test_pipeline_effects_chain_order():
"""Verify effects execute in config order when chained."""
pipeline = Pipeline()
pipeline.build(auto_inject=False)
# Source and render
source = _create_mock_stage("source", "source", {"source"}, set())
render = _create_mock_stage("render", "render", {"render"}, {"source"})
# Effects chain: effect_a → effect_b → effect_c
effect_a = _create_mock_stage("effect_a", "effect", {"effect_a"}, {"render"})
effect_b = _create_mock_stage("effect_b", "effect", {"effect_b"}, {"effect_a"})
effect_c = _create_mock_stage("effect_c", "effect", {"effect_c"}, {"effect_b"})
# Display
display = _create_mock_stage("display", "display", {"display"}, {"effect_c"})
for stage in [source, render, effect_a, effect_b, effect_c, display]:
pipeline.add_stage(stage.name, stage, initialize=False)
pipeline._rebuild()
effect_names = [
name for name in pipeline.execution_order if name.startswith("effect_")
]
assert effect_names == ["effect_a", "effect_b", "effect_c"]
def test_pipeline_overlay_executes_after_regular_effects():
"""Overlay stages should execute after all regular effects."""
pipeline = Pipeline()
pipeline.build(auto_inject=False)
effect = _create_mock_stage("effect1", "effect", {"effect1"}, {"render"})
overlay = _create_mock_stage("overlay_test", "overlay", {"overlay"}, {"effect1"})
display = _create_mock_stage("display", "display", {"display"}, {"overlay"})
for stage in [effect, overlay, display]:
pipeline.add_stage(stage.name, stage, initialize=False)
pipeline._rebuild()
names = pipeline.execution_order
idx_effect = names.index("effect1")
idx_overlay = names.index("overlay_test")
idx_display = names.index("display")
assert idx_effect < idx_overlay < idx_display

View File

@@ -1,164 +0,0 @@
"""
Unit tests for engine.display.renderer module.
Tests ANSI parsing and PIL rendering utilities.
"""
import pytest
try:
from PIL import Image
PIL_AVAILABLE = True
except ImportError:
PIL_AVAILABLE = False
from engine.display.renderer import ANSI_COLORS, parse_ansi, render_to_pil
class TestParseANSI:
"""Tests for parse_ansi function."""
def test_plain_text(self):
"""Plain text without ANSI codes returns single token."""
tokens = parse_ansi("Hello World")
assert len(tokens) == 1
assert tokens[0][0] == "Hello World"
# Check default colors
assert tokens[0][1] == (204, 204, 204) # fg
assert tokens[0][2] == (0, 0, 0) # bg
assert tokens[0][3] is False # bold
def test_empty_string(self):
"""Empty string returns single empty token."""
tokens = parse_ansi("")
assert tokens == [("", (204, 204, 204), (0, 0, 0), False)]
def test_reset_code(self):
"""Reset code (ESC[0m) restores defaults."""
tokens = parse_ansi("\x1b[31mRed\x1b[0mNormal")
assert len(tokens) == 2
assert tokens[0][0] == "Red"
# Red fg should be ANSI_COLORS[1]
assert tokens[0][1] == ANSI_COLORS[1]
assert tokens[1][0] == "Normal"
assert tokens[1][1] == (204, 204, 204) # back to default
def test_bold_code(self):
"""Bold code (ESC[1m) sets bold flag."""
tokens = parse_ansi("\x1b[1mBold")
assert tokens[0][3] is True
def test_bold_off_code(self):
"""Bold off (ESC[22m) clears bold."""
tokens = parse_ansi("\x1b[1mBold\x1b[22mNormal")
assert tokens[0][3] is True
assert tokens[1][3] is False
def test_4bit_foreground_colors(self):
"""4-bit foreground colors (30-37, 90-97) work."""
# Test normal red (31)
tokens = parse_ansi("\x1b[31mRed")
assert tokens[0][1] == ANSI_COLORS[1] # color 1 = red
# Test bright cyan (96) - maps to index 14 (bright cyan)
tokens = parse_ansi("\x1b[96mCyan")
assert tokens[0][1] == ANSI_COLORS[14] # bright cyan
def test_4bit_background_colors(self):
"""4-bit background colors (40-47, 100-107) work."""
# Green bg = 42
tokens = parse_ansi("\x1b[42mText")
assert tokens[0][2] == ANSI_COLORS[2] # color 2 = green
# Bright magenta bg = 105
tokens = parse_ansi("\x1b[105mText")
assert tokens[0][2] == ANSI_COLORS[13] # bright magenta (13)
def test_multiple_ansi_codes_in_sequence(self):
"""Multiple codes in one escape sequence are parsed."""
tokens = parse_ansi("\x1b[1;31;42mBold Red on Green")
assert tokens[0][0] == "Bold Red on Green"
assert tokens[0][3] is True # bold
assert tokens[0][1] == ANSI_COLORS[1] # red fg
assert tokens[0][2] == ANSI_COLORS[2] # green bg
def test_nested_ansi_sequences(self):
"""Multiple separate ANSI sequences are tokenized correctly."""
text = "\x1b[31mRed\x1b[32mGreen\x1b[0mNormal"
tokens = parse_ansi(text)
assert len(tokens) == 3
assert tokens[0][0] == "Red"
assert tokens[1][0] == "Green"
assert tokens[2][0] == "Normal"
def test_interleaved_text_and_ansi(self):
"""Text before and after ANSI codes is tokenized."""
tokens = parse_ansi("Pre\x1b[31mRedPost")
assert len(tokens) == 2
assert tokens[0][0] == "Pre"
assert tokens[1][0] == "RedPost"
assert tokens[1][1] == ANSI_COLORS[1]
def test_all_standard_4bit_colors(self):
"""All 4-bit color indices (0-15) map to valid RGB."""
for i in range(16):
tokens = parse_ansi(f"\x1b[{i}mX")
# Should be a defined color or default fg
fg = tokens[0][1]
valid = fg in ANSI_COLORS.values() or fg == (204, 204, 204)
assert valid, f"Color {i} produced invalid fg {fg}"
def test_unknown_code_ignored(self):
"""Unknown numeric codes are ignored, keep current style."""
tokens = parse_ansi("\x1b[99mText")
# 99 is not recognized, should keep previous state (defaults)
assert tokens[0][1] == (204, 204, 204)
@pytest.mark.skipif(not PIL_AVAILABLE, reason="PIL not available")
class TestRenderToPIL:
"""Tests for render_to_pil function (requires PIL)."""
def test_renders_plain_text(self):
"""Plain buffer renders as image."""
buffer = ["Hello"]
img = render_to_pil(buffer, width=10, height=1)
assert isinstance(img, Image.Image)
assert img.mode == "RGBA"
def test_renders_with_ansi_colors(self):
"""Buffer with ANSI colors renders correctly."""
buffer = ["\x1b[31mRed\x1b[0mNormal"]
img = render_to_pil(buffer, width=20, height=1)
assert isinstance(img, Image.Image)
def test_multi_line_buffer(self):
"""Multiple lines render with correct height."""
buffer = ["Line1", "Line2", "Line3"]
img = render_to_pil(buffer, width=10, height=3)
# Height should be approximately 3 * cell_height (18-2 padding)
assert img.height > 0
def test_clipping_to_height(self):
"""Buffer longer than height is clipped."""
buffer = ["Line1", "Line2", "Line3", "Line4"]
img = render_to_pil(buffer, width=10, height=2)
# Should only render 2 lines
assert img.height < img.width * 2 # roughly 2 lines tall
def test_cell_dimensions_respected(self):
"""Custom cell_width and cell_height are used."""
buffer = ["Test"]
img = render_to_pil(buffer, width=5, height=1, cell_width=20, cell_height=25)
assert img.width == 5 * 20
assert img.height == 25
def test_font_fallback_on_invalid(self):
"""Invalid font path falls back to default font."""
buffer = ["Test"]
# Should not crash with invalid font path
img = render_to_pil(
buffer, width=5, height=1, font_path="/nonexistent/font.ttf"
)
assert isinstance(img, Image.Image)

View File

@@ -2,7 +2,6 @@
Tests for streaming protocol utilities.
"""
from engine.display.streaming import (
FrameDiff,
MessageType,

View File

@@ -1,4 +1,3 @@
from engine.effects.legacy import vis_offset, vis_trunc

View File

@@ -1,234 +0,0 @@
"""
Visual verification tests for message overlay and effect rendering.
These tests verify that the sideline pipeline produces visual output
that matches the expected behavior of upstream/main, even if the
buffer format differs due to architectural differences.
"""
import json
from pathlib import Path
import pytest
from engine.display import DisplayRegistry
from engine.pipeline import Pipeline, PipelineConfig, PipelineContext
from engine.pipeline.adapters import create_stage_from_display
from engine.pipeline.params import PipelineParams
from engine.pipeline.presets import get_preset
class TestMessageOverlayVisuals:
"""Test message overlay visual rendering."""
def test_message_overlay_produces_output(self):
"""Verify message overlay stage produces output when ntfy message is present."""
# This test verifies the message overlay stage is working
# It doesn't compare with upstream, just verifies functionality
from engine.pipeline.adapters.message_overlay import MessageOverlayStage
from engine.pipeline.adapters import MessageOverlayConfig
# Test the rendering function directly
stage = MessageOverlayStage(
config=MessageOverlayConfig(enabled=True, display_secs=30)
)
# Test with a mock message
msg = ("Test Title", "Test Message Body", 0.0)
w, h = 80, 24
# Render overlay
overlay, _ = stage._render_message_overlay(msg, w, h, (None, None))
# Verify overlay has content
assert len(overlay) > 0, "Overlay should have content when message is present"
# Verify overlay contains expected content
overlay_text = "".join(overlay)
# Note: Message body is rendered as block characters, not text
# The title appears in the metadata line
assert "Test Title" in overlay_text, "Overlay should contain message title"
assert "ntfy" in overlay_text, "Overlay should contain ntfy metadata"
assert "\033[" in overlay_text, "Overlay should contain ANSI codes"
def test_message_overlay_appears_in_correct_position(self):
"""Verify message overlay appears in centered position."""
# This test verifies the message overlay positioning logic
# It checks that the overlay coordinates are calculated correctly
from engine.pipeline.adapters.message_overlay import MessageOverlayStage
from engine.pipeline.adapters import MessageOverlayConfig
stage = MessageOverlayStage(config=MessageOverlayConfig())
# Test positioning calculation
msg = ("Test Title", "Test Body", 0.0)
w, h = 80, 24
# Render overlay
overlay, _ = stage._render_message_overlay(msg, w, h, (None, None))
# Verify overlay has content
assert len(overlay) > 0, "Overlay should have content"
# Verify overlay contains cursor positioning codes
overlay_text = "".join(overlay)
assert "\033[" in overlay_text, "Overlay should contain ANSI codes"
assert "H" in overlay_text, "Overlay should contain cursor positioning"
# Verify panel is centered (check first line's position)
# Panel height is len(msg_rows) + 2 (content + meta + border)
# panel_top = max(0, (h - panel_h) // 2)
# First content line should be at panel_top + 1
first_line = overlay[0]
assert "\033[" in first_line, "First line should have cursor positioning"
assert ";1H" in first_line, "First line should position at column 1"
def test_theme_system_integration(self):
"""Verify theme system is integrated with message overlay."""
from engine import config as engine_config
from engine.themes import THEME_REGISTRY
# Verify theme registry has expected themes
assert "green" in THEME_REGISTRY, "Green theme should exist"
assert "orange" in THEME_REGISTRY, "Orange theme should exist"
assert "purple" in THEME_REGISTRY, "Purple theme should exist"
# Verify active theme is set
assert engine_config.ACTIVE_THEME is not None, "Active theme should be set"
assert engine_config.ACTIVE_THEME.name in THEME_REGISTRY, (
"Active theme should be in registry"
)
# Verify theme has gradient colors
assert len(engine_config.ACTIVE_THEME.main_gradient) == 12, (
"Main gradient should have 12 colors"
)
assert len(engine_config.ACTIVE_THEME.message_gradient) == 12, (
"Message gradient should have 12 colors"
)
class TestPipelineExecutionOrder:
"""Test pipeline execution order for visual consistency."""
def test_message_overlay_after_camera(self):
"""Verify message overlay is applied after camera transformation."""
from engine.pipeline import Pipeline, PipelineConfig, PipelineContext
from engine.pipeline.adapters import (
create_stage_from_display,
MessageOverlayStage,
MessageOverlayConfig,
)
from engine.display import DisplayRegistry
# Create pipeline
config = PipelineConfig(
source="empty",
display="null",
camera="feed",
effects=[],
)
ctx = PipelineContext()
pipeline = Pipeline(config=config, context=ctx)
# Add stages
from engine.data_sources.sources import EmptyDataSource
from engine.pipeline.adapters import DataSourceStage
pipeline.add_stage(
"source",
DataSourceStage(EmptyDataSource(width=80, height=24), name="empty"),
)
pipeline.add_stage(
"message_overlay", MessageOverlayStage(config=MessageOverlayConfig())
)
pipeline.add_stage(
"display", create_stage_from_display(DisplayRegistry.create("null"), "null")
)
# Build and check order
pipeline.build()
execution_order = pipeline.execution_order
# Verify message_overlay comes after camera stages
camera_idx = next(
(i for i, name in enumerate(execution_order) if "camera" in name), -1
)
msg_idx = next(
(i for i, name in enumerate(execution_order) if "message_overlay" in name),
-1,
)
if camera_idx >= 0 and msg_idx >= 0:
assert msg_idx > camera_idx, "Message overlay should be after camera stage"
class TestCapturedOutputAnalysis:
"""Test analysis of captured output files."""
def test_captured_files_exist(self):
"""Verify captured output files exist."""
sideline_path = Path("output/sideline_demo.json")
upstream_path = Path("output/upstream_demo.json")
assert sideline_path.exists(), "Sideline capture file should exist"
assert upstream_path.exists(), "Upstream capture file should exist"
def test_captured_files_valid(self):
"""Verify captured output files are valid JSON."""
sideline_path = Path("output/sideline_demo.json")
upstream_path = Path("output/upstream_demo.json")
with open(sideline_path) as f:
sideline = json.load(f)
with open(upstream_path) as f:
upstream = json.load(f)
# Verify structure
assert "frames" in sideline, "Sideline should have frames"
assert "frames" in upstream, "Upstream should have frames"
assert len(sideline["frames"]) > 0, "Sideline should have at least one frame"
assert len(upstream["frames"]) > 0, "Upstream should have at least one frame"
def test_sideline_buffer_format(self):
"""Verify sideline buffer format is plain text."""
sideline_path = Path("output/sideline_demo.json")
with open(sideline_path) as f:
sideline = json.load(f)
# Check first frame
frame0 = sideline["frames"][0]["buffer"]
# Sideline should have plain text lines (no cursor positioning)
# Check first few lines
for i, line in enumerate(frame0[:5]):
# Should not start with cursor positioning
if line.strip():
assert not line.startswith("\033["), (
f"Line {i} should not start with cursor positioning"
)
# Should have actual content
assert len(line.strip()) > 0, f"Line {i} should have content"
def test_upstream_buffer_format(self):
"""Verify upstream buffer format includes cursor positioning."""
upstream_path = Path("output/upstream_demo.json")
with open(upstream_path) as f:
upstream = json.load(f)
# Check first frame
frame0 = upstream["frames"][0]["buffer"]
# Upstream should have cursor positioning codes
overlay_text = "".join(frame0[:10])
assert "\033[" in overlay_text, "Upstream buffer should contain ANSI codes"
assert "H" in overlay_text, "Upstream buffer should contain cursor positioning"
if __name__ == "__main__":
pytest.main([__file__, "-v"])