Files

227 lines
9.0 KiB
Python
Executable File

"""Export FCPXML to other NLE formats.
Supports:
- Simplified FCPXML v1.9 for DaVinci Resolve compatibility
- FCP7 XML (XMEML) for Premiere Pro / Resolve / Avid compatibility
"""
import copy
import xml.etree.ElementTree as ET
from typing import Any, Dict, List
from .parser import FCPXMLParser
from .safe_xml import serialize_xml
from .writer import _sanitize_xml_value
class DaVinciExporter:
"""Export FCPXML in formats compatible with other NLEs."""
def __init__(self, source_path: str):
"""Load the source FCPXML file.
Args:
source_path: Path to the source FCPXML file
"""
self.source_path = source_path
from .safe_xml import safe_parse
self.tree = safe_parse(source_path)
self.root = self.tree.getroot()
self.parser = FCPXMLParser()
self.project = self.parser.parse_file(source_path)
def export_simplified_fcpxml(self, output_path: str,
flatten_compounds: bool = True) -> str:
"""Generate simplified FCPXML v1.9 for DaVinci Resolve.
Strips features that cause Resolve import issues:
- Downgrades version to 1.9
- Optionally flattens compound clips
- Removes unsupported attributes
Args:
output_path: Destination file path
flatten_compounds: Whether to flatten compound clips
Returns:
Path to the generated file
"""
root = copy.deepcopy(self.root)
root.set('version', '1.9')
# Strip attributes that cause Resolve issues
unsupported_attrs = ['mcClipAngle', 'modDate', 'colorProcessing']
for elem in root.iter():
for attr in unsupported_attrs:
if attr in elem.attrib:
del elem.attrib[attr]
# Flatten compound clips (ref-clips pointing to media sequences)
if flatten_compounds:
for spine in root.findall('.//spine'):
for ref_clip in spine.findall('ref-clip'):
# Convert ref-clip to simple asset-clip
ref_clip.tag = 'asset-clip'
# Keep core attributes, strip compound-specific ones
for attr in list(ref_clip.attrib.keys()):
if attr not in ('ref', 'offset', 'duration', 'start', 'name', 'format'):
del ref_clip.attrib[attr]
return serialize_xml(root, output_path, '<!DOCTYPE fcpxml>')
def export_xmeml(self, output_path: str) -> str:
"""Generate FCP7 XML (XMEML) for maximum NLE compatibility.
Converts FCPXML's spine-based model to XMEML's track-based model:
- Primary storyline -> Video Track 1
- Connected clips lane +N -> Video Track N+1
- Connected clips lane -N -> Audio Track N+1
Args:
output_path: Destination file path
Returns:
Path to the generated file
"""
tl = self.project.primary_timeline
if tl is None:
raise ValueError("No timeline found in source FCPXML")
# Build track model from spine
video_tracks, audio_tracks = self._spine_to_tracks()
# Build XMEML document
xmeml = ET.Element('xmeml')
xmeml.set('version', '5')
sequence = ET.SubElement(xmeml, 'sequence')
ET.SubElement(sequence, 'name').text = _sanitize_xml_value(tl.name, 512)
total_frames = int(tl.duration.seconds * tl.frame_rate)
ET.SubElement(sequence, 'duration').text = str(total_frames)
rate = ET.SubElement(sequence, 'rate')
ET.SubElement(rate, 'timebase').text = str(int(tl.frame_rate))
is_ntsc = tl.frame_rate in (29.97, 23.976, 59.94)
ET.SubElement(rate, 'ntsc').text = 'TRUE' if is_ntsc else 'FALSE'
# Timecode
tc = ET.SubElement(sequence, 'timecode')
tc_rate = ET.SubElement(tc, 'rate')
ET.SubElement(tc_rate, 'timebase').text = str(int(tl.frame_rate))
ET.SubElement(tc_rate, 'ntsc').text = 'TRUE' if is_ntsc else 'FALSE'
ET.SubElement(tc, 'string').text = '00:00:00:00'
ET.SubElement(tc, 'frame').text = '0'
media = ET.SubElement(sequence, 'media')
# Video tracks
video = ET.SubElement(media, 'video')
format_elem = ET.SubElement(video, 'format')
sample = ET.SubElement(format_elem, 'samplecharacteristics')
ET.SubElement(sample, 'width').text = str(tl.width)
ET.SubElement(sample, 'height').text = str(tl.height)
for track_num in sorted(video_tracks.keys()):
track = ET.SubElement(video, 'track')
for clip_data in video_tracks[track_num]:
self._add_xmeml_clipitem(track, clip_data, tl.frame_rate)
# Audio tracks
audio = ET.SubElement(media, 'audio')
for track_num in sorted(audio_tracks.keys()):
track = ET.SubElement(audio, 'track')
for clip_data in audio_tracks[track_num]:
self._add_xmeml_clipitem(track, clip_data, tl.frame_rate)
# If no explicit audio tracks, create one from primary storyline
if not audio_tracks and video_tracks.get(0):
track = ET.SubElement(audio, 'track')
for clip_data in video_tracks[0]:
if clip_data.get('has_audio', True):
self._add_xmeml_clipitem(track, clip_data, tl.frame_rate)
return serialize_xml(xmeml, output_path, '<!DOCTYPE xmeml>')
def _spine_to_tracks(self) -> tuple:
"""Convert spine + connected clips to track-based model.
Returns:
Tuple of (video_tracks, audio_tracks) where each is a
dict mapping track number to list of clip data dicts.
"""
tl = self.project.primary_timeline
video_tracks: Dict[int, List[Dict[str, Any]]] = {0: []}
audio_tracks: Dict[int, List[Dict[str, Any]]] = {}
# Primary storyline -> track 0
for clip in tl.clips:
video_tracks[0].append({
'name': clip.name,
'start_seconds': clip.start.seconds,
'duration_seconds': clip.duration_seconds,
'source_start_seconds': clip.source_start.seconds if clip.source_start else 0,
'media_path': clip.media_path,
'has_audio': True,
})
# Connected clips -> higher tracks
for cc in tl.connected_clips:
if cc.lane > 0:
track_num = cc.lane
if track_num not in video_tracks:
video_tracks[track_num] = []
video_tracks[track_num].append({
'name': cc.name,
'start_seconds': cc.offset.seconds if cc.offset else 0,
'duration_seconds': cc.duration_seconds,
'source_start_seconds': cc.source_start.seconds if cc.source_start else 0,
'media_path': cc.media_path,
'has_audio': cc.clip_type not in ('title', 'video'),
})
elif cc.lane < 0:
track_num = abs(cc.lane) - 1
if track_num not in audio_tracks:
audio_tracks[track_num] = []
audio_tracks[track_num].append({
'name': cc.name,
'start_seconds': cc.offset.seconds if cc.offset else 0,
'duration_seconds': cc.duration_seconds,
'source_start_seconds': cc.source_start.seconds if cc.source_start else 0,
'media_path': cc.media_path,
'has_audio': True,
})
return video_tracks, audio_tracks
def _add_xmeml_clipitem(self, track: ET.Element,
clip_data: Dict[str, Any],
frame_rate: float):
"""Add a clipitem element to an XMEML track."""
clipitem = ET.SubElement(track, 'clipitem')
ET.SubElement(clipitem, 'name').text = _sanitize_xml_value(
clip_data['name'], 512
)
duration_frames = int(clip_data['duration_seconds'] * frame_rate)
ET.SubElement(clipitem, 'duration').text = str(duration_frames)
rate = ET.SubElement(clipitem, 'rate')
ET.SubElement(rate, 'timebase').text = str(int(frame_rate))
is_ntsc = frame_rate in (29.97, 23.976, 59.94)
ET.SubElement(rate, 'ntsc').text = 'TRUE' if is_ntsc else 'FALSE'
start_frame = int(clip_data['start_seconds'] * frame_rate)
source_in = int(clip_data['source_start_seconds'] * frame_rate)
source_out = source_in + duration_frames
ET.SubElement(clipitem, 'start').text = str(start_frame)
ET.SubElement(clipitem, 'end').text = str(start_frame + duration_frames)
ET.SubElement(clipitem, 'in').text = str(source_in)
ET.SubElement(clipitem, 'out').text = str(source_out)
if clip_data.get('media_path'):
file_elem = ET.SubElement(clipitem, 'file')
pathurl = ET.SubElement(file_elem, 'pathurl')
pathurl.text = _sanitize_xml_value(clip_data['media_path'], 2048)