"""Tests for v0.5.0 features: connected clips, roles, diff, reformat, silence, export.""" import os import tempfile import xml.etree.ElementTree as ET import pytest from fcpxml.diff import ClipDiff, MarkerDiff, TimelineDiff, compare_timelines from fcpxml.export import DaVinciExporter from fcpxml.models import ( Clip, CompoundClip, ConnectedClip, SilenceCandidate, Timecode, Timeline, ) from fcpxml.parser import FCPXMLParser from fcpxml.writer import FCPXMLModifier # ============================================================================ # TEST FIXTURES # ============================================================================ SAMPLE = os.path.join(os.path.dirname(__file__), '..', 'examples', 'sample.fcpxml') CONNECTED_CLIPS_XML = """ """ ROLES_XML = """ """ SILENCE_XML = """ """ def _write_temp_fcpxml(xml_str: str) -> str: """Write XML string to a temp file and return path.""" fd, path = tempfile.mkstemp(suffix='.fcpxml') with os.fdopen(fd, 'w') as f: f.write(xml_str) return path # ============================================================================ # CONNECTED CLIPS TESTS # ============================================================================ class TestConnectedClips: def test_parse_connected_clips_from_spine(self): path = _write_temp_fcpxml(CONNECTED_CLIPS_XML) try: parser = FCPXMLParser() project = parser.parse_file(path) tl = project.primary_timeline assert tl is not None assert len(tl.connected_clips) > 0 finally: os.unlink(path) def test_connected_clip_has_lane(self): path = _write_temp_fcpxml(CONNECTED_CLIPS_XML) try: parser = FCPXMLParser() project = parser.parse_file(path) tl = project.primary_timeline broll = [c for c in tl.connected_clips if c.name == "BRoll_City"] assert len(broll) == 1 assert broll[0].lane == 1 finally: os.unlink(path) def test_connected_clip_negative_lane(self): path = _write_temp_fcpxml(CONNECTED_CLIPS_XML) try: parser = FCPXMLParser() project = parser.parse_file(path) tl = project.primary_timeline music = [c for c in tl.connected_clips if c.name == "Music_BG"] assert len(music) == 1 assert music[0].lane == -1 finally: os.unlink(path) def test_connected_clips_on_parent_clip(self): path = _write_temp_fcpxml(CONNECTED_CLIPS_XML) try: parser = FCPXMLParser() project = parser.parse_file(path) tl = project.primary_timeline interview = tl.clips[0] assert len(interview.connected_clips) == 2 # BRoll + Music finally: os.unlink(path) def test_gap_connected_clips(self): path = _write_temp_fcpxml(CONNECTED_CLIPS_XML) try: parser = FCPXMLParser() project = parser.parse_file(path) tl = project.primary_timeline title = [c for c in tl.connected_clips if c.name == "Title_Intro"] assert len(title) == 1 finally: os.unlink(path) def test_secondary_storyline_clips(self): path = _write_temp_fcpxml(CONNECTED_CLIPS_XML) try: parser = FCPXMLParser() project = parser.parse_file(path) tl = project.primary_timeline street = [c for c in tl.connected_clips if c.name == "BRoll_Street"] assert len(street) == 1 assert street[0].lane == 2 finally: os.unlink(path) def test_connected_clip_role(self): path = _write_temp_fcpxml(CONNECTED_CLIPS_XML) try: parser = FCPXMLParser() project = parser.parse_file(path) tl = project.primary_timeline music = [c for c in tl.connected_clips if c.name == "Music_BG"] assert music[0].role == "music" finally: os.unlink(path) def test_connected_clip_parent_name(self): path = _write_temp_fcpxml(CONNECTED_CLIPS_XML) try: parser = FCPXMLParser() project = parser.parse_file(path) tl = project.primary_timeline broll = [c for c in tl.connected_clips if c.name == "BRoll_City"] assert broll[0].parent_clip_name == "Interview" finally: os.unlink(path) def test_total_connected_clips_count(self): path = _write_temp_fcpxml(CONNECTED_CLIPS_XML) try: parser = FCPXMLParser() project = parser.parse_file(path) tl = project.primary_timeline # BRoll_City, Music_BG on Interview # Title_Intro on gap # BRoll_Street in storyline on Interview_2 assert len(tl.connected_clips) == 4 finally: os.unlink(path) def test_add_connected_clip(self): path = _write_temp_fcpxml(CONNECTED_CLIPS_XML) out = path.replace('.fcpxml', '_modified.fcpxml') try: modifier = FCPXMLModifier(path) modifier.add_connected_clip( parent_clip_id="Interview", asset_id="r3", offset="480/24s", duration="240/24s", lane=1, ) modifier.save(out) # Verify the connected clip was added tree = ET.parse(out) interview = tree.find('.//asset-clip[@name="Interview"]') connected = [c for c in interview if c.get('lane') == '1'] assert len(connected) == 2 # Original BRoll_City + new one finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_connected_clip_model_duration_seconds(self): cc = ConnectedClip( name="Test", start=Timecode(0, 24.0), duration=Timecode(48, 24.0), lane=1, ) assert cc.duration_seconds == 2.0 def test_chapter_markers_on_connected_clips(self): """Chapter markers on connected clips must be parsed — not silently dropped.""" from fcpxml.models import MarkerType xml = """ """ path = _write_temp_fcpxml(xml) try: project = FCPXMLParser().parse_file(path) tl = project.primary_timeline overlay = [c for c in tl.connected_clips if c.name == "Overlay"] assert len(overlay) == 1 markers = overlay[0].markers assert len(markers) == 2 by_name = {m.name: m.marker_type for m in markers} assert by_name["CH1"] == MarkerType.CHAPTER assert by_name["Note"] == MarkerType.STANDARD finally: os.unlink(path) def test_compound_clip_model(self): cc = CompoundClip( name="Nested", ref_id="r5", duration=Timecode(120, 24.0), start=Timecode(0, 24.0), ) assert cc.duration_seconds == 5.0 assert cc.ref_id == "r5" # ============================================================================ # ROLES TESTS # ============================================================================ class TestRoles: def test_parse_audio_role(self): path = _write_temp_fcpxml(ROLES_XML) try: parser = FCPXMLParser() project = parser.parse_file(path) tl = project.primary_timeline clip_a = tl.clips[0] assert clip_a.audio_role == "dialogue" finally: os.unlink(path) def test_parse_video_role(self): path = _write_temp_fcpxml(ROLES_XML) try: parser = FCPXMLParser() project = parser.parse_file(path) tl = project.primary_timeline clip_c = tl.clips[2] assert clip_c.video_role == "titles" finally: os.unlink(path) def test_parse_no_role_is_empty(self): path = _write_temp_fcpxml(ROLES_XML) try: parser = FCPXMLParser() project = parser.parse_file(path) tl = project.primary_timeline clip_a = tl.clips[0] assert clip_a.video_role == "" finally: os.unlink(path) def test_assign_role(self): path = _write_temp_fcpxml(ROLES_XML) out = path.replace('.fcpxml', '_role.fcpxml') try: modifier = FCPXMLModifier(path) modifier.assign_role("Clip_A", audio_role="effects") modifier.save(out) tree = ET.parse(out) clip = tree.find('.//asset-clip[@name="Clip_A"]') assert clip.get('audioRole') == 'effects' finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_assign_video_role(self): path = _write_temp_fcpxml(ROLES_XML) out = path.replace('.fcpxml', '_role.fcpxml') try: modifier = FCPXMLModifier(path) modifier.assign_role("Clip_B", video_role="titles") modifier.save(out) tree = ET.parse(out) clip = tree.find('.//asset-clip[@name="Clip_B"]') assert clip.get('videoRole') == 'titles' finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_assign_role_invalid_clip(self): path = _write_temp_fcpxml(ROLES_XML) try: modifier = FCPXMLModifier(path) with pytest.raises(ValueError, match="Clip not found"): modifier.assign_role("Nonexistent", audio_role="music") finally: os.unlink(path) def test_clip_role_fields_default_empty(self): clip = Clip( name="Test", start=Timecode(0, 24.0), duration=Timecode(24, 24.0), ) assert clip.audio_role == "" assert clip.video_role == "" def test_connected_clip_role_parsed(self): path = _write_temp_fcpxml(CONNECTED_CLIPS_XML) try: parser = FCPXMLParser() project = parser.parse_file(path) tl = project.primary_timeline broll = [c for c in tl.connected_clips if c.name == "BRoll_City"] assert broll[0].role == "b-roll" finally: os.unlink(path) # ============================================================================ # TIMELINE DIFF TESTS # ============================================================================ class TestTimelineDiff: def test_identical_timelines_no_changes(self): path = _write_temp_fcpxml(ROLES_XML) try: diff = compare_timelines(path, path) assert not diff.has_changes finally: os.unlink(path) def test_added_clip_detected(self): xml_a = ROLES_XML xml_b = ROLES_XML.replace('duration="720/24s"', 'duration="960/24s"') # Add a new clip to xml_b xml_b = xml_b.replace( '', '\n' ) path_a = _write_temp_fcpxml(xml_a) path_b = _write_temp_fcpxml(xml_b) try: diff = compare_timelines(path_a, path_b) added = [d for d in diff.clip_diffs if d.action == "added"] assert len(added) >= 1 assert any(d.clip_name == "Clip_D" for d in added) finally: os.unlink(path_a) os.unlink(path_b) def test_removed_clip_detected(self): xml_a = ROLES_XML # Remove Clip_C from xml_b xml_b = xml_a.replace( '', '' ) path_a = _write_temp_fcpxml(xml_a) path_b = _write_temp_fcpxml(xml_b) try: diff = compare_timelines(path_a, path_b) removed = [d for d in diff.clip_diffs if d.action == "removed"] assert len(removed) >= 1 assert any(d.clip_name == "Clip_C" for d in removed) finally: os.unlink(path_a) os.unlink(path_b) def test_trimmed_clip_detected(self): xml_a = ROLES_XML xml_b = xml_a.replace( 'name="Clip_A" start="0s" duration="240/24s"', 'name="Clip_A" start="0s" duration="120/24s"' ) path_a = _write_temp_fcpxml(xml_a) path_b = _write_temp_fcpxml(xml_b) try: diff = compare_timelines(path_a, path_b) trimmed = [d for d in diff.clip_diffs if d.action == "trimmed"] assert len(trimmed) >= 1 finally: os.unlink(path_a) os.unlink(path_b) def test_marker_added_detected(self): xml_a = ROLES_XML # Replace the self-closing Clip_A tag with one containing a marker xml_b = xml_a.replace( '', '' '' '' ) path_a = _write_temp_fcpxml(xml_a) path_b = _write_temp_fcpxml(xml_b) try: diff = compare_timelines(path_a, path_b) marker_added = [d for d in diff.marker_diffs if d.action == "added"] assert len(marker_added) >= 1 finally: os.unlink(path_a) os.unlink(path_b) def test_format_change_detected(self): xml_a = ROLES_XML xml_b = xml_a.replace('width="1920" height="1080"', 'width="1080" height="1920"') path_a = _write_temp_fcpxml(xml_a) path_b = _write_temp_fcpxml(xml_b) try: diff = compare_timelines(path_a, path_b) assert len(diff.format_changes) >= 1 assert "Resolution" in diff.format_changes[0] finally: os.unlink(path_a) os.unlink(path_b) def test_diff_result_total_changes(self): diff = TimelineDiff( timeline_a_name="A", timeline_b_name="B", clip_diffs=[ClipDiff(action="added", clip_name="X")], marker_diffs=[MarkerDiff(action="removed", marker_name="M")], ) assert diff.total_changes == 2 def test_diff_result_no_changes(self): diff = TimelineDiff(timeline_a_name="A", timeline_b_name="B") assert not diff.has_changes assert diff.total_changes == 0 # ============================================================================ # SOCIAL MEDIA REFORMAT TESTS # ============================================================================ class TestReformat: def test_reformat_9_16(self): path = _write_temp_fcpxml(ROLES_XML) out = path.replace('.fcpxml', '_reformat.fcpxml') try: modifier = FCPXMLModifier(path) modifier.reformat_resolution(1080, 1920) modifier.save(out) tree = ET.parse(out) fmt = tree.find('.//format') assert fmt.get('width') == '1080' assert fmt.get('height') == '1920' finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_reformat_1_1(self): path = _write_temp_fcpxml(ROLES_XML) out = path.replace('.fcpxml', '_reformat.fcpxml') try: modifier = FCPXMLModifier(path) modifier.reformat_resolution(1080, 1080) modifier.save(out) tree = ET.parse(out) fmt = tree.find('.//format') assert fmt.get('width') == '1080' assert fmt.get('height') == '1080' finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_reformat_preserves_clips(self): path = _write_temp_fcpxml(ROLES_XML) out = path.replace('.fcpxml', '_reformat.fcpxml') try: modifier = FCPXMLModifier(path) modifier.reformat_resolution(1080, 1350) modifier.save(out) parser = FCPXMLParser() project = parser.parse_file(out) tl = project.primary_timeline assert len(tl.clips) == 3 # Same clip count finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_reformat_preserves_frame_rate(self): path = _write_temp_fcpxml(ROLES_XML) out = path.replace('.fcpxml', '_reformat.fcpxml') try: modifier = FCPXMLModifier(path) modifier.reformat_resolution(1080, 1920) modifier.save(out) tree = ET.parse(out) fmt = tree.find('.//format') assert 'frameDuration' in fmt.attrib assert fmt.get('frameDuration') == '1/24s' finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_social_formats_dict(self): formats = FCPXMLModifier.SOCIAL_FORMATS assert formats["9:16"] == (1080, 1920) assert formats["1:1"] == (1080, 1080) assert formats["4:5"] == (1080, 1350) assert formats["16:9"] == (1920, 1080) assert formats["4:3"] == (1440, 1080) # ============================================================================ # SILENCE DETECTION TESTS # ============================================================================ class TestSilenceDetection: def test_detect_gap_as_silence(self): path = _write_temp_fcpxml(SILENCE_XML) try: modifier = FCPXMLModifier(path) candidates = modifier.detect_silence_candidates(min_gap_seconds=0.5) gaps = [c for c in candidates if c['reason'] == 'gap'] assert len(gaps) >= 1 finally: os.unlink(path) def test_detect_name_match(self): path = _write_temp_fcpxml(SILENCE_XML) try: modifier = FCPXMLModifier(path) candidates = modifier.detect_silence_candidates() name_matches = [c for c in candidates if c['reason'] == 'name_match'] assert len(name_matches) >= 1 assert any('Silence' in (c.get('clip_name') or '') for c in name_matches) finally: os.unlink(path) def test_detect_ultra_short(self): path = _write_temp_fcpxml(SILENCE_XML) try: modifier = FCPXMLModifier(path) candidates = modifier.detect_silence_candidates() ultra_short = [c for c in candidates if c['reason'] == 'ultra_short'] assert len(ultra_short) >= 1 finally: os.unlink(path) def test_confidence_filtering(self): path = _write_temp_fcpxml(SILENCE_XML) try: modifier = FCPXMLModifier(path) all_candidates = modifier.detect_silence_candidates() high_conf = [c for c in all_candidates if c['confidence'] >= 0.8] assert len(high_conf) > 0 finally: os.unlink(path) def test_remove_silence_mark_mode(self): path = _write_temp_fcpxml(SILENCE_XML) out = path.replace('.fcpxml', '_cleaned.fcpxml') try: modifier = FCPXMLModifier(path) actions = modifier.remove_silence_candidates(mode="mark", min_confidence=0.7) modifier.save(out) assert len(actions) > 0 assert all(a['action'] == 'marked' for a in actions) finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_remove_silence_delete_mode(self): path = _write_temp_fcpxml(SILENCE_XML) out = path.replace('.fcpxml', '_cleaned.fcpxml') try: modifier = FCPXMLModifier(path) actions = modifier.remove_silence_candidates(mode="delete", min_confidence=0.8) modifier.save(out) # Should have deleted something assert len(actions) > 0 assert all(a['action'] == 'deleted' for a in actions) finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_custom_patterns(self): path = _write_temp_fcpxml(SILENCE_XML) try: modifier = FCPXMLModifier(path) candidates = modifier.detect_silence_candidates(patterns=["nonexistent_pattern"]) name_matches = [c for c in candidates if c['reason'] == 'name_match'] assert len(name_matches) == 0 finally: os.unlink(path) def test_silence_candidate_model(self): sc = SilenceCandidate( start_timecode="00:00:05:00", duration_seconds=2.0, reason="gap", confidence=0.9, ) assert sc.confidence == 0.9 assert sc.clip_name is None # ============================================================================ # EXPORT TESTS # ============================================================================ class TestExport: def test_export_resolve_xml_version(self): path = _write_temp_fcpxml(ROLES_XML) out = path.replace('.fcpxml', '_resolve.fcpxml') try: exporter = DaVinciExporter(path) exporter.export_simplified_fcpxml(out) tree = ET.parse(out) root = tree.getroot() assert root.get('version') == '1.9' finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_export_resolve_preserves_clips(self): path = _write_temp_fcpxml(ROLES_XML) out = path.replace('.fcpxml', '_resolve.fcpxml') try: exporter = DaVinciExporter(path) exporter.export_simplified_fcpxml(out) parser = FCPXMLParser() project = parser.parse_file(out) tl = project.primary_timeline assert len(tl.clips) == 3 finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_export_xmeml_structure(self): path = _write_temp_fcpxml(ROLES_XML) out = path.replace('.fcpxml', '_fcp7.xml') try: exporter = DaVinciExporter(path) exporter.export_xmeml(out) tree = ET.parse(out) root = tree.getroot() assert root.tag == 'xmeml' assert root.get('version') == '5' sequence = root.find('sequence') assert sequence is not None assert sequence.find('name') is not None assert sequence.find('media') is not None finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_export_xmeml_has_video_track(self): path = _write_temp_fcpxml(ROLES_XML) out = path.replace('.fcpxml', '_fcp7.xml') try: exporter = DaVinciExporter(path) exporter.export_xmeml(out) tree = ET.parse(out) video_tracks = tree.findall('.//media/video/track') assert len(video_tracks) >= 1 clipitems = video_tracks[0].findall('clipitem') assert len(clipitems) == 3 # 3 clips from primary storyline finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_export_xmeml_clipitem_has_timing(self): path = _write_temp_fcpxml(ROLES_XML) out = path.replace('.fcpxml', '_fcp7.xml') try: exporter = DaVinciExporter(path) exporter.export_xmeml(out) tree = ET.parse(out) clipitem = tree.find('.//clipitem') assert clipitem.find('name') is not None assert clipitem.find('duration') is not None assert clipitem.find('start') is not None assert clipitem.find('end') is not None assert clipitem.find('in') is not None assert clipitem.find('out') is not None finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_export_xmeml_connected_clips_to_tracks(self): path = _write_temp_fcpxml(CONNECTED_CLIPS_XML) out = path.replace('.fcpxml', '_fcp7.xml') try: exporter = DaVinciExporter(path) exporter.export_xmeml(out) tree = ET.parse(out) video_tracks = tree.findall('.//media/video/track') # Track 0 = primary, tracks 1+ = connected video clips assert len(video_tracks) >= 2 finally: for f in (path, out): if os.path.exists(f): os.unlink(f) def test_export_xmeml_rate_element(self): path = _write_temp_fcpxml(ROLES_XML) out = path.replace('.fcpxml', '_fcp7.xml') try: exporter = DaVinciExporter(path) exporter.export_xmeml(out) tree = ET.parse(out) rate = tree.find('.//sequence/rate') assert rate is not None assert rate.find('timebase').text == '24' assert rate.find('ntsc').text == 'FALSE' finally: for f in (path, out): if os.path.exists(f): os.unlink(f) # ============================================================================ # BACKWARD COMPATIBILITY TESTS # ============================================================================ class TestBackwardCompatibility: def test_existing_sample_still_parses(self): parser = FCPXMLParser() project = parser.parse_file(SAMPLE) tl = project.primary_timeline assert tl is not None assert len(tl.clips) > 0 def test_existing_sample_no_connected_clips(self): parser = FCPXMLParser() project = parser.parse_file(SAMPLE) tl = project.primary_timeline assert len(tl.connected_clips) == 0 def test_clip_without_roles_has_empty_strings(self): parser = FCPXMLParser() project = parser.parse_file(SAMPLE) tl = project.primary_timeline for clip in tl.clips: assert isinstance(clip.audio_role, str) assert isinstance(clip.video_role, str) def test_timeline_new_fields_default_empty(self): tl = Timeline( name="Test", duration=Timecode(100, 24.0), ) assert tl.connected_clips == [] assert tl.compound_clips == []