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