""" Data models for Final Cut Pro FCPXML structures. Provides a clean Python interface for working with Final Cut Pro timelines, clips, markers, and other elements. """ import operator from dataclasses import dataclass, field from enum import Enum from fractions import Fraction from functools import total_ordering from math import gcd from typing import Any, Callable, Dict, List, Optional, Tuple from .text_layout import REFERENCE_BLOCK_LINE_GAP, TEXT_TEMPLATE_FONT_SCALE # ============================================================================ # ENUMS # ============================================================================ # Maximum length for marker type strings to prevent memory abuse _MAX_MARKER_TYPE_LENGTH = 64 class MarkerType(Enum): """Types of markers in Final Cut Pro. Members: STANDARD — Default marker with no completion state. INCOMPLETE — Task marker (completed="0" in FCPXML). ← canonical name TODO — Alias for INCOMPLETE. Kept for backward compatibility; resolves to the same object (``MarkerType.TODO is MarkerType.INCOMPLETE``). Python enums treat the first member with a given value as canonical; all subsequent members sharing that value become aliases. CHAPTER — Chapter marker (```` element). COMPLETED — Task marker with completed="1". Serialization helpers: ``from_string()`` — Accepts values, names, and legacy aliases (e.g. ``"todo-marker"``). Always returns the canonical member. ``from_xml_element()`` — Reads an ``lxml``/``ElementTree`` element and returns the appropriate type based on the tag name and ``completed`` attribute. ``xml_tag`` — The FCPXML element tag to emit when writing. ``xml_attrs`` — Extra attributes required when writing (e.g. ``completed="0"`` for INCOMPLETE). """ STANDARD = "standard" INCOMPLETE = "todo" TODO = "todo" # Backward-compat alias — resolves to INCOMPLETE at runtime CHAPTER = "chapter" COMPLETED = "completed" @classmethod def from_string(cls, value: str) -> 'MarkerType': """Convert a string to MarkerType, accepting both enum names and values. Includes input validation: rejects null bytes, control characters, and excessively long strings to prevent injection and memory abuse. Examples: MarkerType.from_string("todo") -> MarkerType.INCOMPLETE MarkerType.from_string("TODO") -> MarkerType.INCOMPLETE MarkerType.from_string("completed") -> MarkerType.COMPLETED """ if not isinstance(value, str): raise TypeError(f"Expected str, got {type(value).__name__}") if '\x00' in value or any(ord(c) < 32 and c not in ('\n', '\r', '\t') for c in value): raise ValueError("Marker type contains invalid control characters") if len(value) > _MAX_MARKER_TYPE_LENGTH: raise ValueError( f"Marker type exceeds maximum length ({_MAX_MARKER_TYPE_LENGTH} chars)" ) lowered = value.strip().lower() if not lowered: raise ValueError("Marker type cannot be empty") # Accept legacy aliases from older specs (e.g. "todo-marker" → INCOMPLETE) aliases = { "todo-marker": "todo", "completed-marker": "completed", "chapter-marker": "chapter", } lowered = aliases.get(lowered, lowered) try: return cls(lowered) except ValueError: raise ValueError( f"Invalid marker type: '{value}'. " f"Valid types: {', '.join(m.value for m in cls)}" ) @classmethod def from_xml_element(cls, elem) -> 'MarkerType': """Determine MarkerType from an XML element's tag and attributes. Centralises the parse-side mapping so the parser doesn't need to know about completed-attribute semantics. Rules (in priority order): 1. tag → CHAPTER (completed attr ignored) 2. completed='0' (exact) → INCOMPLETE 3. completed='1' (exact) → COMPLETED 4. Everything else → STANDARD (including whitespace-padded, absent, empty, or non-boolean completed values) Matching is intentionally strict — no .strip(), no case folding. This prevents whitespace-injected attributes like ' 0 ' from being misclassified. """ if elem.tag == 'chapter-marker': return cls.CHAPTER completed = elem.get('completed') if completed == '0': return cls.INCOMPLETE if completed == '1': return cls.COMPLETED return cls.STANDARD @property def xml_tag(self) -> str: """Return the FCPXML element tag for this marker type.""" return 'chapter-marker' if self == MarkerType.CHAPTER else 'marker' @property def xml_attrs(self) -> dict: """Return extra XML attributes this marker type requires when writing. Centralises the write-side mapping so both FCPXMLModifier and FCPXMLWriter use a single source of truth. """ if self == MarkerType.CHAPTER: return {'posterOffset': '0s'} if self == MarkerType.INCOMPLETE: return {'completed': '0'} if self == MarkerType.COMPLETED: return {'completed': '1'} return {} # Recognised marker XML tags — used by the parser for single-pass collection # and by the writer to validate element creation. MARKER_XML_TAGS = ('marker', 'chapter-marker') class MarkerColor(Enum): """Marker color options (FCP internal values).""" BLUE = 0 CYAN = 1 GREEN = 2 YELLOW = 3 ORANGE = 4 RED = 5 PINK = 6 PURPLE = 7 class TransitionType(Enum): """Built-in transition types.""" CROSS_DISSOLVE = "Cross Dissolve" FADE_TO_BLACK = "Fade to Color" FADE_FROM_BLACK = "Fade from Color" DIP_TO_COLOR = "Dip to Color" WIPE = "Wipe" SLIDE = "Slide" class PacingStyle(Enum): """Pacing presets for rough cut generation.""" SLOW = "slow" # 5-10 second cuts MEDIUM = "medium" # 2-5 second cuts FAST = "fast" # 0.5-2 second cuts DYNAMIC = "dynamic" # Varies throughout class FlashFrameSeverity(Enum): """Severity levels for flash frame detection.""" CRITICAL = "critical" # < 2 frames, almost certainly an error WARNING = "warning" # < 6 frames, potentially intentional but suspicious class PacingCurve(Enum): """Pacing curves for montage generation.""" CONSTANT = "constant" # Same clip duration throughout ACCELERATING = "accelerating" # Starts slow, gets faster DECELERATING = "decelerating" # Starts fast, gets slower PYRAMID = "pyramid" # Slow → fast → slow class ValidationIssueType(Enum): """Types of timeline validation issues.""" FLASH_FRAME = "flash_frame" GAP = "gap" DUPLICATE = "duplicate" ORPHAN_REF = "orphan_ref" INVALID_OFFSET = "invalid_offset" # DTD validation types (v0.6.0) ELEMENT_ORDER = "element_order" MISSING_ATTRIBUTE = "missing_attribute" INVALID_TIMEBASE = "invalid_timebase" FRAME_MISALIGNMENT = "frame_misalignment" MISSING_EFFECT_REF = "missing_effect_ref" MISSING_MEDIA_REP = "missing_media_rep" # ============================================================================ # TIME VALUE - Rational Time Representation # ============================================================================ # Standard FCPXML timebase denominators that FCP's DTD validator accepts. # TimeValue.to_fcpxml() only simplifies fractions when the result uses one # of these denominators, preventing values like "8/3s" that FCP rejects. _FCPXML_STANDARD_TIMEBASES = frozenset({ 1, 24, 25, 30, 48, 50, 60, 90, 96, 100, 120, 240, 600, 2400, 4800, 9600, 48000, }) @total_ordering @dataclass class TimeValue: """ Represents time in FCPXML's rational format. FCPXML uses fractions of seconds (e.g., "90/30s" for 3 seconds at 30fps). This class handles conversion between timecode, seconds, and FCPXML format. Examples: TimeValue(90, 30) # 3 seconds at 30fps TimeValue(1, 1) # 1 second TimeValue.from_timecode("00:01:30:15", fps=30) # 90.5 seconds """ numerator: int denominator: int = 1 def __post_init__(self): if self.denominator == 0: raise ValueError( f"TimeValue denominator cannot be zero (got {self.numerator}/0). " "This would corrupt all downstream time calculations." ) # Normalize sign: denominator must always be positive. # Cross-multiplication in __lt__/__eq__ assumes positive denominators; # __hash__ assumes canonical form. Without this, TimeValue(1, -2) # compares/hashes incorrectly against TimeValue(-1, 2). if self.denominator < 0: # Use object.__setattr__ because dataclass may be frozen-like object.__setattr__(self, 'numerator', -self.numerator) object.__setattr__(self, 'denominator', -self.denominator) @classmethod def from_timecode(cls, tc: str, fps: float = 30.0) -> 'TimeValue': """ Create TimeValue from various string formats. Supported formats: - "HH:MM:SS:FF" - Standard timecode - "HH:MM:SS;FF" - Drop-frame timecode - "30s" - Seconds - "90/30s" - FCPXML rational format - "15f" - Frames """ if not tc: return cls(0, 1) tc = str(tc).strip() # FCPXML format: "90/30s" or "30s" if tc.endswith('s'): tc_val = tc[:-1] if '/' in tc_val: parts = tc_val.split('/', 1) num, denom = int(parts[0]), int(parts[1]) if denom == 0: raise ValueError(f"Zero denominator in timecode: {tc}") return cls(num, denom) else: seconds = float(tc_val) frames = int(round(seconds * fps)) # int(fps) truncates NTSC rates (23.976/29.97/59.94fps) to # their nominal integer, mismatching the numerator (computed # with the real fps) against the denominator — e.g. at # 23.976fps this silently produced values ~1.04x too large. # Reconstruct the exact rational fps (24000/1001, etc.) from # the float instead, so numerator and denominator agree. fps_frac = Fraction(fps).limit_denominator(100_000) return cls(frames * fps_frac.denominator, fps_frac.numerator) # Frame format: "15f" if tc.endswith('f'): frames = int(tc[:-1]) return cls(frames, int(fps)) # Timecode format: "HH:MM:SS:FF" or "HH:MM:SS;FF" if ':' in tc or ';' in tc: parts = tc.replace(';', ':').split(':') if len(parts) == 4: h, m, s, f = map(int, parts) total_frames = int((h * 3600 + m * 60 + s) * fps + f) return cls(total_frames, int(fps)) elif len(parts) == 3: h, m, s = map(int, parts) total_frames = int((h * 3600 + m * 60 + s) * fps) return cls(total_frames, int(fps)) # Try as plain number (seconds) try: seconds = float(tc) frames = int(round(seconds * fps)) return cls(frames, int(fps)) except ValueError: raise ValueError(f"Invalid timecode format: {tc}") @classmethod def from_seconds(cls, seconds: float, fps: float = 30.0) -> 'TimeValue': """Create TimeValue from decimal seconds.""" frames = int(round(seconds * fps)) return cls(frames, int(fps)) @classmethod def zero(cls) -> 'TimeValue': """Return zero time value.""" return cls(0, 1) def to_fcpxml(self) -> str: """Convert to FCPXML time string (e.g., "90/30s"). Only simplifies when the denominator reduces to 1 (whole seconds) or stays a standard FCPXML timebase. Avoids producing denominators like 3, 7, etc. that FCP's DTD validator may reject. """ simplified = self.simplify() if simplified.denominator == 1: return f"{simplified.numerator}s" # Keep original denominator if simplification produces a non-standard # denominator (not a multiple of common timebases: 24, 30, 25, 2400) if simplified.denominator in _FCPXML_STANDARD_TIMEBASES: return f"{simplified.numerator}/{simplified.denominator}s" # Fall back to unsimplified form return f"{self.numerator}/{self.denominator}s" def to_seconds(self) -> float: """Convert to decimal seconds.""" return self.numerator / self.denominator def to_timecode(self, fps: float = 30.0) -> str: """Convert to HH:MM:SS:FF timecode string.""" total_frames = int(round(self.to_seconds() * fps)) total_secs, frames = divmod(total_frames, int(fps)) total_mins, secs = divmod(total_secs, 60) hours, mins = divmod(total_mins, 60) return f"{hours:02d}:{mins:02d}:{secs:02d}:{frames:02d}" def to_frames(self, fps: float = 30.0) -> int: """Convert to frame count.""" return int(round(self.to_seconds() * fps)) def simplify(self) -> 'TimeValue': """Reduce fraction to simplest form.""" if self.numerator == 0: return TimeValue(0, 1) divisor = gcd(abs(self.numerator), abs(self.denominator)) return TimeValue( self.numerator // divisor, self.denominator // divisor ) @staticmethod def _lcm_denom(d1: int, d2: int) -> int: """LCM of two denominators for cross-timebase arithmetic.""" return d1 // gcd(d1, d2) * d2 def _binop(self, other: 'TimeValue', op: Callable[[int, int], int]) -> 'TimeValue': """Shared logic for add/sub: same-denom fast path, then LCM alignment.""" if self.denominator == other.denominator: return TimeValue(op(self.numerator, other.numerator), self.denominator) lcd = TimeValue._lcm_denom(self.denominator, other.denominator) return TimeValue( op( self.numerator * (lcd // self.denominator), other.numerator * (lcd // other.denominator), ), lcd, ) def __add__(self, other: 'TimeValue') -> 'TimeValue': return self._binop(other, operator.add) def __sub__(self, other: 'TimeValue') -> 'TimeValue': return self._binop(other, operator.sub) def __mul__(self, scalar: float) -> 'TimeValue': new_num = round(self.numerator * scalar) return TimeValue(new_num, self.denominator) def __truediv__(self, scalar: float) -> 'TimeValue': if scalar == 0: raise ZeroDivisionError("Cannot divide TimeValue by zero") new_denom = round(self.denominator * scalar) if new_denom == 0: raise ZeroDivisionError( f"Division by {scalar} rounds denominator {self.denominator} to zero" ) return TimeValue(self.numerator, new_denom) def __lt__(self, other: 'TimeValue') -> bool: # Cross-multiply to compare without float conversion: # a/b < c/d ↔ a*d < c*b (denominators are always positive) return self.numerator * other.denominator < other.numerator * self.denominator def __eq__(self, other: object) -> bool: if not isinstance(other, TimeValue): return False # Cross-multiply for exact integer comparison return self.numerator * other.denominator == other.numerator * self.denominator def __hash__(self) -> int: # Delegate to simplify() — single source of truth for canonical form. # __post_init__ guarantees denominator > 0, so no zero guard needed. s = self.simplify() return hash((s.numerator, s.denominator)) def snap_to_frame(self, fps: float) -> 'TimeValue': """Round this time value to the nearest frame boundary at the given fps. Uses the 2400-tick timebase (LCM of common frame rates) so results always land on clean frame boundaries. Args: fps: Frame rate to snap to (e.g. 24, 30, 60) Returns: New TimeValue snapped to the nearest frame in 2400-tick timebase. """ fps_int = int(fps) if fps_int <= 0: raise ValueError(f"fps must be positive, got {fps}") ticks_per_frame = 2400 // fps_int total_ticks = round(self.to_seconds() * 2400) snapped_ticks = round(total_ticks / ticks_per_frame) * ticks_per_frame return TimeValue(snapped_ticks, 2400) def is_standard_timebase(self) -> bool: """Check if this TimeValue's denominator is an FCP-accepted timebase.""" simplified = self.simplify() return simplified.denominator in _FCPXML_STANDARD_TIMEBASES def __repr__(self) -> str: return f"TimeValue({self.numerator}/{self.denominator}s = {self.to_seconds():.3f}s)" # ============================================================================ # TIMECODE (Legacy compatibility - wraps TimeValue) # ============================================================================ @dataclass class Timecode: """ Represents a timecode value. Note: This class exists for backwards compatibility with the parser. New code should prefer TimeValue for rational time math. """ frames: int frame_rate: float = 24.0 drop_frame: bool = False @property def seconds(self) -> float: return self.frames / self.frame_rate @property def total_frames(self) -> int: return self.frames def to_smpte(self) -> str: """Convert to SMPTE timecode string (HH:MM:SS:FF).""" total_seconds = int(self.seconds) hours = total_seconds // 3600 minutes = (total_seconds % 3600) // 60 secs = total_seconds % 60 frames = int((self.seconds - total_seconds) * self.frame_rate) separator = ";" if self.drop_frame else ":" return f"{hours:02d}:{minutes:02d}:{secs:02d}{separator}{frames:02d}" @classmethod def from_rational(cls, rational_str: str, frame_rate: float = 24.0) -> "Timecode": """Parse FCPXML rational time format (e.g., '3600/24s').""" if not rational_str: return cls(frames=0, frame_rate=frame_rate) if rational_str.endswith('s'): rational_str = rational_str[:-1] if '/' in rational_str: num, denom = rational_str.split('/') seconds = int(num) / int(denom) else: seconds = float(rational_str) frames = int(seconds * frame_rate) return cls(frames=frames, frame_rate=frame_rate) def to_rational(self) -> str: """Convert to FCPXML rational format.""" return f"{self.frames}/{int(self.frame_rate)}s" def to_time_value(self) -> TimeValue: """Convert to TimeValue for rational math.""" return TimeValue(self.frames, int(self.frame_rate)) # ============================================================================ # CORE MODELS # ============================================================================ @dataclass class Keyword: """Represents a keyword/tag applied to a clip.""" value: str start: Optional[Timecode] = None duration: Optional[Timecode] = None @dataclass class Marker: """Represents a marker in the timeline.""" name: str start: Timecode duration: Optional[Timecode] = None marker_type: MarkerType = MarkerType.STANDARD note: str = "" color: Optional[MarkerColor] = None def to_youtube_timestamp(self) -> str: """Format as YouTube chapter timestamp.""" total_seconds = int(self.start.seconds) hours = total_seconds // 3600 minutes = (total_seconds % 3600) // 60 secs = total_seconds % 60 if hours > 0: return f"{hours}:{minutes:02d}:{secs:02d}" return f"{minutes}:{secs:02d}" @dataclass class Clip: """Represents a clip in the timeline.""" name: str start: Timecode duration: Timecode source_start: Optional[Timecode] = None source_end: Optional[Timecode] = None media_path: str = "" markers: List[Marker] = field(default_factory=list) keywords: List[Keyword] = field(default_factory=list) # Extended metadata rating: int = 0 # 0=unrated, 1-5 stars is_favorite: bool = False is_rejected: bool = False # Roles (FCP audio/video role assignments) audio_role: str = "" video_role: str = "" # Connected clips (B-roll, titles, audio attached to this clip) connected_clips: List['ConnectedClip'] = field(default_factory=list) @property def end(self) -> Timecode: return Timecode( frames=self.start.frames + self.duration.frames, frame_rate=self.start.frame_rate ) @property def duration_seconds(self) -> float: return self.duration.seconds @property def keyword_values(self) -> List[str]: """Get list of keyword strings.""" return [k.value for k in self.keywords] @dataclass class AudioClip(Clip): """Audio-specific clip.""" channels: int = 2 sample_rate: int = 48000 role: str = "dialogue" @dataclass class VideoClip(Clip): """Video-specific clip.""" width: int = 1920 height: int = 1080 has_audio: bool = True @dataclass class ConnectedClip: """A clip connected to a primary storyline clip (B-roll, titles, audio). In FCP's magnetic timeline, connected clips hang off spine clips via lanes. Positive lanes are above (video overlays), negative lanes are below (audio). """ name: str start: Timecode duration: Timecode lane: int = 1 offset: Optional[Timecode] = None source_start: Optional[Timecode] = None media_path: str = "" clip_type: str = "asset-clip" role: str = "" ref_id: str = "" parent_clip_name: str = "" markers: List[Marker] = field(default_factory=list) keywords: List[Keyword] = field(default_factory=list) @property def duration_seconds(self) -> float: return self.duration.seconds @dataclass class CompoundClip: """A compound clip (ref-clip) containing a nested timeline.""" name: str ref_id: str duration: Timecode start: Timecode clips: List[Clip] = field(default_factory=list) connected_clips: List[ConnectedClip] = field(default_factory=list) @property def duration_seconds(self) -> float: return self.duration.seconds @dataclass class SilenceCandidate: """A potential silence region detected by timeline heuristics.""" start_timecode: str duration_seconds: float reason: str # "gap", "ultra_short", "name_match", "duration_anomaly" confidence: float = 0.5 # 0.0 to 1.0 clip_name: Optional[str] = None clip_index: Optional[int] = None @dataclass class Transition: """Represents a transition between clips.""" name: str duration: Timecode start: Timecode transition_type: str = "cross-dissolve" @dataclass class Timeline: """Represents a Final Cut Pro timeline/sequence.""" name: str duration: Timecode frame_rate: float = 24.0 width: int = 1920 height: int = 1080 clips: List[Clip] = field(default_factory=list) audio_clips: List[AudioClip] = field(default_factory=list) transitions: List[Transition] = field(default_factory=list) markers: List[Marker] = field(default_factory=list) connected_clips: List[ConnectedClip] = field(default_factory=list) compound_clips: List[CompoundClip] = field(default_factory=list) @property def total_clips(self) -> int: return len(self.clips) @property def total_cuts(self) -> int: return max(0, len(self.clips) - 1) @property def average_clip_duration(self) -> float: if not self.clips: return 0.0 return sum(c.duration_seconds for c in self.clips) / len(self.clips) @property def cuts_per_minute(self) -> float: """Average cuts per minute.""" if self.duration.seconds <= 0: return 0.0 return (self.total_cuts / self.duration.seconds) * 60 def get_clips_shorter_than(self, seconds: float) -> List[Clip]: """Find clips shorter than threshold (flash frame detection).""" return [c for c in self.clips if c.duration_seconds < seconds] def get_clips_longer_than(self, seconds: float) -> List[Clip]: """Find clips longer than threshold.""" return [c for c in self.clips if c.duration_seconds > seconds] def get_clip_at(self, timecode: float) -> Optional[Clip]: """Find the clip at a specific timecode (seconds).""" for clip in self.clips: start_sec = clip.start.seconds end_sec = clip.end.seconds if start_sec <= timecode < end_sec: return clip return None def get_clips_by_keyword(self, keyword: str) -> List[Clip]: """Find all clips with a specific keyword.""" return [c for c in self.clips if keyword in c.keyword_values] @dataclass class Project: """Represents a Final Cut Pro project/library.""" name: str timelines: List[Timeline] = field(default_factory=list) fcpxml_version: str = "1.13" @property def primary_timeline(self) -> Optional[Timeline]: return self.timelines[0] if self.timelines else None # ============================================================================ # ROUGH CUT MODELS # ============================================================================ @dataclass class SegmentSpec: """Specification for a segment in auto rough cut.""" name: str keywords: List[str] = field(default_factory=list) duration_seconds: float = 0.0 priority: str = "best" # favorites, longest, shortest, random, best @dataclass class PacingConfig: """Configuration for rough cut pacing.""" pacing: str = "medium" # slow, medium, fast, dynamic min_clip_duration: float = 1.0 max_clip_duration: float = 8.0 avg_clip_duration: Optional[float] = None vary_pacing: bool = True def get_duration_range(self) -> Tuple[float, float]: """Get min/max based on pacing style.""" ranges = { "slow": (5.0, 10.0), "medium": (2.0, 5.0), "fast": (0.5, 2.0), "dynamic": (1.0, 6.0), } return ranges.get(self.pacing, (2.0, 5.0)) @dataclass class RoughCutResult: """Result of auto rough cut generation.""" output_path: str clips_used: int clips_available: int target_duration: float actual_duration: float segments: int average_clip_duration: float # ============================================================================ # SPEED CUTTING & VALIDATION MODELS (v0.3.0) # ============================================================================ @dataclass class FlashFrame: """ Represents a detected flash frame (ultra-short clip). Flash frames are typically editing errors - clips that are too short to be perceived as intentional cuts. """ clip_name: str clip_id: str start: Timecode duration_frames: int duration_seconds: float severity: 'FlashFrameSeverity' @property def is_critical(self) -> bool: """Check if this is a critical flash frame.""" return self.severity == FlashFrameSeverity.CRITICAL @dataclass class GapInfo: """ Represents a detected gap in the timeline. Gaps can be intentional (black frames) or errors from deleted clips. """ start: Timecode duration_frames: int duration_seconds: float previous_clip: Optional[str] = None # Clip name before the gap next_clip: Optional[str] = None # Clip name after the gap @property def timecode(self) -> str: """Get timecode string for the gap start.""" return self.start.to_smpte() @dataclass class DuplicateGroup: """ Represents a group of clips using the same source media. Useful for detecting duplicate clips that may be unintentional. """ source_ref: str # The asset/media reference ID source_name: str # Human-readable source name clips: List[Dict[str, Any]] = field(default_factory=list) # List of clip info dicts @property def count(self) -> int: """Number of clips using this source.""" return len(self.clips) @property def has_overlapping_ranges(self) -> bool: """Check if any clips use overlapping portions of the source.""" # Sort clips by source_start sorted_clips = sorted(self.clips, key=lambda c: c.get('source_start', 0)) for i in range(len(sorted_clips) - 1): curr_end = sorted_clips[i].get('source_start', 0) + sorted_clips[i].get('source_duration', 0) next_start = sorted_clips[i + 1].get('source_start', 0) if curr_end > next_start: return True return False @dataclass class ValidationIssue: """ Represents a single validation issue found in a timeline. Used by validate_timeline to report problems. """ issue_type: 'ValidationIssueType' severity: str # "error", "warning", "info" message: str timecode: Optional[str] = None clip_name: Optional[str] = None details: Dict[str, Any] = field(default_factory=dict) @dataclass class ValidationResult: """ Result of timeline validation. Provides a health score and categorized list of issues. """ is_valid: bool health_score: int # 0-100 percentage issues: List[ValidationIssue] = field(default_factory=list) flash_frames: List[FlashFrame] = field(default_factory=list) gaps: List[GapInfo] = field(default_factory=list) duplicates: List[DuplicateGroup] = field(default_factory=list) @property def error_count(self) -> int: return len([i for i in self.issues if i.severity == "error"]) @property def warning_count(self) -> int: return len([i for i in self.issues if i.severity == "warning"]) def summary(self) -> str: """Generate a summary string.""" return ( f"Timeline Health: {self.health_score}% | " f"Errors: {self.error_count} | Warnings: {self.warning_count} | " f"Flash frames: {len(self.flash_frames)} | Gaps: {len(self.gaps)}" ) @dataclass class MontageConfig: """Configuration for montage generation with pacing curves.""" target_duration: float # Target duration in seconds pacing_curve: 'PacingCurve' start_duration: float = 2.0 # Clip duration at start end_duration: float = 0.5 # Clip duration at end min_duration: float = 0.2 # Minimum allowed clip duration max_duration: float = 5.0 # Maximum allowed clip duration def get_duration_at_position(self, position: float) -> float: """ Calculate clip duration for a given position (0.0 to 1.0). Args: position: Position in montage (0.0 = start, 1.0 = end) Returns: Target duration in seconds for a clip at this position """ if self.pacing_curve == PacingCurve.CONSTANT: duration = (self.start_duration + self.end_duration) / 2 elif self.pacing_curve == PacingCurve.ACCELERATING: # Linear interpolation from start to end duration duration = self.start_duration + (self.end_duration - self.start_duration) * position elif self.pacing_curve == PacingCurve.DECELERATING: # Reverse: start fast, end slow duration = self.end_duration + (self.start_duration - self.end_duration) * position elif self.pacing_curve == PacingCurve.PYRAMID: # Slow → fast → slow (parabolic curve) if position < 0.5: # First half: slow to fast duration = self.start_duration + (self.end_duration - self.start_duration) * (position * 2) else: # Second half: fast to slow duration = self.end_duration + (self.start_duration - self.end_duration) * ((position - 0.5) * 2) else: duration = self.start_duration # Clamp to min/max return max(self.min_duration, min(self.max_duration, duration)) # The palette and type treatment of the calibration export # ("Exemplo Letra.fcpxmld", sentence "Toda a minha vida, assim,"), copied # verbatim from what the user set in Final Cut's Inspector. COLOR_INDIGO = "0.156863 0 0.596079 1" COLOR_YELLOW = "0.997808 0.882664 0.0388632 1" COLOR_GREY = "0.7 0.7 0.7 1" COLOR_WHITE = "1 1 1 1" @dataclass class WordLook: """How one word is set: size, colour and type treatment. A sentence cycles through a tuple of these, so its typography reads with a deliberate rhythm rather than a uniform block. """ font_size: int color: str font: str = "Helvetica Neue" face: Optional[str] = None # Final Cut's fontFace, e.g. "Light Italic" kerning: float = 2.048 @property def italic(self) -> bool: return bool(self.face) and "italic" in self.face.lower() # One entry per word of the reference sentence, in order: # Toda(170, indigo, Helvetica Light) a(128, yellow) minha(151, grey) # vida,(128, white) assim,(128, grey, Light Italic) REFERENCE_RHYTHM = ( WordLook(170, COLOR_INDIGO, font="Helvetica", face="Light", kerning=2.72), WordLook(128, COLOR_YELLOW), WordLook(151, COLOR_GREY, kerning=2.416), WordLook(128, COLOR_WHITE), WordLook(128, COLOR_GREY, face="Light Italic"), ) # The progressive-composition look (reference: the reel the user sent, # 2026-08-17). Supporting text in a small grotesque, the sentence's key word # large in a display italic, everything white — the two-font contrast IS the # style. Playfair Display ships in the user's ~/Library/Fonts and its real # advance widths are embedded in font_metrics, so the lines can be measured # rather than guessed. Both are plain WordLooks: swap them for any installed # family (a script/calligraphic face for the emphasis, say) and layout follows. EDITORIAL_EMPHASIS_LOOK = WordLook( 230, COLOR_WHITE, font="Playfair Display", face="Medium Italic", kerning=0.0, ) EDITORIAL_BODY_LOOK = WordLook( 88, COLOR_WHITE, font="Helvetica Neue", face="Bold", kerning=1.2, ) @dataclass class WordStyle: """Per-word text styling for dynamic (karaoke-style) subtitles. ``rhythm`` drives size, colour and face, cycling by the word's index within its sentence — deterministic, so regenerating a transcript twice yields the same look. ``font``/``font_size`` are the fallback when ``rhythm`` is empty. """ font: str = "Helvetica Neue" font_size: int = 128 active_color: str = COLOR_WHITE inactive_color: str = COLOR_GREY bold: bool = False kerning: float = 2.048 rhythm: tuple = REFERENCE_RHYTHM # Progressive composition only (granularity="phrase"). emphasis_look: Optional[WordLook] = None body_look: Optional[WordLook] = None def look_for(self, index: int) -> WordLook: """The look for the word at *index* within its sentence.""" if not self.rhythm: return WordLook( self.font_size, self.active_color, font=self.font, kerning=self.kerning, ) return self.rhythm[index % len(self.rhythm)] def look_for_emphasis(self) -> WordLook: """The look for a composition's key word (progressive composition).""" return self.emphasis_look or EDITORIAL_EMPHASIS_LOOK def look_for_body(self) -> WordLook: """The look for a composition's supporting lines.""" return self.body_look or EDITORIAL_BODY_LOOK @dataclass class SubtitlePosition: """Screen position for generated title clips, in FCP title coordinate space.""" x: float = 0.0 y: float = -300.0 alignment: str = "center" # left | center | right @dataclass class DynamicSubtitleConfig: """Options for FCPXMLWriter.generate_dynamic_subtitles(). Dynamic subtitles are animated TITLES, not captions. Both templates below render on the video title lane and neither carries a ``role`` attribute — a ``role="subtitles.*"`` would make Final Cut treat them as captions and hide them behind the caption-display toggle. ``animated`` picks the template: True uses "Essencial - Título" (Essential Title), which animates on its own Motion defaults; False uses the static "Título Básico" (Basic Title). Default is True — the animated reveal is the feature's purpose. Words are grouped into sentences and laid out as a compact typographic block: each word becomes its own positioned ````, appearing as it is spoken and accumulating on screen, with every word of a block clearing at the same instant so the sentence vanishes as a whole. ``band_height`` is the fraction of frame height the block may occupy, and ``block_center_y`` its centre in canvas points (negative is below frame centre). The defaults reproduce the calibration export the user built by hand: a block of at most three lines sitting just below centre. A sentence taller than the band splits into successive blocks. """ style: WordStyle = field(default_factory=WordStyle) position: SubtitlePosition = field(default_factory=SubtitlePosition) animated: bool = True band_height: float = 0.22 block_center_y: float = -167.0 # "phrase": one title per LINE of the composition — supporting words # grouped, the key word alone and large (the reference look). "word": one # title per word, the earlier rhythm. granularity: str = "phrase" # Ratio between the template's fontSize space and the canvas-point space # its Position uses. See text_layout.TEXT_TEMPLATE_FONT_SCALE: the "Text" # (Text.moti) template sizes type in frame pixels, so a size chosen in # points renders half as large unless it is converted on the way out. text_scale: float = TEXT_TEMPLATE_FONT_SCALE # Vertical air between stacked lines, in canvas points. Negative values # deliberately overlap the lines — the display italic tucking under the # line above is a real editorial look, and the stacking arithmetic places # ink boxes edge to edge, so a negative gap moves them by exactly that # much rather than colliding unpredictably. line_gap: float = REFERENCE_BLOCK_LINE_GAP # Run the post-generation collision validation (collision.validate_titles) # and refuse to emit when it reports a blocking overlap. Off by default so # generation stays byte-identical to before this flag existed; flip it on # for a guaranteed no-collision export. validate: bool = False