"""Ajudantes de nível de módulo do writer: sanitização, escalas, elementos base. Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto. """ import subprocess import xml.etree.ElementTree as ET from pathlib import Path from typing import Any, Dict, List, Optional from ..models import ( MarkerType, ) # 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)} # How close to the end of a clip a zoom must finish for the return to be # skipped. Within this margin the cut arrives before the eye registers the # move back, so the return reads as a twitch rather than a resolution. HOLD_AT_CUT_THRESHOLD = 1.0 # How close to the start of a clip a zoom must begin for the ramp-in to be # skipped and the shot to simply open already zoomed. Tighter than the end # margin on purpose: at the end the cut hides an unfinished return, but at # the start a ramp is visible from frame one and reads as the shot settling. START_AT_CUT_THRESHOLD = 0.5 def _fmt_scale(value: float) -> str: """Format a scale factor without trailing float noise (1.0 -> "1").""" return f"{value:.6f}".rstrip("0").rstrip(".") or "0" 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