"""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, '') 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, '') 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)