"""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 = """ """ 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 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 = """ """ 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 = """ """ 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 = """ """ 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 = """ """ 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 = """ """ 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 = """ """ 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 '' in content assert "" 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()