Files
gart/code/tests/test_rough_cut.py

686 lines
26 KiB
Python
Executable File

"""Tests for RoughCutGenerator — the flagship rough cut generation feature.
Covers: __init__, _detect_fps, _index_clips, _extract_clip_data, _index_resources,
generate, _parse_duration, _filter_clips, _select_clips_simple,
_select_clips_by_segments, _build_ab_sequence, _build_output,
generate_rough_cut, generate_segmented_rough_cut convenience functions.
"""
import shutil
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path
import pytest
from fcpxml.models import PacingConfig, RoughCutResult, SegmentSpec, TimeValue
from fcpxml.rough_cut import (
RoughCutGenerator,
generate_rough_cut,
generate_segmented_rough_cut,
)
SAMPLE = Path(__file__).parent.parent / "examples" / "sample.fcpxml"
@pytest.fixture
def temp_fcpxml():
"""Create a temp copy of sample.fcpxml for tests."""
with tempfile.NamedTemporaryFile(suffix=".fcpxml", delete=False) as f:
shutil.copy(SAMPLE, f.name)
yield f.name
Path(f.name).unlink(missing_ok=True)
@pytest.fixture
def temp_output():
"""Create a temp file path for output."""
with tempfile.NamedTemporaryFile(suffix=".fcpxml", delete=False) as f:
yield f.name
Path(f.name).unlink(missing_ok=True)
@pytest.fixture
def generator(temp_fcpxml):
"""Pre-loaded RoughCutGenerator from sample."""
return RoughCutGenerator(temp_fcpxml)
# ============================================================
# Initialization & Indexing
# ============================================================
def test_detect_fps(generator):
"""Should detect 24fps from sample.fcpxml's frameDuration='1/24s'."""
assert generator.fps == 24.0
def test_detect_fps_default():
"""Should default to 30fps when format has no frameDuration."""
xml = """<?xml version="1.0"?>
<fcpxml version="1.11">
<resources><format id="r1" name="test"/></resources>
<library><event name="E"><project name="P">
<sequence format="r1" duration="100s"><spine>
<asset-clip ref="r1" name="C" duration="10s"/>
</spine></sequence>
</project></event></library>
</fcpxml>"""
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
f.write(xml)
f.flush()
gen = RoughCutGenerator(f.name)
assert gen.fps == 30.0
Path(f.name).unlink(missing_ok=True)
def test_index_clips_count(generator):
"""Sample has 9 asset-clips in spine — all should be indexed."""
assert len(generator.clips) == 9
def test_index_clips_extracts_names(generator):
"""Clip names should match the FCPXML source."""
names = [c["name"] for c in generator.clips]
assert "Interview_A" in names
assert "Broll_City" in names
assert "Broll_Studio" in names
def test_index_clips_extracts_keywords(generator):
"""Keywords should be extracted from <keyword> children."""
interview_clips = [c for c in generator.clips if "Interview" in c["keywords"]]
broll_clips = [c for c in generator.clips if "B-Roll" in c["keywords"]]
assert len(interview_clips) == 3
assert len(broll_clips) == 2
def test_index_clips_skips_very_short():
"""Clips shorter than 0.1s should be skipped by _extract_clip_data."""
xml = """<?xml version="1.0"?>
<fcpxml version="1.11">
<resources><format id="r1" frameDuration="1/24s"/></resources>
<library><event name="E"><project name="P">
<sequence format="r1" duration="100s"><spine>
<asset-clip ref="r1" name="Tiny" duration="1/24s"/>
<asset-clip ref="r1" name="Normal" duration="48/24s"/>
</spine></sequence>
</project></event></library>
</fcpxml>"""
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
f.write(xml)
f.flush()
gen = RoughCutGenerator(f.name)
names = [c["name"] for c in gen.clips]
assert "Normal" in names
# 1/24s = 0.0417s < 0.1s threshold, should be skipped
assert "Tiny" not in names
Path(f.name).unlink(missing_ok=True)
def test_index_resources(generator):
"""Should index assets r2, r3, r4 from sample."""
assert "r2" in generator.resources
assert "r3" in generator.resources
assert "r4" in generator.resources
def test_index_formats(generator):
"""Should index format r1 from sample."""
assert "r1" in generator.formats
def test_extract_clip_data_favorites():
"""Should detect favorited and rejected clips."""
xml = """<?xml version="1.0"?>
<fcpxml version="1.11">
<resources><format id="r1" frameDuration="1/24s"/></resources>
<library><event name="E"><project name="P">
<sequence format="r1" duration="100s"><spine>
<asset-clip ref="r1" name="Fav" duration="48/24s" rating="1"/>
<asset-clip ref="r1" name="Rej" duration="48/24s" rating="-1"/>
<asset-clip ref="r1" name="Neutral" duration="48/24s"/>
</spine></sequence>
</project></event></library>
</fcpxml>"""
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
f.write(xml)
f.flush()
gen = RoughCutGenerator(f.name)
by_name = {c["name"]: c for c in gen.clips}
assert by_name["Fav"]["is_favorite"] is True
assert by_name["Fav"]["is_rejected"] is False
assert by_name["Rej"]["is_rejected"] is True
assert by_name["Neutral"]["is_favorite"] is False
assert by_name["Neutral"]["is_rejected"] is False
Path(f.name).unlink(missing_ok=True)
# ============================================================
# _parse_duration
# ============================================================
def test_parse_duration_shorthand_minutes(generator):
"""'3m' should parse to 180 seconds."""
tv = generator._parse_duration("3m")
assert abs(tv.to_seconds() - 180.0) < 0.1
def test_parse_duration_shorthand_minutes_seconds(generator):
"""'3m30s' should parse to 210 seconds."""
tv = generator._parse_duration("3m30s")
assert abs(tv.to_seconds() - 210.0) < 0.1
def test_parse_duration_shorthand_minutes_only_trailing_s(generator):
"""'2m' should parse to 120 seconds (no trailing 's' after empty seconds)."""
tv = generator._parse_duration("2m")
assert abs(tv.to_seconds() - 120.0) < 0.1
def test_parse_duration_timecode(generator):
"""'00:00:10:00' timecode should parse to 10 seconds at 24fps."""
tv = generator._parse_duration("00:00:10:00")
assert abs(tv.to_seconds() - 10.0) < 0.1
def test_parse_duration_rational(generator):
"""'240/24s' should parse to 10 seconds."""
tv = generator._parse_duration("240/24s")
assert abs(tv.to_seconds() - 10.0) < 0.1
def test_parse_duration_fractional_seconds(generator):
"""'1m30.5s' should parse to 90.5 seconds, not crash."""
tv = generator._parse_duration("1m30.5s")
assert abs(tv.to_seconds() - 90.5) < 0.5
# ============================================================
# _filter_clips
# ============================================================
def test_filter_clips_no_filters(generator):
"""No filters should return all non-rejected clips (sample has none rejected)."""
result = generator._filter_clips()
assert len(result) == 9
def test_filter_clips_by_keyword(generator):
"""Filtering by 'Interview' keyword should return 3 clips."""
result = generator._filter_clips(keywords=["Interview"])
assert len(result) == 3
for clip in result:
assert "Interview" in clip["keywords"]
def test_filter_clips_keyword_case_insensitive(generator):
"""Keyword filtering should be case-insensitive."""
result = generator._filter_clips(keywords=["interview"])
assert len(result) == 3
def test_filter_clips_exclude_rejected():
"""Should exclude rejected clips when exclude_rejected=True."""
xml = """<?xml version="1.0"?>
<fcpxml version="1.11">
<resources><format id="r1" frameDuration="1/24s"/></resources>
<library><event name="E"><project name="P">
<sequence format="r1" duration="100s"><spine>
<asset-clip ref="r1" name="Good" duration="48/24s"/>
<asset-clip ref="r1" name="Bad" duration="48/24s" rating="-1"/>
</spine></sequence>
</project></event></library>
</fcpxml>"""
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
f.write(xml)
f.flush()
gen = RoughCutGenerator(f.name)
result = gen._filter_clips(exclude_rejected=True)
names = [c["name"] for c in result]
assert "Good" in names
assert "Bad" not in names
Path(f.name).unlink(missing_ok=True)
def test_filter_clips_include_rejected():
"""Should include rejected clips when exclude_rejected=False."""
xml = """<?xml version="1.0"?>
<fcpxml version="1.11">
<resources><format id="r1" frameDuration="1/24s"/></resources>
<library><event name="E"><project name="P">
<sequence format="r1" duration="100s"><spine>
<asset-clip ref="r1" name="Good" duration="48/24s"/>
<asset-clip ref="r1" name="Bad" duration="48/24s" rating="-1"/>
</spine></sequence>
</project></event></library>
</fcpxml>"""
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
f.write(xml)
f.flush()
gen = RoughCutGenerator(f.name)
result = gen._filter_clips(exclude_rejected=False)
names = [c["name"] for c in result]
assert "Good" in names
assert "Bad" in names
Path(f.name).unlink(missing_ok=True)
def test_filter_clips_favorites_only():
"""Should return only favorited clips when favorites_only=True."""
xml = """<?xml version="1.0"?>
<fcpxml version="1.11">
<resources><format id="r1" frameDuration="1/24s"/></resources>
<library><event name="E"><project name="P">
<sequence format="r1" duration="100s"><spine>
<asset-clip ref="r1" name="Fav" duration="48/24s" rating="1"/>
<asset-clip ref="r1" name="Neutral" duration="48/24s"/>
</spine></sequence>
</project></event></library>
</fcpxml>"""
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
f.write(xml)
f.flush()
gen = RoughCutGenerator(f.name)
result = gen._filter_clips(favorites_only=True)
assert len(result) == 1
assert result[0]["name"] == "Fav"
Path(f.name).unlink(missing_ok=True)
# ============================================================
# _select_clips_simple — priority modes
# ============================================================
def test_select_clips_simple_respects_target(generator):
"""Should stop selecting when target duration is reached."""
clips = generator._filter_clips()
target = TimeValue.from_seconds(5.0, generator.fps)
pacing = PacingConfig(pacing="medium")
selected = generator._select_clips_simple(clips, target, pacing, "best")
total = sum(c["use_duration"].to_seconds() for c in selected)
assert total <= 6.0 # Should be close to 5s target
def test_select_clips_simple_priority_longest(generator):
"""Priority 'longest' should select longest clips first."""
clips = generator._filter_clips()
target = TimeValue.from_seconds(60.0, generator.fps)
pacing = PacingConfig(pacing="slow")
selected = generator._select_clips_simple(clips, target, pacing, "longest")
# First selected clip should be the longest available
assert len(selected) > 0
durations = [c["use_duration"].to_seconds() for c in selected]
# First clip duration should be >= second (sorted descending by source duration)
if len(durations) > 1:
assert durations[0] >= durations[1] - 0.1 # tolerance for pacing clamp
def test_select_clips_simple_priority_shortest(generator):
"""Priority 'shortest' should select shortest clips first."""
clips = generator._filter_clips()
target = TimeValue.from_seconds(60.0, generator.fps)
pacing = PacingConfig(pacing="fast")
selected = generator._select_clips_simple(clips, target, pacing, "shortest")
assert len(selected) > 0
def test_select_clips_simple_adds_in_out_points(generator):
"""Selected clips should have in_point and out_point set."""
clips = generator._filter_clips()
target = TimeValue.from_seconds(10.0, generator.fps)
pacing = PacingConfig(pacing="medium")
selected = generator._select_clips_simple(clips, target, pacing, "best")
for clip in selected:
assert "in_point" in clip
assert "out_point" in clip
assert "use_duration" in clip
assert isinstance(clip["in_point"], TimeValue)
assert isinstance(clip["out_point"], TimeValue)
# ============================================================
# _select_clips_by_segments
# ============================================================
def test_select_clips_by_segments_uses_keywords(generator):
"""Segments with keywords should only select matching clips."""
clips = generator._filter_clips()
segments = [
SegmentSpec(name="Interviews", keywords=["Interview"], duration_seconds=10),
SegmentSpec(name="B-Roll", keywords=["B-Roll"], duration_seconds=5),
]
target = TimeValue.from_seconds(15.0, generator.fps)
pacing = PacingConfig(pacing="medium")
selected = generator._select_clips_by_segments(clips, segments, target, pacing)
# Check segment labels were applied
interview_clips = [c for c in selected if c.get("segment") == "Interviews"]
broll_clips = [c for c in selected if c.get("segment") == "B-Roll"]
assert len(interview_clips) > 0
assert len(broll_clips) > 0
def test_select_clips_by_segments_distributes_unspecified_duration(generator):
"""Segments without explicit duration should share remaining time equally."""
clips = generator._filter_clips()
# Total target = 20s, first segment = 10s, second should get ~10s
segments = [
SegmentSpec(name="Fixed", keywords=["Interview"], duration_seconds=10),
SegmentSpec(name="Auto", keywords=["B-Roll"], duration_seconds=0),
]
target = TimeValue.from_seconds(20.0, generator.fps)
pacing = PacingConfig(pacing="medium")
selected = generator._select_clips_by_segments(clips, segments, target, pacing)
auto_clips = [c for c in selected if c.get("segment") == "Auto"]
assert len(auto_clips) > 0
def test_select_clips_by_segments_overcommitted_clamps_to_zero(generator):
"""When specified segments exceed target, unspecified segments get 0 not negative."""
clips = generator._filter_clips()
# Target=10s but specified segments total 30s — unspecified should get 0, not -20
segments = [
SegmentSpec(name="Big1", keywords=None, duration_seconds=15),
SegmentSpec(name="Big2", keywords=None, duration_seconds=15),
SegmentSpec(name="Leftover", keywords=None, duration_seconds=0),
]
target = TimeValue.from_seconds(10.0, generator.fps)
pacing = PacingConfig(pacing="medium")
# Should not crash and leftover segment should produce no negative durations
selected = generator._select_clips_by_segments(clips, segments, target, pacing)
leftover_clips = [c for c in selected if c.get("segment") == "Leftover"]
for c in leftover_clips:
assert c.get('duration_seconds', 0) >= 0
def test_select_clips_by_segments_no_reuse_across_segments(generator):
"""Clips used in one segment must not appear in subsequent segments."""
clips = generator._filter_clips()
# Two segments, both want all clips — second should get different ones
segments = [
SegmentSpec(name="Seg1", keywords=None, duration_seconds=5),
SegmentSpec(name="Seg2", keywords=None, duration_seconds=5),
]
target = TimeValue.from_seconds(10.0, generator.fps)
pacing = PacingConfig(pacing="medium")
selected = generator._select_clips_by_segments(clips, segments, target, pacing)
seg1_names = [c['name'] for c in selected if c.get('segment') == 'Seg1']
seg2_names = [c['name'] for c in selected if c.get('segment') == 'Seg2']
# No clip name should appear in both segments
overlap = set(seg1_names) & set(seg2_names)
assert len(overlap) == 0, f"Clips reused across segments: {overlap}"
# ============================================================
# generate() — full pipeline
# ============================================================
def test_generate_creates_file(generator, temp_output):
"""generate() should create a valid FCPXML output file."""
result = generator.generate(output_path=temp_output, target_duration="10s")
assert Path(temp_output).exists()
assert isinstance(result, RoughCutResult)
assert result.clips_used > 0
def test_generate_returns_correct_result(generator, temp_output):
"""RoughCutResult should have all expected fields populated."""
result = generator.generate(
output_path=temp_output, target_duration="15s", pacing="fast"
)
assert result.output_path == temp_output
assert result.clips_available > 0
assert result.clips_used > 0
assert result.target_duration > 0
assert result.actual_duration > 0
assert result.segments == 1 # No segments specified
assert result.average_clip_duration > 0
def test_generate_with_keyword_filter(generator, temp_output):
"""generate() with keywords should only use matching clips."""
result = generator.generate(
output_path=temp_output,
target_duration="10s",
keywords=["Interview"],
)
assert result.clips_available == 3 # Only Interview-tagged clips
def test_generate_no_matching_clips_raises(generator, temp_output):
"""generate() should raise ValueError when no clips match filters."""
with pytest.raises(ValueError, match="No clips match"):
generator.generate(
output_path=temp_output,
target_duration="10s",
keywords=["nonexistent_keyword"],
)
def test_generate_with_segments(generator, temp_output):
"""generate() with segments should report correct segment count."""
segments = [
SegmentSpec(name="Intro", keywords=["Interview"], duration_seconds=5),
SegmentSpec(name="Outro", keywords=["B-Roll"], duration_seconds=5),
]
result = generator.generate(
output_path=temp_output,
target_duration="10s",
segments=segments,
)
assert result.segments == 2
assert result.clips_used > 0
def test_generate_output_is_valid_xml(generator, temp_output):
"""Output FCPXML should be parseable XML with correct structure."""
generator.generate(output_path=temp_output, target_duration="10s")
tree = ET.parse(temp_output)
root = tree.getroot()
assert root.tag == "fcpxml"
assert root.find(".//spine") is not None
assert len(root.findall(".//asset-clip")) > 0
def test_generate_favorites_only(temp_output):
"""favorites_only=True with no favorites should raise ValueError."""
xml = """<?xml version="1.0"?>
<fcpxml version="1.11">
<resources><format id="r1" frameDuration="1/24s"/></resources>
<library><event name="E"><project name="P">
<sequence format="r1" duration="100s"><spine>
<asset-clip ref="r1" name="C1" duration="48/24s"/>
<asset-clip ref="r1" name="C2" duration="48/24s"/>
</spine></sequence>
</project></event></library>
</fcpxml>"""
with tempfile.NamedTemporaryFile(suffix=".fcpxml", mode="w", delete=False) as f:
f.write(xml)
f.flush()
gen = RoughCutGenerator(f.name)
with pytest.raises(ValueError, match="No clips match"):
gen.generate(
output_path=temp_output,
target_duration="5s",
favorites_only=True,
)
Path(f.name).unlink(missing_ok=True)
# ============================================================
# _build_output
# ============================================================
def test_build_output_xml_structure(generator, temp_output):
"""Output should have fcpxml > resources, library > event > project > sequence > spine."""
generator.generate(output_path=temp_output, target_duration="10s")
tree = ET.parse(temp_output)
root = tree.getroot()
assert root.find("resources") is not None
assert root.find("library") is not None
assert root.find(".//event") is not None
assert root.find(".//project") is not None
assert root.find(".//sequence") is not None
def test_build_output_copies_resources(generator, temp_output):
"""Output should contain format and asset resources for used clips."""
generator.generate(
output_path=temp_output,
target_duration="10s",
keywords=["Interview"],
)
tree = ET.parse(temp_output)
root = tree.getroot()
resources = root.find("resources")
# Should have at least a format element
assert resources.find("format") is not None
def test_build_output_doctype(generator, temp_output):
"""Output should start with XML declaration and DOCTYPE."""
generator.generate(output_path=temp_output, target_duration="10s")
content = Path(temp_output).read_text()
assert '<?xml version="1.0" encoding="UTF-8"?>' in content
assert "<!DOCTYPE fcpxml>" in content
# ============================================================
# _build_ab_sequence
# ============================================================
def test_build_ab_sequence_alternates(generator):
"""A/B sequence should alternate between roll types."""
a_clips = generator._filter_clips(keywords=["Interview"])
b_clips = generator._filter_clips(keywords=["B-Roll"])
target = TimeValue.from_seconds(20.0, generator.fps)
a_dur = TimeValue.from_seconds(5.0, generator.fps)
b_dur = TimeValue.from_seconds(3.0, generator.fps)
selected = generator._build_ab_sequence(
a_clips, b_clips, target, a_dur, b_dur, "a"
)
# First clip should be A-roll, second should be B-roll
assert selected[0]["roll_type"] == "A"
assert selected[1]["roll_type"] == "B"
def test_build_ab_sequence_starts_with_b(generator):
"""start_with='b' should begin with B-roll."""
a_clips = generator._filter_clips(keywords=["Interview"])
b_clips = generator._filter_clips(keywords=["B-Roll"])
target = TimeValue.from_seconds(10.0, generator.fps)
a_dur = TimeValue.from_seconds(3.0, generator.fps)
b_dur = TimeValue.from_seconds(3.0, generator.fps)
selected = generator._build_ab_sequence(
a_clips, b_clips, target, a_dur, b_dur, "b"
)
assert selected[0]["roll_type"] == "B"
def test_build_ab_sequence_loops_clips(generator):
"""Should loop clips when pool is exhausted before target duration."""
a_clips = generator._filter_clips(keywords=["Interview"]) # 3 clips
b_clips = generator._filter_clips(keywords=["B-Roll"]) # 2 clips
# Set a long target to force looping
target = TimeValue.from_seconds(120.0, generator.fps)
a_dur = TimeValue.from_seconds(3.0, generator.fps)
b_dur = TimeValue.from_seconds(3.0, generator.fps)
selected = generator._build_ab_sequence(
a_clips, b_clips, target, a_dur, b_dur, "a"
)
# Should have more clips than available in either pool (looped)
a_count = sum(1 for c in selected if c["roll_type"] == "A")
b_count = sum(1 for c in selected if c["roll_type"] == "B")
assert a_count > len(a_clips) or b_count > len(b_clips)
# ============================================================
# generate_ab_roll — error cases
# ============================================================
def test_generate_ab_roll_no_a_clips_raises(generator, temp_output):
"""Should raise ValueError when no A-roll clips match keywords."""
with pytest.raises(ValueError, match="No A-roll clips found"):
generator.generate_ab_roll(
output_path=temp_output,
target_duration="10s",
a_keywords=["nonexistent"],
b_keywords=["B-Roll"],
)
def test_generate_ab_roll_no_b_clips_raises(generator, temp_output):
"""Should raise ValueError when no B-roll clips match keywords."""
with pytest.raises(ValueError, match="No B-roll clips found"):
generator.generate_ab_roll(
output_path=temp_output,
target_duration="10s",
a_keywords=["Interview"],
b_keywords=["nonexistent"],
)
# ============================================================
# Convenience functions
# ============================================================
def test_generate_rough_cut_convenience(temp_fcpxml, temp_output):
"""generate_rough_cut() convenience function should produce output."""
result = generate_rough_cut(
source_fcpxml=temp_fcpxml,
output_path=temp_output,
target_duration="10s",
pacing="fast",
)
assert isinstance(result, RoughCutResult)
assert Path(temp_output).exists()
assert result.clips_used > 0
def test_generate_rough_cut_with_keywords(temp_fcpxml, temp_output):
"""Convenience function should pass keyword filter through."""
result = generate_rough_cut(
source_fcpxml=temp_fcpxml,
output_path=temp_output,
target_duration="10s",
keywords=["Interview"],
)
assert result.clips_available == 3
def test_generate_segmented_rough_cut_convenience(temp_fcpxml, temp_output):
"""generate_segmented_rough_cut() should create segmented output."""
result = generate_segmented_rough_cut(
source_fcpxml=temp_fcpxml,
output_path=temp_output,
segments=[
{"name": "Intro", "keywords": ["Interview"], "duration": 5},
{"name": "Cutaway", "keywords": ["B-Roll"], "duration": 5},
],
pacing="medium",
)
assert isinstance(result, RoughCutResult)
assert result.segments == 2
assert Path(temp_output).exists()