Files
gart/code/fcpxml/writer/validation.py
T
João HenriqueandClaude Opus 5 4f5cf94443 refactor: writer.py vira pacote, um módulo por assunto
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 <noreply@anthropic.com>
2026-08-19 21:38:49 -04:00

233 lines
8.7 KiB
Python

"""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 ``<sequence>`` 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"<filter-video> 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"<asset id=\"{asset.get('id', '?')}\" "
f"name=\"{asset.get('name', '')}\"> "
f"has no src attribute and no <media-rep> 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 <fcpxml> 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