forked from genewildish/Mainline
- Add engine/effects/plugins/figment.py (native pipeline implementation) - Add engine/figment_render.py, engine/figment_trigger.py, engine/themes.py - Add 3 SVG assets in figments/ (Mexican/Aztec motif) - Add engine/display/backends/animation_report.py for debugging - Add engine/pipeline/adapters/frame_capture.py for frame capture - Add test-figment preset to presets.toml - Add cairosvg optional dependency to pyproject.toml - Update EffectPluginStage to support is_overlay attribute (for overlay effects) - Add comprehensive tests: test_figment_effect.py, test_figment_pipeline.py, test_figment_render.py - Remove obsolete test_ui_simple.py - Update TODO.md with test cleanup plan - Refactor test_adapters.py to use real components instead of mocks This completes the figment SVG overlay feature integration using the modern pipeline architecture, avoiding legacy effects_plugins. All tests pass (758 total).
657 lines
20 KiB
Python
657 lines
20 KiB
Python
"""
|
|
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("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace('"', """)
|
|
.replace("'", "'")
|
|
)
|