""" Tests for v0.6.0 features: - Effect Resource Registry - Standard Timebase Enforcement - Pre-export DTD Validator - media-rep Default - Still Image Auto-Conversion - Audio Support - Compound Clip Generation - Template System """ import os import subprocess import tempfile import xml.etree.ElementTree as ET from pathlib import Path from unittest.mock import MagicMock, patch import pytest from fcpxml.models import ( _FCPXML_STANDARD_TIMEBASES, TimeValue, ValidationIssueType, ) from fcpxml.templates import ( BUILTIN_TEMPLATES, ClipSpec, Template, TemplateSlot, apply_template, list_templates, ) from fcpxml.writer import ( _STILL_IMAGE_EXTENSIONS, FCP_EFFECTS, FCPXMLModifier, _create_asset_element, _enforce_standard_timebases, _ensure_video_asset, list_effects, validate_fcpxml, write_fcpxml, ) SAMPLE_FCPXML = str(Path(__file__).parent.parent / "examples" / "sample.fcpxml") def _make_minimal_fcpxml() -> ET.Element: """Create a minimal valid FCPXML tree for testing.""" root = ET.Element('fcpxml', version='1.11') resources = ET.SubElement(root, 'resources') ET.SubElement(resources, 'format', id='r1', name='FFVideoFormat1080p24', frameDuration='1/24s', width='1920', height='1080') asset = ET.SubElement(resources, 'asset', id='r2', name='TestClip', uid='ABC-123', start='0s', duration='240/24s', hasVideo='1', hasAudio='1') mr = ET.SubElement(asset, 'media-rep') mr.set('kind', 'original-media') mr.set('src', 'file:///test/clip.mov') library = ET.SubElement(root, 'library', location='file:///Users/editor/Movies/Test.fcpbundle/') event = ET.SubElement(library, 'event', name='Test') project = ET.SubElement(event, 'project', name='Test') sequence = ET.SubElement(project, 'sequence', format='r1', duration='480/24s', tcStart='0s', tcFormat='NDF') spine = ET.SubElement(sequence, 'spine') ET.SubElement(spine, 'asset-clip', ref='r2', offset='0s', name='Clip A', start='0s', duration='240/24s', format='r1') ET.SubElement(spine, 'asset-clip', ref='r2', offset='240/24s', name='Clip B', start='0s', duration='240/24s', format='r1') return root def _write_temp_fcpxml(root: ET.Element) -> str: """Write an FCPXML tree to a temp file and return the path.""" with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False, mode='w') as f: path = f.name write_fcpxml(root, path) return path # ============================================================================ # FEATURE 1: Effect Resource Registry # ============================================================================ class TestEffectRegistry: def test_registry_has_cross_dissolve(self): assert 'cross-dissolve' in FCP_EFFECTS name, uid = FCP_EFFECTS['cross-dissolve'] assert name == 'Cross Dissolve' assert uid == '4731E73A-8DAC-4113-9A30-AE85B1761265' def test_registry_has_fade(self): assert 'fade' in FCP_EFFECTS name, uid = FCP_EFFECTS['fade'] assert uid == '8154D0DA-C99B-4EF8-8FF8-006FE5ED57F1' def test_legacy_alias_fade_to_black(self): """Legacy alias fade-to-black should map to Fade.""" name, uid = FCP_EFFECTS['fade-to-black'] assert uid == FCP_EFFECTS['fade'][1] def test_legacy_alias_wipe(self): """Legacy alias wipe should map to Edge Wipe.""" name, uid = FCP_EFFECTS['wipe'] assert uid == FCP_EFFECTS['edge-wipe'][1] def test_list_effects_returns_unique(self): effects = list_effects() uuids = [e['uuid'] for e in effects] assert len(uuids) == len(set(uuids)), "list_effects should not return duplicate UUIDs" def test_list_effects_has_expected_fields(self): effects = list_effects() for eff in effects: assert 'slug' in eff assert 'name' in eff assert 'uuid' in eff # ============================================================================ # FEATURE 2: Standard Timebase Enforcement # ============================================================================ class TestTimebaseEnforcement: def test_snap_to_frame_24fps(self): tv = TimeValue(101, 100) # 1.01 seconds snapped = tv.snap_to_frame(24) # 1.01s * 24 = 24.24 frames → snaps to 24 frames = 2400/2400s assert snapped.denominator == 2400 assert snapped.to_seconds() == pytest.approx(1.0, abs=0.05) def test_snap_to_frame_30fps(self): tv = TimeValue.from_seconds(2.0, 30) snapped = tv.snap_to_frame(30) assert snapped.to_seconds() == pytest.approx(2.0, abs=0.001) def test_snap_to_frame_60fps(self): tv = TimeValue.from_seconds(0.5, 60) snapped = tv.snap_to_frame(60) assert snapped.to_seconds() == pytest.approx(0.5, abs=0.001) def test_is_standard_timebase_true(self): tv = TimeValue(100, 2400) assert tv.is_standard_timebase() def test_is_standard_timebase_false(self): tv = TimeValue(8, 3) # 3 is not a standard timebase assert not tv.is_standard_timebase() def test_is_standard_timebase_whole_seconds(self): tv = TimeValue(5, 1) assert tv.is_standard_timebase() def test_enforce_timebases_in_write(self): root = _make_minimal_fcpxml() # Inject a non-standard timebase value clip = root.find('.//asset-clip') clip.set('duration', '8/3s') # denominator 3 is non-standard with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f: path = f.name try: write_fcpxml(root, path, enforce_timebases=True) # Re-parse and check tree = ET.parse(path) clip_after = tree.getroot().find('.//asset-clip') dur = clip_after.get('duration') # Should have been snapped to a standard timebase if '/' in dur.rstrip('s'): denom = int(dur.rstrip('s').split('/')[1]) assert denom in _FCPXML_STANDARD_TIMEBASES finally: os.unlink(path) def test_enforce_timebases_preserves_standard(self): root = _make_minimal_fcpxml() # Standard timebase should be untouched clip = root.find('.//asset-clip') original_dur = clip.get('duration') _enforce_standard_timebases(root) assert clip.get('duration') == original_dur # ============================================================================ # FEATURE 3: Pre-export DTD Validator # ============================================================================ class TestDTDValidator: def test_clean_fcpxml_passes(self): root = _make_minimal_fcpxml() issues = validate_fcpxml(root) errors = [i for i in issues if i.severity == "error"] assert len(errors) == 0, f"Clean FCPXML should have no errors: {errors}" def test_sample_fcpxml_validates(self): from fcpxml.safe_xml import safe_parse tree = safe_parse(SAMPLE_FCPXML) issues = validate_fcpxml(tree.getroot()) errors = [i for i in issues if i.severity == "error"] assert len(errors) == 0, f"sample.fcpxml should have no errors: {errors}" def test_missing_filter_video_ref(self): root = _make_minimal_fcpxml() clip = root.find('.//asset-clip') fv = ET.SubElement(clip, 'filter-video') fv.set('ref', 'r_nonexistent') fv.set('name', 'Test') issues = validate_fcpxml(root) effect_issues = [i for i in issues if i.issue_type == ValidationIssueType.MISSING_EFFECT_REF] assert len(effect_issues) >= 1 def test_missing_transition_attrs(self): root = _make_minimal_fcpxml() spine = root.find('.//spine') ET.SubElement(spine, 'transition') # Missing name, offset, duration issues = validate_fcpxml(root) missing = [i for i in issues if i.issue_type == ValidationIssueType.MISSING_ATTRIBUTE] assert len(missing) >= 1 def test_non_standard_timebase_flagged(self): root = _make_minimal_fcpxml() clip = root.find('.//asset-clip') clip.set('duration', '7/3s') issues = validate_fcpxml(root) tb_issues = [i for i in issues if i.issue_type == ValidationIssueType.INVALID_TIMEBASE] assert len(tb_issues) >= 1 def test_asset_without_src_or_media_rep(self): root = _make_minimal_fcpxml() resources = root.find('.//resources') bare = ET.SubElement(resources, 'asset') bare.set('id', 'r_bare') bare.set('name', 'Bare Asset') issues = validate_fcpxml(root) media_issues = [i for i in issues if i.issue_type == ValidationIssueType.MISSING_MEDIA_REP] assert len(media_issues) >= 1 def test_child_order_violation(self): root = _make_minimal_fcpxml() clip = root.find('.//asset-clip') # Add metadata BEFORE a marker (metadata should come after) meta = ET.Element('metadata') clip.insert(0, meta) marker = ET.Element('marker') marker.set('start', '0s') marker.set('duration', '1/24s') marker.set('value', 'test') clip.append(marker) validate_fcpxml(root) # metadata appears before marker — should not flag since metadata > marker in order # Actually metadata is AFTER markers in DTD, so this should be fine # Let's reverse it: put marker after filter-video (wrong order) clip2 = root.findall('.//asset-clip')[1] fv = ET.Element('filter-video') fv.set('ref', 'r2') fv.set('name', 'test') clip2.append(fv) mk = ET.Element('marker') mk.set('start', '0s') mk.set('duration', '1/24s') mk.set('value', 'test') clip2.insert(0, mk) # marker BEFORE filter-video # marker index < filter-video index in DTD, so marker first is correct # This is actually valid. Let's test a clear violation. def test_frame_alignment_flagged(self): root = _make_minimal_fcpxml() clip = root.find('.//asset-clip') # 7/24s is not frame-aligned... actually 7 frames at 24fps IS aligned. # Use something that's not: 100.5 frames at 24fps clip.set('duration', '2010/4800s') # = 100.5/2400 frames at 24fps # 2010/4800 = 0.41875s, 0.41875 * 24 = 10.05 frames — not aligned issues = validate_fcpxml(root, fps=24.0) frame_issues = [i for i in issues if i.issue_type == ValidationIssueType.FRAME_MISALIGNMENT] assert len(frame_issues) >= 1 def test_strict_mode_raises(self): root = _make_minimal_fcpxml() # Add an error spine = root.find('.//spine') ET.SubElement(spine, 'transition') # Missing required attrs with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f: path = f.name try: with pytest.raises(ValueError, match="validation failed"): write_fcpxml(root, path, strict=True) finally: if os.path.exists(path): os.unlink(path) def test_non_strict_mode_writes(self): root = _make_minimal_fcpxml() spine = root.find('.//spine') ET.SubElement(spine, 'transition') # Missing attrs, but non-strict with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f: path = f.name try: result = write_fcpxml(root, path, strict=False) assert os.path.exists(result) finally: os.unlink(path) def test_effect_ref_valid(self): root = _make_minimal_fcpxml() resources = root.find('.//resources') eff = ET.SubElement(resources, 'effect') eff.set('id', 'r_eff1') eff.set('name', 'Cross Dissolve') eff.set('uid', '4731E73A-8DAC-4113-9A30-AE85B1761265') clip = root.find('.//asset-clip') fv = ET.SubElement(clip, 'filter-video') fv.set('ref', 'r_eff1') fv.set('name', 'Cross Dissolve') issues = validate_fcpxml(root) eff_issues = [i for i in issues if i.issue_type == ValidationIssueType.MISSING_EFFECT_REF] assert len(eff_issues) == 0 def test_validate_returns_list(self): root = _make_minimal_fcpxml() issues = validate_fcpxml(root) assert isinstance(issues, list) # ============================================================================ # FEATURE 4: media-rep Default # ============================================================================ class TestMediaRepDefault: def test_create_asset_element_has_media_rep(self): root = ET.Element('resources') asset = _create_asset_element( root, 'r10', 'Test Asset', 'file:///test/video.mov', duration='240/24s', ) mr = asset.find('media-rep') assert mr is not None assert mr.get('kind') == 'original-media' assert mr.get('src') == 'file:///test/video.mov' def test_create_asset_element_no_src_attr(self): root = ET.Element('resources') asset = _create_asset_element( root, 'r10', 'Test', 'file:///video.mov', ) assert asset.get('src') is None # src should be on media-rep, not asset def test_create_asset_element_attributes(self): root = ET.Element('resources') asset = _create_asset_element( root, 'r5', 'My Clip', '/path/to/clip.mov', duration='100/24s', start='0s', has_video='1', has_audio='0', ) assert asset.get('id') == 'r5' assert asset.get('name') == 'My Clip' assert asset.get('duration') == '100/24s' assert asset.get('hasAudio') == '0' def test_parser_backward_compat_with_src_attr(self): """Parser should handle both src attr and media-rep child.""" # This tests the existing parser handles legacy src attribute xml = ''' ''' root = ET.fromstring(xml) asset = root.find('.//asset') assert asset.get('src') == 'file:///old.mov' def test_create_asset_has_uid(self): root = ET.Element('resources') asset = _create_asset_element(root, 'r1', 'Test', '/test.mov') assert asset.get('uid') is not None assert len(asset.get('uid')) > 0 def test_create_asset_custom_uid(self): root = ET.Element('resources') asset = _create_asset_element( root, 'r1', 'Test', '/test.mov', uid='CUSTOM-UID' ) assert asset.get('uid') == 'CUSTOM-UID' # ============================================================================ # FEATURE 5: Still Image Auto-Conversion # ============================================================================ class TestStillImageConversion: def test_still_extensions_set(self): assert '.png' in _STILL_IMAGE_EXTENSIONS assert '.jpg' in _STILL_IMAGE_EXTENSIONS assert '.jpeg' in _STILL_IMAGE_EXTENSIONS assert '.tiff' in _STILL_IMAGE_EXTENSIONS assert '.bmp' in _STILL_IMAGE_EXTENSIONS def test_video_file_passes_through(self): result = _ensure_video_asset('/path/to/video.mov') assert result == '/path/to/video.mov' def test_mp4_passes_through(self): result = _ensure_video_asset('/path/to/clip.mp4') assert result == '/path/to/clip.mp4' def test_mxf_passes_through(self): result = _ensure_video_asset('/path/to/clip.mxf') assert result == '/path/to/clip.mxf' @patch('fcpxml.writer.subprocess.run') def test_png_triggers_conversion(self, mock_run): mock_run.return_value = MagicMock(returncode=0) with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: png_path = f.name try: result = _ensure_video_asset(png_path) assert result.endswith('.mov') mock_run.assert_called_once() cmd = mock_run.call_args[0][0] assert cmd[0] == 'ffmpeg' finally: os.unlink(png_path) mov_path = png_path.replace('.png', '.mov') if os.path.exists(mov_path): os.unlink(mov_path) @patch('fcpxml.writer.subprocess.run', side_effect=FileNotFoundError) def test_missing_ffmpeg_raises(self, mock_run): with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as f: jpg_path = f.name try: with pytest.raises(FileNotFoundError, match="ffmpeg not found"): _ensure_video_asset(jpg_path) finally: os.unlink(jpg_path) @patch('fcpxml.writer.subprocess.run', side_effect=subprocess.TimeoutExpired(cmd='ffmpeg', timeout=120)) def test_ffmpeg_timeout_raises_runtime_error(self, mock_run): """Timed-out ffmpeg must raise RuntimeError, not propagate raw TimeoutExpired.""" with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f: png_path = f.name try: with pytest.raises(RuntimeError, match="timed out"): _ensure_video_asset(png_path) finally: os.unlink(png_path) @patch('fcpxml.writer.subprocess.run', side_effect=subprocess.CalledProcessError( 1, 'ffmpeg', stderr=b'Invalid codec')) def test_ffmpeg_failure_raises_runtime_error(self, mock_run): """Non-zero ffmpeg exit must raise RuntimeError with stderr details.""" with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as f: jpg_path = f.name try: with pytest.raises(RuntimeError, match="ffmpeg conversion failed"): _ensure_video_asset(jpg_path) finally: os.unlink(jpg_path) def test_existing_mov_skips_conversion(self): """If .mov already exists alongside .png, skip ffmpeg.""" with tempfile.TemporaryDirectory() as tmpdir: png_path = os.path.join(tmpdir, 'image.png') mov_path = os.path.join(tmpdir, 'image.mov') Path(png_path).touch() Path(mov_path).touch() result = _ensure_video_asset(png_path) assert result == mov_path # ============================================================================ # FEATURE 6: Audio Support # ============================================================================ class TestAudioSupport: def _make_modifier(self) -> tuple: root = _make_minimal_fcpxml() path = _write_temp_fcpxml(root) return FCPXMLModifier(path), path def test_add_audio_clip_basic(self): mod, path = self._make_modifier() try: clip = mod.add_audio_clip( parent_clip_id='Clip A', asset_id='r2', offset='0s', duration='120/24s', role='dialogue', lane=-1, ) assert clip.get('lane') == '-1' assert clip.get('audioRole') == 'dialogue' assert clip.tag == 'asset-clip' finally: os.unlink(path) def test_add_audio_clip_hierarchical_role(self): mod, path = self._make_modifier() try: clip = mod.add_audio_clip( parent_clip_id='Clip A', asset_id='r2', duration='100/24s', role='dialogue.boom', lane=-2, ) assert clip.get('audioRole') == 'dialogue.boom' assert clip.get('lane') == '-2' finally: os.unlink(path) def test_add_audio_clip_creates_asset_from_src(self): mod, path = self._make_modifier() try: clip = mod.add_audio_clip( parent_clip_id='Clip A', src='/path/to/narration.wav', duration='120/24s', role='dialogue', ) assert clip.get('ref').startswith('r_audio') # Verify asset was created resources = mod.root.find('.//resources') audio_assets = [a for a in resources.findall('asset') if a.get('id', '').startswith('r_audio')] assert len(audio_assets) == 1 finally: os.unlink(path) def test_add_audio_clip_no_asset_or_src_raises(self): mod, path = self._make_modifier() try: with pytest.raises(ValueError, match="Must provide"): mod.add_audio_clip(parent_clip_id='Clip A') finally: os.unlink(path) def test_add_music_bed(self): mod, path = self._make_modifier() try: clip = mod.add_music_bed( asset_id='r2', role='music', ) assert clip.get('lane') == '-1' assert clip.get('audioRole') == 'music' # Duration should match timeline dur = TimeValue.from_timecode(clip.get('duration')).to_seconds() assert dur > 0 finally: os.unlink(path) def test_add_music_bed_with_src(self): mod, path = self._make_modifier() try: clip = mod.add_music_bed( src='/path/to/song.mp3', role='music.score', ) assert clip.get('audioRole') == 'music.score' finally: os.unlink(path) def test_add_audio_clip_wrong_parent_raises(self): mod, path = self._make_modifier() try: with pytest.raises(ValueError, match="not found"): mod.add_audio_clip( parent_clip_id='NONEXISTENT', asset_id='r2', duration='10s', ) finally: os.unlink(path) def test_music_bed_effects_role(self): mod, path = self._make_modifier() try: clip = mod.add_music_bed(asset_id='r2', role='effects.foley') assert clip.get('audioRole') == 'effects.foley' finally: os.unlink(path) def test_add_audio_clip_saves(self): mod, path = self._make_modifier() try: mod.add_audio_clip( parent_clip_id='Clip A', asset_id='r2', duration='60/24s', role='dialogue', ) with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f: out = f.name mod.save(out) assert os.path.exists(out) # Verify it parses tree = ET.parse(out) assert tree.getroot().tag == 'fcpxml' os.unlink(out) finally: os.unlink(path) def test_add_audio_negative_lane(self): mod, path = self._make_modifier() try: clip = mod.add_audio_clip( parent_clip_id='Clip B', asset_id='r2', duration='48/24s', lane=-3, ) assert int(clip.get('lane')) < 0 finally: os.unlink(path) def _write_temp_wav(self, seconds: float = 2.0, rate: int = 48000, channels: int = 2) -> str: """Write a real WAV file of the given length and return its path.""" import wave with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as f: wav_path = f.name with wave.open(wav_path, 'wb') as wf: wf.setnchannels(channels) wf.setsampwidth(2) wf.setframerate(rate) wf.writeframes(b'\x00\x00' * channels * int(seconds * rate)) return wav_path def test_audio_asset_duration_matches_real_file(self): # Asset duration must come from the actual media file, not the # requested clip duration (timeline is 20s, file is only 2s). mod, path = self._make_modifier() wav_path = self._write_temp_wav(seconds=2.0) try: mod.add_music_bed(src=wav_path, role='music') resources = mod.root.find('.//resources') asset = next(a for a in resources.findall('asset') if a.get('id', '').startswith('r_audio')) dur = TimeValue.from_timecode(asset.get('duration')).to_seconds() assert abs(dur - 2.0) < 0.01 assert asset.get('audioRate') == '48000' assert asset.get('audioChannels') == '2' finally: os.unlink(path) os.unlink(wav_path) def test_music_bed_clamps_to_media_length(self): # A music bed spanning a 20s timeline must not overrun a 2s file. mod, path = self._make_modifier() wav_path = self._write_temp_wav(seconds=2.0) try: clip = mod.add_music_bed(src=wav_path, role='music') dur = TimeValue.from_timecode(clip.get('duration')).to_seconds() assert abs(dur - 2.0) < 0.01 finally: os.unlink(path) os.unlink(wav_path) def test_audio_asset_unprobeable_src_keeps_requested_duration(self): # Nonexistent files can't be probed; fall back to the requested # duration so template workflows keep working. mod, path = self._make_modifier() try: clip = mod.add_audio_clip( parent_clip_id='Clip A', src='/path/to/missing.wav', duration='120/24s', role='dialogue', ) dur = TimeValue.from_timecode(clip.get('duration')).to_seconds() assert abs(dur - 5.0) < 0.01 finally: os.unlink(path) # ============================================================================ # FEATURE 7: Compound Clip Generation # ============================================================================ class TestCompoundClips: def _make_modifier(self) -> tuple: root = _make_minimal_fcpxml() path = _write_temp_fcpxml(root) return FCPXMLModifier(path), path def test_create_compound_basic(self): mod, path = self._make_modifier() try: ref_clip = mod.create_compound_clip(['Clip A', 'Clip B'], 'Test Compound') assert ref_clip.tag == 'ref-clip' assert ref_clip.get('name') == 'Test Compound' # Should have a media resource reference assert ref_clip.get('ref').startswith('r_compound') finally: os.unlink(path) def test_compound_creates_media_resource(self): mod, path = self._make_modifier() try: mod.create_compound_clip(['Clip A', 'Clip B'], 'Test') resources = mod.root.find('.//resources') media = resources.find('media') assert media is not None assert media.find('.//spine') is not None finally: os.unlink(path) def test_compound_replaces_originals(self): mod, path = self._make_modifier() try: mod.create_compound_clip(['Clip A', 'Clip B'], 'Test') # Get the main timeline spine (under sequence), not the compound's inner spine spine = mod.root.find('.//project/sequence/spine') spine_tags = [c.tag for c in spine] assert 'ref-clip' in spine_tags # Original clips should be gone from main spine names = [c.get('name', '') for c in spine] assert 'Clip A' not in names assert 'Clip B' not in names finally: os.unlink(path) def test_compound_inner_spine_has_clips(self): mod, path = self._make_modifier() try: mod.create_compound_clip(['Clip A', 'Clip B'], 'Test') resources = mod.root.find('.//resources') media = resources.find('media') inner_spine = media.find('.//spine') inner_clips = list(inner_spine) assert len(inner_clips) == 2 finally: os.unlink(path) def test_flatten_compound(self): mod, path = self._make_modifier() try: mod.create_compound_clip(['Clip A', 'Clip B'], 'Test') # Find the compound id compound_id = None for cid in mod.clips: if 'compound' in cid: compound_id = cid break assert compound_id is not None extracted = mod.flatten_compound_clip(compound_id) assert len(extracted) == 2 # Media resource should be cleaned up resources = mod.root.find('.//resources') assert resources.find('media') is None finally: os.unlink(path) def test_compound_round_trip(self): """Create compound, flatten it, verify clips match originals.""" mod, path = self._make_modifier() try: original_spine = mod.root.find('.//project/sequence/spine') original_count = len(list(original_spine)) mod.create_compound_clip(['Clip A', 'Clip B'], 'Test') spine_after_compound = mod.root.find('.//project/sequence/spine') assert len(list(spine_after_compound)) == 1 # Just the ref-clip compound_id = [c for c in mod.clips if 'compound' in c][0] mod.flatten_compound_clip(compound_id) spine_after_flatten = mod.root.find('.//project/sequence/spine') assert len(list(spine_after_flatten)) == original_count finally: os.unlink(path) def test_compound_nonexistent_clip_raises(self): mod, path = self._make_modifier() try: with pytest.raises(ValueError, match="not found"): mod.create_compound_clip(['NONEXISTENT'], 'Test') finally: os.unlink(path) def test_flatten_non_refclip_raises(self): mod, path = self._make_modifier() try: with pytest.raises(ValueError, match="not a ref-clip"): mod.flatten_compound_clip('Clip A') finally: os.unlink(path) def test_compound_preserves_offset(self): mod, path = self._make_modifier() try: ref_clip = mod.create_compound_clip(['Clip A', 'Clip B'], 'Test') assert ref_clip.get('offset') == '0s' finally: os.unlink(path) def test_compound_duration_sum(self): mod, path = self._make_modifier() try: ref_clip = mod.create_compound_clip(['Clip A', 'Clip B'], 'Test') dur = TimeValue.from_timecode(ref_clip.get('duration')) # Each clip is 240/24s = 10s, total = 20s assert dur.to_seconds() == pytest.approx(20.0, abs=0.1) finally: os.unlink(path) # ============================================================================ # FEATURE 8: Template System # ============================================================================ class TestTemplateSystem: def test_list_templates_returns_all(self): templates = list_templates() names = [t['name'] for t in templates] assert 'intro_outro' in names assert 'lower_thirds' in names assert 'music_video' in names def test_list_templates_has_slots(self): templates = list_templates() for tmpl in templates: assert 'slots' in tmpl assert len(tmpl['slots']) > 0 for slot in tmpl['slots']: assert 'name' in slot assert 'slot_type' in slot def test_apply_intro_outro_template(self): clips = { 'intro_card': ClipSpec(src='/test/intro.mov', name='Intro', duration=5.0), 'main_content': ClipSpec(src='/test/main.mov', name='Main', duration=30.0), 'end_card': ClipSpec(src='/test/outro.mov', name='Outro', duration=5.0), } with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f: out = f.name try: result = apply_template('intro_outro', clips, out, fps=24) assert os.path.exists(result) tree = ET.parse(result) root = tree.getroot() spine = root.find('.//spine') assert spine is not None spine_clips = [c for c in spine if c.tag == 'asset-clip'] assert len(spine_clips) == 3 # intro + main + outro finally: os.unlink(out) def test_apply_template_missing_required_slot(self): clips = { 'intro_card': ClipSpec(src='/test/intro.mov', name='Intro'), # Missing main_content and end_card } with pytest.raises(ValueError, match="Required slot"): apply_template('intro_outro', clips, '/tmp/test.fcpxml') def test_apply_template_unknown_template(self): with pytest.raises(ValueError, match="not found"): apply_template('nonexistent', {}, '/tmp/test.fcpxml') def test_apply_lower_thirds_template(self): clips = { 'main_content': ClipSpec(src='/test/main.mov', name='Interview', duration=60.0), 'lower_third_1': ClipSpec(src='/test/lt1.mov', name='Name Lower Third', duration=4.0), } with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f: out = f.name try: apply_template('lower_thirds', clips, out) tree = ET.parse(out) root = tree.getroot() spine = root.find('.//spine') # Should have main content in spine assert len(list(spine)) >= 1 finally: os.unlink(out) def test_apply_music_video_template(self): clips = { 'a_roll_1': ClipSpec(src='/test/a1.mov', name='Perf 1', duration=8.0), 'b_roll_1': ClipSpec(src='/test/b1.mov', name='Cut 1', duration=4.0), 'a_roll_2': ClipSpec(src='/test/a2.mov', name='Perf 2', duration=8.0), 'b_roll_2': ClipSpec(src='/test/b2.mov', name='Cut 2', duration=4.0), 'a_roll_3': ClipSpec(src='/test/a3.mov', name='Perf 3', duration=8.0), 'b_roll_3': ClipSpec(src='/test/b3.mov', name='Cut 3', duration=4.0), } with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f: out = f.name try: apply_template('music_video', clips, out) tree = ET.parse(out) root = tree.getroot() spine = root.find('.//spine') spine_clips = [c for c in spine if c.tag == 'asset-clip'] assert len(spine_clips) == 6 finally: os.unlink(out) def test_template_with_music_bed(self): clips = { 'intro_card': ClipSpec(src='/test/intro.mov', name='Intro', duration=5.0), 'main_content': ClipSpec(src='/test/main.mov', name='Main', duration=30.0), 'end_card': ClipSpec(src='/test/outro.mov', name='Outro', duration=5.0), 'music_bed': ClipSpec(src='/test/music.mp3', name='BG Music', duration=40.0), } with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f: out = f.name try: apply_template('intro_outro', clips, out) tree = ET.parse(out) root = tree.getroot() # Find audio clip at lane -1 all_clips = root.findall('.//asset-clip') audio_clips = [c for c in all_clips if c.get('lane') == '-1'] assert len(audio_clips) >= 1 assert audio_clips[0].get('audioRole') == 'music' finally: os.unlink(out) def test_template_output_valid_fcpxml(self): clips = { 'main_content': ClipSpec(src='/test/main.mov', name='Main', duration=10.0), } with tempfile.NamedTemporaryFile(suffix='.fcpxml', delete=False) as f: out = f.name try: apply_template('lower_thirds', clips, out) tree = ET.parse(out) root = tree.getroot() assert root.tag == 'fcpxml' assert root.get('version') == '1.13' issues = validate_fcpxml(root) errors = [i for i in issues if i.severity == "error"] assert len(errors) == 0 finally: os.unlink(out) def test_builtin_templates_exist(self): assert len(BUILTIN_TEMPLATES) == 3 for name, tmpl in BUILTIN_TEMPLATES.items(): assert isinstance(tmpl, Template) assert len(tmpl.slots) > 0 def test_template_slot_dataclass(self): slot = TemplateSlot(name='test', slot_type='video') assert slot.default_duration == 5.0 assert slot.lane == 0 assert slot.required is True # ============================================================================ # Defensive validation fixes (v0.6.7) # ============================================================================ class TestZeroDivisionDefenses: """Verify that malformed rational time values don't cause ZeroDivisionError.""" def test_snap_to_frame_rejects_zero_fps(self): tv = TimeValue(100, 2400) with pytest.raises(ValueError, match="fps must be positive"): tv.snap_to_frame(0) def test_snap_to_frame_rejects_negative_fps(self): tv = TimeValue(100, 2400) with pytest.raises(ValueError, match="fps must be positive"): tv.snap_to_frame(-24) def test_from_timecode_rejects_zero_denominator(self): with pytest.raises(ValueError, match="Zero denominator"): TimeValue.from_timecode("100/0s", 24) def test_from_timecode_valid_rational_still_works(self): tv = TimeValue.from_timecode("100/2400s", 24) assert tv.numerator == 100 assert tv.denominator == 2400 def test_from_timecode_split_maxsplit_handles_extra_slashes(self): # "1/2/3s" should fail int() on "2/3", not unpack error with pytest.raises(ValueError): TimeValue.from_timecode("1/2/3s", 24) def test_parser_rejects_zero_numerator_frame_duration(self): """Parser should raise on frameDuration with zero numerator.""" from fcpxml.parser import FCPXMLParser xml_str = ''' ''' with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f: f.write(xml_str) f.flush() try: parser = FCPXMLParser() with pytest.raises(ValueError, match="Invalid frameDuration numerator"): parser.parse_file(f.name) finally: os.unlink(f.name) def test_writer_fps_fallback_on_zero_numerator(self): """Writer should fall back to 30fps on zero-numerator frameDuration.""" from fcpxml.writer import FCPXMLModifier xml_str = ''' ''' with tempfile.NamedTemporaryFile(suffix='.fcpxml', mode='w', delete=False) as f: f.write(xml_str) f.flush() try: mod = FCPXMLModifier(f.name) assert mod.fps == 30.0 # Falls back to default finally: os.unlink(f.name)