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>
233 lines
8.7 KiB
Python
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
|
|
|
|
|