"""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 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='')