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:
João Henrique
2026-08-19 21:38:49 -04:00
co-authored by Claude Opus 5
parent 1bebee4359
commit 4f5cf94443
28 changed files with 4719 additions and 4203 deletions
+279
View File
@@ -0,0 +1,279 @@
"""Ajudantes de nível de módulo do writer: sanitização, escalas, elementos base.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import subprocess
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any, Dict, List, Optional
from ..models import (
MarkerType,
)
# 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 <asset> element with <media-rep> child instead of src attribute.
FCP's DTD prefers <media-rep kind="original-media" src="..."/> children
over the src attribute on <asset>. This helper produces the preferred form.
Args:
resources: Parent <resources> 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 <asset> 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