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,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user