This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/tests/test_music_video_integration.py
Claude b098b00959 test: add integration tests with real media for music video pipeline
Build real PNG, WAV, and MP4 fixtures (no AI models) and exercise the
full assembler and Creative Director pipeline end-to-end.  Fix MoviePy v2
crossfade API (vfx.CrossFadeIn) and font resolution (DejaVu-Sans).

14 new integration tests — 638 total, all passing.

https://claude.ai/code/session_01KJm6jQkNi3aA3yoQJn636c
2026-02-24 16:48:14 +00:00

445 lines
15 KiB
Python

"""Integration test: end-to-end music video pipeline with real media files.
Exercises the Creative Director pipeline and Assembler with genuine PNG,
WAV, and MP4 files. Only AI model inference is replaced with fixture
generators; all MoviePy / FFmpeg operations run for real.
The final output video is inspected for:
- Duration — correct within tolerance
- Resolution — 320x180 (fixture default)
- Audio stream — present
- File size — non-trivial (>10 kB)
"""
import pytest
from pathlib import Path
from unittest.mock import patch
from moviepy import VideoFileClip
from creative.director import (
create_project,
run_storyboard,
run_music,
run_video_generation,
run_assembly,
run_full_pipeline,
_projects,
)
from creative.assembler import (
stitch_clips,
overlay_audio,
add_title_card,
add_subtitles,
export_final,
)
from fixtures.media import (
make_storyboard,
make_audio_track,
make_video_clip,
make_scene_clips,
)
# ── Fixtures ──────────────────────────────────────────────────────────────────
SCENES = [
{"description": "Dawn breaks over misty mountains", "duration": 4},
{"description": "A river carves through green valleys", "duration": 4},
{"description": "Wildflowers sway in warm sunlight", "duration": 4},
{"description": "Clouds gather as evening approaches", "duration": 4},
{"description": "Stars emerge over a quiet lake", "duration": 4},
]
@pytest.fixture(autouse=True)
def clear_projects():
"""Clear in-memory project store between tests."""
_projects.clear()
yield
_projects.clear()
@pytest.fixture
def media_dir(tmp_path):
d = tmp_path / "media"
d.mkdir()
return d
@pytest.fixture
def scene_defs():
"""Five-scene creative brief for a short music video."""
return [dict(s) for s in SCENES]
@pytest.fixture
def storyboard_frames(media_dir):
"""Real PNG storyboard frames for all scenes."""
return make_storyboard(
media_dir / "frames",
[s["description"][:20] for s in SCENES],
width=320, height=180,
)
@pytest.fixture
def audio_track(media_dir):
"""Real 25-second WAV audio track."""
return make_audio_track(
media_dir / "soundtrack.wav",
duration_seconds=25.0,
frequency=440.0,
)
@pytest.fixture
def video_clips(media_dir):
"""Real 4-second MP4 clips, one per scene (~20s total)."""
return make_scene_clips(
media_dir / "clips",
[s["description"][:20] for s in SCENES],
duration_per_clip=4.0,
fps=12,
width=320,
height=180,
)
# ── Direct assembly (zero AI mocking) ───────────────────────────────────────
class TestMusicVideoAssembly:
"""Build a real music video from fixture clips + audio, inspect output."""
def test_full_music_video(self, video_clips, audio_track, tmp_path):
"""Stitch 5 clips -> overlay audio -> title -> credits -> inspect."""
# 1. Stitch with crossfade
stitched = tmp_path / "stitched.mp4"
stitch_result = stitch_clips(
[str(p) for p in video_clips],
transition_duration=0.5,
output_path=str(stitched),
)
assert stitch_result["success"]
assert stitch_result["clip_count"] == 5
# 2. Overlay audio
with_audio = tmp_path / "with_audio.mp4"
audio_result = overlay_audio(
str(stitched), str(audio_track),
output_path=str(with_audio),
)
assert audio_result["success"]
# 3. Title card at start
titled = tmp_path / "titled.mp4"
title_result = add_title_card(
str(with_audio),
title="Dawn to Dusk",
duration=3.0,
position="start",
output_path=str(titled),
)
assert title_result["success"]
# 4. Credits at end
final_path = tmp_path / "final_music_video.mp4"
credits_result = add_title_card(
str(titled),
title="THE END",
duration=2.0,
position="end",
output_path=str(final_path),
)
assert credits_result["success"]
# ── Inspect final video ──────────────────────────────────────────
assert final_path.exists()
assert final_path.stat().st_size > 10_000 # non-trivial file
video = VideoFileClip(str(final_path))
# Duration: 5x4s - 4x0.5s crossfade = 18s + 3s title + 2s credits = 23s
expected_body = 5 * 4.0 - 4 * 0.5 # 18s
expected_total = expected_body + 3.0 + 2.0 # 23s
assert video.duration >= 15.0 # floor sanity check
assert video.duration == pytest.approx(expected_total, abs=3.0)
# Resolution
assert video.size == [320, 180]
# Audio present
assert video.audio is not None
video.close()
def test_with_subtitles(self, video_clips, audio_track, tmp_path):
"""Full video with burned-in captions."""
# Stitch without transitions for predictable duration
stitched = tmp_path / "stitched.mp4"
stitch_clips(
[str(p) for p in video_clips],
transition_duration=0,
output_path=str(stitched),
)
# Overlay audio
with_audio = tmp_path / "with_audio.mp4"
overlay_audio(
str(stitched), str(audio_track),
output_path=str(with_audio),
)
# Burn subtitles — one caption per scene
captions = [
{"text": "Dawn breaks over misty mountains", "start": 0.0, "end": 3.5},
{"text": "A river carves through green valleys", "start": 4.0, "end": 7.5},
{"text": "Wildflowers sway in warm sunlight", "start": 8.0, "end": 11.5},
{"text": "Clouds gather as evening approaches", "start": 12.0, "end": 15.5},
{"text": "Stars emerge over a quiet lake", "start": 16.0, "end": 19.5},
]
final = tmp_path / "subtitled_video.mp4"
result = add_subtitles(str(with_audio), captions, output_path=str(final))
assert result["success"]
assert result["caption_count"] == 5
video = VideoFileClip(str(final))
# 5x4s = 20s total (no crossfade)
assert video.duration == pytest.approx(20.0, abs=1.0)
assert video.size == [320, 180]
assert video.audio is not None
video.close()
def test_export_final_quality(self, video_clips, tmp_path):
"""Export with specific codec/bitrate and verify."""
stitched = tmp_path / "raw.mp4"
stitch_clips(
[str(p) for p in video_clips[:2]],
transition_duration=0,
output_path=str(stitched),
)
final = tmp_path / "hq.mp4"
result = export_final(
str(stitched),
output_path=str(final),
codec="libx264",
bitrate="5000k",
)
assert result["success"]
assert result["codec"] == "libx264"
assert final.stat().st_size > 5000
video = VideoFileClip(str(final))
# Two 4s clips = 8s
assert video.duration == pytest.approx(8.0, abs=1.0)
video.close()
# ── Creative Director pipeline (AI calls replaced with fixtures) ────────────
class TestCreativeDirectorPipeline:
"""Run the full director pipeline; only AI model inference is stubbed
with real-file fixture generators. All assembly runs for real."""
def _make_storyboard_stub(self, frames_dir):
"""Return a callable that produces real PNGs in tool-result format."""
def stub(descriptions):
frames = make_storyboard(
frames_dir, descriptions, width=320, height=180,
)
return {
"success": True,
"frame_count": len(frames),
"frames": [
{"path": str(f), "scene_index": i, "prompt": descriptions[i]}
for i, f in enumerate(frames)
],
}
return stub
def _make_song_stub(self, audio_dir):
"""Return a callable that produces a real WAV in tool-result format."""
def stub(lyrics="", genre="pop", duration=60, title=""):
path = make_audio_track(
audio_dir / "song.wav",
duration_seconds=min(duration, 25),
)
return {
"success": True,
"path": str(path),
"genre": genre,
"duration": min(duration, 25),
}
return stub
def _make_video_stub(self, clips_dir):
"""Return a callable that produces real MP4s in tool-result format."""
counter = [0]
def stub(image_path=None, prompt="scene", duration=4, **kwargs):
path = make_video_clip(
clips_dir / f"gen_{counter[0]:03d}.mp4",
duration_seconds=duration,
fps=12, width=320, height=180,
label=prompt[:20],
)
counter[0] += 1
return {
"success": True,
"path": str(path),
"duration": duration,
}
return stub
def test_full_pipeline_end_to_end(self, scene_defs, tmp_path):
"""run_full_pipeline with real fixtures at every stage."""
frames_dir = tmp_path / "frames"
frames_dir.mkdir()
audio_dir = tmp_path / "audio"
audio_dir.mkdir()
clips_dir = tmp_path / "clips"
clips_dir.mkdir()
assembly_dir = tmp_path / "assembly"
assembly_dir.mkdir()
with (
patch("tools.image_tools.generate_storyboard",
side_effect=self._make_storyboard_stub(frames_dir)),
patch("tools.music_tools.generate_song",
side_effect=self._make_song_stub(audio_dir)),
patch("tools.video_tools.image_to_video",
side_effect=self._make_video_stub(clips_dir)),
patch("tools.video_tools.generate_video_clip",
side_effect=self._make_video_stub(clips_dir)),
patch("creative.director._project_dir",
return_value=tmp_path / "project"),
patch("creative.director._save_project"),
patch("creative.assembler._output_dir",
return_value=assembly_dir),
):
result = run_full_pipeline(
title="Integration Test Video",
description="End-to-end pipeline test",
scenes=scene_defs,
lyrics="Test lyrics for the song",
genre="rock",
)
assert result["success"], f"Pipeline failed: {result}"
assert result["project_id"]
assert result["final_video"] is not None
assert result["project"]["status"] == "complete"
assert result["project"]["has_final"] is True
assert result["project"]["clip_count"] == 5
# Inspect the final video
final_path = Path(result["final_video"]["path"])
assert final_path.exists()
assert final_path.stat().st_size > 5000
video = VideoFileClip(str(final_path))
# 5x4s clips - 4x1s crossfade = 16s body + 4s title card ~= 20s
assert video.duration >= 10.0
assert video.size == [320, 180]
assert video.audio is not None
video.close()
def test_step_by_step_pipeline(self, scene_defs, tmp_path):
"""Run each pipeline step individually — mirrors manual usage."""
frames_dir = tmp_path / "frames"
frames_dir.mkdir()
audio_dir = tmp_path / "audio"
audio_dir.mkdir()
clips_dir = tmp_path / "clips"
clips_dir.mkdir()
assembly_dir = tmp_path / "assembly"
assembly_dir.mkdir()
# 1. Create project
with (
patch("creative.director._project_dir",
return_value=tmp_path / "proj"),
patch("creative.director._save_project"),
):
proj = create_project(
"Step-by-Step Video",
"Manual pipeline test",
scenes=scene_defs,
lyrics="Step by step, we build it all",
)
pid = proj["project"]["id"]
assert proj["success"]
# 2. Storyboard
with (
patch("tools.image_tools.generate_storyboard",
side_effect=self._make_storyboard_stub(frames_dir)),
patch("creative.director._save_project"),
):
sb = run_storyboard(pid)
assert sb["success"]
assert sb["frame_count"] == 5
# 3. Music
with (
patch("tools.music_tools.generate_song",
side_effect=self._make_song_stub(audio_dir)),
patch("creative.director._save_project"),
):
mus = run_music(pid, genre="electronic")
assert mus["success"]
assert mus["genre"] == "electronic"
# Verify the audio file exists and is valid
audio_path = Path(mus["path"])
assert audio_path.exists()
assert audio_path.stat().st_size > 1000
# 4. Video generation (uses storyboard frames → image_to_video)
with (
patch("tools.video_tools.image_to_video",
side_effect=self._make_video_stub(clips_dir)),
patch("creative.director._save_project"),
):
vid = run_video_generation(pid)
assert vid["success"]
assert vid["clip_count"] == 5
# Verify each clip exists
for clip_info in vid["clips"]:
clip_path = Path(clip_info["path"])
assert clip_path.exists()
assert clip_path.stat().st_size > 1000
# 5. Assembly (all real MoviePy operations)
with (
patch("creative.director._save_project"),
patch("creative.assembler._output_dir",
return_value=assembly_dir),
):
asm = run_assembly(pid, transition_duration=0.5)
assert asm["success"]
# Inspect final output
final_path = Path(asm["path"])
assert final_path.exists()
assert final_path.stat().st_size > 5000
video = VideoFileClip(str(final_path))
# 5x4s - 4x0.5s = 18s body, + title card ~= 22s
assert video.duration >= 10.0
assert video.size == [320, 180]
assert video.audio is not None
video.close()
# Verify project reached completion
project = _projects[pid]
assert project.status == "complete"
assert project.final_video is not None
assert len(project.video_clips) == 5
assert len(project.storyboard_frames) == 5
assert project.music_track is not None