Initial project import
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# src.export package — FCPXML / EDL export
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
src/export/edl_writer.py — EditTimeline → CMX 3600 EDL
|
||||
|
||||
Generates a standard CMX 3600 Edit Decision List compatible with
|
||||
Avid, DaVinci Resolve, Premiere Pro, and most NLEs.
|
||||
|
||||
CMX 3600 format reference:
|
||||
https://en.wikipedia.org/wiki/Edit_decision_list#CMX_3600
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from src.core.config import AppConfig
|
||||
from src.core.models import EditClip, EditTimeline
|
||||
from src.export.timecode import seconds_to_smpte
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EDL line builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _edl_header(title: str) -> str:
|
||||
return f"TITLE: {title}\nFCM: NON-DROP FRAME\n"
|
||||
|
||||
|
||||
def _edl_event(
|
||||
event_num: int,
|
||||
clip: EditClip,
|
||||
fps: float,
|
||||
) -> str:
|
||||
"""
|
||||
Build one CMX 3600 event block for a single clip.
|
||||
|
||||
Format:
|
||||
NNN AX V C <SRC_IN> <SRC_OUT> <REC_IN> <REC_OUT>
|
||||
* FROM CLIP NAME: ...
|
||||
* COMMENT: ...
|
||||
"""
|
||||
src_in = seconds_to_smpte(clip.match.in_point_s, fps)
|
||||
source_duration_s = clip.source_timeline_duration_s
|
||||
src_out = seconds_to_smpte(clip.match.in_point_s + source_duration_s, fps)
|
||||
rec_in = seconds_to_smpte(clip.timeline_start_s, fps)
|
||||
rec_out = seconds_to_smpte(clip.timeline_start_s + source_duration_s, fps)
|
||||
|
||||
event_line = f"{event_num:03d} AX V C {src_in} {src_out} {rec_in} {rec_out}"
|
||||
name_line = f"* FROM CLIP NAME: {clip.match.source_path.name}"
|
||||
comment_line = (
|
||||
f"* BEAT {clip.beat.beat_id:03d} | {clip.beat.beat_type.name} | "
|
||||
f"score={clip.match.match_score:.3f}"
|
||||
)
|
||||
|
||||
return "\n".join([event_line, name_line, comment_line, ""])
|
||||
|
||||
|
||||
def _edl_black_tail_event(event_num: int, clip: EditClip, fps: float) -> str:
|
||||
rec_in = seconds_to_smpte(clip.timeline_start_s + clip.source_timeline_duration_s, fps)
|
||||
rec_out = seconds_to_smpte(clip.timeline_end_s, fps)
|
||||
event_line = f"{event_num:03d} BL V C 00:00:00:00 00:00:00:00 {rec_in} {rec_out}"
|
||||
comment_line = (
|
||||
f"* BEAT {clip.beat.beat_id:03d} TRAILER-ONLY TAIL | "
|
||||
"add fade/dissolve to black"
|
||||
)
|
||||
return "\n".join([event_line, "* FROM CLIP NAME: BLACK", comment_line, ""])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_edl(
|
||||
timeline: EditTimeline,
|
||||
cfg: AppConfig,
|
||||
output_path: Path | None = None,
|
||||
) -> Path:
|
||||
"""
|
||||
Write the EditTimeline as a CMX 3600 EDL file.
|
||||
|
||||
Args:
|
||||
timeline: EditTimeline from build_timeline().
|
||||
cfg: Application configuration.
|
||||
output_path: Override destination. Defaults to
|
||||
<output_dir>/<project_name>.edl.
|
||||
|
||||
Returns:
|
||||
Path to the written .edl file.
|
||||
"""
|
||||
if output_path is None:
|
||||
output_path = cfg.paths.output_dir / f"{timeline.title}.edl"
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
fps = timeline.frame_rate
|
||||
lines = [_edl_header(timeline.title), "\n"]
|
||||
|
||||
event_num = 1
|
||||
for clip in sorted(timeline.clips, key=lambda c: c.clip_index):
|
||||
lines.append(_edl_event(event_num, clip, fps))
|
||||
event_num += 1
|
||||
if clip.trailer_tail_s > 0:
|
||||
lines.append("\n")
|
||||
lines.append(_edl_black_tail_event(event_num, clip, fps))
|
||||
event_num += 1
|
||||
lines.append("\n")
|
||||
|
||||
edl_text = "\n".join(lines)
|
||||
output_path.write_text(edl_text, encoding="utf-8")
|
||||
|
||||
logger.info("EDL written → %s (%d events)", output_path, timeline.clip_count)
|
||||
return output_path
|
||||
@@ -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
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
src/export/timecode.py — Timecode / rational-time conversion helpers
|
||||
|
||||
FCPXML uses rational fractions ("1001/24000s") for all time values.
|
||||
EDL uses SMPTE timecode strings ("HH:MM:SS:FF").
|
||||
|
||||
All conversion functions are pure — no I/O, no state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from fractions import Fraction
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Common frame-rate denominators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FPS_RATIONAL: dict[float, tuple[int, int]] = {
|
||||
23.976: (24000, 1001),
|
||||
24.0: (24, 1),
|
||||
25.0: (25, 1),
|
||||
29.97: (30000, 1001),
|
||||
30.0: (30, 1),
|
||||
50.0: (50, 1),
|
||||
59.94: (60000, 1001),
|
||||
60.0: (60, 1),
|
||||
}
|
||||
|
||||
_TOLERANCE = 0.01 # fps match tolerance
|
||||
|
||||
|
||||
def _fps_to_rational(fps: float) -> tuple[int, int]:
|
||||
"""Return (numerator, denominator) for common fps values."""
|
||||
for ref_fps, rational in _FPS_RATIONAL.items():
|
||||
if abs(fps - ref_fps) < _TOLERANCE:
|
||||
return rational
|
||||
# Fallback: convert float to exact fraction
|
||||
f = Fraction(fps).limit_denominator(1001)
|
||||
return f.numerator, f.denominator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seconds → FCPXML rational string
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def seconds_to_fcpxml(seconds: float, fps: float) -> str:
|
||||
"""
|
||||
Convert *seconds* to FCPXML rational time string.
|
||||
|
||||
FCPXML requires exact rational arithmetic to avoid drift.
|
||||
Example: 10.0s @23.976fps → "240240/24000s"
|
||||
|
||||
Args:
|
||||
seconds: Time in seconds (float).
|
||||
fps: Project frame rate.
|
||||
|
||||
Returns:
|
||||
FCPXML time string, e.g. "240240/24000s".
|
||||
"""
|
||||
if seconds == 0.0:
|
||||
return "0s"
|
||||
|
||||
num, den = _fps_to_rational(fps) # frames per second = num/den
|
||||
# seconds × (num/den) = frames (float); round to nearest frame
|
||||
frames = round(seconds * num / den)
|
||||
# frames ÷ (num/den) = frames × den/num → rational seconds
|
||||
total_num = frames * den
|
||||
total_den = num
|
||||
# Reduce fraction
|
||||
g = math.gcd(total_num, total_den)
|
||||
return f"{total_num // g}/{total_den // g}s"
|
||||
|
||||
|
||||
def seconds_to_frame_count(seconds: float, fps: float) -> int:
|
||||
"""Convert seconds to integer frame count."""
|
||||
return round(seconds * fps)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seconds → SMPTE timecode (for EDL)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def seconds_to_smpte(seconds: float, fps: float, drop_frame: bool = False) -> str:
|
||||
"""
|
||||
Convert *seconds* to SMPTE timecode string "HH:MM:SS:FF".
|
||||
|
||||
Drop-frame timecode (;) is not implemented — always returns NDF (:).
|
||||
|
||||
Args:
|
||||
seconds: Time in float seconds.
|
||||
fps: Frame rate (23.976, 24, 25, etc.).
|
||||
drop_frame: Ignored; placeholder for future DF support.
|
||||
|
||||
Returns:
|
||||
"HH:MM:SS:FF" string.
|
||||
"""
|
||||
total_frames = seconds_to_frame_count(seconds, fps)
|
||||
nominal_fps = round(fps) # e.g. 23.976 → 24
|
||||
|
||||
ff = total_frames % nominal_fps
|
||||
total_s = total_frames // nominal_fps
|
||||
ss = total_s % 60
|
||||
total_m = total_s // 60
|
||||
mm = total_m % 60
|
||||
hh = total_m // 60
|
||||
|
||||
return f"{hh:02d}:{mm:02d}:{ss:02d}:{ff:02d}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FCPXML format ID helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fcpxml_format_name(fps: float, width: int = 1920, height: int = 1080) -> str:
|
||||
"""
|
||||
Return an FCPXML format name string for a given frame rate and resolution.
|
||||
|
||||
Example: fps=23.976, 1080p → "FFVideoFormat1080p2398"
|
||||
"""
|
||||
res = f"{height}p"
|
||||
fps_tag = {
|
||||
23.976: "2398",
|
||||
24.0: "24",
|
||||
25.0: "25",
|
||||
29.97: "2997",
|
||||
30.0: "30",
|
||||
50.0: "50",
|
||||
59.94: "5994",
|
||||
60.0: "60",
|
||||
}.get(fps, str(int(fps * 100)))
|
||||
return f"FFVideoFormat{res}{fps_tag}"
|
||||
|
||||
|
||||
def fcpxml_frame_duration(fps: float) -> str:
|
||||
"""
|
||||
Return FCPXML frameDuration attribute for a given fps.
|
||||
|
||||
frame duration = 1 frame = 1/fps seconds = den/num seconds
|
||||
Example: 23.976fps → num=24000, den=1001 → frame duration = 1001/24000s
|
||||
"""
|
||||
num, den = _fps_to_rational(fps) # fps = num/den (e.g. 24000/1001)
|
||||
# frame duration = den/num seconds
|
||||
g = math.gcd(den, num)
|
||||
return f"{den // g}/{num // g}s"
|
||||
Reference in New Issue
Block a user