Initial release — OpenMontage: the first open-source agentic video production system

11 production pipelines, 47 tools, 124 agent skills.
Supports cloud APIs (fal.ai, OpenAI, ElevenLabs, Suno, HeyGen, Runway) and
free local providers (diffusers, Piper TTS, WAN 2.1, Hunyuan, CogVideo).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
calesthio
2026-03-29 08:25:17 -07:00
commit a3e735cc7a
1147 changed files with 240221 additions and 0 deletions

72
tests/qa/QA_PLAN.md Normal file
View File

@@ -0,0 +1,72 @@
# Quality Validation Plan — Phase 3.5 + G3.11
## Purpose
Run every tool with real API keys, inspect outputs (see images, listen to audio, watch video), find gaps, fix them. This is the gate before calling Phase 3.5 "Verified."
## Test Scripts (all ready to run)
| Script | Tools Tested | API Keys Used | Est. Cost |
|--------|-------------|---------------|-----------|
| `test_01_tts.py` | `elevenlabs_tts` (ElevenLabs) | ELEVENLABS_API_KEY | ~$0.02 |
| `test_02_image_gen.py` | `image_gen` (DALL-E 3 + FLUX) | OPENAI_API_KEY, FAL_AI_API_KEY | ~$0.15 |
| `test_03_music.py` | `music_gen` (ElevenLabs) | ELEVENLABS_API_KEY | ~$0.10 |
| `test_04_audio_mix.py` | `audio_mixer` | None (ffmpeg only) | $0 |
| `test_05_video_compose.py` | `video_compose` | None (ffmpeg only) | $0 |
| `test_06_video_stitch.py` | `video_stitch` | None (ffmpeg only) | $0 |
| `test_07_playbook_intelligence.py` | `playbook_loader.py` functions | None (pure Python) | $0 |
| `test_08_end_to_end.py` | Full animated-explainer pipeline | None (ffmpeg fixtures) | $0 |
## Inspection Protocol
For each output:
1. **Audio files**: Use `ffprobe` for format/duration/channels, then LISTEN (play in media player or use Whisper to verify content matches prompt)
2. **Image files**: Use `ffprobe` for dimensions, then VIEW (open image, check composition, text readability, style match)
3. **Video files**: Use `ffprobe` for resolution/fps/duration/codec, then WATCH (check A/V sync, transitions, subtitle timing)
4. **Design intelligence**: Run against all 3 playbooks, verify contrast ratios match manual calculation, check CVD warnings are accurate
## Known Risk Areas
| Area | Risk | How to Validate |
|------|------|-----------------|
| TTS voice selection | Default voice may not match playbook mood | Test with multiple voice IDs, compare against playbook `voice_style` |
| Image gen consistency | DALL-E/FLUX outputs vary wildly per prompt | Test with playbook `image_prompt_prefix` prepended |
| Music duration alignment | Music may not match narration duration | Compare `music.duration` vs `tts.duration`, check padding/looping |
| Audio ducking timing | Ducking may cut music too aggressively | Inspect waveform: music should duck ~6dB under speech, recover smoothly |
| Video stitch transitions | Crossfade may flicker with mismatched codecs | Test with both matching and mismatched clips, check `auto_normalize` |
| Subtitle burn-in | Font size/position may clip on mobile formats | Test with 9:16 (TikTok) and 16:9 (YouTube) profiles |
| Remotion render | Components may fail with real data | Build a test composition with all 8 components, render at 1080p |
| Playbook contrast | Edge cases in dark-on-dark or light-on-light themes | Test with all 3 playbooks + a deliberately low-contrast custom one |
## Run Order
```bash
cd C:/Users/ishan/Documents/OpenMontage
# Phase 1: Individual tools (can run in parallel)
python tests/qa/test_01_tts.py
python tests/qa/test_02_image_gen.py
python tests/qa/test_03_music.py
# Phase 2: Composition (depends on Phase 1 outputs)
python tests/qa/test_04_audio_mix.py
python tests/qa/test_05_video_compose.py
python tests/qa/test_06_video_stitch.py
# Phase 3: Intelligence validation (no API calls)
python tests/qa/test_07_playbook_intelligence.py
# Phase 4: Full pipeline
python tests/qa/test_08_end_to_end.py
```
## Success Criteria
- [ ] All 3 TTS samples: clear speech, correct content, no artifacts, ≥44.1kHz
- [ ] All 4 images: match prompt intent, correct dimensions, no watermarks, good composition
- [ ] Both music tracks: match mood prompt, correct duration (±2s), no abrupt cuts
- [ ] Audio mix: speech clearly above music, ducking smooth, no clipping
- [ ] Video compose: A/V sync within 50ms, correct resolution, playable in VLC
- [ ] Video stitch: smooth transitions, no frame drops, PIP correctly positioned
- [ ] Playbook intelligence: all 3 playbooks pass a11y, contrast ratios within 0.1 of manual calc
- [ ] End-to-end: 60-second explainer renders without errors, all stages checkpoint correctly

80
tests/qa/test_01_tts.py Normal file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""QA Test 01: TTS voice generation via ElevenLabs."""
import sys, os, json, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from lib.env_loader import load_env
load_env()
from tools.audio.elevenlabs_tts import ElevenLabsTTS
OUT = os.path.join(os.path.dirname(__file__), "output")
os.makedirs(OUT, exist_ok=True)
tool = ElevenLabsTTS()
print(f"Tool status: {tool.get_status()}")
# Test 1: Short narration
print("\n--- Test 1: Short narration ---")
r1 = tool.execute({
"text": "Welcome to OpenMontage. Let's build something amazing together.",
"output_path": os.path.join(OUT, "tts_short.mp3"),
})
print(f"Success: {r1.success}, Cost: ${r1.cost_usd:.4f}, Duration: {r1.duration_seconds:.2f}s")
if r1.error: print(f"Error: {r1.error}")
if r1.artifacts: print(f"Artifacts: {r1.artifacts}")
# Test 2: Longer paragraph with technical content
print("\n--- Test 2: Technical narration ---")
r2 = tool.execute({
"text": (
"Quantum computing leverages quantum mechanical phenomena like superposition and entanglement "
"to process information in fundamentally different ways than classical computers. "
"While a classical bit can be either zero or one, a quantum bit, or qubit, can exist in "
"a superposition of both states simultaneously. This allows quantum computers to explore "
"many possible solutions at once, making them exceptionally powerful for certain types of problems."
),
"output_path": os.path.join(OUT, "tts_technical.mp3"),
})
print(f"Success: {r2.success}, Cost: ${r2.cost_usd:.4f}, Duration: {r2.duration_seconds:.2f}s")
if r2.error: print(f"Error: {r2.error}")
if r2.artifacts: print(f"Artifacts: {r2.artifacts}")
# Test 3: Emotional / storytelling narration
print("\n--- Test 3: Storytelling narration ---")
r3 = tool.execute({
"text": (
"Picture this. You wake up one morning, check your phone, and discover that overnight, "
"your side project went viral. Thousands of people are using it. Messages are flooding in. "
"This isn't a dream. This is what happens when you build something people actually need."
),
"output_path": os.path.join(OUT, "tts_story.mp3"),
})
print(f"Success: {r3.success}, Cost: ${r3.cost_usd:.4f}, Duration: {r3.duration_seconds:.2f}s")
if r3.error: print(f"Error: {r3.error}")
if r3.artifacts: print(f"Artifacts: {r3.artifacts}")
# Probe outputs with ffprobe
import subprocess
for name in ["tts_short.mp3", "tts_technical.mp3", "tts_story.mp3"]:
path = os.path.join(OUT, name)
if os.path.exists(path):
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path],
capture_output=True, text=True
)
info = json.loads(probe.stdout)
fmt = info.get("format", {})
streams = info.get("streams", [{}])
audio = streams[0] if streams else {}
print(f"\n[{name}] Duration: {fmt.get('duration', '?')}s, "
f"Sample rate: {audio.get('sample_rate', '?')}Hz, "
f"Channels: {audio.get('channels', '?')}, "
f"Codec: {audio.get('codec_name', '?')}, "
f"Size: {os.path.getsize(path)} bytes")
else:
print(f"\n[{name}] FILE NOT FOUND")
print("\n=== TTS TEST COMPLETE ===")

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""QA Test 02: Image generation via OpenAI (DALL-E 3) and fal.ai (FLUX)."""
import sys, os, json, time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from lib.env_loader import load_env
load_env()
from tools.graphics.image_gen import ImageGen
OUT = os.path.join(os.path.dirname(__file__), "output")
os.makedirs(OUT, exist_ok=True)
tool = ImageGen()
print(f"Tool status: {tool.get_status()}")
# Test 1: DALL-E — professional diagram
print("\n--- Test 1: DALL-E professional diagram ---")
r1 = tool.execute({
"prompt": "A clean, professional infographic showing the 5 stages of a video production pipeline: Idea, Script, Assets, Edit, Publish. Flat design, blue and amber color scheme, white background, no text.",
"width": 1280,
"height": 720,
"provider": "openai",
"output_path": os.path.join(OUT, "img_dalle_diagram.png"),
})
print(f"Success: {r1.success}, Cost: ${r1.cost_usd:.4f}")
if r1.error: print(f"Error: {r1.error}")
if r1.artifacts: print(f"Artifacts: {r1.artifacts}")
# Test 2: DALL-E — cinematic scene
print("\n--- Test 2: DALL-E cinematic scene ---")
r2 = tool.execute({
"prompt": "A futuristic control room with holographic displays showing data visualizations, cinematic lighting, wide angle, film grain, warm tones",
"width": 1280,
"height": 720,
"provider": "openai",
"output_path": os.path.join(OUT, "img_dalle_cinematic.png"),
})
print(f"Success: {r2.success}, Cost: ${r2.cost_usd:.4f}")
if r2.error: print(f"Error: {r2.error}")
if r2.artifacts: print(f"Artifacts: {r2.artifacts}")
# Test 3: FLUX via fal.ai — abstract tech
print("\n--- Test 3: FLUX abstract tech ---")
r3 = tool.execute({
"prompt": "Abstract visualization of neural network connections, glowing nodes and edges, dark background, neon blue and purple, high detail, 8k render",
"width": 1280,
"height": 720,
"provider": "flux",
"output_path": os.path.join(OUT, "img_flux_abstract.png"),
})
print(f"Success: {r3.success}, Cost: ${r3.cost_usd:.4f}")
if r3.error: print(f"Error: {r3.error}")
if r3.artifacts: print(f"Artifacts: {r3.artifacts}")
# Test 4: FLUX — character/mascot
print("\n--- Test 4: FLUX character illustration ---")
r4 = tool.execute({
"prompt": "Friendly robot mascot character, simple geometric design, holding a film clapboard, isometric view, clean white background, flat illustration style",
"width": 1024,
"height": 1024,
"provider": "flux",
"output_path": os.path.join(OUT, "img_flux_mascot.png"),
})
print(f"Success: {r4.success}, Cost: ${r4.cost_usd:.4f}")
if r4.error: print(f"Error: {r4.error}")
if r4.artifacts: print(f"Artifacts: {r4.artifacts}")
# Probe outputs
import subprocess
for name in ["img_dalle_diagram.png", "img_dalle_cinematic.png", "img_flux_abstract.png", "img_flux_mascot.png"]:
path = os.path.join(OUT, name)
if os.path.exists(path):
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", path],
capture_output=True, text=True
)
info = json.loads(probe.stdout)
stream = info.get("streams", [{}])[0]
print(f"\n[{name}] {stream.get('width', '?')}x{stream.get('height', '?')}, "
f"Format: {stream.get('codec_name', '?')}, "
f"Size: {os.path.getsize(path)} bytes")
else:
print(f"\n[{name}] FILE NOT FOUND")
print("\n=== IMAGE GEN TEST COMPLETE ===")

62
tests/qa/test_03_music.py Normal file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""QA Test 03: Music generation via ElevenLabs."""
import sys, os, json
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from lib.env_loader import load_env
load_env()
from tools.audio.music_gen import MusicGen
OUT = os.path.join(os.path.dirname(__file__), "output")
os.makedirs(OUT, exist_ok=True)
tool = MusicGen()
print(f"Tool status: {tool.get_status()}")
# Test 1: Upbeat tech background
print("\n--- Test 1: Upbeat tech background ---")
r1 = tool.execute({
"prompt": "Upbeat electronic background music, 120 BPM, energetic but not overwhelming, suitable for a tech explainer video",
"duration_seconds": 30,
"output_path": os.path.join(OUT, "music_upbeat.mp3"),
})
print(f"Success: {r1.success}, Cost: ${r1.cost_usd:.4f}")
if r1.error: print(f"Error: {r1.error}")
if r1.artifacts: print(f"Artifacts: {r1.artifacts}")
# Test 2: Calm ambient
print("\n--- Test 2: Calm ambient ---")
r2 = tool.execute({
"prompt": "Calm ambient background music, soft piano and strings, 80 BPM, reflective mood, suitable for documentary narration",
"duration_seconds": 30,
"output_path": os.path.join(OUT, "music_calm.mp3"),
})
print(f"Success: {r2.success}, Cost: ${r2.cost_usd:.4f}")
if r2.error: print(f"Error: {r2.error}")
if r2.artifacts: print(f"Artifacts: {r2.artifacts}")
# Probe outputs
import subprocess
for name in ["music_upbeat.mp3", "music_calm.mp3"]:
path = os.path.join(OUT, name)
if os.path.exists(path):
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path],
capture_output=True, text=True
)
info = json.loads(probe.stdout)
fmt = info.get("format", {})
streams = info.get("streams", [{}])
audio = streams[0] if streams else {}
print(f"\n[{name}] Duration: {fmt.get('duration', '?')}s, "
f"Sample rate: {audio.get('sample_rate', '?')}Hz, "
f"Channels: {audio.get('channels', '?')}, "
f"Codec: {audio.get('codec_name', '?')}, "
f"Size: {os.path.getsize(path)} bytes")
else:
print(f"\n[{name}] FILE NOT FOUND")
print("\n=== MUSIC GEN TEST COMPLETE ===")

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""QA Test 04: Audio mixing — mix TTS + music with ducking, verify levels.
Depends on test_01 and test_03 outputs (TTS + music files).
If those don't exist, generates minimal test fixtures via ffmpeg.
"""
import sys, os, json, subprocess
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from lib.env_loader import load_env
load_env()
from tools.audio.audio_mixer import AudioMixer
OUT = os.path.join(os.path.dirname(__file__), "output")
os.makedirs(OUT, exist_ok=True)
# --- Fixture generation (if test_01/test_03 outputs don't exist) ---
SPEECH_FILE = os.path.join(OUT, "tts_short.mp3")
MUSIC_FILE = os.path.join(OUT, "music_calm.mp3")
def generate_fixture(path, description, duration=5):
"""Generate a minimal audio fixture with ffmpeg if the file doesn't exist."""
if os.path.exists(path):
print(f" [fixture] Using existing: {path}")
return
print(f" [fixture] Generating {description}: {path}")
# Sine wave for speech stand-in, pink noise for music stand-in
if "speech" in description or "tts" in description:
src = f"sine=frequency=440:duration={duration}"
else:
src = f"anoisesrc=d={duration}:c=pink"
subprocess.run(
["ffmpeg", "-y", "-f", "lavfi", "-i", src, "-ar", "44100", "-ac", "1", path],
capture_output=True, check=True,
)
generate_fixture(SPEECH_FILE, "speech/tts fixture", duration=8)
generate_fixture(MUSIC_FILE, "music fixture", duration=15)
# --- Tool setup ---
tool = AudioMixer()
print(f"Tool status: {tool.get_status()}")
# --- Test 1: Basic mix (speech + music, no ducking) ---
print("\n--- Test 1: Basic mix (speech + music) ---")
r1 = tool.execute({
"operation": "mix",
"tracks": [
{"path": SPEECH_FILE, "role": "speech", "volume": 1.0},
{"path": MUSIC_FILE, "role": "music", "volume": 0.3},
],
"normalize": True,
"output_path": os.path.join(OUT, "mix_basic.wav"),
})
print(f"Success: {r1.success}, Duration: {r1.duration_seconds:.2f}s")
if r1.error: print(f"Error: {r1.error}")
if r1.artifacts: print(f"Artifacts: {r1.artifacts}")
# --- Test 2: Mix with fades ---
print("\n--- Test 2: Mix with fades ---")
r2 = tool.execute({
"operation": "mix",
"tracks": [
{"path": SPEECH_FILE, "role": "speech", "volume": 1.0, "fade_in_seconds": 0.5},
{"path": MUSIC_FILE, "role": "music", "volume": 0.25, "fade_in_seconds": 1.0, "fade_out_seconds": 2.0},
],
"normalize": True,
"output_path": os.path.join(OUT, "mix_fades.wav"),
})
print(f"Success: {r2.success}, Duration: {r2.duration_seconds:.2f}s")
if r2.error: print(f"Error: {r2.error}")
if r2.artifacts: print(f"Artifacts: {r2.artifacts}")
# --- Test 3: Ducking (sidechain compress music under speech) ---
print("\n--- Test 3: Ducking ---")
r3 = tool.execute({
"operation": "duck",
"tracks": [
{"path": SPEECH_FILE, "role": "speech"},
{"path": MUSIC_FILE, "role": "music"},
],
"ducking": {
"enabled": True,
"music_volume_during_speech": 0.15,
"attack_ms": 200,
"release_ms": 500,
},
"output_path": os.path.join(OUT, "mix_ducked.wav"),
})
print(f"Success: {r3.success}, Duration: {r3.duration_seconds:.2f}s")
if r3.error: print(f"Error: {r3.error}")
if r3.artifacts: print(f"Artifacts: {r3.artifacts}")
# --- Test 4: Mix with delayed music start ---
print("\n--- Test 4: Delayed music start ---")
r4 = tool.execute({
"operation": "mix",
"tracks": [
{"path": SPEECH_FILE, "role": "speech", "volume": 1.0},
{"path": MUSIC_FILE, "role": "music", "volume": 0.2, "start_seconds": 3.0},
],
"normalize": False,
"output_path": os.path.join(OUT, "mix_delayed.wav"),
})
print(f"Success: {r4.success}, Duration: {r4.duration_seconds:.2f}s")
if r4.error: print(f"Error: {r4.error}")
if r4.artifacts: print(f"Artifacts: {r4.artifacts}")
# --- Probe all outputs ---
print("\n--- Output inspection ---")
for name in ["mix_basic.wav", "mix_fades.wav", "mix_ducked.wav", "mix_delayed.wav"]:
path = os.path.join(OUT, name)
if os.path.exists(path):
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", path],
capture_output=True, text=True,
)
info = json.loads(probe.stdout)
fmt = info.get("format", {})
streams = info.get("streams", [{}])
audio = streams[0] if streams else {}
print(f"\n[{name}] Duration: {fmt.get('duration', '?')}s, "
f"Sample rate: {audio.get('sample_rate', '?')}Hz, "
f"Channels: {audio.get('channels', '?')}, "
f"Codec: {audio.get('codec_name', '?')}, "
f"Size: {os.path.getsize(path)} bytes")
# Check for clipping via loudnorm stats
loud = subprocess.run(
["ffmpeg", "-i", path, "-af", "loudnorm=print_format=json", "-f", "null", "-"],
capture_output=True, text=True,
)
# Parse loudnorm JSON from stderr (ffmpeg writes it there)
stderr = loud.stderr
json_start = stderr.rfind("{")
json_end = stderr.rfind("}") + 1
if json_start >= 0 and json_end > json_start:
try:
loudness = json.loads(stderr[json_start:json_end])
print(f" Loudness: I={loudness.get('input_i', '?')} LUFS, "
f"TP={loudness.get('input_tp', '?')} dBTP, "
f"LRA={loudness.get('input_lra', '?')} LU")
except json.JSONDecodeError:
print(" (Could not parse loudness stats)")
else:
print(f"\n[{name}] FILE NOT FOUND")
print("\n=== AUDIO MIX TEST COMPLETE ===")

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""QA Test 05: Video composition — image + mixed audio → video, verify A/V sync.
Creates a video from static images with audio and optional subtitles.
Uses ffmpeg-generated fixtures if prior test outputs don't exist.
"""
import sys, os, json, subprocess
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from lib.env_loader import load_env
load_env()
from tools.video.video_compose import VideoCompose
OUT = os.path.join(os.path.dirname(__file__), "output")
os.makedirs(OUT, exist_ok=True)
# --- Fixture generation ---
def ensure_image(path, width=1280, height=720, color="blue"):
"""Generate a test image with ffmpeg if it doesn't exist."""
if os.path.exists(path):
print(f" [fixture] Using existing: {path}")
return
print(f" [fixture] Generating {color} image: {path}")
subprocess.run(
["ffmpeg", "-y", "-f", "lavfi", "-i",
f"color=c={color}:s={width}x{height}:d=1",
"-frames:v", "1", path],
capture_output=True, check=True,
)
def ensure_video(path, duration=5, width=1280, height=720, color="blue"):
"""Generate a test video clip with ffmpeg if it doesn't exist.
Uses forced keyframes every 1s so copy-mode trimming retains the video stream.
"""
if os.path.exists(path):
print(f" [fixture] Using existing: {path}")
return
print(f" [fixture] Generating {duration}s {color} video: {path}")
subprocess.run(
["ffmpeg", "-y", "-f", "lavfi", "-i",
f"color=c={color}:s={width}x{height}:d={duration}:r=30",
"-f", "lavfi", "-i", f"sine=frequency=440:duration={duration}",
"-c:v", "libx264", "-crf", "23", "-pix_fmt", "yuv420p",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-shortest", path],
capture_output=True, check=True,
)
def ensure_audio(path, duration=5):
"""Generate a test audio file if it doesn't exist."""
if os.path.exists(path):
print(f" [fixture] Using existing: {path}")
return
print(f" [fixture] Generating {duration}s audio: {path}")
subprocess.run(
["ffmpeg", "-y", "-f", "lavfi", "-i",
f"sine=frequency=440:duration={duration}",
"-ar", "44100", "-ac", "2", path],
capture_output=True, check=True,
)
def ensure_subtitle(path):
"""Write a minimal SRT subtitle file."""
if os.path.exists(path):
print(f" [fixture] Using existing: {path}")
return
print(f" [fixture] Generating subtitle: {path}")
with open(path, "w", encoding="utf-8") as f:
f.write("1\n00:00:00,000 --> 00:00:03,000\nWelcome to OpenMontage\n\n")
f.write("2\n00:00:03,000 --> 00:00:06,000\nBuilding amazing videos with AI\n\n")
f.write("3\n00:00:06,000 --> 00:00:10,000\nLet's see what we can create\n\n")
# Create fixtures
CLIP_A = os.path.join(OUT, "compose_clip_a.mp4")
CLIP_B = os.path.join(OUT, "compose_clip_b.mp4")
AUDIO_MIX = os.path.join(OUT, "compose_audio.wav")
SUBTITLE = os.path.join(OUT, "compose_subs.srt")
# Use 10s clips so -c copy has keyframe headroom (5s clips lose video stream)
ensure_video(CLIP_A, duration=10, color="darkblue")
ensure_video(CLIP_B, duration=10, color="darkgreen")
ensure_audio(AUDIO_MIX, duration=10)
ensure_subtitle(SUBTITLE)
# --- Tool setup ---
tool = VideoCompose()
print(f"Tool status: {tool.get_status()}")
# --- Test 1: Compose with cuts + audio ---
print("\n--- Test 1: Compose from edit_decisions ---")
r1 = tool.execute({
"operation": "compose",
"edit_decisions": {
"cuts": [
{"source": CLIP_A, "in_seconds": 0, "out_seconds": 5},
{"source": CLIP_B, "in_seconds": 0, "out_seconds": 5},
],
},
"audio_path": AUDIO_MIX,
"output_path": os.path.join(OUT, "compose_basic.mp4"),
})
print(f"Success: {r1.success}, Duration: {r1.duration_seconds:.2f}s")
if r1.error: print(f"Error: {r1.error}")
if r1.artifacts: print(f"Artifacts: {r1.artifacts}")
# --- Test 2: Compose with subtitles ---
print("\n--- Test 2: Compose with subtitles ---")
r2 = tool.execute({
"operation": "compose",
"edit_decisions": {
"cuts": [
{"source": CLIP_A, "in_seconds": 0, "out_seconds": 5},
{"source": CLIP_B, "in_seconds": 0, "out_seconds": 5},
],
},
"audio_path": AUDIO_MIX,
"subtitle_path": SUBTITLE,
"subtitle_style": {
"font": "Arial",
"font_size": 24,
"primary_color": "&HFFFFFF",
"outline_color": "&H000000",
"outline_width": 2,
"margin_v": 40,
},
"output_path": os.path.join(OUT, "compose_subtitled.mp4"),
})
print(f"Success: {r2.success}, Duration: {r2.duration_seconds:.2f}s")
if r2.error: print(f"Error: {r2.error}")
if r2.artifacts: print(f"Artifacts: {r2.artifacts}")
# --- Test 3: Burn subtitles onto existing video ---
print("\n--- Test 3: Burn subtitles standalone ---")
r3 = tool.execute({
"operation": "burn_subtitles",
"input_path": CLIP_A,
"subtitle_path": SUBTITLE,
"subtitle_style": {
"font": "Arial",
"font_size": 20,
"bold": True,
},
"output_path": os.path.join(OUT, "compose_burn_subs.mp4"),
})
print(f"Success: {r3.success}, Duration: {r3.duration_seconds:.2f}s")
if r3.error: print(f"Error: {r3.error}")
if r3.artifacts: print(f"Artifacts: {r3.artifacts}")
# --- Test 4: Encode with media profile ---
print("\n--- Test 4: Re-encode with profile ---")
r4 = tool.execute({
"operation": "encode",
"input_path": CLIP_A,
"profile": "YOUTUBE_LANDSCAPE",
"crf": 20,
"preset": "fast",
"output_path": os.path.join(OUT, "compose_encoded.mp4"),
})
print(f"Success: {r4.success}, Duration: {r4.duration_seconds:.2f}s")
if r4.error: print(f"Error: {r4.error}")
if r4.artifacts: print(f"Artifacts: {r4.artifacts}")
# --- Test 5: Overlay ---
print("\n--- Test 5: Overlay image on video ---")
OVERLAY_IMG = os.path.join(OUT, "compose_overlay.png")
ensure_image(OVERLAY_IMG, width=200, height=200, color="red")
r5 = tool.execute({
"operation": "overlay",
"input_path": CLIP_A,
"overlays": [
{
"asset_path": OVERLAY_IMG,
"x": 50, "y": 50,
"width": 150, "height": 150,
"start_seconds": 1,
"end_seconds": 4,
"opacity": 0.8,
},
],
"output_path": os.path.join(OUT, "compose_overlay.mp4"),
})
print(f"Success: {r5.success}, Duration: {r5.duration_seconds:.2f}s")
if r5.error: print(f"Error: {r5.error}")
if r5.artifacts: print(f"Artifacts: {r5.artifacts}")
# --- Probe all outputs ---
print("\n--- Output inspection ---")
outputs = [
"compose_basic.mp4",
"compose_subtitled.mp4",
"compose_burn_subs.mp4",
"compose_encoded.mp4",
"compose_overlay.mp4",
]
for name in outputs:
path = os.path.join(OUT, name)
if os.path.exists(path):
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", path],
capture_output=True, text=True,
)
info = json.loads(probe.stdout)
fmt = info.get("format", {})
video = {}
audio = {}
for s in info.get("streams", []):
if s.get("codec_type") == "video" and not video:
video = s
elif s.get("codec_type") == "audio" and not audio:
audio = s
print(f"\n[{name}]"
f" Duration: {fmt.get('duration', '?')}s"
f" | Video: {video.get('width', '?')}x{video.get('height', '?')}"
f" {video.get('codec_name', '?')}@{video.get('r_frame_rate', '?')}fps"
f" | Audio: {audio.get('codec_name', '?')}"
f" {audio.get('sample_rate', '?')}Hz"
f" {audio.get('channels', '?')}ch"
f" | Size: {os.path.getsize(path)} bytes")
else:
print(f"\n[{name}] FILE NOT FOUND")
print("\n=== VIDEO COMPOSE TEST COMPLETE ===")

View File

@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""QA Test 06: Video stitch — sequential concat, crossfade, fade, spatial PIP.
Tests the VideoStitch tool with both matching and mismatched clips.
Generates fixtures via ffmpeg — no API keys needed.
"""
import sys, os, json, subprocess
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from lib.env_loader import load_env
load_env()
from tools.video.video_stitch import VideoStitch
OUT = os.path.join(os.path.dirname(__file__), "output")
os.makedirs(OUT, exist_ok=True)
# --- Fixture generation ---
def ensure_video(path, duration=4, width=1280, height=720, fps=30, color="blue"):
"""Generate a test video clip with ffmpeg.
Uses forced keyframes every 1s so copy-mode operations retain the video stream.
"""
if os.path.exists(path):
print(f" [fixture] Using existing: {path}")
return
print(f" [fixture] Generating {duration}s {width}x{height}@{fps}fps {color}: {path}")
subprocess.run(
["ffmpeg", "-y",
"-f", "lavfi", "-i", f"color=c={color}:s={width}x{height}:d={duration}:r={fps}",
"-f", "lavfi", "-i", f"sine=frequency=440:duration={duration}",
"-c:v", "libx264", "-crf", "23", "-pix_fmt", "yuv420p",
"-g", str(fps), "-keyint_min", str(fps),
"-c:a", "aac", "-ar", "44100", "-ac", "2",
"-shortest", path],
capture_output=True, check=True,
)
# Matching clips (same resolution/fps/codec)
CLIP_1 = os.path.join(OUT, "stitch_clip1.mp4")
CLIP_2 = os.path.join(OUT, "stitch_clip2.mp4")
CLIP_3 = os.path.join(OUT, "stitch_clip3.mp4")
ensure_video(CLIP_1, duration=4, color="darkblue")
ensure_video(CLIP_2, duration=4, color="darkgreen")
ensure_video(CLIP_3, duration=4, color="darkred")
# Mismatched clip (different resolution + fps)
CLIP_MISMATCH = os.path.join(OUT, "stitch_clip_mismatch.mp4")
ensure_video(CLIP_MISMATCH, duration=4, width=640, height=480, fps=24, color="purple")
# --- Tool setup ---
tool = VideoStitch()
print(f"Tool status: {tool.get_status()}")
# --- Test 1: Validate matching clips ---
print("\n--- Test 1: Validate matching clips ---")
r1 = tool.execute({
"operation": "validate",
"clips": [CLIP_1, CLIP_2, CLIP_3],
})
print(f"Success: {r1.success}")
if r1.data:
print(f" Compatible: {r1.data.get('compatible')}")
print(f" Total duration: {r1.data.get('total_duration')}s")
print(f" Mismatches: {len(r1.data.get('mismatches', []))}")
if r1.error: print(f"Error: {r1.error}")
# --- Test 2: Validate mismatched clips ---
print("\n--- Test 2: Validate mismatched clips ---")
r2 = tool.execute({
"operation": "validate",
"clips": [CLIP_1, CLIP_MISMATCH],
})
print(f"Success: {r2.success}")
if r2.data:
print(f" Compatible: {r2.data.get('compatible')}")
mismatches = r2.data.get("mismatches", [])
for m in mismatches:
print(f" Clip[{m['clip_index']}]: {', '.join(m['differences'])}")
# --- Test 3: Simple cut stitch (matching clips) ---
print("\n--- Test 3: Cut stitch (2 clips) ---")
r3 = tool.execute({
"operation": "stitch",
"clips": [CLIP_1, CLIP_2],
"transition": "cut",
"output_path": os.path.join(OUT, "stitch_cut.mp4"),
})
print(f"Success: {r3.success}, Duration: {r3.duration_seconds:.2f}s")
if r3.data: print(f" Output duration: {r3.data.get('duration')}s, Method: {r3.data.get('method')}")
if r3.error: print(f"Error: {r3.error}")
# --- Test 4: Crossfade stitch ---
print("\n--- Test 4: Crossfade stitch (2 clips) ---")
r4 = tool.execute({
"operation": "stitch",
"clips": [CLIP_1, CLIP_2],
"transition": "crossfade",
"transition_duration": 1.0,
"output_path": os.path.join(OUT, "stitch_crossfade.mp4"),
})
print(f"Success: {r4.success}, Duration: {r4.duration_seconds:.2f}s")
if r4.data: print(f" Output duration: {r4.data.get('duration')}s, Method: {r4.data.get('method')}")
if r4.error: print(f"Error: {r4.error}")
# --- Test 5: Fade-through-black (3 clips) ---
print("\n--- Test 5: Fade through black (3 clips) ---")
r5 = tool.execute({
"operation": "stitch",
"clips": [CLIP_1, CLIP_2, CLIP_3],
"transition": "fade",
"transition_duration": 0.5,
"output_path": os.path.join(OUT, "stitch_fadeblack.mp4"),
})
print(f"Success: {r5.success}, Duration: {r5.duration_seconds:.2f}s")
if r5.data: print(f" Output duration: {r5.data.get('duration')}s, Method: {r5.data.get('method')}")
if r5.error: print(f"Error: {r5.error}")
# --- Test 6: Auto-normalize mismatched clips ---
print("\n--- Test 6: Stitch mismatched clips (auto_normalize) ---")
r6 = tool.execute({
"operation": "stitch",
"clips": [CLIP_1, CLIP_MISMATCH],
"transition": "cut",
"auto_normalize": True,
"output_path": os.path.join(OUT, "stitch_normalized.mp4"),
})
print(f"Success: {r6.success}, Duration: {r6.duration_seconds:.2f}s")
if r6.data: print(f" Output duration: {r6.data.get('duration')}s, Normalized: {r6.data.get('auto_normalized')}")
if r6.error: print(f"Error: {r6.error}")
# --- Test 7: Preview stitch (low-res) ---
print("\n--- Test 7: Preview stitch ---")
r7 = tool.execute({
"operation": "preview_stitch",
"clips": [CLIP_1, CLIP_2, CLIP_3],
"transition": "cut",
"output_path": os.path.join(OUT, "stitch_preview.mp4"),
})
print(f"Success: {r7.success}, Duration: {r7.duration_seconds:.2f}s")
if r7.data: print(f" Preview resolution: {r7.data.get('preview_resolution')}")
if r7.error: print(f"Error: {r7.error}")
# --- Test 8: Spatial — side by side ---
print("\n--- Test 8: Spatial side-by-side ---")
r8 = tool.execute({
"operation": "spatial",
"clips": [CLIP_1, CLIP_2],
"layout": "side_by_side",
"output_path": os.path.join(OUT, "stitch_side_by_side.mp4"),
})
print(f"Success: {r8.success}, Duration: {r8.duration_seconds:.2f}s")
if r8.data: print(f" Layout: {r8.data.get('layout')}, Duration: {r8.data.get('duration')}s")
if r8.error: print(f"Error: {r8.error}")
# --- Test 9: Spatial — picture-in-picture ---
print("\n--- Test 9: Spatial PIP (bottom-right) ---")
r9 = tool.execute({
"operation": "spatial",
"clips": [CLIP_1, CLIP_2],
"layout": "picture_in_picture",
"pip_position": "bottom_right",
"pip_scale": 0.3,
"pip_margin": 20,
"output_path": os.path.join(OUT, "stitch_pip.mp4"),
})
print(f"Success: {r9.success}, Duration: {r9.duration_seconds:.2f}s")
if r9.data: print(f" Layout: {r9.data.get('layout')}, Duration: {r9.data.get('duration')}s")
if r9.error: print(f"Error: {r9.error}")
# --- Test 10: Spatial — vertical stack ---
print("\n--- Test 10: Spatial vertical stack ---")
r10 = tool.execute({
"operation": "spatial",
"clips": [CLIP_1, CLIP_2],
"layout": "vertical_stack",
"output_path": os.path.join(OUT, "stitch_vstack.mp4"),
})
print(f"Success: {r10.success}, Duration: {r10.duration_seconds:.2f}s")
if r10.data: print(f" Layout: {r10.data.get('layout')}, Duration: {r10.data.get('duration')}s")
if r10.error: print(f"Error: {r10.error}")
# --- Probe all video outputs ---
print("\n--- Output inspection ---")
outputs = [
"stitch_cut.mp4",
"stitch_crossfade.mp4",
"stitch_fadeblack.mp4",
"stitch_normalized.mp4",
"stitch_preview.mp4",
"stitch_side_by_side.mp4",
"stitch_pip.mp4",
"stitch_vstack.mp4",
]
for name in outputs:
path = os.path.join(OUT, name)
if os.path.exists(path):
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", path],
capture_output=True, text=True,
)
info = json.loads(probe.stdout)
fmt = info.get("format", {})
video = {}
audio = {}
for s in info.get("streams", []):
if s.get("codec_type") == "video" and not video:
video = s
elif s.get("codec_type") == "audio" and not audio:
audio = s
print(f"\n[{name}]"
f" Duration: {fmt.get('duration', '?')}s"
f" | Video: {video.get('width', '?')}x{video.get('height', '?')}"
f" {video.get('codec_name', '?')}"
f" | Audio: {audio.get('codec_name', '?')}"
f" | Size: {os.path.getsize(path)} bytes")
else:
print(f"\n[{name}] FILE NOT FOUND")
print("\n=== VIDEO STITCH TEST COMPLETE ===")

View File

@@ -0,0 +1,279 @@
#!/usr/bin/env python3
"""QA Test 07: Playbook design intelligence — no API calls.
Tests contrast validation, color harmony generation, color-blind safety,
type scale computation, type hierarchy validation, font pairing suggestions,
and full accessibility audit across all 3 playbooks.
"""
import sys, os, json
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
from styles.playbook_loader import (
load_playbook,
validate_playbook,
list_playbooks,
validate_contrast,
check_color_blind_safety,
validate_palette,
generate_harmony,
compute_type_scale,
validate_type_hierarchy,
suggest_font_pairing,
validate_accessibility,
TYPE_SCALE_RATIOS,
)
PASS = 0
FAIL = 0
def check(name, condition, detail=""):
global PASS, FAIL
status = "PASS" if condition else "FAIL"
if condition:
PASS += 1
else:
FAIL += 1
suffix = f"{detail}" if detail else ""
print(f" [{status}] {name}{suffix}")
# ===================================================================
# Test 1: List and load all playbooks
# ===================================================================
print("--- Test 1: List and load playbooks ---")
playbooks_available = list_playbooks()
print(f" Found {len(playbooks_available)} playbooks: {playbooks_available}")
check("At least 3 playbooks exist", len(playbooks_available) >= 3)
loaded = {}
for name in ["clean-professional", "flat-motion-graphics", "minimalist-diagram"]:
try:
pb = load_playbook(name)
loaded[name] = pb
check(f"Load + validate {name}", True)
except Exception as e:
loaded[name] = None
check(f"Load + validate {name}", False, str(e))
# ===================================================================
# Test 2: Contrast validation (manual spot-checks)
# ===================================================================
print("\n--- Test 2: Contrast validation ---")
# Known pair: black on white should be 21:1
r = validate_contrast("#000000", "#FFFFFF")
check("Black on white ~21:1", abs(r["ratio"] - 21.0) < 0.1, f"ratio={r['ratio']}")
check("Black on white passes AAA", r["normal_text"]["AAA"])
# Known pair: white on white should be 1:1
r = validate_contrast("#FFFFFF", "#FFFFFF")
check("White on white = 1:1", abs(r["ratio"] - 1.0) < 0.01, f"ratio={r['ratio']}")
check("White on white fails AA", not r["normal_text"]["AA"])
# Mid-gray on white (~4.5:1 boundary)
r = validate_contrast("#767676", "#FFFFFF")
check("#767676 on white passes AA normal", r["normal_text"]["AA"], f"ratio={r['ratio']}")
# Just-below threshold
r = validate_contrast("#777777", "#FFFFFF")
check("#777777 on white borderline", r["ratio"] >= 4.4, f"ratio={r['ratio']}")
# Dark-on-dark: low contrast
r = validate_contrast("#1A1A1A", "#2B2B2B")
check("Dark-on-dark fails AA", not r["normal_text"]["AA"], f"ratio={r['ratio']}")
# Check each playbook's text-on-bg contrast
for name, pb in loaded.items():
if pb is None:
continue
palette = pb.get("visual_language", {}).get("color_palette", {})
text = palette.get("text", "#000000")
bg = palette.get("background", "#FFFFFF")
r = validate_contrast(text, bg)
check(f"{name}: text on bg passes AA", r["normal_text"]["AA"], f"ratio={r['ratio']}")
# ===================================================================
# Test 3: Color harmony generation
# ===================================================================
print("\n--- Test 3: Color harmony generation ---")
for harmony_type in ["complementary", "analogous", "triadic", "split-complementary"]:
colors = generate_harmony("#3B82F6", harmony_type)
check(f"Harmony {harmony_type}", len(colors) >= 2, f"generated {len(colors)} colors: {colors}")
# First color should match base
check(f" Base preserved in {harmony_type}", colors[0].upper() == generate_harmony("#3B82F6", harmony_type)[0].upper())
# Edge case: pure red
colors = generate_harmony("#FF0000", "triadic")
check("Triadic from pure red", len(colors) == 3, f"{colors}")
# ===================================================================
# Test 4: Color-blind safety
# ===================================================================
print("\n--- Test 4: Color-blind safety ---")
# Safe palette (blue + orange — distinguishable by all CVD types due to lightness diff)
safe = check_color_blind_safety(["#2563EB", "#F59E0B"])
print(f" Blue + orange: safe={safe['safe']}, issues={len(safe.get('issues', []))}")
# Problematic palette (red + green, similar lightness)
risky = check_color_blind_safety(["#DC2626", "#16A34A"])
print(f" Red + green: safe={risky['safe']}, issues={len(risky.get('issues', []))}")
check("Red+green flagged as risky", not risky["safe"] or len(risky.get("issues", [])) > 0,
"should flag deuteranopia/protanopia")
# Single color = no pairs to check
single = check_color_blind_safety(["#FF0000"])
check("Single color is safe", single["safe"])
# Grays should be safe (low saturation)
grays = check_color_blind_safety(["#333333", "#999999", "#CCCCCC"])
check("Grays are safe", grays["safe"])
# ===================================================================
# Test 5: Full palette validation per playbook
# ===================================================================
print("\n--- Test 5: Palette validation (all playbooks) ---")
for name, pb in loaded.items():
if pb is None:
continue
issues = validate_palette(pb)
errors = [i for i in issues if i.get("severity") == "error"]
warnings = [i for i in issues if i.get("severity") == "warning"]
print(f" [{name}] {len(errors)} errors, {len(warnings)} warnings, {len(issues)} total issues")
check(f"{name}: no contrast errors", len(errors) == 0,
"; ".join(e["message"] for e in errors) if errors else "all clear")
for issue in issues:
sev = issue.get("severity", "?")
print(f" [{sev}] {issue.get('message', '')}")
# ===================================================================
# Test 6: Type scale computation
# ===================================================================
print("\n--- Test 6: Type scale computation ---")
for ratio_name, ratio_val in TYPE_SCALE_RATIOS.items():
scale = compute_type_scale(24, ratio_name)
sizes = scale["sizes"]
check(f"Scale {ratio_name}: display > heading > subheading > body > caption",
sizes["display"] > sizes["heading"] > sizes["subheading"] > sizes["body"] > sizes["caption"],
f"{sizes}")
check(f" Base preserved", sizes["body"] == 24)
# Custom numeric ratio
scale = compute_type_scale(24, "1.5")
check("Custom ratio 1.5", scale["ratio_value"] == 1.5, f"sizes={scale['sizes']}")
# ===================================================================
# Test 7: Type hierarchy validation
# ===================================================================
print("\n--- Test 7: Type hierarchy validation ---")
for name, pb in loaded.items():
if pb is None:
continue
issues = validate_type_hierarchy(pb)
print(f" [{name}] {len(issues)} type hierarchy issues")
for issue in issues:
print(f" [{issue.get('severity')}] {issue.get('message')}")
# No errors expected in shipped playbooks
errors = [i for i in issues if i.get("severity") == "error"]
check(f"{name}: no type hierarchy errors", len(errors) == 0)
# Deliberately bad playbook
bad_typography = {
"typography": {
"headings": {"font": "Inter", "weight": 400},
"body": {"font": "Inter", "weight": 400},
"stat_card": {"font": "Inter", "size_multiplier": 0.8},
}
}
issues = validate_type_hierarchy(bad_typography)
check("Bad typography flagged", len(issues) > 0, f"{len(issues)} issues found")
# ===================================================================
# Test 8: Font pairing suggestions
# ===================================================================
print("\n--- Test 8: Font pairing suggestions ---")
for font in ["Inter", "Space Grotesk", "IBM Plex Sans", "Lora", "JetBrains Mono"]:
pairings = suggest_font_pairing(font)
check(f"Pairings for {font}", len(pairings) >= 1, f"{len(pairings)} suggestions")
for p in pairings:
print(f"{p['font']} ({p['category']}): {p['rationale']}")
# Unknown font fallback
pairings = suggest_font_pairing("UnknownFont")
check("Unknown font gets fallback", len(pairings) >= 1)
# ===================================================================
# Test 9: Full accessibility audit (all playbooks)
# ===================================================================
print("\n--- Test 9: Accessibility audit (all playbooks) ---")
for name, pb in loaded.items():
if pb is None:
continue
result = validate_accessibility(pb)
status = "PASS" if result["pass"] else "FAIL"
print(f"\n [{name}] Overall: {status}"
f" | Errors: {result['error_count']}"
f" | Warnings: {result['warning_count']}"
f" | Total: {result['total_issues']}")
check(f"{name}: a11y audit passes", result["pass"])
for issue in result["issues"]:
cat = issue.get("category", "?")
sev = issue.get("severity", "?")
print(f" [{cat}/{sev}] {issue.get('message', '')}")
# ===================================================================
# Test 10: Deliberately low-contrast custom playbook
# ===================================================================
print("\n--- Test 10: Low-contrast custom playbook ---")
low_contrast_pb = {
"identity": {"name": "low-contrast-test", "category": "test", "mood": "test", "pace": "moderate", "best_for": ["testing"]},
"visual_language": {
"color_palette": {
"primary": ["#555555"],
"accent": ["#666666"],
"background": "#444444",
"text": "#555555",
"muted": "#4A4A4A",
},
"composition": "centered",
"texture": "none",
},
"typography": {
"headings": {"font": "Arial", "weight": 700, "size_multiplier": 1.5},
"body": {"font": "Arial", "weight": 400, "size_multiplier": 1.0},
"code": {"font": "Courier", "weight": 400, "size_multiplier": 0.9},
"stat_card": {"font": "Arial", "weight": 700, "size_multiplier": 2.5},
"scale_system": "major_third",
"weight_matrix": {"title": 800, "heading": 700, "body": 400, "caption": 300},
},
"motion": {"transitions": "cut", "animation_style": "none", "pacing_rules": {}},
"audio": {"voice_style": "neutral", "music_mood": "none"},
"asset_generation": {"image_prompt_prefix": "test", "negative_prompt": ""},
"overlays": {
"stat_card": {"bg": "#444444", "text": "#555555", "border": "#444444", "radius": 8, "shadow": "none"},
},
"quality_rules": [],
"chart_palette": ["#555555", "#666666", "#777777"],
}
# This should be flagged with errors
issues = validate_palette(low_contrast_pb)
errors = [i for i in issues if i.get("severity") == "error"]
check("Low-contrast playbook has errors", len(errors) > 0, f"{len(errors)} contrast errors")
for e in errors:
print(f" [error] {e.get('message')}")
# ===================================================================
# Summary
# ===================================================================
print(f"\n{'='*60}")
print(f"PLAYBOOK INTELLIGENCE TEST COMPLETE: {PASS} passed, {FAIL} failed")
print(f"{'='*60}")

View File

@@ -0,0 +1,548 @@
#!/usr/bin/env python3
"""QA Test 08: End-to-end animated-explainer pipeline simulation.
Walks through all 7 stages (idea -> publish) with synthetic artifacts,
validating checkpoints, artifact schemas, and cost tracking at each step.
The compose stage runs real tools (audio_mixer + video_compose) to produce
an actual output video. All other stages use synthetic data.
No API keys needed -- uses ffmpeg-generated fixtures throughout.
"""
import sys, os, json, subprocess, shutil
from datetime import datetime, timezone
from pathlib import Path
PROJECT_ROOT = str(Path(__file__).resolve().parent.parent.parent)
sys.path.insert(0, PROJECT_ROOT)
from lib.env_loader import load_env
load_env()
from lib.checkpoint import (
write_checkpoint,
read_checkpoint,
get_completed_stages,
get_next_stage,
STAGES,
CANONICAL_STAGE_ARTIFACTS,
)
from tools.cost_tracker import CostTracker, BudgetMode
from schemas.artifacts import validate_artifact, list_schemas
from styles.playbook_loader import load_playbook, validate_accessibility
OUT = os.path.join(os.path.dirname(__file__), "output")
PIPELINE_DIR = Path(OUT) / "e2e_pipeline"
PROJECT_ID = "qa_e2e_test"
ASSETS_DIR = Path(OUT) / "e2e_assets"
# Clean previous run
if PIPELINE_DIR.exists():
shutil.rmtree(PIPELINE_DIR)
if ASSETS_DIR.exists():
shutil.rmtree(ASSETS_DIR)
ASSETS_DIR.mkdir(parents=True, exist_ok=True)
PASS = 0
FAIL = 0
def check(name, condition, detail=""):
global PASS, FAIL
if condition:
PASS += 1
print(f" [PASS] {name}" + (f" -- {detail}" if detail else ""))
else:
FAIL += 1
print(f" [FAIL] {name}" + (f" -- {detail}" if detail else ""))
def ensure_audio(path, duration=5):
if os.path.exists(path):
return
subprocess.run(
["ffmpeg", "-y", "-f", "lavfi", "-i",
f"sine=frequency=440:duration={duration}",
"-ar", "44100", "-ac", "1", path],
capture_output=True, check=True,
)
def ensure_video(path, duration=5, width=1280, height=720, color="blue"):
if os.path.exists(path):
return
subprocess.run(
["ffmpeg", "-y",
"-f", "lavfi", "-i", f"color=c={color}:s={width}x{height}:d={duration}:r=30",
"-f", "lavfi", "-i", f"sine=frequency=440:duration={duration}",
"-c:v", "libx264", "-crf", "23", "-pix_fmt", "yuv420p",
"-g", "30", "-keyint_min", "30",
"-c:a", "aac", "-shortest", path],
capture_output=True, check=True,
)
# ===================================================================
# Setup: Cost tracker + playbook
# ===================================================================
print("--- Setup ---")
cost_log = PIPELINE_DIR / PROJECT_ID / "cost_log.json"
tracker = CostTracker(
budget_total_usd=5.0,
mode=BudgetMode.OBSERVE,
cost_log_path=cost_log,
)
print(f" Budget: ${tracker.budget_total_usd}")
print(f" Available schemas: {list_schemas()}")
playbook = load_playbook("clean-professional")
a11y = validate_accessibility(playbook)
print(f" Playbook a11y: pass={a11y['pass']}, errors={a11y['error_count']}, warnings={a11y['warning_count']}")
# ===================================================================
# Stage 1: idea -> brief
# ===================================================================
print("\n--- Stage 1: idea ---")
brief = {
"version": "1.0",
"title": "AI Video Production in 60 Seconds",
"hook": "What if you could create a professional video in 60 seconds with just a text prompt?",
"key_points": [
"Traditional video production takes days or weeks",
"AI can automate scripting, visuals, narration, and editing",
"OpenMontage orchestrates the full pipeline",
],
"tone": "confident, energetic",
"style": "clean-professional",
"target_platform": "youtube",
"target_duration_seconds": 60,
"target_audience": "content creators and developers",
"cta": "Try OpenMontage today",
"angle_options": [
{"name": "democratization", "description": "AI makes video creation accessible to everyone"},
{"name": "workflow", "description": "AI automates the tedious parts of video production"},
{"name": "quality", "description": "AI-generated content is reaching professional quality"},
],
"selected_angle": "workflow",
}
try:
validate_artifact("brief", brief)
check("Brief validates against schema", True)
except Exception as e:
check("Brief validates against schema", False, str(e))
cp_path = write_checkpoint(
PIPELINE_DIR, PROJECT_ID, "idea", "completed",
artifacts={"brief": brief},
pipeline_type="animated-explainer",
style_playbook="clean-professional",
)
check("Idea checkpoint written", cp_path.exists())
# Next uncompleted stage in global STAGES order (research/proposal come before idea)
check("Next stage after idea", get_next_stage(PIPELINE_DIR, PROJECT_ID) == "research")
# ===================================================================
# Stage 2: script
# ===================================================================
print("\n--- Stage 2: script ---")
# Section timestamps (cumulative)
SECTIONS = [
("s1_hook", "Hook", 0, 8, "What if creating a professional video took less time than making your morning coffee?"),
("s2_setup", "Setup", 8, 20, "Traditional video production involves scripting, filming, editing, and post-production. It takes days, sometimes weeks."),
("s3_build", "Build", 20, 38, "Now imagine an AI that handles all of that. You type a topic, and it writes the script, generates visuals, creates narration, mixes audio, and delivers a finished video."),
("s4_climax", "Climax", 38, 50, "This is not science fiction. OpenMontage is an open-source platform that orchestrates AI tools into a complete video pipeline."),
("s5_landing", "Landing", 50, 60, "The future of video creation is open, automated, and available right now. Try it yourself."),
]
script = {
"version": "1.0",
"title": "AI Video Production in 60 Seconds",
"total_duration_seconds": 60,
"sections": [
{
"id": sid,
"label": label,
"text": text,
"start_seconds": start,
"end_seconds": end,
"speaker_directions": "Confident, engaging tone",
"enhancement_cues": [
{"type": "overlay", "description": f"Visual for {label} section", "timestamp_seconds": start + 2},
],
}
for sid, label, start, end, text in SECTIONS
],
}
try:
validate_artifact("script", script)
check("Script validates against schema", True)
except Exception as e:
check("Script validates against schema", False, str(e))
write_checkpoint(
PIPELINE_DIR, PROJECT_ID, "script", "completed",
artifacts={"script": script},
pipeline_type="animated-explainer",
)
check("Completed stages", get_completed_stages(PIPELINE_DIR, PROJECT_ID) == ["idea", "script"]) # idea and script appear in STAGES order
# ===================================================================
# Stage 3: scene_plan
# ===================================================================
print("\n--- Stage 3: scene_plan ---")
SCENE_TYPES = ["text_card", "diagram", "animation", "generated", "text_card"]
scene_plan = {
"version": "1.0",
"style_playbook": "clean-professional",
"scenes": [
{
"id": f"sc{i+1}",
"type": SCENE_TYPES[i],
"description": f"Scene for {label} section",
"start_seconds": start,
"end_seconds": end,
"script_section_id": sid,
"required_assets": [
{"type": "narration", "description": f"TTS narration for {label}", "source": "generate"},
{"type": "image", "description": f"Visual for {label}", "source": "generate"},
],
}
for i, (sid, label, start, end, _) in enumerate(SECTIONS)
],
}
try:
validate_artifact("scene_plan", scene_plan)
check("Scene plan validates against schema", True)
except Exception as e:
check("Scene plan validates against schema", False, str(e))
write_checkpoint(
PIPELINE_DIR, PROJECT_ID, "scene_plan", "completed",
artifacts={"scene_plan": scene_plan},
pipeline_type="animated-explainer",
)
# ===================================================================
# Stage 4: assets (generate fixtures)
# ===================================================================
print("\n--- Stage 4: assets ---")
# Generate TTS fixtures (one per section)
tts_files = {}
for sid, label, start, end, _ in SECTIONS:
path = str(ASSETS_DIR / f"tts_{sid}.mp3")
ensure_audio(path, duration=end - start)
tts_files[sid] = path
# Generate music fixture
music_path = str(ASSETS_DIR / "music_bg.mp3")
ensure_audio(music_path, duration=60)
# Build asset manifest (schema: id, type, path, source_tool, scene_id required)
clean_assets = []
for i, (sid, label, start, end, _) in enumerate(SECTIONS):
scene_id = f"sc{i+1}"
clean_assets.append({
"id": f"a_tts_{sid}",
"type": "narration",
"path": tts_files[sid],
"source_tool": "elevenlabs_tts",
"scene_id": scene_id,
})
# Image fixture
img_path = str(ASSETS_DIR / f"img_{scene_id}.png")
subprocess.run(
["ffmpeg", "-y", "-f", "lavfi", "-i",
f"color=c=darkblue:s=1280x720:d=1", "-frames:v", "1", img_path],
capture_output=True, check=True,
)
clean_assets.append({
"id": f"a_img_{scene_id}",
"type": "image",
"path": img_path,
"source_tool": "image_selector",
"scene_id": scene_id,
})
clean_assets.append({
"id": "a_music",
"type": "music",
"path": music_path,
"source_tool": "music_gen",
"scene_id": "sc1",
})
asset_manifest = {
"version": "1.0",
"assets": clean_assets,
"total_cost_usd": 0.0,
}
try:
validate_artifact("asset_manifest", asset_manifest)
check("Asset manifest validates against schema", True)
except Exception as e:
check("Asset manifest validates against schema", False, str(e))
# Verify all files exist
all_exist = all(os.path.exists(a["path"]) for a in asset_manifest["assets"])
check("All asset files exist on disk", all_exist)
# Track costs
eid = tracker.estimate("image_selector", "generate", 0.15)
tracker.approve_tool("image_selector")
tracker.reserve(eid)
tracker.reconcile(eid, 0.0, success=True)
print(f" Cost snapshot: {tracker.cost_snapshot()}")
write_checkpoint(
PIPELINE_DIR, PROJECT_ID, "assets", "completed",
artifacts={"asset_manifest": asset_manifest},
pipeline_type="animated-explainer",
cost_snapshot=tracker.cost_snapshot(),
)
# ===================================================================
# Stage 5: edit (edit_decisions)
# ===================================================================
print("\n--- Stage 5: edit ---")
# Create video clips for the compose step
colors = ["darkblue", "darkgreen", "darkorange", "darkred", "purple"]
scene_videos = {}
for i, scene in enumerate(scene_plan["scenes"]):
scid = scene["id"]
dur = scene["end_seconds"] - scene["start_seconds"]
vid_path = str(ASSETS_DIR / f"scene_{scid}.mp4")
ensure_video(vid_path, duration=dur, color=colors[i % len(colors)])
scene_videos[scid] = vid_path
edit_decisions = {
"version": "1.0",
"cuts": [
{
"id": f"cut_{scene['id']}",
"source": scene_videos[scene["id"]],
"in_seconds": 0,
"out_seconds": scene["end_seconds"] - scene["start_seconds"],
"speed": 1.0,
}
for scene in scene_plan["scenes"]
],
"music": {
"asset_id": "a_music",
"volume": 0.2,
"ducking": True,
"fade_in_seconds": 1.0,
"fade_out_seconds": 2.0,
},
"subtitles": {
"enabled": True,
"style": "clean-professional",
},
}
try:
validate_artifact("edit_decisions", edit_decisions)
check("Edit decisions validates against schema", True)
except Exception as e:
check("Edit decisions validates against schema", False, str(e))
write_checkpoint(
PIPELINE_DIR, PROJECT_ID, "edit", "completed",
artifacts={"edit_decisions": edit_decisions},
pipeline_type="animated-explainer",
)
# ===================================================================
# Stage 6: compose (REAL tool execution)
# ===================================================================
print("\n--- Stage 6: compose (real tools) ---")
from tools.audio.audio_mixer import AudioMixer
from tools.video.video_compose import VideoCompose
# Step 1: Mix narration + music
print(" Mixing audio...")
mixer = AudioMixer()
mix_output = str(ASSETS_DIR / "final_mix.wav")
# Combine all narration into one track first
concat_narration = str(ASSETS_DIR / "narration_concat.wav")
narration_list = str(ASSETS_DIR / "narration_list.txt")
with open(narration_list, "w") as f:
for sid, _, _, _, _ in SECTIONS:
safe = tts_files[sid].replace("\\", "/")
f.write(f"file '{safe}'\n")
subprocess.run(
["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", narration_list,
"-c", "copy", concat_narration],
capture_output=True, check=True,
)
mix_result = mixer.execute({
"operation": "duck",
"tracks": [
{"path": concat_narration, "role": "speech"},
{"path": music_path, "role": "music"},
],
"ducking": {"enabled": True, "music_volume_during_speech": 0.15},
"output_path": mix_output,
})
check("Audio mix succeeded", mix_result.success, mix_result.error or "")
# Step 2: Compose video
print(" Composing video...")
composer = VideoCompose()
final_video = str(Path(OUT) / "e2e_final_output.mp4")
compose_result = composer.execute({
"operation": "compose",
"edit_decisions": {
"cuts": [
{"source": c["source"], "in_seconds": c["in_seconds"], "out_seconds": c["out_seconds"], "speed": c.get("speed", 1.0)}
for c in edit_decisions["cuts"]
],
},
"audio_path": mix_output,
"codec": "libx264",
"crf": 23,
"preset": "fast",
"output_path": final_video,
})
check("Video compose succeeded", compose_result.success, compose_result.error or "")
check("Output video exists", os.path.exists(final_video))
# Probe the output
duration = 0.0
video_stream = {}
audio_stream = {}
if os.path.exists(final_video):
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json",
"-show_format", "-show_streams", final_video],
capture_output=True, text=True,
)
info = json.loads(probe.stdout)
fmt = info.get("format", {})
duration = float(fmt.get("duration", 0))
for s in info.get("streams", []):
if s.get("codec_type") == "video" and not video_stream:
video_stream = s
elif s.get("codec_type") == "audio" and not audio_stream:
audio_stream = s
print(f" Output: {video_stream.get('width')}x{video_stream.get('height')}"
f" {video_stream.get('codec_name')} | {duration:.1f}s"
f" | Audio: {audio_stream.get('codec_name')}"
f" | Size: {os.path.getsize(final_video)} bytes")
check("Video has audio track", bool(audio_stream))
check("Video has video track", bool(video_stream))
check("Duration > 30s", duration > 30, f"{duration:.1f}s")
render_report = {
"version": "1.0",
"outputs": [
{
"path": final_video,
"format": "mp4",
"codec": video_stream.get("codec_name", "h264"),
"audio_codec": audio_stream.get("codec_name", "aac"),
"resolution": f"{video_stream.get('width', 1280)}x{video_stream.get('height', 720)}",
"fps": 30,
"duration_seconds": round(duration, 2),
"file_size_bytes": os.path.getsize(final_video) if os.path.exists(final_video) else 0,
"platform_target": "youtube",
}
],
"render_time_seconds": compose_result.duration_seconds,
}
try:
validate_artifact("render_report", render_report)
check("Render report validates against schema", True)
except Exception as e:
check("Render report validates against schema", False, str(e))
write_checkpoint(
PIPELINE_DIR, PROJECT_ID, "compose", "completed",
artifacts={"render_report": render_report},
pipeline_type="animated-explainer",
cost_snapshot=tracker.cost_snapshot(),
)
# ===================================================================
# Stage 7: publish
# ===================================================================
print("\n--- Stage 7: publish ---")
publish_log = {
"version": "1.0",
"entries": [
{
"platform": "youtube",
"status": "exported",
"timestamp": datetime.now(timezone.utc).isoformat(),
"export_path": str(Path(OUT) / "e2e_export"),
"metadata_used": {
"title": "AI Video Production in 60 Seconds",
"description": "See how AI orchestrates an entire video production pipeline.",
"hashtags": ["#AI", "#VideoProduction", "#OpenMontage"],
"chapters": [
{"time": "0:00", "label": "Hook"},
{"time": "0:08", "label": "The Problem"},
{"time": "0:20", "label": "The Solution"},
{"time": "0:38", "label": "OpenMontage"},
{"time": "0:50", "label": "Try It"},
],
},
}
],
}
try:
validate_artifact("publish_log", publish_log)
check("Publish log validates against schema", True)
except Exception as e:
check("Publish log validates against schema", False, str(e))
write_checkpoint(
PIPELINE_DIR, PROJECT_ID, "publish", "completed",
artifacts={"publish_log": publish_log},
pipeline_type="animated-explainer",
)
# ===================================================================
# Final validation
# ===================================================================
print("\n--- Final validation ---")
completed = get_completed_stages(PIPELINE_DIR, PROJECT_ID)
check("All 7 stages completed", len(completed) == 7, f"completed={completed}")
check("Next stage is None (done)", get_next_stage(PIPELINE_DIR, PROJECT_ID) is None)
check("Stages in correct order", completed == STAGES, f"{completed}")
# Verify all checkpoints are readable
for stage in STAGES:
cp = read_checkpoint(PIPELINE_DIR, PROJECT_ID, stage)
check(f"Checkpoint {stage} readable", cp is not None)
if cp:
expected_artifact = CANONICAL_STAGE_ARTIFACTS[stage]
check(f" Has canonical artifact '{expected_artifact}'", expected_artifact in cp.get("artifacts", {}))
# Cost summary
print(f"\n Final cost: {tracker.cost_snapshot()}")
# ===================================================================
# Summary
# ===================================================================
print(f"\n{'='*60}")
print(f"END-TO-END TEST COMPLETE: {PASS} passed, {FAIL} failed")
print(f"{'='*60}")
if os.path.exists(final_video):
print(f"\nFinal video: {final_video}")
print("INSPECT: Open in VLC/media player to verify A/V sync, transitions, and content.")