223 lines
7.5 KiB
Python
223 lines
7.5 KiB
Python
"""
|
|
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 <asset> 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
|
|
<asset> 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 <asset> elements come before
|
|
# the <library> 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
|
|
<output_dir>/<project_name>.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 = (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<!DOCTYPE fcpxml>\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
|