587 lines
18 KiB
Python
Executable File
587 lines
18 KiB
Python
Executable File
# models.py - Data Models for FCP MCP Server
|
|
|
|
"""
|
|
Core data models for representing FCPXML structures.
|
|
|
|
These models provide a clean Python interface for working with
|
|
Final Cut Pro timelines, clips, markers, and other elements.
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import List, Optional, Dict, Any, Tuple
|
|
from enum import Enum
|
|
from datetime import timedelta
|
|
|
|
|
|
# ============================================================================
|
|
# ENUMS
|
|
# ============================================================================
|
|
|
|
class MarkerType(Enum):
|
|
"""Types of markers in Final Cut Pro.
|
|
|
|
In FCPXML, INCOMPLETE and COMPLETED are both <marker> elements
|
|
distinguished by the completed attribute: '0' = INCOMPLETE, '1' = COMPLETED.
|
|
Only CHAPTER uses a separate <chapter-marker> tag.
|
|
"""
|
|
STANDARD = "standard"
|
|
INCOMPLETE = "todo"
|
|
CHAPTER = "chapter"
|
|
COMPLETED = "completed"
|
|
|
|
|
|
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 ClipType(Enum):
|
|
"""Types of clips in timeline."""
|
|
VIDEO = "video"
|
|
AUDIO = "audio"
|
|
COMPOUND = "compound"
|
|
MULTICAM = "multicam"
|
|
SYNC = "sync"
|
|
GAP = "gap"
|
|
TITLE = "title"
|
|
GENERATOR = "generator"
|
|
|
|
|
|
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
|
|
|
|
|
|
# ============================================================================
|
|
# TIME VALUE
|
|
# ============================================================================
|
|
|
|
@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
|
|
|
|
@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:
|
|
num, denom = tc_val.split('/')
|
|
return cls(int(num), int(denom))
|
|
else:
|
|
seconds = float(tc_val)
|
|
frames = int(round(seconds * fps))
|
|
return cls(frames, int(fps))
|
|
|
|
# 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:
|
|
# HH:MM:SS without frames
|
|
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")."""
|
|
simplified = self.simplify()
|
|
if simplified.denominator == 1:
|
|
return f"{simplified.numerator}s"
|
|
return f"{simplified.numerator}/{simplified.denominator}s"
|
|
|
|
def to_seconds(self) -> float:
|
|
"""Convert to decimal seconds."""
|
|
if self.denominator == 0:
|
|
return 0.0
|
|
return self.numerator / self.denominator
|
|
|
|
def to_timecode(self, fps: float = 30.0) -> str:
|
|
"""Convert to HH:MM:SS:FF timecode string."""
|
|
total_seconds = self.to_seconds()
|
|
total_frames = int(round(total_seconds * fps))
|
|
|
|
frames = int(total_frames % fps)
|
|
total_secs = total_frames // int(fps)
|
|
secs = total_secs % 60
|
|
total_mins = total_secs // 60
|
|
mins = total_mins % 60
|
|
hours = 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)
|
|
from math import gcd
|
|
divisor = gcd(abs(self.numerator), abs(self.denominator))
|
|
return TimeValue(
|
|
self.numerator // divisor,
|
|
self.denominator // divisor
|
|
)
|
|
|
|
def __add__(self, other: 'TimeValue') -> 'TimeValue':
|
|
new_denom = self.denominator * other.denominator
|
|
new_num = (self.numerator * other.denominator) + (other.numerator * self.denominator)
|
|
return TimeValue(new_num, new_denom).simplify()
|
|
|
|
def __sub__(self, other: 'TimeValue') -> 'TimeValue':
|
|
new_denom = self.denominator * other.denominator
|
|
new_num = (self.numerator * other.denominator) - (other.numerator * self.denominator)
|
|
return TimeValue(new_num, new_denom).simplify()
|
|
|
|
def __mul__(self, scalar: float) -> 'TimeValue':
|
|
new_num = int(self.numerator * scalar)
|
|
return TimeValue(new_num, self.denominator).simplify()
|
|
|
|
def __truediv__(self, scalar: float) -> 'TimeValue':
|
|
new_denom = int(self.denominator * scalar)
|
|
return TimeValue(self.numerator, new_denom).simplify()
|
|
|
|
def __lt__(self, other: 'TimeValue') -> bool:
|
|
return self.to_seconds() < other.to_seconds()
|
|
|
|
def __le__(self, other: 'TimeValue') -> bool:
|
|
return self.to_seconds() <= other.to_seconds()
|
|
|
|
def __gt__(self, other: 'TimeValue') -> bool:
|
|
return self.to_seconds() > other.to_seconds()
|
|
|
|
def __ge__(self, other: 'TimeValue') -> bool:
|
|
return self.to_seconds() >= other.to_seconds()
|
|
|
|
def __eq__(self, other: object) -> bool:
|
|
if not isinstance(other, TimeValue):
|
|
return False
|
|
return abs(self.to_seconds() - other.to_seconds()) < 0.0001
|
|
|
|
def __repr__(self) -> str:
|
|
return f"TimeValue({self.numerator}/{self.denominator}s = {self.to_seconds():.3f}s)"
|
|
|
|
|
|
# ============================================================================
|
|
# CORE MODELS
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class Marker:
|
|
"""A marker in the timeline."""
|
|
time: float # Position in seconds
|
|
name: str
|
|
marker_type: str = "standard"
|
|
color: Optional[str] = None
|
|
note: Optional[str] = None
|
|
duration: float = 0.0 # Usually 0 or 1 frame
|
|
|
|
# Reference to containing clip (if any)
|
|
clip_id: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class Keyword:
|
|
"""A keyword range applied to a clip or portion of a clip."""
|
|
value: str # The keyword text
|
|
start: float # Start time within clip (seconds)
|
|
duration: float # Duration of keyword range
|
|
|
|
# Some keywords apply to entire clips
|
|
is_clip_level: bool = False
|
|
|
|
|
|
@dataclass
|
|
class Effect:
|
|
"""A video or audio effect applied to a clip."""
|
|
name: str
|
|
effect_id: str # FCP internal identifier
|
|
parameters: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
# Effect category
|
|
is_audio: bool = False
|
|
is_transition: bool = False
|
|
|
|
|
|
@dataclass
|
|
class Clip:
|
|
"""
|
|
A clip in the timeline.
|
|
|
|
Represents video, audio, gap, or compound clips.
|
|
"""
|
|
# Identity
|
|
id: str
|
|
name: str
|
|
clip_type: str = "video" # video, audio, gap, compound, etc.
|
|
|
|
# Timeline position
|
|
offset: float # Position in timeline (seconds)
|
|
duration: float # Duration in timeline (seconds)
|
|
|
|
# Source reference
|
|
source_start: float = 0.0 # In-point in source media
|
|
source_path: Optional[str] = None # Path to source file
|
|
asset_id: Optional[str] = None # Reference to asset in resources
|
|
|
|
# Metadata
|
|
keywords: List[str] = field(default_factory=list)
|
|
rating: int = 0 # 0=unrated, 1-5 stars
|
|
is_favorite: bool = False
|
|
is_rejected: bool = False
|
|
notes: str = ""
|
|
|
|
# Markers within this clip
|
|
markers: List[Marker] = field(default_factory=list)
|
|
|
|
# Effects applied
|
|
effects: List[Effect] = field(default_factory=list)
|
|
|
|
# Speed modification
|
|
speed: float = 1.0 # 1.0 = normal, 0.5 = half speed, 2.0 = double
|
|
has_speed_ramp: bool = False
|
|
|
|
# Audio properties
|
|
is_audio_only: bool = False
|
|
audio_volume: float = 0.0 # dB adjustment
|
|
is_muted: bool = False
|
|
|
|
# Technical
|
|
resolution: Optional[Tuple[int, int]] = None
|
|
frame_rate: Optional[float] = None
|
|
|
|
# Relationships
|
|
lane: int = 0 # 0 = primary storyline, negative = audio, positive = connected
|
|
connected_to: Optional[str] = None # ID of clip this connects to
|
|
|
|
@property
|
|
def end_offset(self) -> float:
|
|
"""Calculate end position in timeline."""
|
|
return self.offset + self.duration
|
|
|
|
@property
|
|
def source_end(self) -> float:
|
|
"""Calculate end point in source media."""
|
|
return self.source_start + (self.duration * self.speed)
|
|
|
|
def contains_timecode(self, tc: float) -> bool:
|
|
"""Check if this clip contains the given timecode."""
|
|
return self.offset <= tc < self.end_offset
|
|
|
|
|
|
@dataclass
|
|
class Transition:
|
|
"""A transition between clips."""
|
|
name: str
|
|
transition_type: str # cross-dissolve, fade, wipe, etc.
|
|
offset: float # Position in timeline
|
|
duration: float
|
|
|
|
# Which clips it connects
|
|
from_clip_id: Optional[str] = None
|
|
to_clip_id: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class Timeline:
|
|
"""
|
|
A complete FCP timeline/sequence.
|
|
|
|
Contains all clips, markers, and metadata for a project.
|
|
"""
|
|
# Identity
|
|
name: str
|
|
id: Optional[str] = None
|
|
|
|
# Technical properties
|
|
frame_rate: float = 30.0
|
|
resolution: Tuple[int, int] = (1920, 1080)
|
|
|
|
# Content
|
|
clips: List[Clip] = field(default_factory=list)
|
|
markers: List[Marker] = field(default_factory=list) # Timeline-level markers
|
|
transitions: List[Transition] = field(default_factory=list)
|
|
|
|
# Computed properties
|
|
@property
|
|
def duration(self) -> float:
|
|
"""Total timeline duration in seconds."""
|
|
if not self.clips:
|
|
return 0.0
|
|
return max(c.end_offset for c in self.clips)
|
|
|
|
@property
|
|
def clip_count(self) -> int:
|
|
"""Number of clips (excluding gaps)."""
|
|
return len([c for c in self.clips if c.clip_type != "gap"])
|
|
|
|
@property
|
|
def cut_count(self) -> int:
|
|
"""Number of cuts (transitions between clips)."""
|
|
return max(0, self.clip_count - 1)
|
|
|
|
@property
|
|
def cuts_per_minute(self) -> float:
|
|
"""Average cuts per minute."""
|
|
if self.duration <= 0:
|
|
return 0.0
|
|
return (self.cut_count / self.duration) * 60
|
|
|
|
@property
|
|
def average_clip_duration(self) -> float:
|
|
"""Average clip duration in seconds."""
|
|
clips = [c for c in self.clips if c.clip_type != "gap"]
|
|
if not clips:
|
|
return 0.0
|
|
return sum(c.duration for c in clips) / len(clips)
|
|
|
|
def get_clip_at(self, timecode: float) -> Optional[Clip]:
|
|
"""Find the clip at a specific timecode."""
|
|
for clip in self.clips:
|
|
if clip.contains_timecode(timecode):
|
|
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.keywords]
|
|
|
|
def get_all_markers(self) -> List[Marker]:
|
|
"""Get all markers (timeline + clip-level)."""
|
|
all_markers = list(self.markers)
|
|
for clip in self.clips:
|
|
for marker in clip.markers:
|
|
# Adjust marker time to timeline position
|
|
adjusted = Marker(
|
|
time=clip.offset + marker.time,
|
|
name=marker.name,
|
|
marker_type=marker.marker_type,
|
|
color=marker.color,
|
|
note=marker.note,
|
|
clip_id=clip.id
|
|
)
|
|
all_markers.append(adjusted)
|
|
return sorted(all_markers, key=lambda m: m.time)
|
|
|
|
|
|
# ============================================================================
|
|
# ROUGH CUT MODELS
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class SegmentSpec:
|
|
"""Specification for a segment in auto rough cut."""
|
|
name: str
|
|
keywords: List[str] = field(default_factory=list)
|
|
duration: Optional[TimeValue] = None # Target duration
|
|
duration_seconds: float = 0.0 # Alternative: duration in seconds
|
|
priority: str = "best" # favorites, longest, shortest, random, best
|
|
|
|
def get_duration_seconds(self) -> float:
|
|
"""Get duration as seconds."""
|
|
if self.duration:
|
|
return self.duration.to_seconds()
|
|
return self.duration_seconds
|
|
|
|
|
|
@dataclass
|
|
class PacingConfig:
|
|
"""Configuration for rough cut pacing."""
|
|
pacing: str = "medium" # slow, medium, fast, dynamic
|
|
min_clip_duration: float = 1.0 # Minimum seconds per clip
|
|
max_clip_duration: float = 8.0 # Maximum seconds per clip
|
|
avg_clip_duration: Optional[float] = None # Target average
|
|
vary_pacing: bool = True # Randomize within range
|
|
|
|
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 ClipSelection:
|
|
"""A clip selected for inclusion in rough cut."""
|
|
clip: Clip
|
|
segment: str # Which segment this belongs to
|
|
in_point: TimeValue # Where to start in source
|
|
out_point: TimeValue # Where to end in source
|
|
order: int # Position in final sequence
|
|
|
|
@property
|
|
def duration(self) -> TimeValue:
|
|
"""Duration of this selection."""
|
|
return self.out_point - self.in_point
|
|
|
|
|
|
@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
|
|
|
|
# Detailed breakdown
|
|
segment_durations: Dict[str, float] = field(default_factory=dict)
|
|
clips_per_segment: Dict[str, int] = field(default_factory=dict)
|
|
|
|
|
|
# ============================================================================
|
|
# ASSET MODELS
|
|
# ============================================================================
|
|
|
|
@dataclass
|
|
class Asset:
|
|
"""A media asset referenced by clips."""
|
|
id: str
|
|
name: str
|
|
src: str # File path or URL
|
|
|
|
# Duration in source
|
|
start: float = 0.0
|
|
duration: float = 0.0
|
|
|
|
# Media properties
|
|
has_video: bool = True
|
|
has_audio: bool = True
|
|
format_id: Optional[str] = None
|
|
|
|
|
|
@dataclass
|
|
class Format:
|
|
"""A format definition (resolution, frame rate, etc.)."""
|
|
id: str
|
|
name: str
|
|
width: int = 1920
|
|
height: int = 1080
|
|
frame_duration: str = "1/30s" # FCPXML format
|
|
|
|
@property
|
|
def frame_rate(self) -> float:
|
|
"""Calculate frame rate from frame duration."""
|
|
if '/' in self.frame_duration:
|
|
num, denom = self.frame_duration.replace('s', '').split('/')
|
|
return int(denom) / int(num)
|
|
return 30.0
|
|
|
|
|
|
@dataclass
|
|
class FCPXMLDocument:
|
|
"""
|
|
Complete FCPXML document structure.
|
|
|
|
Represents the entire file including resources and library structure.
|
|
"""
|
|
version: str = "1.11"
|
|
|
|
# Resources
|
|
formats: List[Format] = field(default_factory=list)
|
|
assets: List[Asset] = field(default_factory=list)
|
|
effects: List[Effect] = field(default_factory=list)
|
|
|
|
# Library structure
|
|
library_name: str = "Library"
|
|
events: List[str] = field(default_factory=list) # Event names
|
|
|
|
# Projects/Sequences
|
|
timelines: List[Timeline] = field(default_factory=list)
|
|
|
|
def get_asset(self, asset_id: str) -> Optional[Asset]:
|
|
"""Find asset by ID."""
|
|
for asset in self.assets:
|
|
if asset.id == asset_id:
|
|
return asset
|
|
return None
|
|
|
|
def get_format(self, format_id: str) -> Optional[Format]:
|
|
"""Find format by ID."""
|
|
for fmt in self.formats:
|
|
if fmt.id == format_id:
|
|
return fmt
|
|
return None
|