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>
171 lines
6.1 KiB
Python
171 lines
6.1 KiB
Python
"""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>')
|
||
|
||
|