Initial project import

This commit is contained in:
Melbar
2026-05-02 09:07:41 +02:00
commit 8e1bcf142f
38 changed files with 7928 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
"""
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