chore: adiciona .gitignore e commit.command
This commit is contained in:
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())
|
||||
Reference in New Issue
Block a user