324 lines
11 KiB
Python
Executable File
324 lines
11 KiB
Python
Executable File
"""Tests for fcpxml/export.py — DaVinci Resolve and XMEML export.
|
|
|
|
Covers: attribute stripping, compound clip flattening, audio track
|
|
generation, lane mapping, file path handling, no-timeline error,
|
|
doctype injection, NTSC detection.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
import xml.etree.ElementTree as ET
|
|
|
|
import pytest
|
|
|
|
from fcpxml.export import DaVinciExporter
|
|
from fcpxml.safe_xml import serialize_xml
|
|
|
|
BASE_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE fcpxml>
|
|
<fcpxml version="1.11">
|
|
<resources>
|
|
<format id="r1" name="FFVideoFormat1080p24" frameDuration="1/24s" width="1920" height="1080"/>
|
|
<asset id="r2" name="Clip_A" src="file:///Media/a.mov" start="0s" duration="240/24s" hasVideo="1" hasAudio="1" format="r1"/>
|
|
<asset id="r3" name="Clip_B" src="file:///Media/b.mov" start="0s" duration="240/24s" hasVideo="1" hasAudio="1" format="r1"/>
|
|
</resources>
|
|
<library>
|
|
<event name="Test">
|
|
<project name="Export Test" modDate="2025-01-15 10:30:00 -0500">
|
|
<sequence format="r1" duration="480/24s" tcStart="0s" tcFormat="NDF" audioLayout="stereo" audioRate="48k">
|
|
<spine>
|
|
<asset-clip ref="r2" offset="0s" name="Clip_A" start="0s" duration="240/24s" format="r1"/>
|
|
<asset-clip ref="r3" offset="240/24s" name="Clip_B" start="0s" duration="240/24s" format="r1"/>
|
|
</spine>
|
|
</sequence>
|
|
</project>
|
|
</event>
|
|
</library>
|
|
</fcpxml>"""
|
|
|
|
CONNECTED_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE fcpxml>
|
|
<fcpxml version="1.11">
|
|
<resources>
|
|
<format id="r1" name="FFVideoFormat1080p24" frameDuration="1/24s" width="1920" height="1080"/>
|
|
<asset id="r2" name="Main" src="file:///Media/main.mov" start="0s" duration="240/24s" hasVideo="1" hasAudio="1" format="r1"/>
|
|
<asset id="r3" name="Broll" src="file:///Media/broll.mov" start="0s" duration="120/24s" hasVideo="1" hasAudio="1" format="r1"/>
|
|
<asset id="r4" name="VO" src="file:///Media/vo.mov" start="0s" duration="120/24s" hasVideo="0" hasAudio="1" format="r1"/>
|
|
</resources>
|
|
<library>
|
|
<event name="Test">
|
|
<project name="Connected Test">
|
|
<sequence format="r1" duration="240/24s">
|
|
<spine>
|
|
<asset-clip ref="r2" offset="0s" name="Main" start="0s" duration="240/24s" format="r1" lane="0">
|
|
<asset-clip ref="r3" lane="1" offset="48/24s" name="Broll" start="0s" duration="120/24s"/>
|
|
<asset-clip ref="r4" lane="-1" offset="0s" name="VO" start="0s" duration="120/24s"/>
|
|
</asset-clip>
|
|
</spine>
|
|
</sequence>
|
|
</project>
|
|
</event>
|
|
</library>
|
|
</fcpxml>"""
|
|
|
|
COMPOUND_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE fcpxml>
|
|
<fcpxml version="1.11">
|
|
<resources>
|
|
<format id="r1" name="FFVideoFormat1080p24" frameDuration="1/24s" width="1920" height="1080"/>
|
|
<asset id="r2" name="Clip_A" src="file:///a.mov" start="0s" duration="240/24s" hasVideo="1" hasAudio="1" format="r1"/>
|
|
<media id="r5" name="Compound">
|
|
<sequence format="r1" duration="240/24s">
|
|
<spine>
|
|
<asset-clip ref="r2" offset="0s" name="Clip_A" start="0s" duration="240/24s"/>
|
|
</spine>
|
|
</sequence>
|
|
</media>
|
|
</resources>
|
|
<library>
|
|
<event name="Test">
|
|
<project name="Compound Test">
|
|
<sequence format="r1" duration="240/24s">
|
|
<spine>
|
|
<ref-clip ref="r5" offset="0s" name="Compound" duration="240/24s" srcEnable="all"/>
|
|
</spine>
|
|
</sequence>
|
|
</project>
|
|
</event>
|
|
</library>
|
|
</fcpxml>"""
|
|
|
|
|
|
def _tmp(xml: str) -> str:
|
|
fd, path = tempfile.mkstemp(suffix='.fcpxml')
|
|
with os.fdopen(fd, 'w') as f:
|
|
f.write(xml)
|
|
return path
|
|
|
|
|
|
def _out(suffix='.fcpxml') -> str:
|
|
fd, path = tempfile.mkstemp(suffix=suffix)
|
|
os.close(fd)
|
|
return path
|
|
|
|
|
|
class TestAttributeStripping:
|
|
"""Simplified FCPXML should strip Resolve-incompatible attributes."""
|
|
|
|
def test_moddate_removed(self):
|
|
src = _tmp(BASE_XML)
|
|
out = _out()
|
|
try:
|
|
exp = DaVinciExporter(src)
|
|
exp.export_simplified_fcpxml(out)
|
|
tree = ET.parse(out)
|
|
for elem in tree.iter():
|
|
assert 'modDate' not in elem.attrib
|
|
finally:
|
|
os.unlink(src)
|
|
os.unlink(out)
|
|
|
|
def test_version_downgraded_to_1_9(self):
|
|
src = _tmp(BASE_XML)
|
|
out = _out()
|
|
try:
|
|
exp = DaVinciExporter(src)
|
|
exp.export_simplified_fcpxml(out)
|
|
tree = ET.parse(out)
|
|
assert tree.getroot().get('version') == '1.9'
|
|
finally:
|
|
os.unlink(src)
|
|
os.unlink(out)
|
|
|
|
def test_colorprocessing_stripped(self):
|
|
xml = BASE_XML.replace('<fcpxml version="1.11">', '<fcpxml version="1.11" colorProcessing="wide">')
|
|
src = _tmp(xml)
|
|
out = _out()
|
|
try:
|
|
exp = DaVinciExporter(src)
|
|
exp.export_simplified_fcpxml(out)
|
|
tree = ET.parse(out)
|
|
assert 'colorProcessing' not in tree.getroot().attrib
|
|
finally:
|
|
os.unlink(src)
|
|
os.unlink(out)
|
|
|
|
|
|
class TestCompoundClipFlattening:
|
|
"""ref-clip → asset-clip conversion when flatten_compounds=True."""
|
|
|
|
def test_ref_clip_becomes_asset_clip(self):
|
|
src = _tmp(COMPOUND_XML)
|
|
out = _out()
|
|
try:
|
|
exp = DaVinciExporter(src)
|
|
exp.export_simplified_fcpxml(out, flatten_compounds=True)
|
|
tree = ET.parse(out)
|
|
ref_clips = tree.findall('.//ref-clip')
|
|
asset_clips = tree.findall('.//asset-clip')
|
|
assert len(ref_clips) == 0, "ref-clip should be converted"
|
|
assert len(asset_clips) >= 1
|
|
finally:
|
|
os.unlink(src)
|
|
os.unlink(out)
|
|
|
|
def test_flatten_strips_compound_attrs(self):
|
|
"""srcEnable and other compound-specific attrs should be removed."""
|
|
src = _tmp(COMPOUND_XML)
|
|
out = _out()
|
|
try:
|
|
exp = DaVinciExporter(src)
|
|
exp.export_simplified_fcpxml(out, flatten_compounds=True)
|
|
tree = ET.parse(out)
|
|
for clip in tree.findall('.//asset-clip'):
|
|
assert 'srcEnable' not in clip.attrib
|
|
finally:
|
|
os.unlink(src)
|
|
os.unlink(out)
|
|
|
|
def test_no_flatten_preserves_ref_clip(self):
|
|
src = _tmp(COMPOUND_XML)
|
|
out = _out()
|
|
try:
|
|
exp = DaVinciExporter(src)
|
|
exp.export_simplified_fcpxml(out, flatten_compounds=False)
|
|
tree = ET.parse(out)
|
|
ref_clips = tree.findall('.//ref-clip')
|
|
assert len(ref_clips) >= 1, "ref-clip should be preserved"
|
|
finally:
|
|
os.unlink(src)
|
|
os.unlink(out)
|
|
|
|
|
|
class TestXmemlAudioTracks:
|
|
"""Audio tracks from connected clips with negative lanes."""
|
|
|
|
def test_audio_track_generated_from_negative_lane(self):
|
|
src = _tmp(CONNECTED_XML)
|
|
out = _out('.xml')
|
|
try:
|
|
exp = DaVinciExporter(src)
|
|
exp.export_xmeml(out)
|
|
tree = ET.parse(out)
|
|
audio = tree.find('.//audio')
|
|
assert audio is not None
|
|
audio_tracks = audio.findall('track')
|
|
assert len(audio_tracks) >= 1
|
|
finally:
|
|
os.unlink(src)
|
|
os.unlink(out)
|
|
|
|
def test_primary_storyline_audio_fallback(self):
|
|
"""When no connected audio clips, primary storyline creates audio track."""
|
|
src = _tmp(BASE_XML)
|
|
out = _out('.xml')
|
|
try:
|
|
exp = DaVinciExporter(src)
|
|
exp.export_xmeml(out)
|
|
tree = ET.parse(out)
|
|
audio = tree.find('.//audio')
|
|
tracks = audio.findall('track')
|
|
assert len(tracks) >= 1
|
|
clipitems = tracks[0].findall('clipitem')
|
|
assert len(clipitems) == 2 # Both primary clips have audio
|
|
finally:
|
|
os.unlink(src)
|
|
os.unlink(out)
|
|
|
|
|
|
class TestXmemlFilePathHandling:
|
|
"""Clipitem file/pathurl elements from media_path."""
|
|
|
|
def test_clipitem_has_file_pathurl(self):
|
|
src = _tmp(BASE_XML)
|
|
out = _out('.xml')
|
|
try:
|
|
exp = DaVinciExporter(src)
|
|
exp.export_xmeml(out)
|
|
tree = ET.parse(out)
|
|
clipitems = tree.findall('.//clipitem')
|
|
found_path = False
|
|
for ci in clipitems:
|
|
file_elem = ci.find('file')
|
|
if file_elem is not None:
|
|
pathurl = file_elem.find('pathurl')
|
|
if pathurl is not None and pathurl.text:
|
|
found_path = True
|
|
break
|
|
assert found_path, "At least one clipitem should have file/pathurl"
|
|
finally:
|
|
os.unlink(src)
|
|
os.unlink(out)
|
|
|
|
|
|
class TestNoTimelineError:
|
|
"""Export should fail cleanly when FCPXML has no timeline."""
|
|
|
|
NO_TL_XML = """<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE fcpxml>
|
|
<fcpxml version="1.11">
|
|
<resources>
|
|
<format id="r1" name="FFVideoFormat1080p24" frameDuration="1/24s" width="1920" height="1080"/>
|
|
</resources>
|
|
<library>
|
|
<event name="Empty"/>
|
|
</library>
|
|
</fcpxml>"""
|
|
|
|
def test_xmeml_raises_on_no_timeline(self):
|
|
src = _tmp(self.NO_TL_XML)
|
|
out = _out('.xml')
|
|
try:
|
|
exp = DaVinciExporter(src)
|
|
with pytest.raises(ValueError, match="No timeline"):
|
|
exp.export_xmeml(out)
|
|
finally:
|
|
os.unlink(src)
|
|
if os.path.exists(out):
|
|
os.unlink(out)
|
|
|
|
|
|
class TestDoctypeInjection:
|
|
"""serialize_xml should inject DOCTYPE correctly."""
|
|
|
|
def test_doctype_inserted(self):
|
|
root = ET.Element('fcpxml')
|
|
root.set('version', '1.9')
|
|
out = _out()
|
|
try:
|
|
serialize_xml(root, out, '<!DOCTYPE fcpxml>')
|
|
with open(out) as f:
|
|
content = f.read()
|
|
assert '<!DOCTYPE fcpxml>' in content
|
|
assert '<?xml version="1.0" encoding="UTF-8"?>' in content
|
|
finally:
|
|
os.unlink(out)
|
|
|
|
def test_no_doctype(self):
|
|
root = ET.Element('test')
|
|
out = _out()
|
|
try:
|
|
serialize_xml(root, out, '')
|
|
with open(out) as f:
|
|
content = f.read()
|
|
assert '<!DOCTYPE' not in content
|
|
finally:
|
|
os.unlink(out)
|
|
|
|
|
|
class TestNtscDetection:
|
|
"""NTSC flag set correctly for various frame rates."""
|
|
|
|
def test_24fps_not_ntsc(self):
|
|
src = _tmp(BASE_XML)
|
|
out = _out('.xml')
|
|
try:
|
|
exp = DaVinciExporter(src)
|
|
exp.export_xmeml(out)
|
|
tree = ET.parse(out)
|
|
ntsc = tree.find('.//rate/ntsc')
|
|
assert ntsc.text == 'FALSE'
|
|
finally:
|
|
os.unlink(src)
|
|
os.unlink(out)
|