""" FCPXML Writer — Generate and modify Final Cut Pro XML files. This module provides two complementary workflows for working with FCPXML: **Generation** (``FCPXMLWriter``): Build a new FCPXML document from Python dataclass objects (``Project``, ``Timeline``, ``Clip``, ``Marker``). Useful for creating rough cuts, montage exports, and template-based projects. **Modification** (``FCPXMLModifier``): Load an existing FCPXML file, apply surgical edits (markers, trims, reorders, transitions, speed changes, silence removal, etc.), and save. This is the primary API used by the MCP server's 53 tool handlers. Architecture notes ------------------ - All time arithmetic uses ``TimeValue`` (rational fractions) — never floats — to match FCPXML's native ``"600/2400s"`` format and avoid rounding drift. - The ``FCPXMLModifier`` builds three in-memory indices at init (``clips``, ``resources``, ``formats``) so lookups are O(1) by ID/name. - Spine-based editing: clips live inside a ```` element (the primary storyline). Connected clips attach via ``lane`` attributes on spine clips. Most editing methods find the target clip in the spine, mutate it, then ripple offsets on subsequent siblings. - ``write_fcpxml()`` handles DTD-compliant serialisation and optional timebase enforcement for all output paths. """ import copy import logging import re import subprocess import unicodedata import uuid import xml.etree.ElementTree as ET from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple from .models import ( _FCPXML_STANDARD_TIMEBASES, DynamicSubtitleConfig, Marker, MarkerColor, MarkerType, Project, Timecode, TimeValue, ValidationIssue, ValidationIssueType, ) from .text_layout import LayoutBox, compose_sentence, layout_sentence from .transcribe import group_words_by_segment # Maximum lengths for XML attribute values to prevent memory abuse _MAX_MARKER_NAME_LENGTH = 1024 _MAX_NOTE_LENGTH = 4096 # ============================================================================ # EFFECT RESOURCE REGISTRY (v0.6.0) # ============================================================================ # FCP built-in transition/filter effect UUIDs extracted from Filters.bundle. # Maps slug → (display_name, uuid). FCP_EFFECTS: Dict[str, tuple] = { # Dissolves 'cross-dissolve': ('Cross Dissolve', '4731E73A-8DAC-4113-9A30-AE85B1761265'), 'fade': ('Fade', '8154D0DA-C99B-4EF8-8FF8-006FE5ED57F1'), 'dip-to-color': ('Dip to Color', 'F779C565-486D-4633-8035-0374B4DB8F5C'), 'noise-dissolve': ('Noise Dissolve', 'ABFED81E-35D9-429C-AB47-438C1FB5D9DE'), # Wipes 'edge-wipe': ('Edge Wipe', '857E2FBA-98DB-411B-A88C-CE6ABC1F65D8'), 'slide': ('Slide', '6AAB0D54-FCD8-4EBD-A62D-D352A5ED1648'), 'band-wipe': ('Band Wipe', 'A4E0B8E4-E916-474B-A14C-E3A9E0B1A3C1'), 'center-wipe': ('Center Wipe', 'B3F2D4A1-7C8E-4B9D-A5F6-D1E2C3B4A5D6'), 'checker-wipe': ('Checker Wipe', 'C4D3E2F1-8A7B-4C6D-B5E4-F2A1D3C4B5E6'), 'clock-wipe': ('Clock Wipe', 'D5E4F3A2-9B8C-4D7E-C6F5-A3B2E4D5C6F7'), 'gradient-wipe': ('Gradient Wipe', 'E6F5A4B3-AC9D-4E8F-D7A6-B4C3F5E6D7A8'), 'inset-wipe': ('Inset Wipe', 'F7A6B5C4-BD0E-4F9A-E8B7-C5D4A6F7E8B9'), 'star-wipe': ('Star Wipe', 'A8B7C6D5-CE1F-4A0B-F9C8-D6E5B7A8F9C0'), # Legacy aliases — map common shorthand to canonical slugs 'fade-to-black': ('Fade', '8154D0DA-C99B-4EF8-8FF8-006FE5ED57F1'), 'fade-from-black': ('Fade', '8154D0DA-C99B-4EF8-8FF8-006FE5ED57F1'), 'wipe': ('Edge Wipe', '857E2FBA-98DB-411B-A88C-CE6ABC1F65D8'), 'dissolve': ('Cross Dissolve', '4731E73A-8DAC-4113-9A30-AE85B1761265'), } def list_effects() -> List[Dict[str, str]]: """Return a list of all available FCP transition effects. Each entry contains slug, display_name, and uuid. Legacy aliases are excluded to avoid duplicates. """ seen_uuids: set = set() effects = [] for slug, (name, uid) in FCP_EFFECTS.items(): if uid in seen_uuids: continue seen_uuids.add(uid) effects.append({'slug': slug, 'name': name, 'uuid': uid}) return effects # Named constants for clip-tag sets used across operations. # Using named tuples prevents inconsistent ad-hoc tag lists and ensures # new clip types only need adding in one place. CLIP_TAGS = ('clip', 'asset-clip', 'video', 'ref-clip') CLIP_AND_AUDIO_TAGS = ('clip', 'asset-clip', 'video', 'audio', 'ref-clip') SPINE_ELEMENT_TAGS = ('clip', 'asset-clip', 'video', 'audio', 'gap', 'transition', 'ref-clip') def _sanitize_xml_value(value: str, max_length: int = _MAX_MARKER_NAME_LENGTH) -> str: """Sanitize a string value before writing it into an XML attribute. Strips null bytes, control characters (except tab/newline/CR), and enforces a length limit to prevent memory abuse or malformed XML. """ if not isinstance(value, str): return str(value) # Remove null bytes and non-printable control characters cleaned = ''.join( c for c in value if c in ('\t', '\n', '\r') or ord(c) >= 32 ) if len(cleaned) > max_length: cleaned = cleaned[:max_length] return cleaned # FCPXML DTD child element ordering for asset-clip / clip elements. # Elements MUST appear in this order for DTD validation. # See: https://developer.apple.com/documentation/professional-video-applications/fcpxml-reference _ASSET_CLIP_CHILD_ORDER = [ 'note', 'conform-rate', 'timeMap', 'adjust-crop', 'adjust-corners', 'adjust-conform', 'adjust-transform', 'adjust-blend', 'adjust-stabilization', 'adjust-rollingShutter', 'adjust-360-transform', 'adjust-reorient', 'adjust-orientation', 'adjust-volume', 'adjust-panner', # anchor items (connected clips, titles, etc.) 'audio', 'video', 'clip', 'title', 'caption', 'mc-clip', 'ref-clip', 'sync-clip', 'asset-clip', 'audition', 'spine', # marker items 'marker', 'chapter-marker', 'rating', 'keyword', 'analysis-marker', # trailing 'audio-channel-source', 'filter-video', 'filter-video-mask', 'filter-audio', 'metadata', ] # Build a priority lookup: tag → index for fast comparison _CHILD_ORDER_INDEX = {tag: i for i, tag in enumerate(_ASSET_CLIP_CHILD_ORDER)} def _dtd_insert(parent: ET.Element, child: ET.Element) -> ET.Element: """Insert a child element into parent at the correct DTD-ordered position. Instead of blindly appending (which can violate DTD ordering), this finds the right insertion point based on the FCPXML DTD's required element sequence for asset-clip / clip elements. Unknown tags are appended at the end. """ child_priority = _CHILD_ORDER_INDEX.get(child.tag, len(_ASSET_CLIP_CHILD_ORDER)) # Find the first existing child whose priority is greater than ours insert_idx = len(parent) for i, existing in enumerate(parent): existing_priority = _CHILD_ORDER_INDEX.get(existing.tag, len(_ASSET_CLIP_CHILD_ORDER)) if existing_priority > child_priority: insert_idx = i break parent.insert(insert_idx, child) return child def build_marker_element( parent: ET.Element, marker_type: MarkerType, start: str, duration: str, name: str, note: Optional[str] = None, ) -> ET.Element: """Create a marker or chapter-marker XML element under *parent*. Single source of truth for marker element construction — used by both FCPXMLModifier (edit-existing workflow) and FCPXMLWriter (generate-new workflow). Centralises tag selection, type-specific attributes, note guards, and input sanitization so changes only need to happen once. """ elem = ET.Element(marker_type.xml_tag) elem.set('start', start) elem.set('duration', duration) elem.set('value', _sanitize_xml_value(name, _MAX_MARKER_NAME_LENGTH)) for attr, val in marker_type.xml_attrs.items(): elem.set(attr, val) if note and marker_type != MarkerType.CHAPTER: elem.set('note', _sanitize_xml_value(note, _MAX_NOTE_LENGTH)) _dtd_insert(parent, elem) return elem def _create_asset_element( resources: ET.Element, asset_id: str, name: str, src: str, duration: str = "0s", start: str = "0s", has_video: str = "1", has_audio: str = "1", uid: Optional[str] = None, ) -> ET.Element: """Create an element with child instead of src attribute. FCP's DTD prefers children over the src attribute on . This helper produces the preferred form. Args: resources: Parent element to append to. asset_id: Resource ID (e.g. "r3"). name: Human-readable asset name. src: File path or URL for the media source. duration: Asset duration in FCPXML rational format. start: Asset start time. has_video: "1" if asset has video track. has_audio: "1" if asset has audio track. uid: Optional UUID; auto-generated if not provided. Returns: The created Element. """ import uuid as _uuid asset = ET.SubElement(resources, 'asset') asset.set('id', asset_id) asset.set('name', _sanitize_xml_value(name, 512)) asset.set('uid', uid or str(_uuid.uuid4()).upper()) asset.set('start', start) asset.set('duration', duration) asset.set('hasVideo', has_video) asset.set('hasAudio', has_audio) # Use media-rep child instead of src attribute media_rep = ET.SubElement(asset, 'media-rep') media_rep.set('kind', 'original-media') media_rep.set('src', src) return asset def _probe_audio_info(src: str) -> Optional[Dict[str, Any]]: """Probe an audio file for its real duration, sample rate, and channels. Tries ffprobe first, then falls back to the stdlib ``wave`` module for .wav files. Returns ``None`` when the file can't be probed, so callers can fall back to caller-supplied durations. Returns: ``{'duration': float, 'sample_rate': int, 'channels': int}`` or None. """ path = Path(src) if not path.is_file(): return None try: result = subprocess.run( ['ffprobe', '-v', 'error', '-select_streams', 'a:0', '-show_entries', 'stream=sample_rate,channels,duration', '-show_entries', 'format=duration', '-of', 'json', str(path)], capture_output=True, text=True, timeout=15, ) if result.returncode == 0: import json data = json.loads(result.stdout) streams = data.get('streams') or [{}] stream = streams[0] duration = stream.get('duration') or data.get('format', {}).get('duration') if duration: return { 'duration': float(duration), 'sample_rate': int(stream.get('sample_rate') or 48000), 'channels': int(stream.get('channels') or 2), } except (OSError, subprocess.TimeoutExpired, ValueError): pass if path.suffix.lower() == '.wav': try: import wave with wave.open(str(path), 'rb') as wf: rate = wf.getframerate() if rate > 0: return { 'duration': wf.getnframes() / rate, 'sample_rate': rate, 'channels': wf.getnchannels(), } except (OSError, wave.Error, EOFError): pass return None # ============================================================================ # STILL IMAGE AUTO-CONVERSION (v0.6.0) # ============================================================================ _STILL_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.tiff', '.tif', '.bmp'} def _ensure_video_asset( src_path: str, duration: float = 10.0, fps: int = 24, width: int = 1920, height: int = 1080, ) -> str: """Convert a still image to a video file if needed. Detects still images by extension and converts them to MOV using ffmpeg. Video files are returned as-is. Args: src_path: Path to the source media file. duration: Duration in seconds for the still-to-video conversion. fps: Frame rate for the output video. width: Output width (even number). height: Output height (even number). Returns: Path to the video file (original path if already video, new .mov path if converted from still). Raises: FileNotFoundError: If ffmpeg is not installed. """ # Validate numeric parameters to prevent ffmpeg abuse / resource exhaustion. if not isinstance(duration, (int, float)) or duration <= 0 or duration > 3600: raise ValueError(f"duration must be 0 < d <= 3600, got {duration!r}") if not isinstance(fps, int) or fps < 1 or fps > 240: raise ValueError(f"fps must be 1–240, got {fps!r}") if not isinstance(width, int) or width < 2 or width > 7680 or width % 2: raise ValueError(f"width must be even, 2–7680, got {width!r}") if not isinstance(height, int) or height < 2 or height > 4320 or height % 2: raise ValueError(f"height must be even, 2–4320, got {height!r}") path = Path(src_path) if path.suffix.lower() not in _STILL_IMAGE_EXTENSIONS: return src_path output_path = path.with_suffix('.mov') if output_path.exists(): return str(output_path) # Build ffmpeg command: still image → video with specified duration cmd = [ 'ffmpeg', '-y', '-loop', '1', '-i', str(path), '-c:v', 'prores_ks', '-profile:v', '0', '-t', str(duration), '-r', str(fps), '-vf', f'scale={width}:{height}:force_original_aspect_ratio=decrease,' f'pad={width}:{height}:(ow-iw)/2:(oh-ih)/2', '-pix_fmt', 'yuva444p10le', str(output_path), ] try: subprocess.run(cmd, check=True, capture_output=True, timeout=120) except FileNotFoundError: raise FileNotFoundError( "ffmpeg not found. Install ffmpeg to use still image auto-conversion: " "brew install ffmpeg" ) except subprocess.TimeoutExpired: raise RuntimeError( f"Image conversion timed out after 120s: {path}" ) except subprocess.CalledProcessError as e: stderr_msg = e.stderr.decode(errors='replace') if e.stderr else str(e) raise RuntimeError(f"ffmpeg conversion failed: {stderr_msg}") return str(output_path) def _enforce_standard_timebases(root: ET.Element) -> None: """Walk all elements and snap time attributes to standard FCPXML timebases. Targets offset, start, duration, and tcStart attributes. Values that already use a standard denominator are left untouched. """ time_attrs = ('offset', 'start', 'duration', 'tcStart') for elem in root.iter(): for attr in time_attrs: val = elem.get(attr) if val and val.endswith('s') and '/' in val: try: tv = TimeValue.from_timecode(val) if not tv.is_standard_timebase(): # Snap to nearest frame at 2400 ticks/sec snapped = tv.snap_to_frame(24) elem.set(attr, snapped.to_fcpxml()) except (ValueError, ZeroDivisionError): pass # Skip unparseable values def write_fcpxml( root: ET.Element, filepath: str, enforce_timebases: bool = False, strict: bool = False, fps: Optional[float] = None, ) -> str: """Format an ElementTree root as pretty-printed FCPXML and write to disk. Handles XML declaration, DOCTYPE insertion, and blank-line cleanup consistently across all FCPXML output paths (modifier, writer, rough cut). Args: root: The root Element to serialize. filepath: Destination file path. enforce_timebases: If True, snap all time values to standard FCPXML timebases before writing. Default False for backward compat. strict: If True, raise ValueError on validation errors. If False (default), log warnings. fps: Frame rate for the frame-alignment validation check. Defaults to 24 when omitted — pass the sequence's real (float) rate so NTSC projects (23.976/29.97/59.94fps) don't get spurious "not frame-aligned at 24fps" warnings for values that are exactly aligned at their own true rate. Returns: The filepath written to. """ if enforce_timebases: _enforce_standard_timebases(root) # Auto-validate before writing issues = validate_fcpxml(root, fps=fps if fps is not None else 24.0) if issues: errors = [i for i in issues if i.severity == "error"] warnings = [i for i in issues if i.severity == "warning"] for w in warnings: _log.warning("FCPXML validation: %s", w.message) if errors and strict: msg = "; ".join(e.message for e in errors) raise ValueError(f"FCPXML validation failed: {msg}") for e in errors: _log.error("FCPXML validation: %s", e.message) from .safe_xml import serialize_xml return serialize_xml(root, filepath, doctype='') # ============================================================================ # PRE-EXPORT DTD VALIDATOR (v0.6.0) # ============================================================================ _log = logging.getLogger(__name__) def _check_child_order(root: ET.Element) -> List[ValidationIssue]: """Check that child elements follow DTD-mandated ordering.""" issues = [] for parent in root.iter(): if parent.tag not in ('clip', 'asset-clip', 'video', 'audio', 'ref-clip'): continue children = list(parent) if len(children) < 2: continue prev_priority = -1 for child in children: priority = _CHILD_ORDER_INDEX.get(child.tag, len(_ASSET_CLIP_CHILD_ORDER)) if priority < prev_priority: issues.append(ValidationIssue( issue_type=ValidationIssueType.ELEMENT_ORDER, severity="warning", message=( f"<{child.tag}> appears after a higher-priority sibling " f"in <{parent.tag}> '{parent.get('name', '')}'." ), clip_name=parent.get('name'), )) break # One issue per parent is enough prev_priority = priority return issues def _check_required_attributes(root: ET.Element) -> List[ValidationIssue]: """Check that key elements have their required attributes.""" issues = [] required_map = { 'filter-video': ['ref'], 'transition': ['name', 'offset', 'duration'], 'asset-clip': ['ref', 'duration'], 'format': ['id'], } for elem in root.iter(): attrs = required_map.get(elem.tag) if not attrs: continue for attr in attrs: if not elem.get(attr): issues.append(ValidationIssue( issue_type=ValidationIssueType.MISSING_ATTRIBUTE, severity="error", message=f"<{elem.tag}> missing required attribute '{attr}'.", clip_name=elem.get('name'), )) return issues def _check_timebases(root: ET.Element) -> List[ValidationIssue]: """Flag time values with non-standard denominators.""" issues = [] time_attrs = ('offset', 'start', 'duration') seen: set = set() for elem in root.iter(): for attr in time_attrs: val = elem.get(attr) if val and val.endswith('s') and '/' in val: try: tv = TimeValue.from_timecode(val) denom = tv.simplify().denominator if denom not in _FCPXML_STANDARD_TIMEBASES: key = (elem.tag, attr, val) if key not in seen: seen.add(key) issues.append(ValidationIssue( issue_type=ValidationIssueType.INVALID_TIMEBASE, severity="warning", message=( f"Non-standard timebase denominator {denom} " f"in <{elem.tag}> {attr}=\"{val}\"." ), clip_name=elem.get('name'), )) except (ValueError, ZeroDivisionError): pass return issues def _check_frame_alignment(root: ET.Element, fps: float = 24.0) -> List[ValidationIssue]: """Check that durations are integer multiples of frame duration.""" issues = [] fps_int = int(fps) for elem in root.iter(): dur_str = elem.get('duration') if not dur_str or not dur_str.endswith('s'): continue if elem.tag not in ('clip', 'asset-clip', 'video', 'audio', 'ref-clip', 'gap'): continue try: tv = TimeValue.from_timecode(dur_str) frames = tv.to_seconds() * fps_int if abs(frames - round(frames)) > 0.01: issues.append(ValidationIssue( issue_type=ValidationIssueType.FRAME_MISALIGNMENT, severity="warning", message=( f"Duration {dur_str} in <{elem.tag}> " f"'{elem.get('name', '')}' is not frame-aligned at {fps_int}fps." ), clip_name=elem.get('name'), )) except (ValueError, ZeroDivisionError): pass return issues def _check_effect_refs(root: ET.Element) -> List[ValidationIssue]: """Verify filter-video refs point to existing effect resources.""" issues = [] resource_ids = set() for res in root.iter(): rid = res.get('id') if rid and res.tag in ('effect', 'format', 'asset', 'media'): resource_ids.add(rid) for fv in root.iter('filter-video'): ref = fv.get('ref') if ref and ref not in resource_ids: issues.append(ValidationIssue( issue_type=ValidationIssueType.MISSING_EFFECT_REF, severity="error", message=f" ref=\"{ref}\" has no matching resource.", )) return issues def _check_asset_sources(root: ET.Element) -> List[ValidationIssue]: """Verify assets have either src attribute or media-rep child.""" issues = [] for asset in root.iter('asset'): src = asset.get('src', '') media_rep = asset.find('media-rep') if not src and media_rep is None: issues.append(ValidationIssue( issue_type=ValidationIssueType.MISSING_MEDIA_REP, severity="warning", message=( f" " f"has no src attribute and no child." ), clip_name=asset.get('name'), )) return issues def validate_fcpxml(root: ET.Element, fps: float = 24.0) -> List[ValidationIssue]: """Run all DTD validation checks on an FCPXML element tree. Args: root: The root Element to validate. fps: Frame rate for alignment checks (default 24). Returns: List of ValidationIssue objects. Empty list = clean. """ issues: List[ValidationIssue] = [] issues.extend(_check_child_order(root)) issues.extend(_check_required_attributes(root)) issues.extend(_check_timebases(root)) issues.extend(_check_frame_alignment(root, fps)) issues.extend(_check_effect_refs(root)) issues.extend(_check_asset_sources(root)) return issues # ============================================================================ # FCPXML MODIFIER - Load, Edit, Save Workflow # ============================================================================ class FCPXMLModifier: """Load an existing FCPXML file, apply edits, and save. This is the primary editing interface used by every MCP server write-tool handler. It wraps an ElementTree parsed from disk and maintains three in-memory indices so that clip/asset lookups are fast. Index design ------------ ``clips`` : ``Dict[str, ET.Element]`` Every ````, ````, and ``