forked from Rockachopa/Timmy-time-dashboard
refactor: Phase 3 — reorganize tests into module-mirroring subdirectories
Move 97 test files from flat tests/ into 13 subdirectories: tests/dashboard/ (8 files — routes, mobile, mission control) tests/swarm/ (17 files — coordinator, docker, routing, tasks) tests/timmy/ (12 files — agent, backends, CLI, tools) tests/self_coding/ (14 files — git safety, indexer, self-modify) tests/lightning/ (3 files — L402, LND, interface) tests/creative/ (8 files — assembler, director, image/music/video) tests/integrations/ (10 files — chat bridge, telegram, voice, websocket) tests/mcp/ (4 files — bootstrap, discovery, executor) tests/spark/ (3 files — engine, tools, events) tests/hands/ (3 files — registry, oracle, phase5) tests/scripture/ (1 file) tests/infrastructure/ (3 files — router cascade, API) tests/security/ (3 files — XSS, regression) Fix Path(__file__) reference in test_mobile_scenarios.py for new depth. Add __init__.py to all test subdirectories. Tests: 1503 passed, 9 failed (pre-existing), 53 errors (pre-existing) https://claude.ai/code/session_019oMFNvD8uSGSSmBMGkBfQN
This commit is contained in:
0
tests/creative/__init__.py
Normal file
0
tests/creative/__init__.py
Normal file
69
tests/creative/test_assembler.py
Normal file
69
tests/creative/test_assembler.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Tests for creative.assembler — Video assembly engine.
|
||||
|
||||
MoviePy is mocked for CI; these tests verify the interface contracts.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from creative.assembler import (
|
||||
ASSEMBLER_TOOL_CATALOG,
|
||||
stitch_clips,
|
||||
overlay_audio,
|
||||
add_title_card,
|
||||
add_subtitles,
|
||||
export_final,
|
||||
_MOVIEPY_AVAILABLE,
|
||||
)
|
||||
|
||||
|
||||
class TestAssemblerToolCatalog:
|
||||
def test_catalog_has_all_tools(self):
|
||||
expected = {
|
||||
"stitch_clips", "overlay_audio", "add_title_card",
|
||||
"add_subtitles", "export_final",
|
||||
}
|
||||
assert expected == set(ASSEMBLER_TOOL_CATALOG.keys())
|
||||
|
||||
def test_catalog_entries_callable(self):
|
||||
for tool_id, info in ASSEMBLER_TOOL_CATALOG.items():
|
||||
assert callable(info["fn"])
|
||||
assert "name" in info
|
||||
assert "description" in info
|
||||
|
||||
|
||||
class TestStitchClipsInterface:
|
||||
@pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
|
||||
def test_raises_on_empty_clips(self):
|
||||
"""Stitch with no clips should fail gracefully."""
|
||||
# MoviePy would fail on empty list
|
||||
with pytest.raises(Exception):
|
||||
stitch_clips([])
|
||||
|
||||
|
||||
class TestOverlayAudioInterface:
|
||||
@pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
|
||||
def test_overlay_requires_valid_paths(self):
|
||||
with pytest.raises(Exception):
|
||||
overlay_audio("/nonexistent/video.mp4", "/nonexistent/audio.wav")
|
||||
|
||||
|
||||
class TestAddTitleCardInterface:
|
||||
@pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
|
||||
def test_add_title_requires_valid_video(self):
|
||||
with pytest.raises(Exception):
|
||||
add_title_card("/nonexistent/video.mp4", "Title")
|
||||
|
||||
|
||||
class TestAddSubtitlesInterface:
|
||||
@pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
|
||||
def test_requires_valid_video(self):
|
||||
with pytest.raises(Exception):
|
||||
add_subtitles("/nonexistent.mp4", [{"text": "Hi", "start": 0, "end": 1}])
|
||||
|
||||
|
||||
class TestExportFinalInterface:
|
||||
@pytest.mark.skipif(not _MOVIEPY_AVAILABLE, reason="MoviePy not installed")
|
||||
def test_requires_valid_video(self):
|
||||
with pytest.raises(Exception):
|
||||
export_final("/nonexistent/video.mp4")
|
||||
275
tests/creative/test_assembler_integration.py
Normal file
275
tests/creative/test_assembler_integration.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""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()
|
||||
190
tests/creative/test_creative_director.py
Normal file
190
tests/creative/test_creative_director.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Tests for creative.director — Creative Director pipeline.
|
||||
|
||||
Tests project management, pipeline orchestration, and tool catalogue.
|
||||
All AI model calls are mocked.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from creative.director import (
|
||||
create_project,
|
||||
get_project,
|
||||
list_projects,
|
||||
run_storyboard,
|
||||
run_music,
|
||||
run_video_generation,
|
||||
run_assembly,
|
||||
run_full_pipeline,
|
||||
CreativeProject,
|
||||
DIRECTOR_TOOL_CATALOG,
|
||||
_projects,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_projects():
|
||||
"""Clear project store between tests."""
|
||||
_projects.clear()
|
||||
yield
|
||||
_projects.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_project(tmp_path):
|
||||
"""Create a sample project with scenes."""
|
||||
with patch("creative.director._project_dir", return_value=tmp_path):
|
||||
result = create_project(
|
||||
title="Test Video",
|
||||
description="A test creative project",
|
||||
scenes=[
|
||||
{"description": "A sunrise over mountains"},
|
||||
{"description": "A river flowing through a valley"},
|
||||
{"description": "A sunset over the ocean"},
|
||||
],
|
||||
lyrics="La la la, the sun rises high",
|
||||
)
|
||||
return result["project"]["id"]
|
||||
|
||||
|
||||
class TestCreateProject:
|
||||
def test_creates_project(self, tmp_path):
|
||||
with patch("creative.director._project_dir", return_value=tmp_path):
|
||||
result = create_project("My Video", "A cool video")
|
||||
assert result["success"]
|
||||
assert result["project"]["title"] == "My Video"
|
||||
assert result["project"]["status"] == "planning"
|
||||
|
||||
def test_project_has_id(self, tmp_path):
|
||||
with patch("creative.director._project_dir", return_value=tmp_path):
|
||||
result = create_project("Test", "Test")
|
||||
assert len(result["project"]["id"]) == 12
|
||||
|
||||
def test_project_with_scenes(self, tmp_path):
|
||||
with patch("creative.director._project_dir", return_value=tmp_path):
|
||||
result = create_project(
|
||||
"Scenes", "With scenes",
|
||||
scenes=[{"description": "Scene 1"}, {"description": "Scene 2"}],
|
||||
)
|
||||
assert result["project"]["scene_count"] == 2
|
||||
|
||||
|
||||
class TestGetProject:
|
||||
def test_get_existing(self, sample_project):
|
||||
result = get_project(sample_project)
|
||||
assert result is not None
|
||||
assert result["title"] == "Test Video"
|
||||
|
||||
def test_get_nonexistent(self):
|
||||
assert get_project("bogus") is None
|
||||
|
||||
|
||||
class TestListProjects:
|
||||
def test_empty(self):
|
||||
assert list_projects() == []
|
||||
|
||||
def test_with_projects(self, sample_project, tmp_path):
|
||||
with patch("creative.director._project_dir", return_value=tmp_path):
|
||||
create_project("Second", "desc")
|
||||
assert len(list_projects()) == 2
|
||||
|
||||
|
||||
class TestRunStoryboard:
|
||||
def test_fails_without_project(self):
|
||||
result = run_storyboard("bogus")
|
||||
assert not result["success"]
|
||||
assert "not found" in result["error"]
|
||||
|
||||
def test_fails_without_scenes(self, tmp_path):
|
||||
with patch("creative.director._project_dir", return_value=tmp_path):
|
||||
result = create_project("Empty", "No scenes")
|
||||
pid = result["project"]["id"]
|
||||
result = run_storyboard(pid)
|
||||
assert not result["success"]
|
||||
assert "No scenes" in result["error"]
|
||||
|
||||
def test_generates_frames(self, sample_project, tmp_path):
|
||||
mock_result = {
|
||||
"success": True,
|
||||
"frame_count": 3,
|
||||
"frames": [
|
||||
{"path": "/fake/1.png", "scene_index": 0, "prompt": "sunrise"},
|
||||
{"path": "/fake/2.png", "scene_index": 1, "prompt": "river"},
|
||||
{"path": "/fake/3.png", "scene_index": 2, "prompt": "sunset"},
|
||||
],
|
||||
}
|
||||
with patch("tools.image_tools.generate_storyboard", return_value=mock_result):
|
||||
with patch("creative.director._save_project"):
|
||||
result = run_storyboard(sample_project)
|
||||
assert result["success"]
|
||||
assert result["frame_count"] == 3
|
||||
|
||||
|
||||
class TestRunMusic:
|
||||
def test_fails_without_project(self):
|
||||
result = run_music("bogus")
|
||||
assert not result["success"]
|
||||
|
||||
def test_generates_track(self, sample_project):
|
||||
mock_result = {
|
||||
"success": True, "path": "/fake/song.wav",
|
||||
"genre": "pop", "duration": 60,
|
||||
}
|
||||
with patch("tools.music_tools.generate_song", return_value=mock_result):
|
||||
with patch("creative.director._save_project"):
|
||||
result = run_music(sample_project, genre="pop")
|
||||
assert result["success"]
|
||||
assert result["path"] == "/fake/song.wav"
|
||||
|
||||
|
||||
class TestRunVideoGeneration:
|
||||
def test_fails_without_project(self):
|
||||
result = run_video_generation("bogus")
|
||||
assert not result["success"]
|
||||
|
||||
def test_generates_clips(self, sample_project):
|
||||
mock_clip = {
|
||||
"success": True, "path": "/fake/clip.mp4",
|
||||
"duration": 5,
|
||||
}
|
||||
with patch("tools.video_tools.generate_video_clip", return_value=mock_clip):
|
||||
with patch("tools.video_tools.image_to_video", return_value=mock_clip):
|
||||
with patch("creative.director._save_project"):
|
||||
result = run_video_generation(sample_project)
|
||||
assert result["success"]
|
||||
assert result["clip_count"] == 3
|
||||
|
||||
|
||||
class TestRunAssembly:
|
||||
def test_fails_without_project(self):
|
||||
result = run_assembly("bogus")
|
||||
assert not result["success"]
|
||||
|
||||
def test_fails_without_clips(self, sample_project):
|
||||
result = run_assembly(sample_project)
|
||||
assert not result["success"]
|
||||
assert "No video clips" in result["error"]
|
||||
|
||||
|
||||
class TestCreativeProject:
|
||||
def test_to_dict(self):
|
||||
p = CreativeProject(title="Test", description="Desc")
|
||||
d = p.to_dict()
|
||||
assert d["title"] == "Test"
|
||||
assert d["status"] == "planning"
|
||||
assert d["scene_count"] == 0
|
||||
assert d["has_storyboard"] is False
|
||||
assert d["has_music"] is False
|
||||
|
||||
|
||||
class TestDirectorToolCatalog:
|
||||
def test_catalog_has_all_tools(self):
|
||||
expected = {
|
||||
"create_project", "run_storyboard", "run_music",
|
||||
"run_video_generation", "run_assembly", "run_full_pipeline",
|
||||
}
|
||||
assert expected == set(DIRECTOR_TOOL_CATALOG.keys())
|
||||
|
||||
def test_catalog_entries_callable(self):
|
||||
for tool_id, info in DIRECTOR_TOOL_CATALOG.items():
|
||||
assert callable(info["fn"])
|
||||
61
tests/creative/test_creative_route.py
Normal file
61
tests/creative/test_creative_route.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Tests for the Creative Studio dashboard route."""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
|
||||
os.environ.setdefault("TIMMY_TEST_MODE", "1")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path, monkeypatch):
|
||||
"""Test client with temp DB paths."""
|
||||
monkeypatch.setattr("swarm.tasks.DB_PATH", tmp_path / "swarm.db")
|
||||
monkeypatch.setattr("swarm.registry.DB_PATH", tmp_path / "swarm.db")
|
||||
monkeypatch.setattr("swarm.stats.DB_PATH", tmp_path / "swarm.db")
|
||||
monkeypatch.setattr("swarm.learner.DB_PATH", tmp_path / "swarm.db")
|
||||
|
||||
from dashboard.app import app
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestCreativeStudioPage:
|
||||
def test_creative_page_loads(self, client):
|
||||
resp = client.get("/creative/ui")
|
||||
assert resp.status_code == 200
|
||||
assert "Creative Studio" in resp.text
|
||||
|
||||
def test_creative_page_has_tabs(self, client):
|
||||
resp = client.get("/creative/ui")
|
||||
assert "tab-images" in resp.text
|
||||
assert "tab-music" in resp.text
|
||||
assert "tab-video" in resp.text
|
||||
assert "tab-director" in resp.text
|
||||
|
||||
def test_creative_page_shows_personas(self, client):
|
||||
resp = client.get("/creative/ui")
|
||||
assert "Pixel" in resp.text
|
||||
assert "Lyra" in resp.text
|
||||
assert "Reel" in resp.text
|
||||
|
||||
|
||||
class TestCreativeAPI:
|
||||
def test_projects_api_empty(self, client):
|
||||
resp = client.get("/creative/api/projects")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "projects" in data
|
||||
|
||||
def test_genres_api(self, client):
|
||||
resp = client.get("/creative/api/genres")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "genres" in data
|
||||
|
||||
def test_video_styles_api(self, client):
|
||||
resp = client.get("/creative/api/video-styles")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "styles" in data
|
||||
assert "resolutions" in data
|
||||
120
tests/creative/test_image_tools.py
Normal file
120
tests/creative/test_image_tools.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Tests for tools.image_tools — Image generation (Pixel persona).
|
||||
|
||||
Heavy AI model tests are skipped; only catalogue, metadata, and
|
||||
interface tests run in CI.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from pathlib import Path
|
||||
|
||||
from tools.image_tools import (
|
||||
IMAGE_TOOL_CATALOG,
|
||||
generate_image,
|
||||
generate_storyboard,
|
||||
image_variations,
|
||||
_save_metadata,
|
||||
)
|
||||
|
||||
|
||||
class TestImageToolCatalog:
|
||||
def test_catalog_has_all_tools(self):
|
||||
expected = {"generate_image", "generate_storyboard", "image_variations"}
|
||||
assert expected == set(IMAGE_TOOL_CATALOG.keys())
|
||||
|
||||
def test_catalog_entries_have_required_keys(self):
|
||||
for tool_id, info in IMAGE_TOOL_CATALOG.items():
|
||||
assert "name" in info
|
||||
assert "description" in info
|
||||
assert "fn" in info
|
||||
assert callable(info["fn"])
|
||||
|
||||
|
||||
class TestSaveMetadata:
|
||||
def test_saves_json_sidecar(self, tmp_path):
|
||||
img_path = tmp_path / "test.png"
|
||||
img_path.write_bytes(b"fake image")
|
||||
meta = {"prompt": "a cat", "width": 512}
|
||||
result = _save_metadata(img_path, meta)
|
||||
assert result.suffix == ".json"
|
||||
assert result.exists()
|
||||
|
||||
import json
|
||||
data = json.loads(result.read_text())
|
||||
assert data["prompt"] == "a cat"
|
||||
|
||||
|
||||
class TestGenerateImageInterface:
|
||||
def test_raises_without_creative_deps(self):
|
||||
"""generate_image raises ImportError when diffusers not available."""
|
||||
with patch("tools.image_tools._pipeline", None):
|
||||
with patch("tools.image_tools._get_pipeline", side_effect=ImportError("no diffusers")):
|
||||
with pytest.raises(ImportError):
|
||||
generate_image("a cat")
|
||||
|
||||
def test_generate_image_with_mocked_pipeline(self, tmp_path):
|
||||
"""generate_image works end-to-end with a mocked pipeline."""
|
||||
import sys
|
||||
|
||||
mock_image = MagicMock()
|
||||
mock_image.save = MagicMock()
|
||||
|
||||
mock_pipe = MagicMock()
|
||||
mock_pipe.device = "cpu"
|
||||
mock_pipe.return_value.images = [mock_image]
|
||||
|
||||
mock_torch = MagicMock()
|
||||
mock_torch.Generator.return_value = MagicMock()
|
||||
|
||||
with patch.dict(sys.modules, {"torch": mock_torch}):
|
||||
with patch("tools.image_tools._get_pipeline", return_value=mock_pipe):
|
||||
with patch("tools.image_tools._output_dir", return_value=tmp_path):
|
||||
result = generate_image("a cat", width=512, height=512, steps=1)
|
||||
|
||||
assert result["success"]
|
||||
assert result["prompt"] == "a cat"
|
||||
assert result["width"] == 512
|
||||
assert "path" in result
|
||||
|
||||
|
||||
class TestGenerateStoryboardInterface:
|
||||
def test_calls_generate_image_per_scene(self):
|
||||
"""Storyboard calls generate_image once per scene."""
|
||||
call_count = 0
|
||||
|
||||
def mock_gen_image(prompt, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return {
|
||||
"success": True, "path": f"/fake/{call_count}.png",
|
||||
"id": str(call_count), "prompt": prompt,
|
||||
}
|
||||
|
||||
with patch("tools.image_tools.generate_image", side_effect=mock_gen_image):
|
||||
result = generate_storyboard(
|
||||
["sunrise", "mountain peak", "sunset"],
|
||||
steps=1,
|
||||
)
|
||||
|
||||
assert result["success"]
|
||||
assert result["frame_count"] == 3
|
||||
assert len(result["frames"]) == 3
|
||||
assert call_count == 3
|
||||
|
||||
|
||||
class TestImageVariationsInterface:
|
||||
def test_generates_multiple_variations(self):
|
||||
"""image_variations generates the requested number of results."""
|
||||
def mock_gen_image(prompt, **kwargs):
|
||||
return {
|
||||
"success": True, "path": "/fake.png",
|
||||
"id": "x", "prompt": prompt,
|
||||
"seed": kwargs.get("seed"),
|
||||
}
|
||||
|
||||
with patch("tools.image_tools.generate_image", side_effect=mock_gen_image):
|
||||
result = image_variations("a dog", count=3, steps=1)
|
||||
|
||||
assert result["success"]
|
||||
assert result["count"] == 3
|
||||
assert len(result["variations"]) == 3
|
||||
124
tests/creative/test_music_tools.py
Normal file
124
tests/creative/test_music_tools.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Tests for tools.music_tools — Music generation (Lyra persona).
|
||||
|
||||
Heavy AI model tests are skipped; only catalogue, interface, and
|
||||
metadata tests run in CI.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from tools.music_tools import (
|
||||
MUSIC_TOOL_CATALOG,
|
||||
GENRES,
|
||||
list_genres,
|
||||
generate_song,
|
||||
generate_instrumental,
|
||||
generate_vocals,
|
||||
)
|
||||
|
||||
|
||||
class TestMusicToolCatalog:
|
||||
def test_catalog_has_all_tools(self):
|
||||
expected = {
|
||||
"generate_song", "generate_instrumental",
|
||||
"generate_vocals", "list_genres",
|
||||
}
|
||||
assert expected == set(MUSIC_TOOL_CATALOG.keys())
|
||||
|
||||
def test_catalog_entries_have_required_keys(self):
|
||||
for tool_id, info in MUSIC_TOOL_CATALOG.items():
|
||||
assert "name" in info
|
||||
assert "description" in info
|
||||
assert "fn" in info
|
||||
assert callable(info["fn"])
|
||||
|
||||
|
||||
class TestListGenres:
|
||||
def test_returns_genre_list(self):
|
||||
result = list_genres()
|
||||
assert result["success"]
|
||||
assert len(result["genres"]) > 10
|
||||
assert "pop" in result["genres"]
|
||||
assert "cinematic" in result["genres"]
|
||||
|
||||
|
||||
class TestGenres:
|
||||
def test_common_genres_present(self):
|
||||
for genre in ["pop", "rock", "hip-hop", "jazz", "electronic", "classical"]:
|
||||
assert genre in GENRES
|
||||
|
||||
|
||||
class TestGenerateSongInterface:
|
||||
def test_raises_without_ace_step(self):
|
||||
with patch("tools.music_tools._model", None):
|
||||
with patch("tools.music_tools._get_model", side_effect=ImportError("no ace-step")):
|
||||
with pytest.raises(ImportError):
|
||||
generate_song("la la la")
|
||||
|
||||
def test_duration_clamped(self):
|
||||
"""Duration is clamped to 30–240 range."""
|
||||
mock_audio = MagicMock()
|
||||
mock_audio.save = MagicMock()
|
||||
|
||||
mock_model = MagicMock()
|
||||
mock_model.generate.return_value = mock_audio
|
||||
|
||||
with patch("tools.music_tools._get_model", return_value=mock_model):
|
||||
with patch("tools.music_tools._output_dir", return_value=MagicMock()):
|
||||
with patch("tools.music_tools._save_metadata"):
|
||||
# Should clamp 5 to 30
|
||||
generate_song("lyrics", duration=5)
|
||||
call_kwargs = mock_model.generate.call_args[1]
|
||||
assert call_kwargs["duration"] == 30
|
||||
|
||||
def test_generate_song_with_mocked_model(self, tmp_path):
|
||||
mock_audio = MagicMock()
|
||||
mock_audio.save = MagicMock()
|
||||
|
||||
mock_model = MagicMock()
|
||||
mock_model.generate.return_value = mock_audio
|
||||
|
||||
with patch("tools.music_tools._get_model", return_value=mock_model):
|
||||
with patch("tools.music_tools._output_dir", return_value=tmp_path):
|
||||
result = generate_song(
|
||||
"hello world", genre="rock", duration=60, title="Test Song"
|
||||
)
|
||||
|
||||
assert result["success"]
|
||||
assert result["genre"] == "rock"
|
||||
assert result["title"] == "Test Song"
|
||||
assert result["duration"] == 60
|
||||
|
||||
|
||||
class TestGenerateInstrumentalInterface:
|
||||
def test_with_mocked_model(self, tmp_path):
|
||||
mock_audio = MagicMock()
|
||||
mock_audio.save = MagicMock()
|
||||
|
||||
mock_model = MagicMock()
|
||||
mock_model.generate.return_value = mock_audio
|
||||
|
||||
with patch("tools.music_tools._get_model", return_value=mock_model):
|
||||
with patch("tools.music_tools._output_dir", return_value=tmp_path):
|
||||
result = generate_instrumental("epic orchestral", genre="cinematic")
|
||||
|
||||
assert result["success"]
|
||||
assert result["genre"] == "cinematic"
|
||||
assert result["instrumental"] is True
|
||||
|
||||
|
||||
class TestGenerateVocalsInterface:
|
||||
def test_with_mocked_model(self, tmp_path):
|
||||
mock_audio = MagicMock()
|
||||
mock_audio.save = MagicMock()
|
||||
|
||||
mock_model = MagicMock()
|
||||
mock_model.generate.return_value = mock_audio
|
||||
|
||||
with patch("tools.music_tools._get_model", return_value=mock_model):
|
||||
with patch("tools.music_tools._output_dir", return_value=tmp_path):
|
||||
result = generate_vocals("do re mi", style="jazz")
|
||||
|
||||
assert result["success"]
|
||||
assert result["vocals_only"] is True
|
||||
assert result["style"] == "jazz"
|
||||
444
tests/creative/test_music_video_integration.py
Normal file
444
tests/creative/test_music_video_integration.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""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
|
||||
93
tests/creative/test_video_tools.py
Normal file
93
tests/creative/test_video_tools.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Tests for tools.video_tools — Video generation (Reel persona).
|
||||
|
||||
Heavy AI model tests are skipped; only catalogue, interface, and
|
||||
resolution preset tests run in CI.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from tools.video_tools import (
|
||||
VIDEO_TOOL_CATALOG,
|
||||
RESOLUTION_PRESETS,
|
||||
VIDEO_STYLES,
|
||||
list_video_styles,
|
||||
generate_video_clip,
|
||||
image_to_video,
|
||||
)
|
||||
|
||||
|
||||
class TestVideoToolCatalog:
|
||||
def test_catalog_has_all_tools(self):
|
||||
expected = {"generate_video_clip", "image_to_video", "list_video_styles"}
|
||||
assert expected == set(VIDEO_TOOL_CATALOG.keys())
|
||||
|
||||
def test_catalog_entries_have_required_keys(self):
|
||||
for tool_id, info in VIDEO_TOOL_CATALOG.items():
|
||||
assert "name" in info
|
||||
assert "description" in info
|
||||
assert "fn" in info
|
||||
assert callable(info["fn"])
|
||||
|
||||
|
||||
class TestResolutionPresets:
|
||||
def test_480p_preset(self):
|
||||
assert RESOLUTION_PRESETS["480p"] == (854, 480)
|
||||
|
||||
def test_720p_preset(self):
|
||||
assert RESOLUTION_PRESETS["720p"] == (1280, 720)
|
||||
|
||||
|
||||
class TestVideoStyles:
|
||||
def test_common_styles_present(self):
|
||||
for style in ["cinematic", "anime", "documentary"]:
|
||||
assert style in VIDEO_STYLES
|
||||
|
||||
|
||||
class TestListVideoStyles:
|
||||
def test_returns_styles_and_resolutions(self):
|
||||
result = list_video_styles()
|
||||
assert result["success"]
|
||||
assert "cinematic" in result["styles"]
|
||||
assert "480p" in result["resolutions"]
|
||||
assert "720p" in result["resolutions"]
|
||||
|
||||
|
||||
class TestGenerateVideoClipInterface:
|
||||
def test_raises_without_creative_deps(self):
|
||||
with patch("tools.video_tools._t2v_pipeline", None):
|
||||
with patch("tools.video_tools._get_t2v_pipeline", side_effect=ImportError("no diffusers")):
|
||||
with pytest.raises(ImportError):
|
||||
generate_video_clip("a sunset")
|
||||
|
||||
def test_duration_clamped(self):
|
||||
"""Duration is clamped to 2–10 range."""
|
||||
import sys
|
||||
|
||||
mock_pipe = MagicMock()
|
||||
mock_pipe.device = "cpu"
|
||||
mock_result = MagicMock()
|
||||
mock_result.frames = [[MagicMock() for _ in range(48)]]
|
||||
mock_pipe.return_value = mock_result
|
||||
|
||||
mock_torch = MagicMock()
|
||||
mock_torch.Generator.return_value = MagicMock()
|
||||
|
||||
out_dir = MagicMock()
|
||||
out_dir.__truediv__ = MagicMock(return_value=MagicMock(__str__=lambda s: "/fake/clip.mp4"))
|
||||
|
||||
with patch.dict(sys.modules, {"torch": mock_torch}):
|
||||
with patch("tools.video_tools._get_t2v_pipeline", return_value=mock_pipe):
|
||||
with patch("tools.video_tools._export_frames_to_mp4"):
|
||||
with patch("tools.video_tools._output_dir", return_value=out_dir):
|
||||
with patch("tools.video_tools._save_metadata"):
|
||||
result = generate_video_clip("test", duration=50)
|
||||
assert result["duration"] == 10 # clamped
|
||||
|
||||
|
||||
class TestImageToVideoInterface:
|
||||
def test_raises_without_creative_deps(self):
|
||||
with patch("tools.video_tools._t2v_pipeline", None):
|
||||
with patch("tools.video_tools._get_t2v_pipeline", side_effect=ImportError("no diffusers")):
|
||||
with pytest.raises(ImportError):
|
||||
image_to_video("/fake/image.png", "animate")
|
||||
Reference in New Issue
Block a user