"""Verificações estruturais do FCPXML antes de salvar. Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto. """ import logging import xml.etree.ElementTree as ET from fractions import Fraction from typing import List, Optional from ..models import ( _FCPXML_STANDARD_TIMEBASES, TimeValue, ValidationIssue, ValidationIssueType, ) from .helpers import _ASSET_CLIP_CHILD_ORDER, _CHILD_ORDER_INDEX # ============================================================================ # 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