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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1bebee4359
commit
4f5cf94443
@@ -0,0 +1,232 @@
|
||||
"""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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user