Files
gart/code/fcpxml/writer/document.py
João HenriqueandClaude Opus 5 4f5cf94443 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>
2026-08-19 21:38:49 -04:00

171 lines
6.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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>')