Files

385 lines
15 KiB
Python
Executable File

"""Tests for FCPXML parser."""
import tempfile
from pathlib import Path
import pytest
from fcpxml.models import MarkerType
from fcpxml.parser import FCPXMLParser, parse_fcpxml
SAMPLE = Path(__file__).parent.parent / "examples" / "sample.fcpxml"
def _fcpxml(spine_content="", resources_extra="", project_name="Test",
frame_dur="1/24s", seq_dur="240/24s"):
"""Build a minimal FCPXML string for testing."""
return (
'<?xml version="1.0" encoding="UTF-8"?>\n'
f'<fcpxml version="1.11"><resources>'
f'<format id="r1" frameDuration="{frame_dur}" width="1920" height="1080"/>'
f'{resources_extra}</resources>'
f'<library location="file:///lib.fcpbundle/"><event name="Evt">'
f'<project name="{project_name}">'
f'<sequence format="r1" duration="{seq_dur}"><spine>'
f'{spine_content}</spine></sequence></project>'
f'</event></library></fcpxml>'
)
ASSET_R2 = '<asset id="r2" name="A" src="file:///a.mov" start="0s" duration="100s"/>'
CLIP_A = '<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="120/24s" format="r1"/>'
# ============================================================
# File-based Parsing (existing)
# ============================================================
def test_parse_sample():
project = FCPXMLParser().parse_file(str(SAMPLE))
assert project.name == "Music Video Edit"
assert len(project.timelines) == 1
def test_timeline_props():
tl = FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline
assert tl.frame_rate == 24.0
assert tl.width == 1920
def test_clips_parsed():
tl = FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline
assert len(tl.clips) > 0
assert tl.clips[0].name == "Interview_A"
def test_markers_parsed():
tl = FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline
assert len([m for m in tl.markers if m.marker_type == MarkerType.CHAPTER]) == 4
def test_short_clip_detection():
assert len(FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline
.get_clips_shorter_than(0.5)) >= 1
# ============================================================
# Library Clips
# ============================================================
def test_get_library_clips_returns_assets():
parser = FCPXMLParser()
parser.parse_file(str(SAMPLE))
assert len(parser.get_library_clips()) == 3
def test_get_library_clips_includes_metadata():
parser = FCPXMLParser()
parser.parse_file(str(SAMPLE))
interview = next(c for c in parser.get_library_clips() if c['name'] == 'Interview_A')
assert interview['asset_id'] == 'r2'
assert interview['duration_seconds'] == 300.0
assert interview['src'] is not None
def test_get_library_clips_filter_by_keyword():
parser = FCPXMLParser()
parser.parse_file(str(SAMPLE))
assert len(parser.get_library_clips(keywords=['NonExistent'])) == 0
# ============================================================
# parse_string
# ============================================================
def test_parse_string_basic():
xml = _fcpxml(CLIP_A, ASSET_R2, project_name="MyProject")
project = FCPXMLParser().parse_string(xml)
assert project.name == "MyProject"
assert len(project.timelines) == 1
def test_parse_string_clip_properties():
xml = _fcpxml(CLIP_A, ASSET_R2)
clip = FCPXMLParser().parse_string(xml).primary_timeline.clips[0]
assert clip.name == "A"
assert clip.duration.seconds == pytest.approx(5.0, abs=0.01)
assert clip.media_path == "file:///a.mov"
def test_parse_string_matches_file():
from_file = FCPXMLParser().parse_file(str(SAMPLE))
from_string = FCPXMLParser().parse_string(SAMPLE.read_text())
assert from_file.name == from_string.name
assert len(from_file.timelines[0].clips) == len(from_string.timelines[0].clips)
def test_parse_string_30fps():
xml = _fcpxml(
'<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="300/30s" format="r1"/>',
'<asset id="r2" name="A" src="a.mov" start="0s" duration="60s"/>',
frame_dur="1/30s", seq_dur="300/30s",
)
assert FCPXMLParser().parse_string(xml).primary_timeline.frame_rate == 30.0
def test_parse_string_2997fps():
xml = _fcpxml(
'<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="30000/1001s" format="r1"/>',
'<asset id="r2" name="A" src="a.mov" start="0s" duration="60s"/>',
frame_dur="1001/30000s", seq_dur="30000/1001s",
)
assert FCPXMLParser().parse_string(xml).primary_timeline.frame_rate == pytest.approx(29.97, abs=0.01)
# ============================================================
# Markers
# ============================================================
def test_standard_marker():
clip_xml = ('<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
'<marker start="24/24s" duration="1/24s" value="Mark1"/></asset-clip>')
clip = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0]
assert clip.markers[0].marker_type == MarkerType.STANDARD
assert clip.markers[0].name == "Mark1"
def test_todo_marker():
clip_xml = ('<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
'<marker start="24/24s" duration="1/24s" value="Fix" completed="0"/></asset-clip>')
m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0]
assert m.marker_type == MarkerType.INCOMPLETE
def test_completed_marker():
"""Marker with completed='1' should be COMPLETED, not incomplete."""
clip_xml = ('<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
'<marker start="24/24s" duration="1/24s" value="Done" completed="1"/></asset-clip>')
m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0]
assert m.marker_type == MarkerType.COMPLETED
def test_multiple_marker_types_on_one_clip():
"""All four marker types coexist on a single clip without cross-contamination."""
clip_xml = (
'<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
'<marker start="6/24s" duration="1/24s" value="Note"/>'
'<marker start="12/24s" duration="1/24s" value="Task" completed="0"/>'
'<marker start="18/24s" duration="1/24s" value="Done" completed="1"/>'
'<chapter-marker start="24/24s" duration="1/24s" value="Ch1"/>'
'</asset-clip>'
)
clip = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0]
by_name = {m.name: m.marker_type for m in clip.markers}
assert by_name["Note"] == MarkerType.STANDARD
assert by_name["Task"] == MarkerType.INCOMPLETE
assert by_name["Done"] == MarkerType.COMPLETED
assert by_name["Ch1"] == MarkerType.CHAPTER
def test_marker_without_completed_is_standard():
"""A plain <marker> with no completed attribute must parse as STANDARD, not incomplete."""
clip_xml = ('<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
'<marker start="6/24s" duration="1/24s" value="Plain"/></asset-clip>')
m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0]
assert m.marker_type == MarkerType.STANDARD
assert m.name == "Plain"
# The strict exact-match tests below verify that only completed='0' and
# completed='1' (no whitespace, no variants) produce non-STANDARD types.
@pytest.mark.parametrize("completed_val,label", [
(" 0 ", "space-padded zero"),
(" 1 ", "space-padded one"),
("", "empty string"),
("\n0\n", "newline-padded zero"),
("\n1\n", "newline-padded one"),
("\t0\t", "tab-padded zero"),
("\r\n1\r\n", "crlf-padded one"),
("00", "double-zero"),
("true", "boolean-string true"),
])
def test_non_exact_completed_values_are_standard(completed_val, label):
"""completed attribute must be exactly '0' or '1' — anything else is STANDARD."""
clip_xml = (
'<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
f'<marker start="24/24s" duration="1/24s" value="{label}" completed="{completed_val}"/>'
'</asset-clip>'
)
m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0]
assert m.marker_type == MarkerType.STANDARD, (
f"completed='{completed_val}' ({label}) should be STANDARD, got {m.marker_type}"
)
def test_chapter_marker_ignores_completed_attribute():
"""A <chapter-marker> with completed='0' must still parse as CHAPTER, not as a to-do."""
clip_xml = (
'<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="240/24s" format="r1">'
'<chapter-marker start="24/24s" duration="1/24s" value="Ch" completed="0"/>'
'</asset-clip>'
)
m = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2)).primary_timeline.clips[0].markers[0]
assert m.marker_type == MarkerType.CHAPTER
def test_chapter_markers_on_sequence():
# Chapter markers are children of sequence (parsed via findall .//chapter-marker)
tl = FCPXMLParser().parse_file(str(SAMPLE)).primary_timeline
chapters = [m for m in tl.markers if m.marker_type == MarkerType.CHAPTER]
assert len(chapters) == 4
assert chapters[0].name == "Intro"
# ============================================================
# Keywords
# ============================================================
def test_keyword_with_range():
clip_xml = ('<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="120/24s" format="r1">'
'<keyword start="0s" duration="48/24s" value="Interview"/></asset-clip>')
kw = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2, seq_dur="120/24s")).primary_timeline.clips[0].keywords[0]
assert kw.start is not None
assert kw.duration.seconds == pytest.approx(2.0, abs=0.01)
def test_keyword_without_range():
clip_xml = ('<asset-clip ref="r2" offset="0s" name="A" start="0s" duration="120/24s" format="r1">'
'<keyword value="B-Roll"/></asset-clip>')
kw = FCPXMLParser().parse_string(_fcpxml(clip_xml, ASSET_R2, seq_dur="120/24s")).primary_timeline.clips[0].keywords[0]
assert kw.start is None
assert kw.duration is None
# ============================================================
# Transitions & Gaps
# ============================================================
def test_transition_parsed():
spine = (CLIP_A
+ '<transition name="Cross Dissolve" offset="120/24s" duration="24/24s"/>'
+ '<asset-clip ref="r2" offset="120/24s" name="B" start="10s" duration="120/24s" format="r1"/>')
tl = FCPXMLParser().parse_string(_fcpxml(spine, ASSET_R2)).primary_timeline
assert len(tl.transitions) == 1
assert tl.transitions[0].name == "Cross Dissolve"
assert len(tl.clips) == 2
def test_gap_advances_offset():
spine = (CLIP_A
+ '<gap offset="120/24s" duration="48/24s"/>'
+ '<asset-clip ref="r2" offset="168/24s" name="B" start="10s" duration="72/24s" format="r1"/>')
tl = FCPXMLParser().parse_string(_fcpxml(spine, ASSET_R2, seq_dur="240/24s")).primary_timeline
assert len(tl.clips) == 2
assert tl.clips[1].start.frames == 168 # 120 clip + 48 gap
# ============================================================
# Error Handling
# ============================================================
def test_parse_file_not_found():
with pytest.raises(FileNotFoundError):
FCPXMLParser().parse_file("/nonexistent/path.fcpxml")
def test_parse_string_invalid_xml():
with pytest.raises(Exception):
FCPXMLParser().parse_string("<not valid xml")
def test_parse_empty_sequence():
xml = ('<?xml version="1.0" encoding="UTF-8"?><fcpxml version="1.11"><resources>'
'<format id="r1" frameDuration="1/24s" width="1920" height="1080"/></resources>'
'<library location="file:///x/"><event name="E"><project name="Empty">'
'<sequence format="r1" duration="0s"/></project></event></library></fcpxml>')
project = FCPXMLParser().parse_string(xml)
assert project.name == "Empty"
assert len(project.primary_timeline.clips) == 0
def test_parse_no_project():
xml = '<?xml version="1.0" encoding="UTF-8"?><fcpxml version="1.11"><resources/></fcpxml>'
project = FCPXMLParser().parse_string(xml)
assert project.name == "Untitled"
assert len(project.timelines) == 0
def test_fcpxmld_missing_info():
with tempfile.TemporaryDirectory(suffix=".fcpxmld") as tmpdir:
with pytest.raises(FileNotFoundError, match="Info.fcpxml not found"):
FCPXMLParser().parse_file(tmpdir)
# ============================================================
# Helpers & Convenience
# ============================================================
def test_duration_to_seconds_rational():
assert FCPXMLParser()._parse_duration_to_seconds("150/30s") == pytest.approx(5.0)
def test_duration_to_seconds_plain():
assert FCPXMLParser()._parse_duration_to_seconds("10s") == pytest.approx(10.0)
def test_duration_to_seconds_bare_number():
assert FCPXMLParser()._parse_duration_to_seconds("5") == pytest.approx(5.0)
def test_duration_to_seconds_zero_denominator():
"""Zero-denominator rational returns 0.0 instead of crashing."""
assert FCPXMLParser()._parse_duration_to_seconds("10/0s") == 0.0
def test_duration_to_seconds_multiple_slashes():
"""Malformed rational with extra slashes returns 0.0 (unparseable)."""
# "10/20/30s" → int("20/30") raises ValueError inside TimeValue.from_timecode,
# which _parse_duration_to_seconds catches and returns 0.0
assert FCPXMLParser()._parse_duration_to_seconds("10/20/30s") == 0.0
def test_parse_fcpxml_convenience():
project = parse_fcpxml(str(SAMPLE))
assert project.name == "Music Video Edit"
assert len(project.timelines[0].clips) == 9
def test_version_preserved():
xml = _fcpxml(CLIP_A, ASSET_R2).replace('version="1.11"', 'version="1.10"')
assert FCPXMLParser().parse_string(xml).fcpxml_version == "1.10"
def test_default_version():
assert FCPXMLParser().parse_string('<fcpxml><resources/></fcpxml>').fcpxml_version == "1.11"
def test_asset_media_rep_fallback():
"""Asset with no direct src should fall back to media-rep child src."""
asset_xml = (
'<asset id="r2" name="MediaRep" start="0s" duration="100s">'
'<media-rep src="file:///video.mov" kind="original-media"/>'
'</asset>'
)
xml = _fcpxml(CLIP_A.replace('ref="r2"', 'ref="r2"'), asset_xml)
parser = FCPXMLParser()
parser.parse_string(xml)
assert parser.resources.get('r2', {}).get('src') == 'file:///video.mov'
def test_asset_no_src_no_media_rep():
"""Asset with neither src attr nor media-rep child should yield empty string, not crash."""
asset_xml = '<asset id="r2" name="NoSrc" start="0s" duration="100s"/>'
xml = _fcpxml(CLIP_A, asset_xml)
parser = FCPXMLParser()
parser.parse_string(xml)
assert parser.resources.get('r2', {}).get('src') == ''
def test_zero_denominator_frame_duration_raises():
"""frameDuration with zero denominator must raise, not silently set fps=0."""
xml = _fcpxml(CLIP_A, ASSET_R2, frame_dur="1/0s")
with pytest.raises(ValueError, match="denominator"):
FCPXMLParser().parse_string(xml)