chore: adiciona .gitignore e commit.command

This commit is contained in:
João Henrique
2026-08-18 08:25:29 -04:00
parent 68958fde00
commit 8fca456ceb
215 changed files with 65752 additions and 0 deletions
+613
View File
@@ -0,0 +1,613 @@
# FCP MCP Server - Tool Schemas
Complete MCP tool definitions for all editing capabilities.
---
## Phase 1: Core Editing
### `add_marker`
```json
{
"name": "add_marker",
"description": "Add a marker to the timeline at a specific timecode. Supports chapter markers (for YouTube), to-do markers, and standard markers with custom colors.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"timecode": {
"type": "string",
"description": "Timecode in format HH:MM:SS:FF or seconds (e.g., '00:01:30:00' or '90.5')"
},
"name": {
"type": "string",
"description": "Marker name/label"
},
"marker_type": {
"type": "string",
"enum": ["standard", "chapter", "todo", "completed"],
"default": "standard",
"description": "Type of marker to create"
},
"color": {
"type": "string",
"enum": ["blue", "cyan", "green", "yellow", "orange", "red", "pink", "purple"],
"default": "blue",
"description": "Marker color"
},
"note": {
"type": "string",
"description": "Optional note/description for the marker"
}
},
"required": ["project_path", "timecode", "name"]
}
}
```
---
### `trim_clip`
```json
{
"name": "trim_clip",
"description": "Adjust the in-point and/or out-point of a clip in the timeline. Can trim by timecode or by duration delta.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_id": {
"type": "string",
"description": "Unique identifier of the clip (from list_clips)"
},
"trim_start": {
"type": "string",
"description": "New in-point timecode, or delta like '+00:00:01:00' or '-15f' (frames)"
},
"trim_end": {
"type": "string",
"description": "New out-point timecode, or delta like '+00:00:02:00' or '-30f'"
},
"ripple": {
"type": "boolean",
"default": true,
"description": "If true, subsequent clips shift to fill/accommodate the change"
}
},
"required": ["project_path", "clip_id"]
}
}
```
---
### `reorder_clips`
```json
{
"name": "reorder_clips",
"description": "Move one or more clips to a new position in the timeline. Supports moving single clips or batch reordering.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_ids": {
"type": "array",
"items": {"type": "string"},
"description": "List of clip IDs to move (maintains relative order)"
},
"target_position": {
"type": "string",
"description": "Where to insert: 'start', 'end', timecode, or 'after:clip_id' / 'before:clip_id'"
},
"ripple": {
"type": "boolean",
"default": true,
"description": "If true, other clips shift to accommodate"
}
},
"required": ["project_path", "clip_ids", "target_position"]
}
}
```
---
### `add_transition`
```json
{
"name": "add_transition",
"description": "Apply a transition between two clips or at clip boundaries.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_id": {
"type": "string",
"description": "Clip ID to add transition to"
},
"position": {
"type": "string",
"enum": ["start", "end", "both"],
"default": "end",
"description": "Where to apply the transition"
},
"transition_type": {
"type": "string",
"enum": ["cross-dissolve", "fade-to-black", "fade-from-black", "dip-to-color", "wipe", "slide"],
"default": "cross-dissolve",
"description": "Type of transition"
},
"duration": {
"type": "string",
"default": "00:00:00:15",
"description": "Transition duration in timecode or frames (e.g., '15f')"
}
},
"required": ["project_path", "clip_id"]
}
}
```
---
## Phase 2: Speed & Precision
### `change_speed`
```json
{
"name": "change_speed",
"description": "Modify the playback speed of a clip. Supports constant speed changes and speed ramps.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_id": {
"type": "string",
"description": "Unique identifier of the clip"
},
"speed": {
"type": "number",
"description": "Speed multiplier (0.5 = 50% slow-mo, 2.0 = 2x fast)"
},
"ramp": {
"type": "object",
"description": "Optional speed ramp configuration",
"properties": {
"start_speed": {"type": "number"},
"end_speed": {"type": "number"},
"curve": {
"type": "string",
"enum": ["linear", "ease-in", "ease-out", "ease-in-out"]
}
}
},
"preserve_pitch": {
"type": "boolean",
"default": true,
"description": "Maintain audio pitch when changing speed"
},
"frame_blending": {
"type": "string",
"enum": ["none", "frame-blending", "optical-flow"],
"default": "optical-flow",
"description": "Frame interpolation method for slow-motion"
}
},
"required": ["project_path", "clip_id", "speed"]
}
}
```
---
### `split_clip`
```json
{
"name": "split_clip",
"description": "Split a clip at one or more timecodes, creating separate clips.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_id": {
"type": "string",
"description": "Unique identifier of the clip to split"
},
"split_points": {
"type": "array",
"items": {"type": "string"},
"description": "List of timecodes within the clip to split at"
},
"split_type": {
"type": "string",
"enum": ["blade", "blade-all"],
"default": "blade",
"description": "'blade' splits only this clip, 'blade-all' splits all tracks at this point"
}
},
"required": ["project_path", "clip_id", "split_points"]
}
}
```
---
### `delete_clip`
```json
{
"name": "delete_clip",
"description": "Remove a clip from the timeline. Supports ripple delete or leaving a gap.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_ids": {
"type": "array",
"items": {"type": "string"},
"description": "List of clip IDs to delete"
},
"ripple": {
"type": "boolean",
"default": true,
"description": "If true, subsequent clips shift to fill the gap"
}
},
"required": ["project_path", "clip_ids"]
}
}
```
---
## Phase 3: Batch & AI-Powered
### `select_by_keyword`
```json
{
"name": "select_by_keyword",
"description": "Find and return clips matching specific keywords, ratings, or metadata criteria.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"keywords": {
"type": "array",
"items": {"type": "string"},
"description": "Keywords to match (OR logic by default)"
},
"match_mode": {
"type": "string",
"enum": ["any", "all", "none"],
"default": "any",
"description": "'any' = OR, 'all' = AND, 'none' = exclude these keywords"
},
"rating": {
"type": "object",
"properties": {
"min": {"type": "integer", "minimum": 1, "maximum": 5},
"max": {"type": "integer", "minimum": 1, "maximum": 5}
},
"description": "Filter by star rating range"
},
"favorites_only": {
"type": "boolean",
"default": false,
"description": "Only return favorited clips"
},
"exclude_rejected": {
"type": "boolean",
"default": true,
"description": "Exclude clips marked as rejected"
}
},
"required": ["project_path", "keywords"]
}
}
```
---
### `batch_trim`
```json
{
"name": "batch_trim",
"description": "Apply trim operations to multiple clips at once based on criteria.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_ids": {
"type": "array",
"items": {"type": "string"},
"description": "List of clip IDs to trim (or use selection_criteria)"
},
"selection_criteria": {
"type": "object",
"description": "Alternative to clip_ids: select clips dynamically",
"properties": {
"keywords": {"type": "array", "items": {"type": "string"}},
"min_duration": {"type": "string"},
"max_duration": {"type": "string"}
}
},
"trim_operation": {
"type": "object",
"properties": {
"trim_start_by": {"type": "string", "description": "Amount to trim from start"},
"trim_end_by": {"type": "string", "description": "Amount to trim from end"},
"set_duration": {"type": "string", "description": "Set all clips to this exact duration"}
}
},
"ripple": {
"type": "boolean",
"default": true
}
},
"required": ["project_path", "trim_operation"]
}
}
```
---
### `auto_rough_cut`
```json
{
"name": "auto_rough_cut",
"description": "AI-powered rough cut generation. Analyzes source clips by keywords and assembles a timeline based on target duration and pacing preferences.",
"inputSchema": {
"type": "object",
"properties": {
"source_path": {
"type": "string",
"description": "Path to FCPXML with source clips (library or event export)"
},
"output_path": {
"type": "string",
"description": "Path to write the generated rough cut FCPXML"
},
"target_duration": {
"type": "string",
"description": "Desired final duration (e.g., '00:03:30:00' for 3.5 minutes)"
},
"structure": {
"type": "array",
"description": "Ordered list of segments with keywords and duration targets",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Segment name (e.g., 'Intro', 'Verse 1')"},
"keywords": {"type": "array", "items": {"type": "string"}},
"duration": {"type": "string", "description": "Target duration for this segment"},
"priority": {"type": "string", "enum": ["favorites", "longest", "shortest", "random"]}
}
}
},
"pacing": {
"type": "string",
"enum": ["slow", "medium", "fast", "dynamic"],
"default": "medium",
"description": "Overall pacing feel - affects average cut length"
},
"pacing_config": {
"type": "object",
"description": "Advanced pacing controls",
"properties": {
"min_clip_duration": {"type": "string", "default": "00:00:01:00"},
"max_clip_duration": {"type": "string", "default": "00:00:08:00"},
"avg_clip_duration": {"type": "string"},
"vary_pacing": {"type": "boolean", "default": true, "description": "Vary cut lengths for organic feel"}
}
},
"transitions": {
"type": "object",
"properties": {
"between_segments": {"type": "string", "enum": ["none", "cross-dissolve", "fade-to-black"], "default": "cross-dissolve"},
"within_segments": {"type": "string", "enum": ["none", "cut", "cross-dissolve"], "default": "cut"}
}
},
"include_audio": {
"type": "boolean",
"default": true,
"description": "Include audio from clips"
},
"music_track": {
"type": "string",
"description": "Optional path to music file to lay under the rough cut"
}
},
"required": ["source_path", "output_path", "target_duration"]
}
}
```
---
## Utility Tools
### `batch_add_markers`
```json
{
"name": "batch_add_markers",
"description": "Add multiple markers at once from a list or based on detection criteria.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"markers": {
"type": "array",
"description": "List of markers to add",
"items": {
"type": "object",
"properties": {
"timecode": {"type": "string"},
"name": {"type": "string"},
"marker_type": {"type": "string"},
"color": {"type": "string"}
},
"required": ["timecode", "name"]
}
},
"auto_detect": {
"type": "object",
"description": "Auto-generate markers based on detection",
"properties": {
"at_cuts": {"type": "boolean", "description": "Add marker at every cut point"},
"at_keywords": {"type": "array", "items": {"type": "string"}, "description": "Add marker where keyword appears"},
"at_intervals": {"type": "string", "description": "Add markers at regular intervals (e.g., '00:00:30:00')"}
}
}
},
"required": ["project_path"]
}
}
```
---
### `apply_effect`
```json
{
"name": "apply_effect",
"description": "Apply a video or audio effect to one or more clips.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"clip_ids": {
"type": "array",
"items": {"type": "string"},
"description": "Clips to apply effect to"
},
"effect_type": {
"type": "string",
"enum": ["color-correction", "lut", "blur", "sharpen", "stabilize", "denoise", "transform"],
"description": "Type of effect to apply"
},
"parameters": {
"type": "object",
"description": "Effect-specific parameters (varies by effect_type)"
}
},
"required": ["project_path", "clip_ids", "effect_type"]
}
}
```
---
### `generate_proxies_list`
```json
{
"name": "generate_proxies_list",
"description": "Analyze project and generate a list of source files that need proxy generation.",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"threshold_resolution": {
"type": "string",
"default": "1920x1080",
"description": "Flag sources above this resolution"
},
"output_format": {
"type": "string",
"enum": ["json", "csv", "shell-script"],
"default": "json",
"description": "Output format for the list"
}
},
"required": ["project_path"]
}
}
```
---
### `export_for_color`
```json
{
"name": "export_for_color",
"description": "Export timeline in formats optimized for color grading roundtrip (DaVinci Resolve, Baselight).",
"inputSchema": {
"type": "object",
"properties": {
"project_path": {
"type": "string",
"description": "Path to the FCPXML file"
},
"output_path": {
"type": "string",
"description": "Path for the exported file"
},
"format": {
"type": "string",
"enum": ["fcpxml", "edl", "aaf", "xml-resolve"],
"default": "fcpxml",
"description": "Export format"
},
"include_grades": {
"type": "boolean",
"default": false,
"description": "Include existing color adjustments"
},
"handle_frames": {
"type": "integer",
"default": 24,
"description": "Frames of handles to include for each clip"
}
},
"required": ["project_path", "output_path"]
}
}
```
+389
View File
@@ -0,0 +1,389 @@
# FCPXML Structure Reference
How FCPXML represents different elements and what nodes need modification for each editing operation.
---
## Document Structure
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE fcpxml>
<fcpxml version="1.11">
<resources>
<!-- Media assets, formats, effects -->
</resources>
<library>
<event name="My Event">
<project name="My Project">
<sequence>
<spine>
<!-- Primary storyline clips -->
</spine>
</sequence>
</project>
</event>
</library>
</fcpxml>
```
---
## Key Elements
### `<asset>` - Media Reference
```xml
<asset id="r1" name="Interview_01" src="file:///path/to/Interview_01.mov"
start="0s" duration="300s" hasVideo="1" hasAudio="1">
<format id="r2" name="FFVideoFormat1080p30"/>
</asset>
```
### `<clip>` - Timeline Clip
```xml
<clip name="Interview_01" offset="0s" duration="120s" start="30s"
tcFormat="NDF" ref="r1">
<!-- offset: position in timeline -->
<!-- duration: length shown in timeline -->
<!-- start: in-point in source media -->
<!-- ref: links to asset id -->
</clip>
```
### `<video>` / `<audio>` - A/V Components
```xml
<video name="B-Roll" offset="120s" duration="60s" start="0s" ref="r3">
<audio lane="-1" offset="0s" duration="60s" start="0s" ref="r3"/>
</video>
```
### `<gap>` - Empty Space
```xml
<gap name="Gap" offset="180s" duration="30s"/>
```
### `<marker>` - Marker
```xml
<marker start="45s" duration="1/30s" value="Chapter 1"/>
<!-- Chapter marker -->
<chapter-marker start="90s" duration="1/30s" value="Intro"
posterOffset="0s"/>
<!-- To-do marker -->
<marker start="120s" duration="1/30s" value="Fix audio">
<note>Audio levels too low</note>
</marker>
```
### `<keyword>` - Keyword Range
```xml
<clip ref="r1" ...>
<keyword start="0s" duration="30s" value="Interview"/>
<keyword start="30s" duration="15s" value="B-Roll"/>
</clip>
```
### `<transition>` - Transition Effect
```xml
<transition name="Cross Dissolve" offset="119/2s" duration="1s">
<filter-video ref="r10" name="Cross Dissolve"/>
</transition>
```
---
## Edit Operations → XML Changes
### ADD_MARKER
**Target:** Inside `<clip>`, `<video>`, `<audio>`, or `<spine>`
```xml
<!-- Before -->
<clip name="Interview" offset="0s" duration="120s" start="0s" ref="r1">
</clip>
<!-- After: Standard marker at 45s -->
<clip name="Interview" offset="0s" duration="120s" start="0s" ref="r1">
<marker start="45s" duration="1/30s" value="Review this"/>
</clip>
<!-- After: Chapter marker -->
<clip name="Interview" offset="0s" duration="120s" start="0s" ref="r1">
<chapter-marker start="45s" duration="1/30s" value="Introduction" posterOffset="0s"/>
</clip>
<!-- After: Colored marker -->
<clip name="Interview" offset="0s" duration="120s" start="0s" ref="r1">
<marker start="45s" duration="1/30s" value="Needs work">
<marker-color color="3"/> <!-- 0=blue, 1=cyan, 2=green, 3=yellow... -->
</marker>
</clip>
```
---
### TRIM_CLIP
**Target:** `start` and `duration` attributes of `<clip>`
```xml
<!-- Before: 2 minute clip starting at source timecode 30s -->
<clip name="Interview" offset="0s" duration="120s" start="30s" ref="r1"/>
<!-- After: Trim 10s from start (new in-point) -->
<clip name="Interview" offset="0s" duration="110s" start="40s" ref="r1"/>
<!-- After: Trim 10s from end (shorter duration) -->
<clip name="Interview" offset="0s" duration="110s" start="30s" ref="r1"/>
```
**Ripple trim** also requires updating `offset` of all subsequent clips:
```xml
<!-- Before -->
<spine>
<clip name="A" offset="0s" duration="60s" .../>
<clip name="B" offset="60s" duration="60s" .../>
<clip name="C" offset="120s" duration="60s" .../>
</spine>
<!-- After: Trim 10s from end of clip A with ripple -->
<spine>
<clip name="A" offset="0s" duration="50s" .../>
<clip name="B" offset="50s" duration="60s" .../> <!-- offset shifted -->
<clip name="C" offset="110s" duration="60s" .../> <!-- offset shifted -->
</spine>
```
---
### REORDER_CLIPS
**Target:** `offset` attributes of all affected clips
```xml
<!-- Before: A, B, C order -->
<spine>
<clip name="A" offset="0s" duration="30s" .../>
<clip name="B" offset="30s" duration="30s" .../>
<clip name="C" offset="60s" duration="30s" .../>
</spine>
<!-- After: Move C to start → C, A, B -->
<spine>
<clip name="C" offset="0s" duration="30s" .../>
<clip name="A" offset="30s" duration="30s" .../>
<clip name="B" offset="60s" duration="30s" .../>
</spine>
```
**Important:** Element order in XML doesn't matter, only `offset` values determine timeline position.
---
### ADD_TRANSITION
**Target:** Insert `<transition>` element between clips
```xml
<!-- Before -->
<spine>
<clip name="A" offset="0s" duration="60s" .../>
<clip name="B" offset="60s" duration="60s" .../>
</spine>
<!-- After: Add 1s cross-dissolve between A and B -->
<spine>
<clip name="A" offset="0s" duration="60s" .../>
<transition name="Cross Dissolve" offset="59s" duration="1s">
<filter-video ref="r_dissolve" name="Cross Dissolve"/>
</transition>
<clip name="B" offset="60s" duration="60s" .../>
</spine>
```
**Note:** Transition `offset` is typically `clip_end - (transition_duration / 2)`
---
### CHANGE_SPEED
**Target:** Add `<timeMap>` or `<conform-rate>` inside clip
```xml
<!-- Constant speed change (50% slow-mo) -->
<clip name="Action" offset="0s" duration="120s" start="0s" ref="r1">
<conform-rate scaleEnabled="1" srcFrameRate="30"/>
<timeMap>
<timept time="0s" value="0s" interp="linear"/>
<timept time="120s" value="60s" interp="linear"/>
</timeMap>
</clip>
<!-- Speed ramp (100% → 50% → 100%) -->
<clip name="Action" offset="0s" duration="90s" start="0s" ref="r1">
<timeMap>
<timept time="0s" value="0s" interp="linear"/>
<timept time="30s" value="30s" interp="smooth2"/>
<timept time="60s" value="45s" interp="smooth2"/>
<timept time="90s" value="75s" interp="linear"/>
</timeMap>
</clip>
```
**Interpolation values:**
- `linear` - constant speed
- `smooth2` - ease in/out
- `smooth` - smoother ease
---
### SPLIT_CLIP
**Target:** Create two clips from one, adjusting `start`, `duration`, `offset`
```xml
<!-- Before: Single 60s clip -->
<spine>
<clip name="Interview" offset="0s" duration="60s" start="0s" ref="r1"/>
</spine>
<!-- After: Split at 30s mark -->
<spine>
<clip name="Interview" offset="0s" duration="30s" start="0s" ref="r1"/>
<clip name="Interview" offset="30s" duration="30s" start="30s" ref="r1"/>
</spine>
```
---
### DELETE_CLIP
**Ripple delete:** Remove clip, shift subsequent clips
```xml
<!-- Before -->
<spine>
<clip name="A" offset="0s" duration="30s" .../>
<clip name="B" offset="30s" duration="30s" .../>
<clip name="C" offset="60s" duration="30s" .../>
</spine>
<!-- After: Delete B with ripple -->
<spine>
<clip name="A" offset="0s" duration="30s" .../>
<clip name="C" offset="30s" duration="30s" .../> <!-- offset updated -->
</spine>
```
**Non-ripple delete:** Replace with gap
```xml
<!-- After: Delete B without ripple -->
<spine>
<clip name="A" offset="0s" duration="30s" .../>
<gap name="Gap" offset="30s" duration="30s"/>
<clip name="C" offset="60s" duration="30s" .../>
</spine>
```
---
## Time Format Conversions
FCPXML uses rational time (fractions of seconds):
| Timecode | FCPXML Time |
|----------|-------------|
| 00:00:01:00 @ 30fps | `1s` or `30/30s` |
| 00:00:00:15 @ 30fps | `15/30s` or `1/2s` |
| 00:01:00:00 | `60s` |
| 00:00:02:10 @ 24fps | `58/24s` |
**Conversion formula:**
```
fcpxml_time = (hours * 3600) + (minutes * 60) + seconds + (frames / fps)
```
**Frame-accurate time:**
```
frames_total = (hours * 3600 * fps) + (minutes * 60 * fps) + (seconds * fps) + frames
fcpxml_time = f"{frames_total}/{fps}s"
```
---
## Resource References
Every clip references resources by `id`:
```xml
<resources>
<format id="r1" name="FFVideoFormat1080p30" width="1920" height="1080"
frameDuration="1/30s"/>
<asset id="r2" name="Interview" src="file:///path/to/file.mov"
start="0s" duration="3600s" format="r1"/>
<effect id="r3" name="Cross Dissolve" uid=".../Cross Dissolve"/>
</resources>
<!-- In timeline -->
<clip ref="r2" .../>
<transition>
<filter-video ref="r3"/>
</transition>
```
---
## Compound Clips & Nested Timelines
```xml
<ref-clip name="Nested Sequence" ref="r5" offset="0s" duration="120s">
<!-- r5 points to another sequence -->
</ref-clip>
<!-- Inline compound (multicam, synchronized) -->
<mc-clip name="Multicam" offset="0s" duration="60s">
<mc-source angleID="angle1">
<clip ref="r10" .../>
</mc-source>
<mc-source angleID="angle2">
<clip ref="r11" .../>
</mc-source>
</mc-clip>
```
---
## Audio Specifics
```xml
<!-- Detached audio -->
<clip ref="r1" ...>
<audio lane="-1" ref="r1" offset="0s" duration="60s"/>
</clip>
<!-- Audio only clip -->
<audio-clip name="Music" lane="-2" offset="0s" duration="180s" ref="r20"/>
<!-- Audio adjustments -->
<clip ref="r1" ...>
<adjust-volume amount="-6dB"/>
<audio lane="-1" ref="r1">
<adjust-volume amount="3dB"/>
</audio>
</clip>
```
---
## Critical Implementation Notes
1. **Always validate XML** after modification - FCP will reject malformed FCPXML
2. **Maintain resource integrity** - never orphan asset references
3. **Recalculate all offsets** after any operation that changes clip positions
4. **Preserve existing attributes** - don't strip attributes you don't understand
5. **Handle connected clips** - clips on other lanes may connect to spine clips
6. **Time precision matters** - use rational fractions, not floats
+885
View File
@@ -0,0 +1,885 @@
# writer.py - FCPXML Write Operations
"""
FCPXML Writer Module
Handles all write operations for Final Cut Pro XML files.
Each function takes parsed FCPXML, performs modifications, and returns valid FCPXML.
"""
from dataclasses import dataclass
from typing import Optional, List, Dict, Union
from xml.etree import ElementTree as ET
from enum import Enum
import copy
# ============================================================================
# DATA MODELS
# ============================================================================
class MarkerType(Enum):
STANDARD = "standard"
INCOMPLETE = "todo"
CHAPTER = "chapter"
COMPLETED = "completed"
class MarkerColor(Enum):
BLUE = 0
CYAN = 1
GREEN = 2
YELLOW = 3
ORANGE = 4
RED = 5
PINK = 6
PURPLE = 7
@dataclass
class TimeValue:
"""Represents FCPXML time format (rational or decimal seconds)"""
numerator: int
denominator: int = 1
@classmethod
def from_timecode(cls, tc: str, fps: float = 30.0) -> 'TimeValue':
"""
Convert timecode string to TimeValue
Accepts: 'HH:MM:SS:FF', 'HH:MM:SS;FF' (drop-frame), 'XXs', 'XX.XXs', 'X/Ys'
"""
# Handle FCPXML format (e.g., "30s", "15/30s", "100/1s")
if tc.endswith('s'):
tc = tc[:-1]
if '/' in tc:
num, denom = tc.split('/')
return cls(int(num), int(denom))
else:
# Decimal seconds
seconds = float(tc)
frames = int(seconds * fps)
return cls(frames, int(fps))
# Handle timecode format (HH:MM:SS:FF)
parts = tc.replace(';', ':').split(':')
if len(parts) == 4:
h, m, s, f = map(int, parts)
total_frames = (h * 3600 + m * 60 + s) * int(fps) + f
return cls(total_frames, int(fps))
raise ValueError(f"Invalid timecode format: {tc}")
def to_fcpxml(self) -> str:
"""Convert to FCPXML time string"""
if self.denominator == 1:
return f"{self.numerator}s"
return f"{self.numerator}/{self.denominator}s"
def to_seconds(self) -> float:
"""Convert to decimal seconds"""
return self.numerator / self.denominator
def __add__(self, other: 'TimeValue') -> 'TimeValue':
# Find common denominator
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 simplify(self) -> 'TimeValue':
"""Reduce fraction to simplest form"""
from math import gcd
divisor = gcd(self.numerator, self.denominator)
return TimeValue(self.numerator // divisor, self.denominator // divisor)
# ============================================================================
# CORE WRITER CLASS
# ============================================================================
class FCPXMLWriter:
"""
Handles all write operations on FCPXML documents.
Usage:
writer = FCPXMLWriter(fcpxml_path)
writer.add_marker(clip_id, timecode, name, marker_type)
writer.save(output_path)
"""
def __init__(self, fcpxml_path: str):
self.tree = ET.parse(fcpxml_path)
self.root = self.tree.getroot()
self.fps = self._detect_fps()
self._build_clip_index()
def _detect_fps(self) -> float:
"""Extract frame rate from format resource"""
for fmt in self.root.findall('.//format'):
frame_dur = fmt.get('frameDuration', '1/30s')
if '/' in frame_dur:
num, denom = frame_dur.replace('s', '').split('/')
return int(denom) / int(num)
return 30.0 # Default
def _build_clip_index(self) -> None:
"""Build index of all clips for fast lookup"""
self.clips: Dict[str, ET.Element] = {}
for i, clip in enumerate(self.root.findall('.//clip')):
clip_id = clip.get('id') or f"clip_{i}"
self.clips[clip_id] = clip
for i, video in enumerate(self.root.findall('.//video')):
vid_id = video.get('id') or f"video_{i}"
self.clips[vid_id] = video
def _get_spine(self) -> ET.Element:
"""Get the primary storyline spine"""
spine = self.root.find('.//spine')
if spine is None:
raise ValueError("No spine found in FCPXML")
return spine
def _recalculate_offsets(self, spine: ET.Element) -> None:
"""Recalculate all clip offsets after modifications"""
current_offset = TimeValue(0)
for child in spine:
if child.tag in ('clip', 'video', 'audio', 'gap', 'transition', 'ref-clip'):
child.set('offset', current_offset.to_fcpxml())
duration_str = child.get('duration', '0s')
duration = TimeValue.from_timecode(duration_str, self.fps)
current_offset = current_offset + duration
def save(self, output_path: str) -> str:
"""Write modified FCPXML to file"""
self.tree.write(output_path, encoding='UTF-8', xml_declaration=True)
return output_path
# ========================================================================
# MARKER OPERATIONS
# ========================================================================
def add_marker(
self,
clip_id: str,
timecode: str,
name: str,
marker_type: MarkerType = MarkerType.STANDARD,
color: Optional[MarkerColor] = None,
note: Optional[str] = None
) -> ET.Element:
"""
Add a marker to a clip.
Args:
clip_id: Target clip identifier
timecode: Position within clip (relative to clip start)
name: Marker label
marker_type: standard, chapter, or todo
color: Optional marker color
note: Optional marker note
Returns:
The created marker element
"""
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
# Convert timecode to FCPXML time
time_value = TimeValue.from_timecode(timecode, self.fps)
# Create marker element
marker = ET.SubElement(clip, marker_type.value)
marker.set('start', time_value.to_fcpxml())
marker.set('duration', f"1/{int(self.fps)}s") # 1 frame duration
marker.set('value', name)
# Add poster offset for chapter markers
if marker_type == MarkerType.CHAPTER:
marker.set('posterOffset', '0s')
# Add color if specified
if color is not None:
color_elem = ET.SubElement(marker, 'marker-color')
color_elem.set('color', str(color.value))
# Add note if specified
if note:
note_elem = ET.SubElement(marker, 'note')
note_elem.text = note
return marker
def batch_add_markers(
self,
markers: List[Dict],
auto_detect: Optional[Dict] = None
) -> List[ET.Element]:
"""
Add multiple markers at once.
Args:
markers: List of marker specs [{clip_id, timecode, name, ...}]
auto_detect: Auto-generate markers (at_cuts, at_keywords, at_intervals)
Returns:
List of created marker elements
"""
created = []
# Handle explicit markers
for m in markers:
marker = self.add_marker(
clip_id=m['clip_id'],
timecode=m['timecode'],
name=m['name'],
marker_type=MarkerType[m.get('marker_type', 'STANDARD').upper()],
color=MarkerColor[m['color'].upper()] if m.get('color') else None,
note=m.get('note')
)
created.append(marker)
# Handle auto-detection
if auto_detect:
if auto_detect.get('at_cuts'):
# Add marker at every cut point
spine = self._get_spine()
for clip in spine.findall('clip'):
offset = clip.get('offset', '0s')
marker = self.add_marker(
clip_id=clip.get('id', 'clip_0'),
timecode='0s', # Start of clip = cut point
name='Cut',
marker_type=MarkerType.STANDARD,
color=MarkerColor.YELLOW
)
created.append(marker)
if auto_detect.get('at_intervals'):
# Add markers at regular intervals
interval = TimeValue.from_timecode(auto_detect['at_intervals'], self.fps)
# Implementation: iterate through timeline at interval steps
pass
return created
# ========================================================================
# TRIM OPERATIONS
# ========================================================================
def trim_clip(
self,
clip_id: str,
trim_start: Optional[str] = None,
trim_end: Optional[str] = None,
ripple: bool = True
) -> ET.Element:
"""
Trim a clip's in-point and/or out-point.
Args:
clip_id: Target clip
trim_start: New in-point or delta ('+1s', '-10f')
trim_end: New out-point or delta
ripple: Whether to shift subsequent clips
Returns:
Modified clip element
"""
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
# Get current values
current_start = TimeValue.from_timecode(clip.get('start', '0s'), self.fps)
current_duration = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
duration_delta = TimeValue(0)
# Handle trim_start
if trim_start:
if trim_start.startswith('+') or trim_start.startswith('-'):
# Delta trim
delta = TimeValue.from_timecode(trim_start.lstrip('+-'), self.fps)
if trim_start.startswith('-'):
new_start = current_start - delta
new_duration = current_duration + delta
else:
new_start = current_start + delta
new_duration = current_duration - delta
else:
# Absolute trim
new_start = TimeValue.from_timecode(trim_start, self.fps)
start_diff = new_start - current_start
new_duration = current_duration - start_diff
clip.set('start', new_start.to_fcpxml())
duration_delta = new_duration - current_duration
current_duration = new_duration
# Handle trim_end
if trim_end:
if trim_end.startswith('+') or trim_end.startswith('-'):
delta = TimeValue.from_timecode(trim_end.lstrip('+-'), self.fps)
if trim_end.startswith('-'):
new_duration = current_duration - delta
else:
new_duration = current_duration + delta
else:
# Absolute end point
end_point = TimeValue.from_timecode(trim_end, self.fps)
new_duration = end_point - current_start
duration_delta = duration_delta + (new_duration - current_duration)
current_duration = new_duration
clip.set('duration', current_duration.to_fcpxml())
# Ripple subsequent clips
if ripple and duration_delta.numerator != 0:
self._ripple_after_clip(clip, duration_delta)
return clip
def _ripple_after_clip(self, clip: ET.Element, delta: TimeValue) -> None:
"""Shift all clips after the given clip by delta"""
spine = self._get_spine()
found_clip = False
for child in spine:
if child == clip:
found_clip = True
continue
if found_clip and child.tag in ('clip', 'video', 'audio', 'gap', 'ref-clip'):
current_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
new_offset = current_offset + delta
child.set('offset', new_offset.to_fcpxml())
# ========================================================================
# REORDER OPERATIONS
# ========================================================================
def reorder_clips(
self,
clip_ids: List[str],
target_position: str,
ripple: bool = True
) -> None:
"""
Move clips to a new position in the timeline.
Args:
clip_ids: Clips to move (maintains relative order)
target_position: 'start', 'end', timecode, or 'after:clip_id'/'before:clip_id'
ripple: Whether to shift other clips
"""
spine = self._get_spine()
# Collect clips to move
clips_to_move = []
for clip_id in clip_ids:
for child in spine:
if child.get('id') == clip_id or child.get('name') == clip_id:
clips_to_move.append(child)
break
if not clips_to_move:
raise ValueError(f"No clips found matching: {clip_ids}")
# Calculate total duration of moving clips
total_duration = TimeValue(0)
for clip in clips_to_move:
dur = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
total_duration = total_duration + dur
# Remove clips from current positions (store for reinsertion)
for clip in clips_to_move:
spine.remove(clip)
# Determine target offset
if target_position == 'start':
target_offset = TimeValue(0)
insert_index = 0
elif target_position == 'end':
# Find end of timeline
last_clip = list(spine)[-1] if len(spine) > 0 else None
if last_clip is not None:
last_offset = TimeValue.from_timecode(last_clip.get('offset', '0s'), self.fps)
last_dur = TimeValue.from_timecode(last_clip.get('duration', '0s'), self.fps)
target_offset = last_offset + last_dur
else:
target_offset = TimeValue(0)
insert_index = len(spine)
elif target_position.startswith('after:'):
ref_id = target_position.split(':')[1]
for i, child in enumerate(spine):
if child.get('id') == ref_id or child.get('name') == ref_id:
ref_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
ref_dur = TimeValue.from_timecode(child.get('duration', '0s'), self.fps)
target_offset = ref_offset + ref_dur
insert_index = i + 1
break
elif target_position.startswith('before:'):
ref_id = target_position.split(':')[1]
for i, child in enumerate(spine):
if child.get('id') == ref_id or child.get('name') == ref_id:
target_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
insert_index = i
break
else:
# Assume timecode
target_offset = TimeValue.from_timecode(target_position, self.fps)
# Find insert position
insert_index = 0
for i, child in enumerate(spine):
child_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
if child_offset.to_seconds() >= target_offset.to_seconds():
insert_index = i
break
insert_index = i + 1
# Insert clips at new position
current_offset = target_offset
for clip in clips_to_move:
clip.set('offset', current_offset.to_fcpxml())
spine.insert(insert_index, clip)
insert_index += 1
dur = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
current_offset = current_offset + dur
# Recalculate all offsets if ripple
if ripple:
self._recalculate_offsets(spine)
# ========================================================================
# TRANSITION OPERATIONS
# ========================================================================
def add_transition(
self,
clip_id: str,
position: str = 'end',
transition_type: str = 'cross-dissolve',
duration: str = '00:00:00:15'
) -> ET.Element:
"""
Add a transition to a clip.
Args:
clip_id: Target clip
position: 'start', 'end', or 'both'
transition_type: Type of transition
duration: Transition duration
"""
spine = self._get_spine()
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
trans_duration = TimeValue.from_timecode(duration, self.fps)
# Find clip index in spine
clip_index = None
for i, child in enumerate(spine):
if child == clip:
clip_index = i
break
if clip_index is None:
raise ValueError(f"Clip not in primary storyline: {clip_id}")
transitions_added = []
# Map transition type to FCPXML effect name
effect_map = {
'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'
}
effect_name = effect_map.get(transition_type, 'Cross Dissolve')
if position in ('end', 'both'):
# Add transition after clip
clip_offset = TimeValue.from_timecode(clip.get('offset', '0s'), self.fps)
clip_dur = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
# Transition starts at clip_end - (duration / 2)
half_dur = TimeValue(trans_duration.numerator, trans_duration.denominator * 2)
trans_offset = clip_offset + clip_dur - half_dur
transition = ET.Element('transition')
transition.set('name', effect_name)
transition.set('offset', trans_offset.to_fcpxml())
transition.set('duration', trans_duration.to_fcpxml())
# Add filter reference
filter_video = ET.SubElement(transition, 'filter-video')
filter_video.set('name', effect_name)
spine.insert(clip_index + 1, transition)
transitions_added.append(transition)
if position in ('start', 'both'):
# Add transition before clip
clip_offset = TimeValue.from_timecode(clip.get('offset', '0s'), self.fps)
half_dur = TimeValue(trans_duration.numerator, trans_duration.denominator * 2)
trans_offset = clip_offset - half_dur
transition = ET.Element('transition')
transition.set('name', effect_name)
transition.set('offset', trans_offset.to_fcpxml())
transition.set('duration', trans_duration.to_fcpxml())
filter_video = ET.SubElement(transition, 'filter-video')
filter_video.set('name', effect_name)
spine.insert(clip_index, transition)
transitions_added.append(transition)
return transitions_added[0] if len(transitions_added) == 1 else transitions_added
# ========================================================================
# SPEED OPERATIONS
# ========================================================================
def change_speed(
self,
clip_id: str,
speed: float,
ramp: Optional[Dict] = None,
preserve_pitch: bool = True,
frame_blending: str = 'optical-flow'
) -> ET.Element:
"""
Change clip playback speed.
Args:
clip_id: Target clip
speed: Speed multiplier (0.5 = half speed, 2.0 = double)
ramp: Optional speed ramp config {start_speed, end_speed, curve}
preserve_pitch: Maintain audio pitch
frame_blending: Interpolation method
"""
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
# Get current duration
current_duration = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
if ramp:
# Speed ramp - create timeMap with multiple keyframes
timemap = ET.SubElement(clip, 'timeMap')
start_speed = ramp.get('start_speed', 1.0)
end_speed = ramp.get('end_speed', speed)
curve = ramp.get('curve', 'linear')
# Map curve to FCPXML interpolation
interp_map = {
'linear': 'linear',
'ease-in': 'smooth2',
'ease-out': 'smooth2',
'ease-in-out': 'smooth'
}
interp = interp_map.get(curve, 'linear')
# Calculate output duration based on speed changes
# This is simplified - real implementation needs integral calculus
avg_speed = (start_speed + end_speed) / 2
new_duration_seconds = current_duration.to_seconds() / avg_speed
# Create keyframes
# Start point
tp1 = ET.SubElement(timemap, 'timept')
tp1.set('time', '0s')
tp1.set('value', '0s')
tp1.set('interp', 'linear')
# Mid point (speed transition)
mid_time = new_duration_seconds / 2
mid_value = current_duration.to_seconds() / 2 / start_speed
tp2 = ET.SubElement(timemap, 'timept')
tp2.set('time', f"{mid_time}s")
tp2.set('value', f"{mid_value}s")
tp2.set('interp', interp)
# End point
tp3 = ET.SubElement(timemap, 'timept')
tp3.set('time', f"{new_duration_seconds}s")
tp3.set('value', current_duration.to_fcpxml())
tp3.set('interp', 'linear')
# Update clip duration
clip.set('duration', f"{new_duration_seconds}s")
else:
# Constant speed change
timemap = ET.SubElement(clip, 'timeMap')
new_duration_seconds = current_duration.to_seconds() / speed
source_duration = current_duration.to_seconds()
# Start keyframe
tp1 = ET.SubElement(timemap, 'timept')
tp1.set('time', '0s')
tp1.set('value', '0s')
tp1.set('interp', 'linear')
# End keyframe
tp2 = ET.SubElement(timemap, 'timept')
tp2.set('time', f"{new_duration_seconds}s")
tp2.set('value', f"{source_duration}s")
tp2.set('interp', 'linear')
# Update clip duration
clip.set('duration', f"{new_duration_seconds}s")
# Add conform-rate for frame blending
conform = ET.SubElement(clip, 'conform-rate')
conform.set('scaleEnabled', '1')
conform.set('srcFrameRate', str(int(self.fps)))
# Frame blending attribute
blend_map = {
'none': '0',
'frame-blending': '1',
'optical-flow': '2'
}
# Note: Actual FCP attribute name may vary
# Audio pitch preservation
if preserve_pitch:
audio = clip.find('audio')
if audio is not None:
audio.set('preservePitch', '1')
return clip
# ========================================================================
# SPLIT OPERATIONS
# ========================================================================
def split_clip(
self,
clip_id: str,
split_points: List[str],
split_type: str = 'blade'
) -> List[ET.Element]:
"""
Split a clip at specified timecodes.
Args:
clip_id: Clip to split
split_points: Timecodes within the clip to split at
split_type: 'blade' (this clip only) or 'blade-all' (all tracks)
Returns:
List of resulting clip elements
"""
spine = self._get_spine()
clip = self.clips.get(clip_id)
if clip is None:
raise ValueError(f"Clip not found: {clip_id}")
# Find clip in spine
clip_index = None
for i, child in enumerate(spine):
if child == clip:
clip_index = i
break
if clip_index is None:
raise ValueError(f"Clip not in spine: {clip_id}")
# Sort split points
split_times = sorted([TimeValue.from_timecode(sp, self.fps) for sp in split_points])
# Get clip properties
clip_offset = TimeValue.from_timecode(clip.get('offset', '0s'), self.fps)
clip_start = TimeValue.from_timecode(clip.get('start', '0s'), self.fps)
clip_duration = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
clip_name = clip.get('name', 'Clip')
clip_ref = clip.get('ref')
# Remove original clip
spine.remove(clip)
# Create new clips
new_clips = []
current_offset = clip_offset
current_start = clip_start
for i, split_time in enumerate(split_times + [clip_duration]):
if i == 0:
segment_duration = split_time
else:
segment_duration = split_time - split_times[i - 1]
# Create new clip element
new_clip = ET.Element('clip')
new_clip.set('name', f"{clip_name}")
new_clip.set('offset', current_offset.to_fcpxml())
new_clip.set('start', current_start.to_fcpxml())
new_clip.set('duration', segment_duration.to_fcpxml())
if clip_ref:
new_clip.set('ref', clip_ref)
# Copy other attributes
for attr in ['tcFormat', 'format']:
if clip.get(attr):
new_clip.set(attr, clip.get(attr))
# Insert into spine
spine.insert(clip_index + i, new_clip)
new_clips.append(new_clip)
# Update for next iteration
current_offset = current_offset + segment_duration
current_start = current_start + segment_duration
# Update clip index
for new_clip in new_clips:
self.clips[new_clip.get('id', new_clip.get('name'))] = new_clip
return new_clips
# ========================================================================
# DELETE OPERATIONS
# ========================================================================
def delete_clip(
self,
clip_ids: List[str],
ripple: bool = True
) -> None:
"""
Delete clips from timeline.
Args:
clip_ids: Clips to delete
ripple: If True, shift subsequent clips. If False, leave gaps.
"""
spine = self._get_spine()
for clip_id in clip_ids:
clip = self.clips.get(clip_id)
if clip is None:
continue
clip_duration = TimeValue.from_timecode(clip.get('duration', '0s'), self.fps)
clip_offset = TimeValue.from_timecode(clip.get('offset', '0s'), self.fps)
# Find clip index
clip_index = None
for i, child in enumerate(spine):
if child == clip:
clip_index = i
break
if clip_index is None:
continue
if ripple:
# Remove clip and shift others
spine.remove(clip)
# Shift subsequent clips
for child in spine[clip_index:]:
if child.tag in ('clip', 'video', 'audio', 'gap', 'ref-clip', 'transition'):
child_offset = TimeValue.from_timecode(child.get('offset', '0s'), self.fps)
new_offset = child_offset - clip_duration
child.set('offset', new_offset.to_fcpxml())
else:
# Replace with gap
gap = ET.Element('gap')
gap.set('name', 'Gap')
gap.set('offset', clip_offset.to_fcpxml())
gap.set('duration', clip_duration.to_fcpxml())
spine.remove(clip)
spine.insert(clip_index, gap)
# Remove from index
del self.clips[clip_id]
# ============================================================================
# CONVENIENCE FUNCTIONS
# ============================================================================
def add_marker(
project_path: str,
clip_id: str,
timecode: str,
name: str,
marker_type: str = 'standard',
color: Optional[str] = None,
note: Optional[str] = None,
output_path: Optional[str] = None
) -> str:
"""
Convenience function to add a marker and save.
Returns:
Path to the modified FCPXML
"""
writer = FCPXMLWriter(project_path)
mt = MarkerType[marker_type.upper()]
mc = MarkerColor[color.upper()] if color else None
writer.add_marker(clip_id, timecode, name, mt, mc, note)
out = output_path or project_path.replace('.fcpxml', '_modified.fcpxml')
return writer.save(out)
def trim_clip(
project_path: str,
clip_id: str,
trim_start: Optional[str] = None,
trim_end: Optional[str] = None,
ripple: bool = True,
output_path: Optional[str] = None
) -> str:
"""Convenience function to trim a clip and save."""
writer = FCPXMLWriter(project_path)
writer.trim_clip(clip_id, trim_start, trim_end, ripple)
out = output_path or project_path.replace('.fcpxml', '_modified.fcpxml')
return writer.save(out)
def reorder_clips(
project_path: str,
clip_ids: List[str],
target_position: str,
ripple: bool = True,
output_path: Optional[str] = None
) -> str:
"""Convenience function to reorder clips and save."""
writer = FCPXMLWriter(project_path)
writer.reorder_clips(clip_ids, target_position, ripple)
out = output_path or project_path.replace('.fcpxml', '_modified.fcpxml')
return writer.save(out)
# Additional convenience functions follow same pattern...
+643
View File
@@ -0,0 +1,643 @@
# Auto Rough Cut Algorithm
The killer feature: AI-powered automatic rough cut generation from source footage.
---
## Overview
`auto_rough_cut` takes:
1. Source clips with keywords/metadata
2. Target duration
3. Structure template (optional)
4. Pacing preferences
And outputs:
- A complete FCPXML timeline with AI-selected clips
- Clips ordered by structure, selected by keywords
- Paced according to preferences
---
## Algorithm Phases
```
┌─────────────────────────────────────────────────────────────────────┐
│ AUTO ROUGH CUT PIPELINE │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ INGEST │ → │ SCORE │ → │ SELECT │ → │ ASSEMBLE │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ Parse clips Rank clips Pick clips Build FCPXML │
│ Extract meta by relevance per segment with transitions │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Phase 1: INGEST
Parse source FCPXML and extract all usable clips with metadata.
```python
@dataclass
class SourceClip:
id: str
name: str
source_path: str
duration: TimeValue
start: TimeValue # In-point in source
end: TimeValue # Out-point in source
# Metadata
keywords: List[str]
rating: int # 1-5 stars, 0 = unrated
is_favorite: bool
is_rejected: bool
notes: str
# Technical
resolution: Tuple[int, int]
frame_rate: float
has_audio: bool
# Computed
usable_duration: TimeValue # Excluding handles
def ingest_source_clips(fcpxml_path: str) -> List[SourceClip]:
"""
Parse FCPXML and extract all clips with their metadata.
Sources can be:
- Library export (all events/clips)
- Event export (single event)
- Project export (existing timeline - use clips from it)
Returns list of SourceClip objects.
"""
tree = ET.parse(fcpxml_path)
root = tree.getroot()
clips = []
# Find all asset-clips (browser clips) or clips (timeline clips)
for asset_clip in root.findall('.//asset-clip'):
clip = parse_asset_clip(asset_clip)
if not clip.is_rejected: # Skip rejected clips
clips.append(clip)
# Also check for clips in existing timelines (for re-cut workflows)
for clip_elem in root.findall('.//clip'):
clip = parse_timeline_clip(clip_elem)
if not clip.is_rejected:
clips.append(clip)
return clips
```
---
## Phase 2: SCORE
Score each clip's relevance for each segment in the structure.
```python
@dataclass
class ScoredClip:
clip: SourceClip
segment_scores: Dict[str, float] # segment_name -> relevance score
def score_clips(
clips: List[SourceClip],
structure: List[SegmentSpec]
) -> List[ScoredClip]:
"""
Score each clip's relevance to each segment.
Scoring factors:
1. Keyword match (highest weight)
2. Rating (higher = better)
3. Favorite status (bonus)
4. Duration fit (clips close to target get bonus)
"""
scored = []
for clip in clips:
segment_scores = {}
for segment in structure:
score = 0.0
# Keyword matching (0-50 points)
keyword_matches = set(clip.keywords) & set(segment.keywords)
if segment.keywords:
keyword_score = len(keyword_matches) / len(segment.keywords) * 50
else:
keyword_score = 25 # No keywords specified = neutral
score += keyword_score
# Rating (0-20 points)
if clip.rating > 0:
score += clip.rating * 4 # 5 stars = 20 points
else:
score += 10 # Unrated = neutral
# Favorite bonus (0-15 points)
if clip.is_favorite:
score += 15
# Duration fit (0-15 points)
# Clips close to ideal segment clip duration get bonus
target_clip_duration = calculate_ideal_clip_duration(segment)
duration_ratio = clip.usable_duration.to_seconds() / target_clip_duration
if 0.5 <= duration_ratio <= 2.0:
# Within usable range
fit_score = 15 - abs(1.0 - duration_ratio) * 10
score += max(0, fit_score)
segment_scores[segment.name] = score
scored.append(ScoredClip(clip=clip, segment_scores=segment_scores))
return scored
```
### Scoring Weights
| Factor | Weight | Notes |
|--------|--------|-------|
| Keyword match | 50% | Primary selection criteria |
| Rating | 20% | Editor's quality signal |
| Favorite | 15% | Strong preference signal |
| Duration fit | 15% | Practical editing fit |
---
## Phase 3: SELECT
Select clips for each segment based on scores and constraints.
```python
@dataclass
class SegmentSpec:
name: str
keywords: List[str]
duration: TimeValue
priority: str # 'favorites', 'longest', 'shortest', 'random', 'best'
@dataclass
class ClipSelection:
clip: SourceClip
segment: str
in_point: TimeValue
out_point: TimeValue
order: int
def select_clips_for_segment(
scored_clips: List[ScoredClip],
segment: SegmentSpec,
pacing_config: PacingConfig,
already_used: Set[str]
) -> List[ClipSelection]:
"""
Select clips for a single segment.
Algorithm:
1. Filter to clips with positive relevance
2. Sort by priority method
3. Greedily select until duration target met
4. Adjust in/out points to fit
"""
# Filter and sort
candidates = [
sc for sc in scored_clips
if sc.segment_scores.get(segment.name, 0) > 0
and sc.clip.id not in already_used
]
# Sort by priority
if segment.priority == 'favorites':
candidates.sort(key=lambda x: (x.clip.is_favorite, x.segment_scores[segment.name]), reverse=True)
elif segment.priority == 'longest':
candidates.sort(key=lambda x: x.clip.usable_duration.to_seconds(), reverse=True)
elif segment.priority == 'shortest':
candidates.sort(key=lambda x: x.clip.usable_duration.to_seconds())
elif segment.priority == 'random':
import random
random.shuffle(candidates)
else: # 'best' - default
candidates.sort(key=lambda x: x.segment_scores[segment.name], reverse=True)
# Greedy selection
selections = []
remaining_duration = segment.duration.to_seconds()
for scored_clip in candidates:
if remaining_duration <= 0:
break
clip = scored_clip.clip
# Determine clip duration for this segment
ideal_duration = calculate_ideal_clip_duration_for_pacing(
pacing_config,
remaining_duration
)
# Clip the clip to fit
actual_duration = min(
clip.usable_duration.to_seconds(),
ideal_duration,
remaining_duration
)
if actual_duration < pacing_config.min_clip_duration:
continue # Skip clips that would be too short
# Determine in/out points
# Default: use clip's existing in-point
in_point = clip.start
out_point = clip.start + TimeValue.from_seconds(actual_duration)
selections.append(ClipSelection(
clip=clip,
segment=segment.name,
in_point=in_point,
out_point=out_point,
order=len(selections)
))
remaining_duration -= actual_duration
already_used.add(clip.id)
return selections
def calculate_ideal_clip_duration_for_pacing(
config: PacingConfig,
remaining: float
) -> float:
"""
Calculate ideal clip duration based on pacing settings.
Pacing styles:
- slow: 5-10 second cuts
- medium: 2-5 second cuts
- fast: 0.5-2 second cuts
- dynamic: varies based on position
"""
pacing_ranges = {
'slow': (5.0, 10.0),
'medium': (2.0, 5.0),
'fast': (0.5, 2.0),
'dynamic': (1.0, 6.0)
}
min_dur, max_dur = pacing_ranges.get(config.pacing, (2.0, 5.0))
if config.avg_clip_duration:
# User specified exact average
target = config.avg_clip_duration
else:
# Random within range for organic feel
if config.vary_pacing:
import random
target = random.uniform(min_dur, max_dur)
else:
target = (min_dur + max_dur) / 2
# Don't exceed remaining duration
return min(target, remaining)
```
---
## Phase 4: ASSEMBLE
Build the final FCPXML from selected clips.
```python
def assemble_rough_cut(
selections: List[ClipSelection],
structure: List[SegmentSpec],
transitions_config: Dict,
output_path: str
) -> str:
"""
Build FCPXML from clip selections.
Steps:
1. Create FCPXML document structure
2. Add resources for all source clips
3. Build spine with clips in order
4. Add transitions between segments
5. Write to file
"""
# Create document
root = ET.Element('fcpxml', version='1.11')
# Resources section
resources = ET.SubElement(root, 'resources')
add_format_resource(resources)
asset_refs = {}
for selection in selections:
if selection.clip.source_path not in asset_refs:
asset_id = f"r{len(asset_refs) + 1}"
add_asset_resource(resources, selection.clip, asset_id)
asset_refs[selection.clip.source_path] = asset_id
# Library/Event/Project structure
library = ET.SubElement(root, 'library')
event = ET.SubElement(library, 'event', name='Rough Cut')
project = ET.SubElement(event, 'project', name='AI Rough Cut')
sequence = ET.SubElement(project, 'sequence')
spine = ET.SubElement(sequence, 'spine')
# Build timeline
current_offset = TimeValue(0)
current_segment = None
for selection in selections:
# Check if segment changed (for transition)
if selection.segment != current_segment:
if current_segment is not None:
# Add segment transition
trans_type = transitions_config.get('between_segments', 'cross-dissolve')
if trans_type != 'none':
add_transition(spine, current_offset, trans_type, duration='1s')
current_segment = selection.segment
# Add clip
asset_id = asset_refs[selection.clip.source_path]
duration = selection.out_point - selection.in_point
clip_elem = ET.SubElement(spine, 'clip')
clip_elem.set('name', selection.clip.name)
clip_elem.set('offset', current_offset.to_fcpxml())
clip_elem.set('duration', duration.to_fcpxml())
clip_elem.set('start', selection.in_point.to_fcpxml())
clip_elem.set('ref', asset_id)
current_offset = current_offset + duration
# Write file
tree = ET.ElementTree(root)
tree.write(output_path, encoding='UTF-8', xml_declaration=True)
return output_path
```
---
## Complete Algorithm
```python
def auto_rough_cut(
source_path: str,
output_path: str,
target_duration: str,
structure: Optional[List[Dict]] = None,
pacing: str = 'medium',
pacing_config: Optional[Dict] = None,
transitions: Optional[Dict] = None
) -> Dict:
"""
Main entry point for auto rough cut.
Args:
source_path: FCPXML with source clips
output_path: Where to save the rough cut
target_duration: Target total duration (timecode string)
structure: Optional segment structure
pacing: Pacing preset ('slow', 'medium', 'fast', 'dynamic')
pacing_config: Override pacing settings
transitions: Transition settings
Returns:
Dict with stats about the generated cut
"""
# Parse target duration
target = TimeValue.from_timecode(target_duration)
# Default structure if not provided
if structure is None:
structure = [
SegmentSpec(
name='Main',
keywords=[], # Use all clips
duration=target,
priority='best'
)
]
else:
structure = [SegmentSpec(**s) for s in structure]
# Normalize segment durations to match target
structure = normalize_segment_durations(structure, target)
# Build pacing config
config = PacingConfig(
pacing=pacing,
min_clip_duration=pacing_config.get('min_clip_duration', 1.0) if pacing_config else 1.0,
max_clip_duration=pacing_config.get('max_clip_duration', 8.0) if pacing_config else 8.0,
avg_clip_duration=pacing_config.get('avg_clip_duration') if pacing_config else None,
vary_pacing=pacing_config.get('vary_pacing', True) if pacing_config else True
)
# Transition config
trans_config = transitions or {
'between_segments': 'cross-dissolve',
'within_segments': 'cut'
}
# === EXECUTE PIPELINE ===
# Phase 1: Ingest
clips = ingest_source_clips(source_path)
print(f"Ingested {len(clips)} source clips")
# Phase 2: Score
scored_clips = score_clips(clips, structure)
# Phase 3: Select
all_selections = []
used_clips = set()
for segment in structure:
segment_selections = select_clips_for_segment(
scored_clips,
segment,
config,
used_clips
)
all_selections.extend(segment_selections)
print(f"Selected {len(segment_selections)} clips for '{segment.name}'")
# Phase 4: Assemble
assemble_rough_cut(
all_selections,
structure,
trans_config,
output_path
)
# Calculate stats
actual_duration = sum(
(s.out_point - s.in_point).to_seconds()
for s in all_selections
)
return {
'output_path': output_path,
'clips_used': len(all_selections),
'clips_available': len(clips),
'target_duration': target.to_seconds(),
'actual_duration': actual_duration,
'segments': len(structure),
'average_clip_duration': actual_duration / len(all_selections) if all_selections else 0
}
```
---
## Example Usage
```python
# Music video rough cut
result = auto_rough_cut(
source_path='/path/to/raw_footage.fcpxml',
output_path='/path/to/rough_cut.fcpxml',
target_duration='00:03:30:00', # 3:30 music video
structure=[
{
'name': 'Intro',
'keywords': ['Wide', 'Establishing'],
'duration': '00:00:15:00',
'priority': 'best'
},
{
'name': 'Verse 1',
'keywords': ['Performance', 'Artist'],
'duration': '00:00:45:00',
'priority': 'favorites'
},
{
'name': 'Chorus',
'keywords': ['Energy', 'B-Roll', 'Crowd'],
'duration': '00:00:30:00',
'priority': 'best'
},
{
'name': 'Verse 2',
'keywords': ['Performance', 'Close-up'],
'duration': '00:00:45:00',
'priority': 'longest'
},
{
'name': 'Bridge',
'keywords': ['Cinematic', 'Slow-mo'],
'duration': '00:00:20:00',
'priority': 'best'
},
{
'name': 'Final Chorus',
'keywords': ['Energy', 'Performance', 'Crowd'],
'duration': '00:00:40:00',
'priority': 'random' # Mix it up
},
{
'name': 'Outro',
'keywords': ['Wide', 'Fade'],
'duration': '00:00:15:00',
'priority': 'best'
}
],
pacing='dynamic',
pacing_config={
'min_clip_duration': '00:00:00:15', # 15 frames min
'max_clip_duration': '00:00:04:00', # 4 seconds max
'vary_pacing': True
},
transitions={
'between_segments': 'cross-dissolve',
'within_segments': 'cut'
}
)
print(f"Generated {result['actual_duration']:.1f}s rough cut using {result['clips_used']} clips")
```
---
## Future Enhancements
### 1. Beat Detection Integration
```python
# Sync cuts to music beats
auto_rough_cut(
...
music_track='/path/to/song.mp3',
sync_to_beats=True,
beat_detection_sensitivity=0.8
)
```
### 2. AI Content Analysis
```python
# Use vision AI to analyze clip content
auto_rough_cut(
...
analyze_content=True, # Run clips through vision model
prefer_faces=True, # Prioritize clips with faces
avoid_duplicates=True # Don't repeat similar shots
)
```
### 3. Style Templates
```python
# Pre-built pacing templates for genres
auto_rough_cut(
...
style='music_video_hiphop' # Fast cuts, lots of variety
# or 'documentary_interview' # Longer clips, less cuts
# or 'commercial_30sec' # Punchy, tight
)
```
### 4. Multi-cam Support
```python
# Select from multiple camera angles
auto_rough_cut(
...
multicam_mode=True,
angle_variety=0.7, # How much to switch angles
prefer_angle='A-Cam' # Default camera
)
```
---
## Performance Considerations
| Clips | Segments | Expected Time |
|-------|----------|---------------|
| 100 | 5 | < 1 second |
| 500 | 10 | 2-3 seconds |
| 1000 | 20 | 5-8 seconds |
| 5000+ | 50+ | 15-30 seconds |
The algorithm is O(clips × segments) for scoring, O(clips log clips) for sorting, and O(selections) for assembly.
For very large projects, consider:
- Pre-filtering clips by keyword before scoring
- Caching scored clips between runs
- Parallel processing of segments
+750
View File
@@ -0,0 +1,750 @@
# server.py - Complete MCP Server Implementation
"""
Final Cut Pro MCP Server - Full Implementation
All read and write tools for FCPXML manipulation.
"""
import json
import logging
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent, CallToolResult
from fcpxml.parser import FCPXMLParser
from fcpxml.writer import (
FCPXMLWriter,
MarkerType,
MarkerColor,
add_marker,
trim_clip,
reorder_clips,
)
from fcpxml.rough_cut import auto_rough_cut
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("fcp-mcp-server")
server = Server("fcp-mcp-server")
# Default projects directory from env
PROJECTS_DIR = os.environ.get("FCP_PROJECTS_DIR", os.path.expanduser("~/Movies"))
# ============================================================================
# TOOL REGISTRY
# ============================================================================
def get_tools() -> List[Tool]:
"""Return all available tools."""
return [
# --- READ TOOLS ---
Tool(
name="list_projects",
description="Find FCPXML files in a directory. Returns paths to all Final Cut Pro project exports.",
inputSchema={
"type": "object",
"properties": {
"directory": {"type": "string", "description": "Directory to search (default: ~/Movies)"},
"recursive": {"type": "boolean", "default": True}
}
}
),
Tool(
name="analyze_timeline",
description="Get comprehensive timeline statistics: clip count, duration, cuts per minute, average cut length, pacing analysis.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string", "description": "Path to FCPXML file"}
},
"required": ["project_path"]
}
),
Tool(
name="list_clips",
description="List all clips with timecodes, durations, and source file references.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"include_audio": {"type": "boolean", "default": True}
},
"required": ["project_path"]
}
),
Tool(
name="list_markers",
description="Extract markers (chapter, todo, standard) with timecodes. Can format for YouTube chapters.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"format": {"type": "string", "enum": ["list", "youtube", "csv"], "default": "list"}
},
"required": ["project_path"]
}
),
Tool(
name="list_keywords",
description="Get all keywords/tags applied to clips in the project.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"}
},
"required": ["project_path"]
}
),
Tool(
name="find_short_cuts",
description="Find clips below a frame threshold - detects flash frames or accidental cuts.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"threshold_frames": {"type": "integer", "default": 6}
},
"required": ["project_path"]
}
),
Tool(
name="find_long_clips",
description="Find clips above a duration threshold - identify pacing issues.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"threshold_seconds": {"type": "number", "default": 10.0}
},
"required": ["project_path"]
}
),
Tool(
name="analyze_pacing",
description="AI analysis of edit pacing with suggestions for improvements.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"target_style": {"type": "string", "enum": ["music_video", "documentary", "commercial", "narrative"]}
},
"required": ["project_path"]
}
),
Tool(
name="export_edl",
description="Generate EDL (Edit Decision List) for color grading roundtrip.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"output_path": {"type": "string"},
"format": {"type": "string", "enum": ["cmx3600", "file32"], "default": "cmx3600"}
},
"required": ["project_path", "output_path"]
}
),
Tool(
name="export_csv",
description="Export timeline data to CSV for spreadsheet analysis.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"output_path": {"type": "string"},
"columns": {"type": "array", "items": {"type": "string"}, "default": ["name", "start", "duration", "source"]}
},
"required": ["project_path", "output_path"]
}
),
# --- WRITE TOOLS ---
Tool(
name="add_marker",
description="Add a marker to the timeline. Supports chapter markers (for YouTube), to-do markers, and colored standard markers.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"timecode": {"type": "string", "description": "Position in HH:MM:SS:FF or seconds"},
"name": {"type": "string", "description": "Marker label"},
"marker_type": {"type": "string", "enum": ["standard", "chapter", "todo"], "default": "standard"},
"color": {"type": "string", "enum": ["blue", "cyan", "green", "yellow", "orange", "red", "pink", "purple"]},
"note": {"type": "string"},
"output_path": {"type": "string", "description": "Save modified project here (default: overwrites original)"}
},
"required": ["project_path", "timecode", "name"]
}
),
Tool(
name="batch_add_markers",
description="Add multiple markers at once. Can also auto-detect cut points and add markers there.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"markers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"timecode": {"type": "string"},
"name": {"type": "string"},
"marker_type": {"type": "string"},
"color": {"type": "string"}
},
"required": ["timecode", "name"]
}
},
"auto_detect": {
"type": "object",
"properties": {
"at_cuts": {"type": "boolean"},
"at_intervals": {"type": "string"}
}
},
"output_path": {"type": "string"}
},
"required": ["project_path"]
}
),
Tool(
name="trim_clip",
description="Adjust a clip's in-point or out-point. Supports absolute timecodes or relative deltas (+1s, -10f).",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_id": {"type": "string", "description": "Clip identifier from list_clips"},
"trim_start": {"type": "string", "description": "New in-point or delta"},
"trim_end": {"type": "string", "description": "New out-point or delta"},
"ripple": {"type": "boolean", "default": True},
"output_path": {"type": "string"}
},
"required": ["project_path", "clip_id"]
}
),
Tool(
name="reorder_clips",
description="Move clips to a new position in the timeline.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_ids": {"type": "array", "items": {"type": "string"}},
"target_position": {"type": "string", "description": "'start', 'end', timecode, or 'after:clip_id'"},
"ripple": {"type": "boolean", "default": True},
"output_path": {"type": "string"}
},
"required": ["project_path", "clip_ids", "target_position"]
}
),
Tool(
name="add_transition",
description="Apply a transition between clips.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_id": {"type": "string"},
"position": {"type": "string", "enum": ["start", "end", "both"], "default": "end"},
"transition_type": {"type": "string", "enum": ["cross-dissolve", "fade-to-black", "fade-from-black", "dip-to-color", "wipe"], "default": "cross-dissolve"},
"duration": {"type": "string", "default": "00:00:00:15"},
"output_path": {"type": "string"}
},
"required": ["project_path", "clip_id"]
}
),
Tool(
name="change_speed",
description="Modify clip playback speed. Supports constant speed and speed ramps.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_id": {"type": "string"},
"speed": {"type": "number", "description": "Speed multiplier (0.5=half, 2.0=double)"},
"ramp": {
"type": "object",
"properties": {
"start_speed": {"type": "number"},
"end_speed": {"type": "number"},
"curve": {"type": "string", "enum": ["linear", "ease-in", "ease-out", "ease-in-out"]}
}
},
"preserve_pitch": {"type": "boolean", "default": True},
"frame_blending": {"type": "string", "enum": ["none", "frame-blending", "optical-flow"], "default": "optical-flow"},
"output_path": {"type": "string"}
},
"required": ["project_path", "clip_id", "speed"]
}
),
Tool(
name="split_clip",
description="Split a clip at one or more timecodes.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_id": {"type": "string"},
"split_points": {"type": "array", "items": {"type": "string"}},
"output_path": {"type": "string"}
},
"required": ["project_path", "clip_id", "split_points"]
}
),
Tool(
name="delete_clip",
description="Remove clips from the timeline. Supports ripple delete or leaving gaps.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_ids": {"type": "array", "items": {"type": "string"}},
"ripple": {"type": "boolean", "default": True},
"output_path": {"type": "string"}
},
"required": ["project_path", "clip_ids"]
}
),
Tool(
name="select_by_keyword",
description="Find clips matching keywords, ratings, or favorites status.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"keywords": {"type": "array", "items": {"type": "string"}},
"match_mode": {"type": "string", "enum": ["any", "all", "none"], "default": "any"},
"favorites_only": {"type": "boolean", "default": False},
"exclude_rejected": {"type": "boolean", "default": True}
},
"required": ["project_path", "keywords"]
}
),
Tool(
name="batch_trim",
description="Apply trim operations to multiple clips based on criteria.",
inputSchema={
"type": "object",
"properties": {
"project_path": {"type": "string"},
"clip_ids": {"type": "array", "items": {"type": "string"}},
"trim_start_by": {"type": "string"},
"trim_end_by": {"type": "string"},
"set_duration": {"type": "string"},
"ripple": {"type": "boolean", "default": True},
"output_path": {"type": "string"}
},
"required": ["project_path"]
}
),
Tool(
name="auto_rough_cut",
description="AI-powered rough cut generation. Analyzes source clips by keywords and assembles a timeline based on target duration and pacing.",
inputSchema={
"type": "object",
"properties": {
"source_path": {"type": "string", "description": "FCPXML with source clips"},
"output_path": {"type": "string", "description": "Where to save the rough cut"},
"target_duration": {"type": "string", "description": "Target duration (e.g., '00:03:30:00')"},
"structure": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"keywords": {"type": "array", "items": {"type": "string"}},
"duration": {"type": "string"},
"priority": {"type": "string", "enum": ["favorites", "longest", "shortest", "random", "best"]}
}
}
},
"pacing": {"type": "string", "enum": ["slow", "medium", "fast", "dynamic"], "default": "medium"},
"pacing_config": {
"type": "object",
"properties": {
"min_clip_duration": {"type": "string"},
"max_clip_duration": {"type": "string"},
"vary_pacing": {"type": "boolean"}
}
},
"transitions": {
"type": "object",
"properties": {
"between_segments": {"type": "string", "enum": ["none", "cross-dissolve", "fade-to-black"]},
"within_segments": {"type": "string", "enum": ["none", "cut", "cross-dissolve"]}
}
}
},
"required": ["source_path", "output_path", "target_duration"]
}
),
]
@server.list_tools()
async def list_tools() -> List[Tool]:
"""Return available tools."""
return get_tools()
# ============================================================================
# TOOL HANDLERS
# ============================================================================
@server.call_tool()
async def call_tool(name: str, arguments: Dict[str, Any]) -> CallToolResult:
"""Handle tool calls."""
try:
if name == "list_projects":
result = handle_list_projects(arguments)
elif name == "analyze_timeline":
result = handle_analyze_timeline(arguments)
elif name == "list_clips":
result = handle_list_clips(arguments)
elif name == "list_markers":
result = handle_list_markers(arguments)
elif name == "list_keywords":
result = handle_list_keywords(arguments)
elif name == "find_short_cuts":
result = handle_find_short_cuts(arguments)
elif name == "find_long_clips":
result = handle_find_long_clips(arguments)
elif name == "analyze_pacing":
result = handle_analyze_pacing(arguments)
elif name == "export_edl":
result = handle_export_edl(arguments)
elif name == "export_csv":
result = handle_export_csv(arguments)
elif name == "add_marker":
result = handle_add_marker(arguments)
elif name == "batch_add_markers":
result = handle_batch_add_markers(arguments)
elif name == "trim_clip":
result = handle_trim_clip(arguments)
elif name == "reorder_clips":
result = handle_reorder_clips(arguments)
elif name == "add_transition":
result = handle_add_transition(arguments)
elif name == "change_speed":
result = handle_change_speed(arguments)
elif name == "split_clip":
result = handle_split_clip(arguments)
elif name == "delete_clip":
result = handle_delete_clip(arguments)
elif name == "select_by_keyword":
result = handle_select_by_keyword(arguments)
elif name == "batch_trim":
result = handle_batch_trim(arguments)
elif name == "auto_rough_cut":
result = handle_auto_rough_cut(arguments)
else:
return CallToolResult(
content=[TextContent(type="text", text=f"Unknown tool: {name}")],
isError=True
)
return CallToolResult(
content=[TextContent(type="text", text=json.dumps(result, indent=2))]
)
except Exception as e:
logger.exception(f"Error in tool {name}")
return CallToolResult(
content=[TextContent(type="text", text=f"Error: {str(e)}")],
isError=True
)
# ============================================================================
# HANDLER IMPLEMENTATIONS
# ============================================================================
def handle_list_projects(args: Dict) -> Dict:
"""List FCPXML files in directory."""
directory = args.get("directory", PROJECTS_DIR)
recursive = args.get("recursive", True)
path = Path(directory).expanduser()
pattern = "**/*.fcpxml" if recursive else "*.fcpxml"
projects = []
for fcpxml in path.glob(pattern):
stat = fcpxml.stat()
projects.append({
"path": str(fcpxml),
"name": fcpxml.stem,
"size_mb": round(stat.st_size / 1024 / 1024, 2),
"modified": stat.st_mtime
})
return {
"directory": str(path),
"count": len(projects),
"projects": projects
}
def handle_analyze_timeline(args: Dict) -> Dict:
"""Analyze timeline statistics."""
parser = FCPXMLParser(args["project_path"])
timeline = parser.parse()
clips = timeline.clips
total_duration = sum(c.duration for c in clips)
cut_count = len(clips) - 1
durations = [c.duration for c in clips]
avg_duration = total_duration / len(clips) if clips else 0
min_duration = min(durations) if durations else 0
max_duration = max(durations) if durations else 0
# Cuts per minute
cpm = (cut_count / total_duration) * 60 if total_duration > 0 else 0
return {
"project_name": timeline.name,
"total_duration_seconds": round(total_duration, 2),
"total_duration_timecode": seconds_to_tc(total_duration),
"clip_count": len(clips),
"cut_count": cut_count,
"cuts_per_minute": round(cpm, 2),
"average_clip_duration": round(avg_duration, 2),
"shortest_clip": round(min_duration, 2),
"longest_clip": round(max_duration, 2),
"marker_count": len(timeline.markers)
}
def handle_list_clips(args: Dict) -> Dict:
"""List all clips with details."""
parser = FCPXMLParser(args["project_path"])
timeline = parser.parse()
clips = []
for i, clip in enumerate(timeline.clips):
clips.append({
"id": clip.id or f"clip_{i}",
"name": clip.name,
"offset_tc": seconds_to_tc(clip.offset),
"duration_tc": seconds_to_tc(clip.duration),
"duration_seconds": round(clip.duration, 3),
"source_start_tc": seconds_to_tc(clip.source_start),
"source_file": clip.source_path,
"keywords": clip.keywords,
"is_audio_only": clip.is_audio_only
})
return {
"clip_count": len(clips),
"clips": clips
}
def handle_list_markers(args: Dict) -> Dict:
"""Extract markers."""
parser = FCPXMLParser(args["project_path"])
timeline = parser.parse()
fmt = args.get("format", "list")
markers = []
for m in timeline.markers:
markers.append({
"timecode": seconds_to_tc(m.time),
"name": m.name,
"type": m.marker_type,
"note": m.note
})
if fmt == "youtube":
# YouTube chapter format
chapters = []
for m in markers:
if m["type"] == "chapter":
tc = m["timecode"]
# Convert to YouTube format (MM:SS or H:MM:SS)
parts = tc.split(":")
if parts[0] == "00":
yt_tc = f"{parts[1]}:{parts[2]}"
else:
yt_tc = f"{parts[0]}:{parts[1]}:{parts[2]}"
chapters.append(f"{yt_tc} {m['name']}")
return {
"format": "youtube",
"chapters": "\n".join(chapters)
}
return {
"marker_count": len(markers),
"markers": markers
}
def handle_add_marker(args: Dict) -> Dict:
"""Add a marker to the timeline."""
project_path = args["project_path"]
output_path = args.get("output_path", project_path)
writer = FCPXMLWriter(project_path)
marker_type = MarkerType[args.get("marker_type", "standard").upper()]
color = MarkerColor[args["color"].upper()] if args.get("color") else None
# Find the clip at this timecode, or add to spine
# For simplicity, we'll add to the first clip that contains this timecode
clip_id = find_clip_at_timecode(writer, args["timecode"])
writer.add_marker(
clip_id=clip_id,
timecode=args["timecode"],
name=args["name"],
marker_type=marker_type,
color=color,
note=args.get("note")
)
saved_path = writer.save(output_path)
return {
"success": True,
"marker_added": args["name"],
"at_timecode": args["timecode"],
"output_path": saved_path
}
def handle_trim_clip(args: Dict) -> Dict:
"""Trim a clip's in/out points."""
project_path = args["project_path"]
output_path = args.get("output_path", project_path)
writer = FCPXMLWriter(project_path)
writer.trim_clip(
clip_id=args["clip_id"],
trim_start=args.get("trim_start"),
trim_end=args.get("trim_end"),
ripple=args.get("ripple", True)
)
saved_path = writer.save(output_path)
return {
"success": True,
"clip_trimmed": args["clip_id"],
"output_path": saved_path
}
def handle_reorder_clips(args: Dict) -> Dict:
"""Reorder clips in timeline."""
project_path = args["project_path"]
output_path = args.get("output_path", project_path)
writer = FCPXMLWriter(project_path)
writer.reorder_clips(
clip_ids=args["clip_ids"],
target_position=args["target_position"],
ripple=args.get("ripple", True)
)
saved_path = writer.save(output_path)
return {
"success": True,
"clips_moved": args["clip_ids"],
"to_position": args["target_position"],
"output_path": saved_path
}
def handle_auto_rough_cut(args: Dict) -> Dict:
"""Generate AI rough cut."""
result = auto_rough_cut(
source_path=args["source_path"],
output_path=args["output_path"],
target_duration=args["target_duration"],
structure=args.get("structure"),
pacing=args.get("pacing", "medium"),
pacing_config=args.get("pacing_config"),
transitions=args.get("transitions")
)
return result
# ============================================================================
# UTILITIES
# ============================================================================
def seconds_to_tc(seconds: float, fps: float = 30.0) -> str:
"""Convert seconds to timecode string."""
total_frames = int(seconds * fps)
frames = total_frames % int(fps)
total_seconds = total_frames // int(fps)
secs = total_seconds % 60
total_minutes = total_seconds // 60
mins = total_minutes % 60
hours = total_minutes // 60
return f"{hours:02d}:{mins:02d}:{secs:02d}:{frames:02d}"
def find_clip_at_timecode(writer: FCPXMLWriter, timecode: str) -> str:
"""Find clip ID that contains the given timecode."""
from fcpxml.writer import TimeValue
target = TimeValue.from_timecode(timecode, writer.fps)
target_seconds = target.to_seconds()
for clip_id, clip in writer.clips.items():
offset = TimeValue.from_timecode(clip.get('offset', '0s'), writer.fps).to_seconds()
duration = TimeValue.from_timecode(clip.get('duration', '0s'), writer.fps).to_seconds()
if offset <= target_seconds < offset + duration:
return clip_id
# If no clip found, return first clip
return list(writer.clips.keys())[0] if writer.clips else None
# Placeholder implementations for remaining handlers
def handle_list_keywords(args): pass
def handle_find_short_cuts(args): pass
def handle_find_long_clips(args): pass
def handle_analyze_pacing(args): pass
def handle_export_edl(args): pass
def handle_export_csv(args): pass
def handle_batch_add_markers(args): pass
def handle_add_transition(args): pass
def handle_change_speed(args): pass
def handle_split_clip(args): pass
def handle_delete_clip(args): pass
def handle_select_by_keyword(args): pass
def handle_batch_trim(args): pass
# ============================================================================
# MAIN
# ============================================================================
async def main():
"""Run the MCP server."""
async with stdio_server() as (read_stream, write_stream):
await server.run(
read_stream,
write_stream,
server.create_initialization_options()
)
if __name__ == "__main__":
import asyncio
asyncio.run(main())
+377
View File
@@ -0,0 +1,377 @@
# FCP MCP Server - Implementation Roadmap
Step-by-step plan to build all editing features.
---
## Current State
✅ **DONE:**
- Repository structure
- Basic parser (read FCPXML)
- 10 read-only MCP tools
- Claude Desktop integration config
🔄 **IN PROGRESS:**
- Writer module architecture
- Tool schemas for editing
---
## Implementation Order
### Sprint 1: Core Write Operations (Week 1)
**Day 1-2: TimeValue & Writer Foundation**
```
Tasks:
□ Implement TimeValue class with all operations
□ Create FCPXMLWriter base class
□ Implement _detect_fps()
□ Implement _build_clip_index()
□ Implement save()
□ Write unit tests for TimeValue
```
**Day 3-4: Marker Operations**
```
Tasks:
□ Implement add_marker()
□ Implement batch_add_markers()
□ Handle marker types (standard, chapter, todo)
□ Handle marker colors
□ Test with real FCP export
□ Verify re-import into FCP
```
**Day 5-6: Trim Operations**
```
Tasks:
□ Implement trim_clip() - absolute timecodes
□ Implement trim_clip() - delta trimming
□ Implement _ripple_after_clip()
□ Test with ripple=True and ripple=False
□ Edge case: trim to zero duration (should fail)
□ Edge case: trim beyond clip bounds (should clamp)
```
**Day 7: Reorder Operations**
```
Tasks:
□ Implement reorder_clips() - move to start/end
□ Implement reorder_clips() - move to timecode
□ Implement reorder_clips() - after/before clip
□ Implement _recalculate_offsets()
□ Test multi-clip moves
```
---
### Sprint 2: Advanced Edit Operations (Week 2)
**Day 1-2: Transitions**
```
Tasks:
□ Implement add_transition() - cross-dissolve
□ Implement add_transition() - fade variants
□ Find FCP effect reference IDs
□ Test position: start, end, both
□ Handle transition overlap calculation
```
**Day 3-4: Speed Changes**
```
Tasks:
□ Implement change_speed() - constant speed
□ Implement change_speed() - speed ramps
□ Create timeMap XML structure
□ Test slow-mo (0.5x) and fast (2x)
□ Test speed ramp with different curves
□ Verify frame blending options
```
**Day 5-6: Split & Delete**
```
Tasks:
□ Implement split_clip() - single split point
□ Implement split_clip() - multiple split points
□ Implement delete_clip() - with ripple
□ Implement delete_clip() - with gap
□ Test split preserves clip attributes
□ Test delete updates clip index
```
**Day 7: Selection & Batch**
```
Tasks:
□ Implement select_by_keyword()
□ Implement batch_trim()
□ Test keyword matching modes (any, all, none)
□ Test batch operations on 10+ clips
```
---
### Sprint 3: Auto Rough Cut (Week 3)
**Day 1-2: Ingest Phase**
```
Tasks:
□ Implement ingest_source_clips()
□ Parse asset-clips from library exports
□ Parse clips from event exports
□ Extract all metadata (keywords, ratings, favorites)
□ Test with 100+ clip library
```
**Day 3-4: Score Phase**
```
Tasks:
□ Implement score_clips()
□ Implement keyword matching scoring
□ Implement rating/favorite scoring
□ Implement duration fit scoring
□ Test scoring produces sensible rankings
```
**Day 5-6: Select Phase**
```
Tasks:
□ Implement select_clips_for_segment()
□ Implement all priority modes
□ Implement pacing-based duration calculation
□ Test clip selection respects already_used
□ Test segment duration targets
```
**Day 7: Assemble Phase**
```
Tasks:
□ Implement assemble_rough_cut()
□ Create proper FCPXML document structure
□ Add asset resources correctly
□ Add transitions between segments
□ Test complete rough cut generation
□ Import result into FCP - verify it works
```
---
### Sprint 4: Polish & Ship (Week 4)
**Day 1-2: Error Handling**
```
Tasks:
□ Add validation for all inputs
□ Graceful handling of malformed FCPXML
□ Clear error messages for common issues
□ Logging throughout
```
**Day 3-4: Integration Testing**
```
Tasks:
□ Create test suite with real FCP exports
□ Test each tool end-to-end
□ Test tool combinations (marker + trim + reorder)
□ Performance testing with large projects
```
**Day 5-6: Documentation**
```
Tasks:
□ Complete README with all tools
□ Add usage examples for each tool
□ Create tutorial: "Your first rough cut"
□ Add troubleshooting guide
□ Record demo video
```
**Day 7: Launch**
```
Tasks:
□ Final testing pass
□ Version bump to 1.0.0
□ Push to GitHub
□ Submit to MCP registry
□ Write launch posts
□ Share with FCP communities
```
---
## Testing Strategy
### Unit Tests
```python
# tests/test_timevalue.py
def test_from_timecode_hmsf():
tv = TimeValue.from_timecode("00:01:30:15", fps=30)
assert tv.to_seconds() == 90.5
def test_from_timecode_fcpxml():
tv = TimeValue.from_timecode("2700/30s")
assert tv.to_seconds() == 90.0
def test_addition():
a = TimeValue(30, 30) # 1 second
b = TimeValue(60, 30) # 2 seconds
c = a + b
assert c.to_seconds() == 3.0
def test_simplify():
tv = TimeValue(60, 30)
simplified = tv.simplify()
assert simplified.numerator == 2
assert simplified.denominator == 1
```
### Integration Tests
```python
# tests/test_writer_integration.py
def test_add_marker_reimports():
"""Marker added by writer should be visible in FCP."""
# 1. Create modified FCPXML
writer = FCPXMLWriter("fixtures/sample.fcpxml")
writer.add_marker("clip_0", "00:00:10:00", "Test Marker", MarkerType.CHAPTER)
writer.save("output/test_marker.fcpxml")
# 2. Parse it back
parser = FCPXMLParser("output/test_marker.fcpxml")
timeline = parser.parse()
# 3. Verify marker exists
marker_names = [m.name for m in timeline.markers]
assert "Test Marker" in marker_names
def test_trim_preserves_structure():
"""Trimming shouldn't corrupt other timeline elements."""
writer = FCPXMLWriter("fixtures/sample.fcpxml")
original_clip_count = len(writer.clips)
writer.trim_clip("clip_0", trim_end="-1s")
writer.save("output/test_trim.fcpxml")
parser = FCPXMLParser("output/test_trim.fcpxml")
timeline = parser.parse()
# Same number of clips
assert len(timeline.clips) == original_clip_count
```
### Golden Set Tests
```python
# tests/golden_set.py
"""
Golden set: known-good FCPXML files that must parse correctly.
If any fail, we broke backward compatibility.
"""
GOLDEN_FILES = [
"fixtures/golden/simple_timeline.fcpxml",
"fixtures/golden/multicam_project.fcpxml",
"fixtures/golden/compound_clips.fcpxml",
"fixtures/golden/with_effects.fcpxml",
"fixtures/golden/fcp_10_6_export.fcpxml",
"fixtures/golden/fcp_10_7_export.fcpxml",
"fixtures/golden/fcp_10_8_export.fcpxml",
]
@pytest.mark.parametrize("filepath", GOLDEN_FILES)
def test_golden_file_parses(filepath):
parser = FCPXMLParser(filepath)
timeline = parser.parse()
assert timeline is not None
assert len(timeline.clips) > 0
```
---
## Risk Mitigation
### Risk: FCPXML format changes between FCP versions
**Mitigation:**
- Test against multiple FCP export versions (10.6, 10.7, 10.8)
- Use conservative parsing (ignore unknown elements)
- Version detection in parser
- Golden set tests for each version
### Risk: Generated FCPXML rejected by FCP
**Mitigation:**
- Always validate output XML
- Round-trip testing (export → modify → import)
- Use FCP's own exports as templates
- Keep original attributes we don't understand
### Risk: Data loss from edit operations
**Mitigation:**
- Never overwrite original by default
- Backup before destructive operations
- Validate timeline integrity after each operation
- Undo capability (save original state)
### Risk: Performance with large projects
**Mitigation:**
- Lazy loading of clip metadata
- Index-based clip lookup
- Stream parsing for very large files
- Progress callbacks for long operations
---
## File Structure (Final)
```
fcp-mcp-server/
├── server.py # MCP server entry point
├── fcpxml/
│ ├── __init__.py
│ ├── parser.py # Read operations
│ ├── writer.py # Write operations
│ ├── rough_cut.py # Auto rough cut algorithm
│ ├── models.py # Data classes
│ └── utils.py # TimeValue, converters
├── tests/
│ ├── __init__.py
│ ├── test_parser.py
│ ├── test_writer.py
│ ├── test_rough_cut.py
│ ├── test_timevalue.py
│ ├── golden_set.py
│ └── fixtures/
│ ├── sample.fcpxml
│ └── golden/
├── examples/
│ ├── basic_usage.py
│ ├── rough_cut_example.py
│ └── batch_markers.py
├── docs/
│ ├── TOOL_REFERENCE.md
│ ├── FCPXML_GUIDE.md
│ └── TROUBLESHOOTING.md
├── pyproject.toml
├── requirements.txt
├── LICENSE
└── README.md
```
---
## Definition of Done
A feature is complete when:
1. ✅ Code implemented and working
2. ✅ Unit tests passing
3. ✅ Integration test with real FCP export
4. ✅ Re-import into FCP verified
5. ✅ Error handling for edge cases
6. ✅ Documented in README
7. ✅ Example usage provided
+586
View File
@@ -0,0 +1,586 @@
# 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