From 4f5cf94443d75e1ebdcf06a3df26c862988a745c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Henrique?= Date: Wed, 19 Aug 2026 21:38:49 -0400 Subject: [PATCH] =?UTF-8?q?refactor:=20writer.py=20vira=20pacote,=20um=20m?= =?UTF-8?q?=C3=B3dulo=20por=20assunto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O writer tinha 4.199 linhas, das quais 3.300 numa única classe com dezoito assuntos dentro. Achar o trecho de zoom exigia rolar por marcadores, velocidade e legendas. Agora é o pacote fcpxml/writer/, com um arquivo por assunto e o FCPXMLModifier montado por composição de mixins. Mixins, e não objetos separados, porque todas essas operações mexem no mesmo documento e nos mesmos índices — separá-las em objetos independentes transformaria toda chamada interna em travessia de fronteira sem nada em troca. A divisão que importa aqui é de leitura, não de estado. Nenhuma mudança de comportamento e nenhuma alteração nos ~50 pontos que importam do writer: o __init__ re-exporta tudo, inclusive os nomes com underscore que a suíte já usava. core 723 carga, índices, navegação na spine, save titles 600 títulos e legendas dinâmicas cut 333 dividir, cortar faixas, apagar speed 297 velocidade e zoom (+ 20 módulos menores) Único ajuste de chamada: quatro testes faziam patch em fcpxml.writer.subprocess, que agora mora em writer.document (ver Engine/docs/05_EXPERIENCIAS.md #23). Lint zerado, 1441 testes passando. Co-Authored-By: Claude Opus 5 --- code/Engine/docs/05_EXPERIENCIAS.md | 28 + code/fcpxml/writer.py | 4199 --------------------------- code/fcpxml/writer/__init__.py | 130 + code/fcpxml/writer/api.py | 55 + code/fcpxml/writer/audio.py | 162 ++ code/fcpxml/writer/compound.py | 196 ++ code/fcpxml/writer/connected.py | 49 + code/fcpxml/writer/core.py | 723 +++++ code/fcpxml/writer/cut.py | 333 +++ code/fcpxml/writer/document.py | 170 ++ code/fcpxml/writer/generator.py | 147 + code/fcpxml/writer/helpers.py | 279 ++ code/fcpxml/writer/insert.py | 78 + code/fcpxml/writer/markers.py | 165 ++ code/fcpxml/writer/modifier.py | 64 + code/fcpxml/writer/rapid.py | 240 ++ code/fcpxml/writer/reformat.py | 43 + code/fcpxml/writer/relink.py | 94 + code/fcpxml/writer/reorder.py | 126 + code/fcpxml/writer/roles.py | 43 + code/fcpxml/writer/selection.py | 57 + code/fcpxml/writer/silence.py | 185 ++ code/fcpxml/writer/speed.py | 297 ++ code/fcpxml/writer/titles.py | 600 ++++ code/fcpxml/writer/transitions.py | 94 + code/fcpxml/writer/trim.py | 125 + code/fcpxml/writer/validation.py | 232 ++ code/tests/test_features_v06.py | 8 +- 28 files changed, 4719 insertions(+), 4203 deletions(-) delete mode 100755 code/fcpxml/writer.py create mode 100644 code/fcpxml/writer/__init__.py create mode 100644 code/fcpxml/writer/api.py create mode 100644 code/fcpxml/writer/audio.py create mode 100644 code/fcpxml/writer/compound.py create mode 100644 code/fcpxml/writer/connected.py create mode 100644 code/fcpxml/writer/core.py create mode 100644 code/fcpxml/writer/cut.py create mode 100644 code/fcpxml/writer/document.py create mode 100644 code/fcpxml/writer/generator.py create mode 100644 code/fcpxml/writer/helpers.py create mode 100644 code/fcpxml/writer/insert.py create mode 100644 code/fcpxml/writer/markers.py create mode 100644 code/fcpxml/writer/modifier.py create mode 100644 code/fcpxml/writer/rapid.py create mode 100644 code/fcpxml/writer/reformat.py create mode 100644 code/fcpxml/writer/relink.py create mode 100644 code/fcpxml/writer/reorder.py create mode 100644 code/fcpxml/writer/roles.py create mode 100644 code/fcpxml/writer/selection.py create mode 100644 code/fcpxml/writer/silence.py create mode 100644 code/fcpxml/writer/speed.py create mode 100644 code/fcpxml/writer/titles.py create mode 100644 code/fcpxml/writer/transitions.py create mode 100644 code/fcpxml/writer/trim.py create mode 100644 code/fcpxml/writer/validation.py diff --git a/code/Engine/docs/05_EXPERIENCIAS.md b/code/Engine/docs/05_EXPERIENCIAS.md index f085a2c..04dd93f 100644 --- a/code/Engine/docs/05_EXPERIENCIAS.md +++ b/code/Engine/docs/05_EXPERIENCIAS.md @@ -1228,6 +1228,33 @@ o outro; percentil entrega um punhado útil nos dois casos. --- +## 23 — 2026-08-19 — Dividir um módulo em pacote quebra quem faz `patch` nele + +- **Sintoma:** ao transformar `fcpxml/writer.py` (4.199 linhas) no pacote + `fcpxml/writer/`, quatro testes passaram a falhar com + `AttributeError: module 'fcpxml.writer' has no attribute 'subprocess'` — + embora nenhuma linha de lógica tivesse mudado. +- **Causa raiz:** os testes usavam `@patch('fcpxml.writer.subprocess.run')`. + Isso não depende da API pública, e sim de *onde o import mora*: com o + módulo dividido, `subprocess` passou a ser importado por + `fcpxml/writer/document.py`, então o alvo do patch deixou de existir. + Re-exportar no `__init__` não resolveria — substituir + `fcpxml.writer.subprocess` não afeta a referência que `document` já tem. +- **Solução adotada:** apontar o patch para o módulo real + (`fcpxml.writer.document.subprocess.run`). Duas armadilhas do tipo foram + evitadas antes: imports relativos precisam de um ponto a mais ao descer um + nível (`from .models` → `from ..models`), inclusive os que ficam *dentro* + de funções, e o `__all__` precisa listar os nomes com underscore que o + resto do projeto já importava, senão a divisão vira quebra de API. +- **Aprendizado:** a suíte protege comportamento, não localização. Antes de + dividir um módulo, procure por `patch('.` e por imports relativos + escondidos dentro de funções — são as duas coisas que uma refatoração + puramente mecânica quebra em silêncio, e as únicas que os testes pegam + tarde. +- **Estado:** `resolvido` + +--- + ## Resumo rápido (índice) | # | Data | Problema | Estado | @@ -1252,5 +1279,6 @@ o outro; percentil entrega um punhado útil nos dois casos. | 20 | 2026-08-19 | `apply_voice_actions` ausente da ponte e do encadeamento do app — dava para analisar e legendar, não para cortar | `resolvido` | | 21 | 2026-08-19 | Teste ainda afirmava o default `zoom scale=1.3` removido do parser (agora vem do `zoom_scale` do usuário) | `resolvido` | | 22 | 2026-08-19 | `VideoPlayer` (AVKit) aborta em runtime no app compilado por `swiftc` — etapa 5 fechava o app; trocado por `AVPlayerLayer` | `resolvido` | +| 23 | 2026-08-19 | Dividir `writer.py` em pacote quebrou `@patch('fcpxml.writer.subprocess')` — a suíte protege comportamento, não localização | `resolvido` | > Mantenha o índice acima sempre sincronizado com as entradas mais recentes. diff --git a/code/fcpxml/writer.py b/code/fcpxml/writer.py deleted file mode 100755 index 8df945d..0000000 --- a/code/fcpxml/writer.py +++ /dev/null @@ -1,4199 +0,0 @@ -""" -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 fractions import Fraction -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -from .collision import blocking, validate_titles -from .models import ( - _FCPXML_STANDARD_TIMEBASES, - DynamicSubtitleConfig, - Marker, - MarkerColor, - MarkerType, - Project, - Timecode, - TimeValue, - ValidationIssue, - ValidationIssueType, -) -from .text_layout import ( - TEXT_TEMPLATE_FONT_SCALE, - 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)} - - -# 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 - - -# ============================================================================ -# 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 _document_frame_duration(root: ET.Element) -> Optional[Fraction]: - """The sequence's exact ``frameDuration`` as a fraction, if declared. - - Read from the format the ```` references (falling back to the - first declared format), so the value is the document's own timebase - rather than an assumed rate. - """ - formats = {f.get('id'): f for f in root.findall('.//format') if f.get('id')} - sequence = root.find('.//sequence') - fmt = formats.get(sequence.get('format')) if sequence is not None else None - if fmt is None: - fmt = next(iter(formats.values()), None) - if fmt is None: - return None - raw = fmt.get('frameDuration', '') - if not (raw.endswith('s') and '/' in raw): - return None - numerator, denominator = raw[:-1].split('/', 1) - try: - value = Fraction(int(numerator), int(denominator)) - except (ValueError, ZeroDivisionError): - return None - return value if value > 0 else None - - -def _check_frame_alignment(root: ET.Element, fps: float = 24.0) -> List[ValidationIssue]: - """Check that durations are integer multiples of the frame duration. - - Uses the document's exact ``frameDuration`` fraction and rational - arithmetic. Comparing against an integer fps instead would flag every - NTSC project as broken: at 1001/24000s (23.976fps) a perfectly aligned - duration is not an integer number of "24fps" frames, so whole timelines - would be reported misaligned when nothing is wrong. - """ - issues = [] - frame_duration = _document_frame_duration(root) - label = f"{1 / float(frame_duration):.3f}".rstrip('0').rstrip('.') if frame_duration else str(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) - if frame_duration is not None: - frames = Fraction(tv.numerator, tv.denominator) / frame_duration - aligned = frames.denominator == 1 - else: - approx = tv.to_seconds() * fps - aligned = abs(approx - round(approx)) <= 0.01 - if not aligned: - 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 {label}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 ``