401 lines
16 KiB
Python
401 lines
16 KiB
Python
"""Timeline & análise (Projeto) — tool schemas and handlers.
|
|
|
|
Extracted from server.py; see Engine/docs/03_SERVER_TOOLS.md for the tool catalog.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Sequence
|
|
|
|
from mcp.types import TextContent, Tool
|
|
|
|
from fcpxml.diff import compare_timelines
|
|
from fcpxml.models import MarkerType
|
|
from fcpxml.parser import FCPXMLParser
|
|
from fcpxml.writer import list_effects
|
|
from server_tools._shared import (
|
|
_SANDBOX_ENABLED,
|
|
PROJECTS_DIR,
|
|
_require_timeline,
|
|
_text_result,
|
|
_validate_directory,
|
|
_validate_filepath,
|
|
find_fcpxml_files,
|
|
format_duration,
|
|
format_timecode,
|
|
)
|
|
|
|
TOOLS = [
|
|
Tool(
|
|
name="list_projects",
|
|
description="List all FCPXML projects in directory",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"directory": {"type": "string", "description": "Directory to search (default: ~/Movies)"}
|
|
}
|
|
}
|
|
),
|
|
Tool(
|
|
name="analyze_timeline",
|
|
description="Get comprehensive timeline statistics including duration, resolution, clip count, pacing metrics",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {"filepath": {"type": "string", "description": "Path to FCPXML file"}},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_clips",
|
|
description="List all clips with timecodes, durations, and metadata",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"limit": {"type": "integer", "description": "Max clips to return"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_markers",
|
|
description="Extract markers (chapter, todo, standard) with timestamps",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string"},
|
|
"marker_type": {"type": "string", "enum": ["all", "chapter", "todo", "standard", "completed"]},
|
|
"format": {"type": "string", "enum": ["detailed", "youtube", "simple"]}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_keywords",
|
|
description="Extract all keywords/tags from project",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {"filepath": {"type": "string"}},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_library_clips",
|
|
description="List all available clips in the library (source media, not yet on timeline)",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"keywords": {"type": "array", "items": {"type": "string"}, "description": "Filter by keywords"},
|
|
"limit": {"type": "integer", "description": "Max clips to return"}
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_connected_clips",
|
|
description="List all connected clips (B-roll, titles, audio) with their lanes and parent clips",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
"lane": {"type": "integer", "description": "Filter by lane number (positive=above, negative=below)"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_compound_clips",
|
|
description="List compound clips (ref-clips) and their nested content",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_roles",
|
|
description="List all audio/video roles used in the timeline with clip counts",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath": {"type": "string", "description": "Path to FCPXML file"},
|
|
},
|
|
"required": ["filepath"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="diff_timelines",
|
|
description="Compare two FCPXML files and report differences in clips, markers, transitions, and format",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"filepath_a": {"type": "string", "description": "Path to first FCPXML file (baseline)"},
|
|
"filepath_b": {"type": "string", "description": "Path to second FCPXML file (comparison)"},
|
|
},
|
|
"required": ["filepath_a", "filepath_b"]
|
|
}
|
|
),
|
|
Tool(
|
|
name="list_effects",
|
|
description="List all available FCP transition effects with slugs and UUIDs",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {},
|
|
}
|
|
),
|
|
]
|
|
|
|
|
|
async def handle_list_projects(arguments: dict) -> Sequence[TextContent]:
|
|
directory = arguments.get("directory", PROJECTS_DIR)
|
|
resolved_dir = _validate_directory(
|
|
directory, allowed_root=PROJECTS_DIR if _SANDBOX_ENABLED else None
|
|
)
|
|
files = find_fcpxml_files(resolved_dir)
|
|
if not files:
|
|
return _text_result(f"No FCPXML files found in {directory}")
|
|
return _text_result(f"Found {len(files)} FCPXML file(s):\n" + "\n".join(f" - {f}" for f in files))
|
|
|
|
|
|
async def handle_analyze_timeline(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
durs = [c.duration_seconds for c in tl.clips]
|
|
avg, med, mn, mx = (0, 0, 0, 0) if not durs else (
|
|
sum(durs)/len(durs), sorted(durs)[len(durs)//2], min(durs), max(durs))
|
|
return _text_result(f"""# Timeline Analysis: {tl.name}
|
|
|
|
## Overview
|
|
- **Duration**: {format_duration(tl.duration.seconds)}
|
|
- **Resolution**: {tl.width}x{tl.height} @ {tl.frame_rate}fps
|
|
|
|
## Clip Statistics
|
|
- **Total Clips**: {tl.total_clips}
|
|
- **Total Cuts**: {tl.total_cuts}
|
|
- **Transitions**: {len(tl.transitions)}
|
|
|
|
## Pacing
|
|
- **Average**: {format_duration(avg)}
|
|
- **Median**: {format_duration(med)}
|
|
- **Shortest**: {format_duration(mn)}
|
|
- **Longest**: {format_duration(mx)}
|
|
- **Cuts/Minute**: {tl.cuts_per_minute:.1f}
|
|
|
|
## Markers
|
|
- **Total**: {len(tl.markers)}
|
|
- **Chapters**: {len([m for m in tl.markers if m.marker_type == MarkerType.CHAPTER])}
|
|
""")
|
|
|
|
|
|
async def handle_list_clips(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
limit = arguments.get("limit")
|
|
clips = tl.clips[:limit] if limit else tl.clips
|
|
result = f"# Clips in {tl.name}\n\n| # | Name | Start | Duration | Keywords |\n|---|------|-------|----------|----------|\n"
|
|
for i, c in enumerate(clips, 1):
|
|
kws = ", ".join(k.value for k in c.keywords) if c.keywords else "-"
|
|
result += f"| {i} | {c.name} | {format_timecode(c.start)} | {format_duration(c.duration_seconds)} | {kws} |\n"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_list_markers(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
markers = list(tl.markers)
|
|
for clip in tl.clips:
|
|
markers.extend(clip.markers)
|
|
marker_type = arguments.get("marker_type", "all")
|
|
if marker_type != "all":
|
|
markers = [m for m in markers if m.marker_type == MarkerType.from_string(marker_type)]
|
|
markers.sort(key=lambda m: m.start.frames)
|
|
fmt = arguments.get("format", "detailed")
|
|
if fmt == "youtube":
|
|
result = "# YouTube Chapters\n\n" + "\n".join(f"{m.to_youtube_timestamp()} {m.name}" for m in markers)
|
|
elif fmt == "simple":
|
|
result = "\n".join(f"{format_timecode(m.start)} - {m.name}" for m in markers)
|
|
else:
|
|
result = f"# Markers ({len(markers)})\n\n| TC | Name | Type |\n|---|------|------|\n"
|
|
result += "\n".join(f"| {format_timecode(m.start)} | {m.name} | {m.marker_type.value} |" for m in markers)
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_list_keywords(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
keywords = {}
|
|
for clip in tl.clips:
|
|
for kw in clip.keywords:
|
|
keywords.setdefault(kw.value, []).append(clip.name)
|
|
if not keywords:
|
|
return _text_result("No keywords found")
|
|
result = f"# Keywords ({len(keywords)})\n\n"
|
|
for kw, clips in sorted(keywords.items()):
|
|
result += f"**{kw}** ({len(clips)} clips)\n"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_list_library_clips(arguments: dict) -> Sequence[TextContent]:
|
|
filepath = _validate_filepath(arguments["filepath"], ('.fcpxml', '.fcpxmld'))
|
|
parser = FCPXMLParser()
|
|
parser.parse_file(filepath)
|
|
keywords = arguments.get("keywords")
|
|
library_clips = parser.get_library_clips(keywords=keywords)
|
|
limit = arguments.get("limit")
|
|
if limit:
|
|
library_clips = library_clips[:limit]
|
|
if not library_clips:
|
|
return _text_result("No library clips found")
|
|
result = f"# Library Clips ({len(library_clips)} available)\n\n"
|
|
result += "| ID | Name | Duration | Has Video | Has Audio |\n"
|
|
result += "|----|------|----------|-----------|----------|\n"
|
|
for c in library_clips:
|
|
result += f"| {c['asset_id']} | {c['name']} | {format_duration(c['duration_seconds'])} | {'Y' if c['has_video'] else 'N'} | {'Y' if c['has_audio'] else 'N'} |\n"
|
|
result += "\n*Use `insert_clip` to add these to your timeline.*"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_list_connected_clips(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
|
|
lane_filter = arguments.get("lane")
|
|
clips = tl.connected_clips
|
|
if lane_filter is not None:
|
|
clips = [c for c in clips if c.lane == lane_filter]
|
|
|
|
if not clips:
|
|
return _text_result("No connected clips found in timeline.")
|
|
|
|
result = f"# Connected Clips in {tl.name}\n\n**Total**: {len(clips)}\n\n"
|
|
result += "| # | Name | Lane | Type | Duration | Parent | Role |\n"
|
|
result += "|---|------|------|------|----------|--------|------|\n"
|
|
for i, c in enumerate(clips, 1):
|
|
result += (
|
|
f"| {i} | {c.name} | {c.lane} | {c.clip_type} | "
|
|
f"{format_duration(c.duration_seconds)} | {c.parent_clip_name} | "
|
|
f"{c.role or '-'} |\n"
|
|
)
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_list_compound_clips(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
|
|
if not tl.compound_clips:
|
|
return _text_result("No compound clips found in timeline.")
|
|
|
|
result = f"# Compound Clips in {tl.name}\n\n"
|
|
for i, cc in enumerate(tl.compound_clips, 1):
|
|
result += f"### {i}. {cc.name}\n"
|
|
result += f"- **Ref ID**: {cc.ref_id}\n"
|
|
result += f"- **Duration**: {format_duration(cc.duration_seconds)}\n"
|
|
result += f"- **Clips inside**: {len(cc.clips)}\n\n"
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_list_roles(arguments: dict) -> Sequence[TextContent]:
|
|
project, tl = _require_timeline(arguments["filepath"])
|
|
|
|
audio_roles: dict[str, int] = {}
|
|
video_roles: dict[str, int] = {}
|
|
|
|
for clip in tl.clips:
|
|
if clip.audio_role:
|
|
audio_roles[clip.audio_role] = audio_roles.get(clip.audio_role, 0) + 1
|
|
if clip.video_role:
|
|
video_roles[clip.video_role] = video_roles.get(clip.video_role, 0) + 1
|
|
|
|
for cc in tl.connected_clips:
|
|
if cc.role:
|
|
# Determine type from clip_type
|
|
if cc.clip_type in ('audio', 'audio-clip'):
|
|
audio_roles[cc.role] = audio_roles.get(cc.role, 0) + 1
|
|
else:
|
|
video_roles[cc.role] = video_roles.get(cc.role, 0) + 1
|
|
|
|
result = f"# Roles in {tl.name}\n\n"
|
|
if audio_roles:
|
|
result += "## Audio Roles\n\n| Role | Clips |\n|------|-------|\n"
|
|
for role, count in sorted(audio_roles.items()):
|
|
result += f"| {role} | {count} |\n"
|
|
else:
|
|
result += "## Audio Roles\n\nNo audio roles assigned.\n"
|
|
|
|
result += "\n"
|
|
if video_roles:
|
|
result += "## Video Roles\n\n| Role | Clips |\n|------|-------|\n"
|
|
for role, count in sorted(video_roles.items()):
|
|
result += f"| {role} | {count} |\n"
|
|
else:
|
|
result += "## Video Roles\n\nNo video roles assigned.\n"
|
|
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_diff_timelines(arguments: dict) -> Sequence[TextContent]:
|
|
filepath_a = _validate_filepath(arguments["filepath_a"], ('.fcpxml', '.fcpxmld'))
|
|
filepath_b = _validate_filepath(arguments["filepath_b"], ('.fcpxml', '.fcpxmld'))
|
|
|
|
diff = compare_timelines(filepath_a, filepath_b)
|
|
|
|
if not diff.has_changes:
|
|
return _text_result((
|
|
f"# Timeline Diff: No Changes\n\n"
|
|
f"**{diff.timeline_a_name}** vs **{diff.timeline_b_name}** are identical."
|
|
))
|
|
|
|
result = (
|
|
f"# Timeline Diff\n\n"
|
|
f"**Baseline**: {diff.timeline_a_name}\n"
|
|
f"**Comparison**: {diff.timeline_b_name}\n"
|
|
f"**Total changes**: {diff.total_changes}\n\n"
|
|
)
|
|
|
|
if diff.format_changes:
|
|
result += "## Format Changes\n\n"
|
|
for change in diff.format_changes:
|
|
result += f"- {change}\n"
|
|
result += "\n"
|
|
|
|
clip_changes = [d for d in diff.clip_diffs if d.action != "unchanged"]
|
|
if clip_changes:
|
|
result += "## Clip Changes\n\n| Action | Clip | Details |\n|--------|------|--------|\n"
|
|
for d in clip_changes:
|
|
result += f"| {d.action.upper()} | {d.clip_name} | {d.details} |\n"
|
|
result += "\n"
|
|
|
|
if diff.marker_diffs:
|
|
result += "## Marker Changes\n\n| Action | Marker | Details |\n|--------|--------|--------|\n"
|
|
for d in diff.marker_diffs:
|
|
result += f"| {d.action.upper()} | {d.marker_name} | {d.details} |\n"
|
|
result += "\n"
|
|
|
|
if diff.transition_diffs:
|
|
result += "## Transition Changes\n\n"
|
|
for change in diff.transition_diffs:
|
|
result += f"- {change}\n"
|
|
|
|
return _text_result(result)
|
|
|
|
|
|
async def handle_list_effects(arguments: dict) -> Sequence[TextContent]:
|
|
effects = list_effects()
|
|
lines = ["# Available FCP Transition Effects\n"]
|
|
for eff in effects:
|
|
lines.append(f"- **{eff['slug']}**: {eff['name']} (`{eff['uuid']}`)")
|
|
return _text_result("\n".join(lines))
|
|
|
|
|
|
HANDLERS = {
|
|
"list_projects": handle_list_projects,
|
|
"analyze_timeline": handle_analyze_timeline,
|
|
"list_clips": handle_list_clips,
|
|
"list_markers": handle_list_markers,
|
|
"list_keywords": handle_list_keywords,
|
|
"list_library_clips": handle_list_library_clips,
|
|
"list_connected_clips": handle_list_connected_clips,
|
|
"list_compound_clips": handle_list_compound_clips,
|
|
"list_roles": handle_list_roles,
|
|
"diff_timelines": handle_diff_timelines,
|
|
"list_effects": handle_list_effects,
|
|
}
|