580 lines
20 KiB
Python
Executable File
580 lines
20 KiB
Python
Executable File
"""Tests for FCPXMLWriter — FCPXML generation from Python objects."""
|
|
|
|
import tempfile
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from fcpxml.models import (
|
|
Clip,
|
|
Keyword,
|
|
Marker,
|
|
MarkerType,
|
|
Project,
|
|
Timecode,
|
|
Timeline,
|
|
)
|
|
from fcpxml.writer import FCPXMLWriter
|
|
|
|
|
|
@pytest.fixture
|
|
def fps():
|
|
return 24.0
|
|
|
|
|
|
@pytest.fixture
|
|
def writer():
|
|
return FCPXMLWriter(version="1.11")
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_clip(fps):
|
|
"""A single clip at 0 frames, 48 frames (2s) duration."""
|
|
return Clip(
|
|
name="Interview_A",
|
|
start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
media_path="file:///media/interview_a.mov",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_timeline(fps, basic_clip):
|
|
"""Timeline with one clip, 2 seconds long."""
|
|
return Timeline(
|
|
name="Main Timeline",
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
frame_rate=fps,
|
|
width=1920,
|
|
height=1080,
|
|
clips=[basic_clip],
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def basic_project(basic_timeline):
|
|
return Project(
|
|
name="Test Project",
|
|
timelines=[basic_timeline],
|
|
fcpxml_version="1.11",
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# Resource ID counter
|
|
# ============================================================
|
|
|
|
|
|
def test_next_resource_id_increments(writer):
|
|
"""Resource IDs should increment: r1, r2, r3, …"""
|
|
assert writer._next_resource_id() == "r1"
|
|
assert writer._next_resource_id() == "r2"
|
|
assert writer._next_resource_id() == "r3"
|
|
|
|
|
|
def test_generate_uid_unique(writer):
|
|
"""Each UID should be unique."""
|
|
uids = {writer._generate_uid() for _ in range(50)}
|
|
assert len(uids) == 50
|
|
|
|
|
|
def test_generate_uid_uppercase(writer):
|
|
"""UIDs should be uppercase hex."""
|
|
uid = writer._generate_uid()
|
|
assert uid == uid.upper()
|
|
|
|
|
|
# ============================================================
|
|
# Timecode → rational string
|
|
# ============================================================
|
|
|
|
|
|
def test_tc_to_rational_basic(writer, fps):
|
|
"""48 frames at 24fps → '48/24s'."""
|
|
tc = Timecode(frames=48, frame_rate=fps)
|
|
assert writer._tc_to_rational(tc) == "48/24s"
|
|
|
|
|
|
def test_tc_to_rational_zero(writer, fps):
|
|
"""0 frames → '0/24s'."""
|
|
tc = Timecode(frames=0, frame_rate=fps)
|
|
assert writer._tc_to_rational(tc) == "0/24s"
|
|
|
|
|
|
# ============================================================
|
|
# write_project — file output
|
|
# ============================================================
|
|
|
|
|
|
def test_write_project_creates_file(writer, basic_project):
|
|
"""write_project should create a file on disk."""
|
|
with tempfile.NamedTemporaryFile(suffix=".fcpxml", delete=False) as f:
|
|
path = f.name
|
|
try:
|
|
writer.write_project(basic_project, path)
|
|
assert Path(path).exists()
|
|
assert Path(path).stat().st_size > 0
|
|
finally:
|
|
Path(path).unlink(missing_ok=True)
|
|
|
|
|
|
def test_write_project_xml_declaration(writer, basic_project):
|
|
"""Output should start with XML declaration and DOCTYPE."""
|
|
with tempfile.NamedTemporaryFile(suffix=".fcpxml", delete=False) as f:
|
|
path = f.name
|
|
try:
|
|
writer.write_project(basic_project, path)
|
|
content = Path(path).read_text()
|
|
assert content.startswith('<?xml version="1.0" encoding="UTF-8"?>')
|
|
assert "<!DOCTYPE fcpxml>" in content
|
|
finally:
|
|
Path(path).unlink(missing_ok=True)
|
|
|
|
|
|
def test_write_project_valid_xml(writer, basic_project):
|
|
"""Output should be parseable XML."""
|
|
with tempfile.NamedTemporaryFile(suffix=".fcpxml", delete=False) as f:
|
|
path = f.name
|
|
try:
|
|
writer.write_project(basic_project, path)
|
|
tree = ET.parse(path)
|
|
root = tree.getroot()
|
|
assert root.tag == "fcpxml"
|
|
finally:
|
|
Path(path).unlink(missing_ok=True)
|
|
|
|
|
|
# ============================================================
|
|
# _build_fcpxml — structure tests
|
|
# ============================================================
|
|
|
|
|
|
def test_build_fcpxml_root_version(writer, basic_project):
|
|
"""Root element should have the correct version."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
assert root.get("version") == "1.11"
|
|
|
|
|
|
def test_build_fcpxml_has_resources(writer, basic_project):
|
|
"""FCPXML should contain a <resources> element."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
resources = root.find("resources")
|
|
assert resources is not None
|
|
|
|
|
|
def test_build_fcpxml_has_format_resource(writer, basic_project):
|
|
"""Resources should include a format element matching timeline specs."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
fmt = root.find(".//format")
|
|
assert fmt is not None
|
|
assert fmt.get("width") == "1920"
|
|
assert fmt.get("height") == "1080"
|
|
assert fmt.get("frameDuration") == "1/24s"
|
|
|
|
|
|
def test_build_fcpxml_has_library(writer, basic_project):
|
|
"""FCPXML should contain a <library> element."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
library = root.find("library")
|
|
assert library is not None
|
|
assert "Test Project" in library.get("location", "")
|
|
|
|
|
|
def test_build_fcpxml_has_event(writer, basic_project):
|
|
"""Library should contain an <event> with the project name."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
event = root.find(".//event")
|
|
assert event is not None
|
|
assert event.get("name") == "Test Project"
|
|
assert event.get("uid") is not None
|
|
|
|
|
|
def test_build_fcpxml_has_project_element(writer, basic_project):
|
|
"""Event should contain a <project> element."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
proj = root.find(".//project")
|
|
assert proj is not None
|
|
assert proj.get("name") == "Main Timeline"
|
|
|
|
|
|
def test_build_fcpxml_has_sequence(writer, basic_project):
|
|
"""Project should contain a <sequence> element."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
seq = root.find(".//sequence")
|
|
assert seq is not None
|
|
assert seq.get("tcFormat") == "NDF"
|
|
assert seq.get("audioLayout") == "stereo"
|
|
assert seq.get("audioRate") == "48k"
|
|
|
|
|
|
def test_build_fcpxml_has_spine(writer, basic_project):
|
|
"""Sequence should contain a <spine> element."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
spine = root.find(".//spine")
|
|
assert spine is not None
|
|
|
|
|
|
# ============================================================
|
|
# Clip generation
|
|
# ============================================================
|
|
|
|
|
|
def test_clip_in_spine(writer, basic_project):
|
|
"""Spine should contain the clip as an <asset-clip>."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
clips = root.findall(".//asset-clip")
|
|
assert len(clips) == 1
|
|
assert clips[0].get("name") == "Interview_A"
|
|
|
|
|
|
def test_clip_has_ref(writer, basic_project):
|
|
"""Asset-clip should reference an asset resource."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
clip = root.find(".//asset-clip")
|
|
ref = clip.get("ref")
|
|
# Verify the referenced asset exists
|
|
asset = root.find(f".//asset[@id='{ref}']")
|
|
assert asset is not None
|
|
assert asset.get("name") == "Interview_A"
|
|
|
|
|
|
def test_clip_duration(writer, basic_project):
|
|
"""Clip should have correct duration in rational format."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
clip = root.find(".//asset-clip")
|
|
assert clip.get("duration") == "48/24s"
|
|
|
|
|
|
def test_clip_offset(writer, basic_project):
|
|
"""First clip offset should be its start timecode in rational format."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
clip = root.find(".//asset-clip")
|
|
assert clip.get("offset") == "0/24s"
|
|
|
|
|
|
def test_asset_has_media_path(writer, basic_project):
|
|
"""Asset resource should include the source media path."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
asset = root.find(".//asset")
|
|
assert asset.get("src") == "file:///media/interview_a.mov"
|
|
|
|
|
|
def test_asset_has_video_audio_flags(writer, basic_project):
|
|
"""Asset should declare video and audio presence."""
|
|
root = writer._build_fcpxml(basic_project)
|
|
asset = root.find(".//asset")
|
|
assert asset.get("hasVideo") == "1"
|
|
assert asset.get("hasAudio") == "1"
|
|
|
|
|
|
# ============================================================
|
|
# Multiple clips / shared media
|
|
# ============================================================
|
|
|
|
|
|
def test_multiple_clips(writer, fps):
|
|
"""Multiple clips should all appear in the spine."""
|
|
clips = [
|
|
Clip(name=f"Clip_{i}", start=Timecode(frames=i * 48, frame_rate=fps),
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
media_path=f"file:///media/clip_{i}.mov")
|
|
for i in range(3)
|
|
]
|
|
tl = Timeline(name="Multi", duration=Timecode(frames=144, frame_rate=fps),
|
|
frame_rate=fps, clips=clips)
|
|
proj = Project(name="Multi", timelines=[tl])
|
|
root = writer._build_fcpxml(proj)
|
|
assert len(root.findall(".//asset-clip")) == 3
|
|
assert len(root.findall(".//asset")) == 3
|
|
|
|
|
|
def test_shared_media_single_asset(writer, fps):
|
|
"""Two clips from the same media should share one asset resource."""
|
|
shared_path = "file:///media/shared.mov"
|
|
clips = [
|
|
Clip(name="PartA", start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=24, frame_rate=fps), media_path=shared_path),
|
|
Clip(name="PartB", start=Timecode(frames=24, frame_rate=fps),
|
|
duration=Timecode(frames=24, frame_rate=fps), media_path=shared_path),
|
|
]
|
|
tl = Timeline(name="Shared", duration=Timecode(frames=48, frame_rate=fps),
|
|
frame_rate=fps, clips=clips)
|
|
proj = Project(name="Shared", timelines=[tl])
|
|
root = writer._build_fcpxml(proj)
|
|
assets = root.findall(".//asset")
|
|
assert len(assets) == 1 # deduplicated
|
|
asset_clips = root.findall(".//asset-clip")
|
|
assert len(asset_clips) == 2
|
|
# Both clips reference the same asset
|
|
refs = {ac.get("ref") for ac in asset_clips}
|
|
assert len(refs) == 1
|
|
|
|
|
|
# ============================================================
|
|
# Markers
|
|
# ============================================================
|
|
|
|
|
|
def test_clip_with_marker(writer, fps):
|
|
"""A clip marker should appear as a child of the asset-clip element."""
|
|
clip = Clip(
|
|
name="Marked",
|
|
start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
media_path="file:///media/marked.mov",
|
|
markers=[Marker(name="Hit", start=Timecode(frames=12, frame_rate=fps))],
|
|
)
|
|
tl = Timeline(name="M", duration=Timecode(frames=48, frame_rate=fps),
|
|
frame_rate=fps, clips=[clip])
|
|
proj = Project(name="M", timelines=[tl])
|
|
root = writer._build_fcpxml(proj)
|
|
marker = root.find(".//asset-clip/marker")
|
|
assert marker is not None
|
|
assert marker.get("value") == "Hit"
|
|
assert marker.get("start") == "12/24s"
|
|
|
|
|
|
def test_chapter_marker(writer, fps):
|
|
"""Chapter markers should use <chapter-marker> tag."""
|
|
clip = Clip(
|
|
name="Chaptered",
|
|
start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
media_path="file:///media/ch.mov",
|
|
markers=[Marker(
|
|
name="Chapter 1", start=Timecode(frames=0, frame_rate=fps),
|
|
marker_type=MarkerType.CHAPTER,
|
|
)],
|
|
)
|
|
tl = Timeline(name="C", duration=Timecode(frames=48, frame_rate=fps),
|
|
frame_rate=fps, clips=[clip])
|
|
proj = Project(name="C", timelines=[tl])
|
|
root = writer._build_fcpxml(proj)
|
|
ch = root.find(".//chapter-marker")
|
|
assert ch is not None
|
|
assert ch.get("value") == "Chapter 1"
|
|
|
|
|
|
def test_marker_with_note(writer, fps):
|
|
"""Standard markers should include the note attribute when set."""
|
|
clip = Clip(
|
|
name="Noted",
|
|
start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
media_path="file:///media/noted.mov",
|
|
markers=[Marker(
|
|
name="Review", start=Timecode(frames=6, frame_rate=fps),
|
|
note="Fix audio",
|
|
)],
|
|
)
|
|
tl = Timeline(name="N", duration=Timecode(frames=48, frame_rate=fps),
|
|
frame_rate=fps, clips=[clip])
|
|
proj = Project(name="N", timelines=[tl])
|
|
root = writer._build_fcpxml(proj)
|
|
marker = root.find(".//marker")
|
|
assert marker.get("note") == "Fix audio"
|
|
|
|
|
|
def test_chapter_marker_no_note(writer, fps):
|
|
"""Chapter markers should NOT include a note attribute even if set."""
|
|
clip = Clip(
|
|
name="ChNoted",
|
|
start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
media_path="file:///media/chnoted.mov",
|
|
markers=[Marker(
|
|
name="Ch", start=Timecode(frames=0, frame_rate=fps),
|
|
marker_type=MarkerType.CHAPTER, note="ignored",
|
|
)],
|
|
)
|
|
tl = Timeline(name="CN", duration=Timecode(frames=48, frame_rate=fps),
|
|
frame_rate=fps, clips=[clip])
|
|
proj = Project(name="CN", timelines=[tl])
|
|
root = writer._build_fcpxml(proj)
|
|
ch = root.find(".//chapter-marker")
|
|
# chapter-marker check: the code only writes note for non-chapter tags
|
|
assert ch.get("note") is None
|
|
|
|
|
|
def test_todo_marker_completed_attr(writer, fps):
|
|
"""INCOMPLETE markers must emit completed='0' — the only way FCP recognises them as open tasks."""
|
|
clip = Clip(
|
|
name="Todo", start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
media_path="file:///media/todo.mov",
|
|
markers=[Marker(
|
|
name="Fix color", start=Timecode(frames=6, frame_rate=fps),
|
|
marker_type=MarkerType.INCOMPLETE,
|
|
)],
|
|
)
|
|
tl = Timeline(name="T", duration=Timecode(frames=48, frame_rate=fps),
|
|
frame_rate=fps, clips=[clip])
|
|
root = writer._build_fcpxml(Project(name="T", timelines=[tl]))
|
|
marker = root.find(".//asset-clip/marker")
|
|
assert marker is not None
|
|
assert marker.get("completed") == "0", "INCOMPLETE marker must have completed='0'"
|
|
assert marker.get("value") == "Fix color"
|
|
|
|
|
|
def test_completed_marker_completed_attr(writer, fps):
|
|
"""COMPLETED markers must emit completed='1' to signal done state in FCP."""
|
|
clip = Clip(
|
|
name="Done", start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
media_path="file:///media/done.mov",
|
|
markers=[Marker(
|
|
name="Approved", start=Timecode(frames=6, frame_rate=fps),
|
|
marker_type=MarkerType.COMPLETED,
|
|
)],
|
|
)
|
|
tl = Timeline(name="D", duration=Timecode(frames=48, frame_rate=fps),
|
|
frame_rate=fps, clips=[clip])
|
|
root = writer._build_fcpxml(Project(name="D", timelines=[tl]))
|
|
marker = root.find(".//asset-clip/marker")
|
|
assert marker is not None
|
|
assert marker.get("completed") == "1", "COMPLETED marker must have completed='1'"
|
|
|
|
|
|
def test_standard_marker_no_completed_attr(writer, fps):
|
|
"""STANDARD markers must NOT emit a completed attribute — it would confuse FCP."""
|
|
clip = Clip(
|
|
name="Std", start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
media_path="file:///media/std.mov",
|
|
markers=[Marker(
|
|
name="Note", start=Timecode(frames=6, frame_rate=fps),
|
|
marker_type=MarkerType.STANDARD,
|
|
)],
|
|
)
|
|
tl = Timeline(name="S", duration=Timecode(frames=48, frame_rate=fps),
|
|
frame_rate=fps, clips=[clip])
|
|
root = writer._build_fcpxml(Project(name="S", timelines=[tl]))
|
|
marker = root.find(".//asset-clip/marker")
|
|
assert marker is not None
|
|
assert marker.get("completed") is None, "STANDARD marker must not have completed attr"
|
|
|
|
|
|
def test_all_marker_types_on_one_clip(writer, fps):
|
|
"""All four marker types on one clip — each gets correct tag and attributes."""
|
|
markers = [
|
|
Marker(name="Std", start=Timecode(frames=0, frame_rate=fps),
|
|
marker_type=MarkerType.STANDARD),
|
|
Marker(name="Task", start=Timecode(frames=6, frame_rate=fps),
|
|
marker_type=MarkerType.INCOMPLETE),
|
|
Marker(name="Done", start=Timecode(frames=12, frame_rate=fps),
|
|
marker_type=MarkerType.COMPLETED),
|
|
Marker(name="Ch1", start=Timecode(frames=18, frame_rate=fps),
|
|
marker_type=MarkerType.CHAPTER),
|
|
]
|
|
clip = Clip(
|
|
name="Multi", start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
media_path="file:///media/multi.mov", markers=markers,
|
|
)
|
|
tl = Timeline(name="A", duration=Timecode(frames=48, frame_rate=fps),
|
|
frame_rate=fps, clips=[clip])
|
|
root = writer._build_fcpxml(Project(name="A", timelines=[tl]))
|
|
ac = root.find(".//asset-clip")
|
|
plain_markers = ac.findall("marker")
|
|
chapter_markers = ac.findall("chapter-marker")
|
|
# 3 <marker> elements (standard + todo + completed), 1 <chapter-marker>
|
|
assert len(plain_markers) == 3
|
|
assert len(chapter_markers) == 1
|
|
by_name = {m.get("value"): m for m in plain_markers}
|
|
assert by_name["Std"].get("completed") is None
|
|
assert by_name["Task"].get("completed") == "0"
|
|
assert by_name["Done"].get("completed") == "1"
|
|
assert chapter_markers[0].get("value") == "Ch1"
|
|
|
|
|
|
def test_timeline_level_markers(writer, fps):
|
|
"""Timeline-level markers should be children of the sequence element."""
|
|
clip = Clip(
|
|
name="C1", start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
media_path="file:///media/c1.mov",
|
|
)
|
|
tl = Timeline(
|
|
name="TL", duration=Timecode(frames=48, frame_rate=fps),
|
|
frame_rate=fps, clips=[clip],
|
|
markers=[Marker(name="Section", start=Timecode(frames=24, frame_rate=fps))],
|
|
)
|
|
proj = Project(name="TL", timelines=[tl])
|
|
root = writer._build_fcpxml(proj)
|
|
seq = root.find(".//sequence")
|
|
seq_markers = seq.findall("marker")
|
|
assert len(seq_markers) == 1
|
|
assert seq_markers[0].get("value") == "Section"
|
|
|
|
|
|
# ============================================================
|
|
# Keywords
|
|
# ============================================================
|
|
|
|
|
|
def test_clip_with_keyword(writer, fps):
|
|
"""Keywords should appear as <keyword> children of the asset-clip."""
|
|
clip = Clip(
|
|
name="Tagged",
|
|
start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
media_path="file:///media/tagged.mov",
|
|
keywords=[Keyword(value="interview")],
|
|
)
|
|
tl = Timeline(name="K", duration=Timecode(frames=48, frame_rate=fps),
|
|
frame_rate=fps, clips=[clip])
|
|
proj = Project(name="K", timelines=[tl])
|
|
root = writer._build_fcpxml(proj)
|
|
kw = root.find(".//asset-clip/keyword")
|
|
assert kw is not None
|
|
assert kw.get("value") == "interview"
|
|
|
|
|
|
def test_keyword_with_range(writer, fps):
|
|
"""Keywords with start/duration should include those attributes."""
|
|
clip = Clip(
|
|
name="Ranged",
|
|
start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=48, frame_rate=fps),
|
|
media_path="file:///media/ranged.mov",
|
|
keywords=[Keyword(
|
|
value="broll",
|
|
start=Timecode(frames=0, frame_rate=fps),
|
|
duration=Timecode(frames=24, frame_rate=fps),
|
|
)],
|
|
)
|
|
tl = Timeline(name="KR", duration=Timecode(frames=48, frame_rate=fps),
|
|
frame_rate=fps, clips=[clip])
|
|
proj = Project(name="KR", timelines=[tl])
|
|
root = writer._build_fcpxml(proj)
|
|
kw = root.find(".//keyword")
|
|
assert kw.get("start") == "0/24s"
|
|
assert kw.get("duration") == "24/24s"
|
|
|
|
|
|
# ============================================================
|
|
# Empty / edge-case projects
|
|
# ============================================================
|
|
|
|
|
|
def test_empty_project_no_timelines(writer):
|
|
"""A project with no timelines should still produce valid FCPXML."""
|
|
proj = Project(name="Empty")
|
|
root = writer._build_fcpxml(proj)
|
|
assert root.tag == "fcpxml"
|
|
assert root.find("library") is not None
|
|
# No format resource needed
|
|
assert root.find(".//sequence") is None
|
|
|
|
|
|
def test_empty_timeline_no_clips(writer, fps):
|
|
"""A timeline with zero clips should produce a spine with no children."""
|
|
tl = Timeline(name="Blank", duration=Timecode(frames=0, frame_rate=fps),
|
|
frame_rate=fps, clips=[])
|
|
proj = Project(name="Blank", timelines=[tl])
|
|
root = writer._build_fcpxml(proj)
|
|
spine = root.find(".//spine")
|
|
assert spine is not None
|
|
assert len(list(spine)) == 0
|