""" src/export/fcpxml_writer.py — EditTimeline → Final Cut Pro XML (FCPXML 1.10) Generates a standards-compliant FCPXML file that can be imported directly into Final Cut Pro X, DaVinci Resolve, or Premiere Pro (via FCPXML plugin). Spec reference: https://developer.apple.com/documentation/professional_video_applications/fcpxml_reference """ from __future__ import annotations import logging from pathlib import Path from urllib.parse import quote from xml.etree import ElementTree as ET from xml.etree.ElementTree import Element, SubElement from src.core.config import AppConfig from src.core.models import EditClip, EditTimeline from src.export.timecode import ( fcpxml_format_name, fcpxml_frame_duration, seconds_to_fcpxml, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Asset registry — one per unique source file # --------------------------------------------------------------------------- class _AssetRegistry: def __init__(self) -> None: self._assets: dict[Path, str] = {} # path → asset id self._counter = 2 # r1 reserved for format def get_or_create(self, path: Path) -> str: if path not in self._assets: rid = f"r{self._counter}" self._assets[path] = rid self._counter += 1 return self._assets[path] @property def items(self) -> dict[Path, str]: return dict(self._assets) # --------------------------------------------------------------------------- # Builder # --------------------------------------------------------------------------- def _path_to_url(path: Path) -> str: """Convert an absolute Path to a file:// URL as required by FCPXML.""" posix = path.as_posix() if not posix.startswith("/"): # Windows drive letter: C:/foo → /C:/foo posix = "/" + posix return "file://" + quote(posix, safe="/:@") def build_fcpxml( timeline: EditTimeline, cfg: AppConfig, source_duration_s: float = 7200.0, # 2-hour fallback if not probed ) -> ET.ElementTree: """ Build a complete FCPXML ElementTree from an EditTimeline. Args: timeline: Ordered sequence of EditClips. cfg: Application configuration. source_duration_s: Duration of the source movie asset (used for duration attribute). Will be probed automatically when possible. Returns: xml.etree.ElementTree.ElementTree — call .write() to serialise. """ fps = timeline.frame_rate # ---- root --------------------------------------------------------------- root = Element("fcpxml", version=cfg.export.fcpxml_version) root.set("xmlns", "http://www.apple.com/dt/FCPXML/1_10") # ---- resources ---------------------------------------------------------- resources = SubElement(root, "resources") format_id = "r1" format_name = fcpxml_format_name(fps) fmt = SubElement(resources, "format", id=format_id, name=format_name, frameDuration=fcpxml_frame_duration(fps), width="1920", height="1080", colorSpace="1-1-1 (Rec. 709)", ) registry = _AssetRegistry() # Pre-register all unique source paths so elements come before # the block (required by FCPXML spec). for clip in timeline.clips: registry.get_or_create(clip.match.source_path) # Probe actual source duration when possible _durations: dict[Path, float] = {} for path in registry.items: try: from src.cv.frame_extractor import get_video_info info = get_video_info(path) _durations[path] = float(info["duration_s"]) except Exception: _durations[path] = source_duration_s for path, rid in registry.items.items(): dur_s = _durations.get(path, source_duration_s) SubElement(resources, "asset", id=rid, name=path.stem, src=_path_to_url(path), start="0s", duration=seconds_to_fcpxml(dur_s, fps), hasVideo="1", hasAudio="1", format=format_id, ) # ---- library / event / project ------------------------------------------ library = SubElement(root, "library") event = SubElement(library, "event", name=timeline.title) project = SubElement(event, "project", name=timeline.title) sequence = SubElement(project, "sequence", duration=seconds_to_fcpxml(timeline.total_duration_s, fps), format=format_id, tcStart="0s", tcFormat="NDF", audioLayout="stereo", audioRate="48k", ) spine = SubElement(sequence, "spine") # ---- clips -------------------------------------------------------------- for clip in sorted(timeline.clips, key=lambda c: c.clip_index): asset_id = registry.get_or_create(clip.match.source_path) source_duration_s = clip.source_timeline_duration_s clip_elem = SubElement(spine, "clip", name=f"Beat_{clip.beat.beat_id:03d}_{clip.beat.beat_type.name}", ref=asset_id, # offset = position on the timeline offset=seconds_to_fcpxml(clip.timeline_start_s, fps), # duration = matched source part only; trailer-only tails become gaps. duration=seconds_to_fcpxml(source_duration_s, fps), # start = in-point inside the source asset start=seconds_to_fcpxml(clip.match.in_point_s, fps), ) # Inline audio role SubElement(clip_elem, "audio", role="dialogue", srcCh="1, 2", outCh="L, R", ) if clip.trailer_tail_s > 0: gap = SubElement(spine, "gap", name=f"Beat_{clip.beat.beat_id:03d}_TRAILER_TAIL_BLACK_FADE", offset=seconds_to_fcpxml(clip.timeline_start_s + source_duration_s, fps), duration=seconds_to_fcpxml(clip.trailer_tail_s, fps), start="0s", ) SubElement(gap, "marker", start="0s", value="Trailer-only tail: add fade/dissolve to black here", completed="0", ) return ET.ElementTree(root) # --------------------------------------------------------------------------- # Writer # --------------------------------------------------------------------------- def write_fcpxml( timeline: EditTimeline, cfg: AppConfig, output_path: Path | None = None, ) -> Path: """ Serialise the EditTimeline to a .fcpxml file. Args: timeline: EditTimeline from build_timeline(). cfg: Application configuration. output_path: Override destination. Defaults to /.fcpxml. Returns: Path to the written .fcpxml file. """ if output_path is None: output_path = cfg.paths.output_dir / f"{timeline.title}.fcpxml" output_path.parent.mkdir(parents=True, exist_ok=True) tree = build_fcpxml(timeline, cfg) # Add XML declaration + DOCTYPE manually (ElementTree doesn't support DOCTYPE) xml_bytes = ET.tostring(tree.getroot(), encoding="unicode", xml_declaration=False) header = ( '\n' '\n' ) output_path.write_text(header + xml_bytes, encoding="utf-8") logger.info("FCPXML written → %s (%d clips)", output_path, timeline.clip_count) return output_path