docs(analysis): Add visual output comparison analysis
- Created analysis/visual_output_comparison.md with detailed architectural comparison - Added capture utilities for output comparison (capture_output.py, capture_upstream.py, compare_outputs.py) - Captured and compared output from upstream/main vs sideline branch - Documented fundamental architectural differences in rendering approaches - Updated Gitea issue #50 with findings
This commit is contained in:
158
analysis/visual_output_comparison.md
Normal file
158
analysis/visual_output_comparison.md
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
# 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
|
||||||
1870
output/sideline_demo.json
Normal file
1870
output/sideline_demo.json
Normal file
File diff suppressed because it is too large
Load Diff
1870
output/upstream_demo.json
Normal file
1870
output/upstream_demo.json
Normal file
File diff suppressed because it is too large
Load Diff
201
scripts/capture_output.py
Normal file
201
scripts/capture_output.py
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
#!/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())
|
||||||
186
scripts/capture_upstream.py
Normal file
186
scripts/capture_upstream.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
#!/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())
|
||||||
220
scripts/compare_outputs.py
Normal file
220
scripts/compare_outputs.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
#!/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())
|
||||||
Reference in New Issue
Block a user