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
276 lines
9.0 KiB
Python
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()
|