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
+170
View File
@@ -0,0 +1,170 @@
"""Escrita do documento FCPXML: assets de vídeo, timebases e serialização.
Extraído de writer.py — ver fcpxml/writer/__init__.py para o conjunto.
"""
import logging
import subprocess
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional
from ..models import (
TimeValue,
)
from .validation import validate_fcpxml
_log = logging.getLogger(__name__)
# ============================================================================
# STILL IMAGE AUTO-CONVERSION (v0.6.0)
# ============================================================================
_STILL_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.tiff', '.tif', '.bmp'}
def _ensure_video_asset(
src_path: str,
duration: float = 10.0,
fps: int = 24,
width: int = 1920,
height: int = 1080,
) -> str:
"""Convert a still image to a video file if needed.
Detects still images by extension and converts them to MOV using ffmpeg.
Video files are returned as-is.
Args:
src_path: Path to the source media file.
duration: Duration in seconds for the still-to-video conversion.
fps: Frame rate for the output video.
width: Output width (even number).
height: Output height (even number).
Returns:
Path to the video file (original path if already video, new .mov path
if converted from still).
Raises:
FileNotFoundError: If ffmpeg is not installed.
"""
# Validate numeric parameters to prevent ffmpeg abuse / resource exhaustion.
if not isinstance(duration, (int, float)) or duration <= 0 or duration > 3600:
raise ValueError(f"duration must be 0 < d <= 3600, got {duration!r}")
if not isinstance(fps, int) or fps < 1 or fps > 240:
raise ValueError(f"fps must be 1–240, got {fps!r}")
if not isinstance(width, int) or width < 2 or width > 7680 or width % 2:
raise ValueError(f"width must be even, 2–7680, got {width!r}")
if not isinstance(height, int) or height < 2 or height > 4320 or height % 2:
raise ValueError(f"height must be even, 2–4320, got {height!r}")
path = Path(src_path)
if path.suffix.lower() not in _STILL_IMAGE_EXTENSIONS:
return src_path
output_path = path.with_suffix('.mov')
if output_path.exists():
return str(output_path)
# Build ffmpeg command: still image → video with specified duration
cmd = [
'ffmpeg', '-y',
'-loop', '1',
'-i', str(path),
'-c:v', 'prores_ks',
'-profile:v', '0',
'-t', str(duration),
'-r', str(fps),
'-vf', f'scale={width}:{height}:force_original_aspect_ratio=decrease,'
f'pad={width}:{height}:(ow-iw)/2:(oh-ih)/2',
'-pix_fmt', 'yuva444p10le',
str(output_path),
]
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=120)
except FileNotFoundError:
raise FileNotFoundError(
"ffmpeg not found. Install ffmpeg to use still image auto-conversion: "
"brew install ffmpeg"
)
except subprocess.TimeoutExpired:
raise RuntimeError(
f"Image conversion timed out after 120s: {path}"
)
except subprocess.CalledProcessError as e:
stderr_msg = e.stderr.decode(errors='replace') if e.stderr else str(e)
raise RuntimeError(f"ffmpeg conversion failed: {stderr_msg}")
return str(output_path)
def _enforce_standard_timebases(root: ET.Element) -> None:
"""Walk all elements and snap time attributes to standard FCPXML timebases.
Targets offset, start, duration, and tcStart attributes. Values that
already use a standard denominator are left untouched.
"""
time_attrs = ('offset', 'start', 'duration', 'tcStart')
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)
if not tv.is_standard_timebase():
# Snap to nearest frame at 2400 ticks/sec
snapped = tv.snap_to_frame(24)
elem.set(attr, snapped.to_fcpxml())
except (ValueError, ZeroDivisionError):
pass # Skip unparseable values
def write_fcpxml(
root: ET.Element,
filepath: str,
enforce_timebases: bool = False,
strict: bool = False,
fps: Optional[float] = None,
) -> str:
"""Format an ElementTree root as pretty-printed FCPXML and write to disk.
Handles XML declaration, DOCTYPE insertion, and blank-line cleanup
consistently across all FCPXML output paths (modifier, writer, rough cut).
Args:
root: The <fcpxml> root Element to serialize.
filepath: Destination file path.
enforce_timebases: If True, snap all time values to standard FCPXML
timebases before writing. Default False for backward compat.
strict: If True, raise ValueError on validation errors.
If False (default), log warnings.
fps: Frame rate for the frame-alignment validation check. Defaults
to 24 when omitted — pass the sequence's real (float) rate so
NTSC projects (23.976/29.97/59.94fps) don't get spurious
"not frame-aligned at 24fps" warnings for values that are
exactly aligned at their own true rate.
Returns:
The filepath written to.
"""
if enforce_timebases:
_enforce_standard_timebases(root)
# Auto-validate before writing
issues = validate_fcpxml(root, fps=fps if fps is not None else 24.0)
if issues:
errors = [i for i in issues if i.severity == "error"]
warnings = [i for i in issues if i.severity == "warning"]
for w in warnings:
_log.warning("FCPXML validation: %s", w.message)
if errors and strict:
msg = "; ".join(e.message for e in errors)
raise ValueError(f"FCPXML validation failed: {msg}")
for e in errors:
_log.error("FCPXML validation: %s", e.message)
from ..safe_xml import serialize_xml
return serialize_xml(root, filepath, doctype='<!DOCTYPE fcpxml>')