Files

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