Files
gart/code/server_tools/timeline.py
T

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,
}