Files
Timmy-time-dashboard/tests/test_assembler_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

276 lines
9.0 KiB
Python

"""Integration tests for creative.assembler — real files, no mocks.
Every test creates actual media files (PNG, WAV, MP4), runs them through
the assembler functions, and inspects the output with MoviePy / Pillow.
"""
import pytest
from pathlib import Path
from moviepy import VideoFileClip, AudioFileClip
from creative.assembler import (
stitch_clips,
overlay_audio,
add_title_card,
add_subtitles,
export_final,
)
from fixtures.media import (
make_audio_track,
make_video_clip,
make_scene_clips,
)
# ── Fixtures ──────────────────────────────────────────────────────────────────
@pytest.fixture
def media_dir(tmp_path):
"""Isolated directory for generated media."""
d = tmp_path / "media"
d.mkdir()
return d
@pytest.fixture
def two_clips(media_dir):
"""Two real 3-second MP4 clips."""
return make_scene_clips(
media_dir, ["Scene A", "Scene B"],
duration_per_clip=3.0, fps=12, width=320, height=180,
)
@pytest.fixture
def five_clips(media_dir):
"""Five real 2-second MP4 clips — enough for a short video."""
return make_scene_clips(
media_dir,
["Dawn", "Sunrise", "Mountains", "River", "Sunset"],
duration_per_clip=2.0, fps=12, width=320, height=180,
)
@pytest.fixture
def audio_10s(media_dir):
"""A real 10-second WAV audio track."""
return make_audio_track(media_dir / "track.wav", duration_seconds=10.0)
@pytest.fixture
def audio_30s(media_dir):
"""A real 30-second WAV audio track."""
return make_audio_track(
media_dir / "track_long.wav",
duration_seconds=30.0,
frequency=330.0,
)
# ── Stitch clips ─────────────────────────────────────────────────────────────
class TestStitchClipsReal:
def test_stitch_two_clips_no_transition(self, two_clips, tmp_path):
"""Stitching 2 x 3s clips → ~6s video."""
out = tmp_path / "stitched.mp4"
result = stitch_clips(
[str(p) for p in two_clips],
transition_duration=0,
output_path=str(out),
)
assert result["success"]
assert result["clip_count"] == 2
assert out.exists()
assert out.stat().st_size > 1000 # non-trivial file
video = VideoFileClip(str(out))
assert video.duration == pytest.approx(6.0, abs=0.5)
assert video.size == [320, 180]
video.close()
def test_stitch_with_crossfade(self, two_clips, tmp_path):
"""Cross-fade transition shortens total duration."""
out = tmp_path / "crossfade.mp4"
result = stitch_clips(
[str(p) for p in two_clips],
transition_duration=1.0,
output_path=str(out),
)
assert result["success"]
video = VideoFileClip(str(out))
# 2 x 3s - 1s overlap = ~5s
assert video.duration == pytest.approx(5.0, abs=1.0)
video.close()
def test_stitch_five_clips(self, five_clips, tmp_path):
"""Stitch 5 clips → continuous video with correct frame count."""
out = tmp_path / "five.mp4"
result = stitch_clips(
[str(p) for p in five_clips],
transition_duration=0.5,
output_path=str(out),
)
assert result["success"]
assert result["clip_count"] == 5
video = VideoFileClip(str(out))
# 5 x 2s - 4 * 0.5s overlap = 8s
assert video.duration >= 7.0
assert video.size == [320, 180]
video.close()
# ── Audio overlay ─────────────────────────────────────────────────────────────
class TestOverlayAudioReal:
def test_overlay_adds_audio_stream(self, two_clips, audio_10s, tmp_path):
"""Overlaying audio onto a silent video produces audible output."""
# First stitch clips
stitched = tmp_path / "silent.mp4"
stitch_clips(
[str(p) for p in two_clips],
transition_duration=0,
output_path=str(stitched),
)
out = tmp_path / "with_audio.mp4"
result = overlay_audio(str(stitched), str(audio_10s), output_path=str(out))
assert result["success"]
assert out.exists()
video = VideoFileClip(str(out))
assert video.audio is not None # has audio stream
assert video.duration == pytest.approx(6.0, abs=0.5)
video.close()
def test_audio_trimmed_to_video_length(self, two_clips, audio_30s, tmp_path):
"""30s audio track is trimmed to match ~6s video duration."""
stitched = tmp_path / "short.mp4"
stitch_clips(
[str(p) for p in two_clips],
transition_duration=0,
output_path=str(stitched),
)
out = tmp_path / "trimmed.mp4"
result = overlay_audio(str(stitched), str(audio_30s), output_path=str(out))
assert result["success"]
video = VideoFileClip(str(out))
# Audio should be trimmed to video length, not 30s
assert video.duration < 10.0
video.close()
# ── Title cards ───────────────────────────────────────────────────────────────
class TestAddTitleCardReal:
def test_prepend_title_card(self, two_clips, tmp_path):
"""Title card at start adds to total duration."""
stitched = tmp_path / "base.mp4"
stitch_clips(
[str(p) for p in two_clips],
transition_duration=0,
output_path=str(stitched),
)
base_video = VideoFileClip(str(stitched))
base_duration = base_video.duration
base_video.close()
out = tmp_path / "titled.mp4"
result = add_title_card(
str(stitched),
title="My Music Video",
duration=3.0,
position="start",
output_path=str(out),
)
assert result["success"]
assert result["title"] == "My Music Video"
video = VideoFileClip(str(out))
# Title card (3s) + base video (~6s) = ~9s
assert video.duration == pytest.approx(base_duration + 3.0, abs=1.0)
video.close()
def test_append_credits(self, two_clips, tmp_path):
"""Credits card at end adds to total duration."""
clip_path = str(two_clips[0]) # single 3s clip
out = tmp_path / "credits.mp4"
result = add_title_card(
clip_path,
title="THE END",
duration=2.0,
position="end",
output_path=str(out),
)
assert result["success"]
video = VideoFileClip(str(out))
# 3s clip + 2s credits = ~5s
assert video.duration == pytest.approx(5.0, abs=1.0)
video.close()
# ── Subtitles ─────────────────────────────────────────────────────────────────
class TestAddSubtitlesReal:
def test_burn_captions(self, two_clips, tmp_path):
"""Subtitles are burned onto the video (duration unchanged)."""
stitched = tmp_path / "base.mp4"
stitch_clips(
[str(p) for p in two_clips],
transition_duration=0,
output_path=str(stitched),
)
captions = [
{"text": "Welcome to the show", "start": 0.0, "end": 2.0},
{"text": "Here we go!", "start": 2.5, "end": 4.5},
{"text": "Finale", "start": 5.0, "end": 6.0},
]
out = tmp_path / "subtitled.mp4"
result = add_subtitles(str(stitched), captions, output_path=str(out))
assert result["success"]
assert result["caption_count"] == 3
video = VideoFileClip(str(out))
# Duration should be unchanged
assert video.duration == pytest.approx(6.0, abs=0.5)
assert video.size == [320, 180]
video.close()
# ── Export final ──────────────────────────────────────────────────────────────
class TestExportFinalReal:
def test_reencodes_video(self, two_clips, tmp_path):
"""Final export produces a valid re-encoded file."""
clip_path = str(two_clips[0])
out = tmp_path / "final.mp4"
result = export_final(
clip_path,
output_path=str(out),
codec="libx264",
bitrate="2000k",
)
assert result["success"]
assert result["codec"] == "libx264"
assert out.exists()
assert out.stat().st_size > 500
video = VideoFileClip(str(out))
assert video.duration == pytest.approx(3.0, abs=0.5)
video.close()