From 368bb62706c5735d1af14c6bc3bdaec15167984a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Henrique?= Date: Wed, 19 Aug 2026 22:31:18 -0400 Subject: [PATCH] =?UTF-8?q?refactor:=20models.py=20vira=20pacote,=20um=20m?= =?UTF-8?q?=C3=B3dulo=20por=20fam=C3=ADlia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eram 1.091 linhas com seis famílias de modelo sem relação entre si — enumerações, tempo racional, timeline, geração, QC e legendas. timing 304 TimeValue e Timecode timeline 217 clipes, marcadores, lanes, projeto enums 183 tipos/cores de marcador, transições, ritmo subtitles 157 paleta e look das legendas dinâmicas qc 121 achados de QC e resultado de validação planning 93 rough cut, ritmo, montagem O __init__ reexporta os 43 nomes, incluindo os com underscore que o writer e a suíte já importavam, então nenhum ponto de uso mudou. Lint zerado, 1454 testes passando. Co-Authored-By: Claude Opus 5 --- code/fcpxml/models.py | 1091 ----------------------------------------- 1 file changed, 1091 deletions(-) delete mode 100755 code/fcpxml/models.py diff --git a/code/fcpxml/models.py b/code/fcpxml/models.py deleted file mode 100755 index f38371a..0000000 --- a/code/fcpxml/models.py +++ /dev/null @@ -1,1091 +0,0 @@ -""" -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