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
+1
View File
@@ -0,0 +1 @@
# src.export package — FCPXML / EDL export
+114
View File
@@ -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
+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
+146
View File
@@ -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"