chore: adiciona .gitignore e commit.command
This commit is contained in:
Executable
+613
@@ -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"]
|
||||
}
|
||||
}
|
||||
```
|
||||
Executable
+389
@@ -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
|
||||
Executable
+885
@@ -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
@@ -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
|
||||
Executable
+750
@@ -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())
|
||||
Executable
+377
@@ -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
|
||||
Executable
+586
@@ -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
|
||||
Reference in New Issue
Block a user