"""Edição — 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.models import MarkerType from fcpxml.writer import FCPXMLModifier from server_tools._shared import ( _format_batch_result, _resolve_io_paths, _setup_modifier, _text_result, format_duration, ) TOOLS = [ Tool( name="add_marker", description="Add a marker at a specific timecode", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string", "description": "Path to FCPXML file"}, "timecode": {"type": "string", "description": "Position (00:00:10:00 or 10s)"}, "name": {"type": "string", "description": "Marker label"}, "marker_type": {"type": "string", "enum": ["standard", "chapter", "todo", "completed"], "default": "standard"}, "note": {"type": "string", "description": "Optional note"}, "output_path": {"type": "string", "description": "Output path (default: adds _modified suffix)"} }, "required": ["filepath", "timecode", "name"] } ), Tool( name="batch_add_markers", description="Add multiple markers at once, or auto-generate at cuts/intervals", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string"}, "markers": { "type": "array", "items": { "type": "object", "properties": { "timecode": {"type": "string"}, "name": {"type": "string"}, "marker_type": {"type": "string"}, "note": {"type": "string"} } }, "description": "List of markers to add" }, "auto_at_cuts": {"type": "boolean", "description": "Add marker at every cut"}, "auto_at_intervals": {"type": "string", "description": "Add markers every N seconds (e.g., '30s')"}, "output_path": {"type": "string"} }, "required": ["filepath"] } ), Tool( name="trim_clip", description="Trim a clip's in-point and/or out-point", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string"}, "clip_id": {"type": "string", "description": "Clip name or ID"}, "trim_start": {"type": "string", "description": "New in-point or delta (+1s, -10f)"}, "trim_end": {"type": "string", "description": "New out-point or delta"}, "ripple": {"type": "boolean", "default": True, "description": "Shift subsequent clips"}, "output_path": {"type": "string"} }, "required": ["filepath", "clip_id"] } ), Tool( name="reorder_clips", description="Move clips to a new position in the timeline", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string"}, "clip_ids": {"type": "array", "items": {"type": "string"}, "description": "Clips to move"}, "target_position": {"type": "string", "description": "'start', 'end', timecode, or 'after:clip_id'"}, "ripple": {"type": "boolean", "default": True}, "output_path": {"type": "string"} }, "required": ["filepath", "clip_ids", "target_position"] } ), Tool( name="add_transition", description="Add a transition between clips", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string"}, "clip_id": {"type": "string", "description": "Clip to add transition to"}, "position": {"type": "string", "enum": ["start", "end", "both"], "default": "end"}, "transition_type": {"type": "string", "enum": ["cross-dissolve", "fade-to-black", "fade-from-black", "wipe"], "default": "cross-dissolve"}, "duration": {"type": "string", "default": "00:00:00:15"}, "output_path": {"type": "string"} }, "required": ["filepath", "clip_id"] } ), Tool( name="change_speed", description="Change clip playback speed (slow motion or speed up)", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string"}, "clip_id": {"type": "string"}, "speed": {"type": "number", "description": "Speed multiplier (0.5 = half, 2.0 = double)"}, "preserve_pitch": {"type": "boolean", "default": True}, "output_path": {"type": "string"} }, "required": ["filepath", "clip_id", "speed"] } ), Tool( name="add_zoom", description="Add a smooth ease-in/ease-out punch-in zoom to a clip, animating 's scale param via keyframes (100% -> scale -> 100%) entirely within [start, end] (clip-relative seconds, i.e. seconds from the clip's own head). The ease portions each last `ease` seconds; the zoom holds at `scale` in between. Replaces any existing zoom on the same clip rather than stacking.", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string"}, "clip_id": {"type": "string", "description": "Name/ID of the clip to zoom"}, "start": {"type": "number", "description": "Clip-relative seconds where the ease-in begins"}, "end": {"type": "number", "description": "Clip-relative seconds where the ease-out ends (back to 100%)"}, "scale": {"type": "number", "default": 1.3, "description": "Zoom scale, e.g. 1.3 = 130%"}, "ease": {"type": "number", "default": 0.3, "description": "Seconds for each of the ease-in/ease-out portions (must fit: 2*ease <= end-start)"}, "position": {"type": "string", "default": "0 0", "description": "Optional pan offset \"x y\" applied for the duration of the transform"}, "output_path": {"type": "string"} }, "required": ["filepath", "clip_id", "start", "end"] } ), Tool( name="delete_clips", description="Delete clips from timeline", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string"}, "clip_ids": {"type": "array", "items": {"type": "string"}}, "ripple": {"type": "boolean", "default": True, "description": "Close gaps after deletion"}, "output_path": {"type": "string"} }, "required": ["filepath", "clip_ids"] } ), Tool( name="split_clip", description="Split a clip at specified timecodes", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string"}, "clip_id": {"type": "string"}, "split_points": {"type": "array", "items": {"type": "string"}, "description": "Timecodes to split at"}, "output_path": {"type": "string"} }, "required": ["filepath", "clip_id", "split_points"] } ), Tool( name="insert_clip", description="Insert a library clip onto the timeline at a specific position", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string", "description": "Path to FCPXML file"}, "asset_id": {"type": "string", "description": "Asset reference ID (e.g., 'r3')"}, "asset_name": {"type": "string", "description": "Asset name (alternative to asset_id)"}, "position": {"type": "string", "description": "'start', 'end', timecode, or 'after:clip_name'"}, "duration": {"type": "string", "description": "Clip duration (if not using in/out points)"}, "in_point": {"type": "string", "description": "Source in-point for subclip"}, "out_point": {"type": "string", "description": "Source out-point for subclip"}, "ripple": {"type": "boolean", "default": True, "description": "Shift subsequent clips"}, "output_path": {"type": "string", "description": "Output path (default: adds _modified suffix)"} }, "required": ["filepath", "position"] } ), Tool( name="fix_flash_frames", description="Automatically fix detected flash frames by extending neighbors or deleting", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string", "description": "Path to FCPXML file"}, "mode": {"type": "string", "enum": ["extend_previous", "extend_next", "delete", "auto"], "default": "auto", "description": "How to fix: extend previous/next clip, delete, or auto"}, "threshold_frames": {"type": "integer", "default": 6, "description": "Frames below this threshold are flash frames"}, "output_path": {"type": "string", "description": "Output path (default: adds _modified suffix)"} }, "required": ["filepath"] } ), Tool( name="rapid_trim", description="Batch trim clips to a maximum duration for fast-paced montages", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string", "description": "Path to FCPXML file"}, "max_duration": {"type": "string", "description": "Maximum clip duration (e.g., '2s', '00:00:02:00')"}, "min_duration": {"type": "string", "description": "Minimum clip duration (optional)"}, "keywords": {"type": "array", "items": {"type": "string"}, "description": "Only trim clips with these keywords"}, "trim_from": {"type": "string", "enum": ["start", "end", "center"], "default": "end", "description": "Where to trim from"}, "output_path": {"type": "string", "description": "Output path (default: adds _modified suffix)"} }, "required": ["filepath", "max_duration"] } ), Tool( name="fill_gaps", description="Automatically fill gaps in the timeline by extending adjacent clips", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string", "description": "Path to FCPXML file"}, "mode": {"type": "string", "enum": ["extend_previous", "extend_next", "delete"], "default": "extend_previous", "description": "How to fill gaps"}, "max_gap": {"type": "string", "description": "Only fill gaps smaller than this (e.g., '1s')"}, "output_path": {"type": "string", "description": "Output path (default: adds _modified suffix)"} }, "required": ["filepath"] } ), Tool( name="add_connected_clip", description="Connect a library clip to an existing timeline clip (B-roll overlay, audio, title)", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string", "description": "Path to FCPXML file"}, "parent_clip_id": {"type": "string", "description": "Name/ID of the clip to attach to"}, "asset_id": {"type": "string", "description": "Asset reference ID"}, "asset_name": {"type": "string", "description": "Asset name (alternative to asset_id)"}, "offset": {"type": "string", "default": "0s", "description": "Position relative to parent clip start"}, "duration": {"type": "string", "description": "Duration (default: full asset)"}, "lane": {"type": "integer", "default": 1, "description": "Lane number (positive=above, negative=below)"}, "output_path": {"type": "string", "description": "Output path (default: adds _modified suffix)"} }, "required": ["filepath", "parent_clip_id"] } ), Tool( name="reformat_timeline", description="Create new FCPXML with different resolution/aspect ratio (9:16 for TikTok, 1:1 for Instagram, etc.)", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string", "description": "Path to FCPXML file"}, "format": {"type": "string", "enum": ["9:16", "1:1", "4:5", "16:9", "4:3", "custom"], "description": "Target format preset"}, "width": {"type": "integer", "description": "Custom width (only with format='custom')"}, "height": {"type": "integer", "description": "Custom height (only with format='custom')"}, "output_path": {"type": "string", "description": "Output path (default: adds _reformatted suffix)"} }, "required": ["filepath", "format"] } ), Tool( name="add_audio", description="Add an audio clip or music bed to the timeline", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string", "description": "Path to FCPXML file"}, "parent_clip_id": {"type": "string", "description": "Clip to attach audio to (omit for music bed spanning full timeline)"}, "asset_id": {"type": "string", "description": "Existing asset reference ID"}, "src": {"type": "string", "description": "Path to audio file (creates new asset)"}, "offset": {"type": "string", "description": "Position relative to parent clip start", "default": "0s"}, "duration": {"type": "string", "description": "Duration of audio clip"}, "role": {"type": "string", "description": "Audio role (dialogue, music, effects, etc.)", "default": "dialogue"}, "lane": {"type": "integer", "description": "Lane number (negative = below)", "default": -1}, "output_path": {"type": "string", "description": "Output path"}, }, "required": ["filepath"] } ), Tool( name="create_compound_clip", description="Group spine clips into a compound clip", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string", "description": "Path to FCPXML file"}, "clip_ids": {"type": "array", "items": {"type": "string"}, "description": "Clip IDs to group"}, "name": {"type": "string", "description": "Name for the compound clip", "default": "Compound Clip"}, "output_path": {"type": "string", "description": "Output path"}, }, "required": ["filepath", "clip_ids"] } ), Tool( name="flatten_compound_clip", description="Flatten a compound clip back into individual clips in the spine", inputSchema={ "type": "object", "properties": { "filepath": {"type": "string", "description": "Path to FCPXML file"}, "ref_clip_id": {"type": "string", "description": "ID of the ref-clip to flatten"}, "output_path": {"type": "string", "description": "Output path"}, }, "required": ["filepath", "ref_clip_id"] } ), ] async def handle_add_marker(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments) marker_type = MarkerType.from_string(arguments.get("marker_type", "standard")) modifier.add_marker_at_timeline( timecode=arguments["timecode"], name=arguments["name"], marker_type=marker_type, note=arguments.get("note"), ) modifier.save(output_path) return _text_result(f"Added marker '{arguments['name']}' at {arguments['timecode']}\n\nSaved to: {output_path}") async def handle_batch_add_markers(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments) markers_added = modifier.batch_add_markers( markers=arguments.get("markers", []), auto_at_cuts=arguments.get("auto_at_cuts", False), auto_at_intervals=arguments.get("auto_at_intervals"), ) modifier.save(output_path) return _text_result(f"Added {len(markers_added)} markers\n\nSaved to: {output_path}") async def handle_trim_clip(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments) modifier.trim_clip( clip_id=arguments["clip_id"], trim_start=arguments.get("trim_start"), trim_end=arguments.get("trim_end"), ripple=arguments.get("ripple", True), ) modifier.save(output_path) return _text_result(f"Trimmed clip '{arguments['clip_id']}'\n\nSaved to: {output_path}") async def handle_reorder_clips(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments) modifier.reorder_clips( clip_ids=arguments["clip_ids"], target_position=arguments["target_position"], ripple=arguments.get("ripple", True), ) modifier.save(output_path) clips_moved = ", ".join(arguments["clip_ids"]) return _text_result(f"Moved clips [{clips_moved}] to {arguments['target_position']}\n\nSaved to: {output_path}") async def handle_add_transition(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments) modifier.add_transition( clip_id=arguments["clip_id"], position=arguments.get("position", "end"), transition_type=arguments.get("transition_type", "cross-dissolve"), duration=arguments.get("duration", "00:00:00:15"), ) modifier.save(output_path) return _text_result(f"Added {arguments.get('transition_type', 'cross-dissolve')} to '{arguments['clip_id']}'\n\nSaved to: {output_path}") async def handle_change_speed(arguments: dict) -> Sequence[TextContent]: speed = arguments["speed"] if not isinstance(speed, (int, float)) or speed <= 0 or speed > 100: raise ValueError( f"Speed must be a positive number between 0 (exclusive) and 100, got {speed!r}" ) filepath, output_path, modifier = _setup_modifier(arguments) modifier.change_speed( clip_id=arguments["clip_id"], speed=speed, preserve_pitch=arguments.get("preserve_pitch", True), ) modifier.save(output_path) speed_desc = f"{speed}x" if speed >= 1 else f"{int(1/speed)}x slow motion" return _text_result(f"Changed speed of '{arguments['clip_id']}' to {speed_desc}\n\nSaved to: {output_path}") async def handle_add_zoom(arguments: dict) -> Sequence[TextContent]: start = float(arguments["start"]) end = float(arguments["end"]) scale = float(arguments.get("scale", 1.3)) ease = float(arguments.get("ease", 0.3)) position = arguments.get("position", "0 0") filepath, output_path, modifier = _setup_modifier(arguments) modifier.add_zoom( clip_id=arguments["clip_id"], start=start, end=end, scale=scale, ease=ease, position=position, ) modifier.save(output_path) return _text_result( f"# Zoom Added\n\n" f"- **Clip**: {arguments['clip_id']}\n" f"- **Window**: {start}s → {end}s (clip-relative)\n" f"- **Scale**: {int(scale * 100)}%\n" f"- **Ease**: {ease}s in/out\n\n" f"Saved to: {output_path}" ) async def handle_delete_clips(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments) modifier.delete_clip( clip_ids=arguments["clip_ids"], ripple=arguments.get("ripple", True), ) modifier.save(output_path) return _text_result(f"Deleted {len(arguments['clip_ids'])} clip(s)\n\nSaved to: {output_path}") async def handle_split_clip(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments) new_clips = modifier.split_clip( clip_id=arguments["clip_id"], split_points=arguments["split_points"], ) modifier.save(output_path) return _text_result(f"Split '{arguments['clip_id']}' into {len(new_clips)} clips\n\nSaved to: {output_path}") async def handle_insert_clip(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments) new_clip = modifier.insert_clip( asset_id=arguments.get("asset_id"), asset_name=arguments.get("asset_name"), position=arguments["position"], duration=arguments.get("duration"), in_point=arguments.get("in_point"), out_point=arguments.get("out_point"), ripple=arguments.get("ripple", True), ) modifier.save(output_path) clip_name = new_clip.get('name', 'Unknown') pos = arguments["position"] return _text_result(f"Inserted '{clip_name}' at position '{pos}'\n\nSaved to: {output_path}") async def handle_fix_flash_frames(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments, "_flash_fixed") fixed = modifier.fix_flash_frames( mode=arguments.get("mode", "auto"), threshold_frames=arguments.get("threshold_frames", 6), ) modifier.save(output_path) if not fixed: return _text_result("No flash frames found to fix.") result = _format_batch_result( title="Flash Frames Fixed", summary={"Fixed": f"{len(fixed)} flash frames", "Mode": arguments.get('mode', 'auto')}, headers=["Clip", "Frames", "Action", "Result"], rows=[ [f['clip_name'], f"{f['duration_frames']}f", f['action'], f"Extended: {f.get('extended_clip', 'N/A')}"] for f in fixed ], output_path=output_path, ) return _text_result(result) async def handle_rapid_trim(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments, "_rapid_trim") trimmed = modifier.rapid_trim( max_duration=arguments["max_duration"], min_duration=arguments.get("min_duration"), keywords=arguments.get("keywords"), trim_from=arguments.get("trim_from", "end"), ) modifier.save(output_path) if not trimmed: return _text_result(f"No clips exceeded {arguments['max_duration']} - nothing trimmed.") total_before = sum(t['original_duration'] for t in trimmed) total_after = sum(t['new_duration'] for t in trimmed) result = _format_batch_result( title="Rapid Trim Complete", summary={ "Clips Trimmed": str(len(trimmed)), "Max Duration": str(arguments['max_duration']), "Trim From": arguments.get('trim_from', 'end'), "Time Saved": format_duration(total_before - total_after), }, headers=["Clip", "Before", "After"], rows=[ [t['clip_name'], format_duration(t['original_duration']), format_duration(t['new_duration'])] for t in trimmed ], output_path=output_path, ) return _text_result(result) async def handle_fill_gaps(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments, "_gaps_filled") filled = modifier.fill_gaps( mode=arguments.get("mode", "extend_previous"), max_gap=arguments.get("max_gap"), ) modifier.save(output_path) if not filled: return _text_result("No gaps found to fill.") result = _format_batch_result( title="Gaps Filled", summary={"Gaps Filled": str(len(filled)), "Mode": arguments.get('mode', 'extend_previous')}, headers=["Position", "Duration", "Action"], rows=[[g['timecode'], f"{g['duration_frames']}f", g['action']] for g in filled], output_path=output_path, ) return _text_result(result) async def handle_add_connected_clip(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments) modifier.add_connected_clip( parent_clip_id=arguments["parent_clip_id"], asset_id=arguments.get("asset_id"), asset_name=arguments.get("asset_name"), offset=arguments.get("offset", "0s"), duration=arguments.get("duration"), lane=arguments.get("lane", 1), ) modifier.save(output_path) return _text_result(( f"Connected clip added to '{arguments['parent_clip_id']}' on lane {arguments.get('lane', 1)}\n\n" f"Saved to: `{output_path}`" )) async def handle_reformat_timeline(arguments: dict) -> Sequence[TextContent]: filepath, output_path = _resolve_io_paths(arguments, "_reformatted") fmt = arguments["format"] if fmt == "custom": width = arguments.get("width") height = arguments.get("height") if not width or not height: return _text_result("Custom format requires both 'width' and 'height' parameters.") else: formats = FCPXMLModifier.SOCIAL_FORMATS if fmt not in formats: return _text_result(f"Unknown format: {fmt}. Valid: {', '.join(formats.keys())}") width, height = formats[fmt] modifier = FCPXMLModifier(filepath) modifier.reformat_resolution(width, height) modifier.save(output_path) return _text_result(( f"# Timeline Reformatted\n\n" f"- **Format**: {fmt} ({width}x{height})\n" f"- **Aspect ratio**: {width}:{height}\n\n" f"Saved to: `{output_path}`\n\n" f"**Next step**: Import into FCP (File > Import > XML). " f"FCP will handle spatial conforming automatically." )) async def handle_add_audio(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments, "_audio") parent_clip_id = arguments.get("parent_clip_id") if parent_clip_id: modifier.add_audio_clip( parent_clip_id=parent_clip_id, asset_id=arguments.get("asset_id"), offset=arguments.get("offset", "0s"), duration=arguments.get("duration"), role=arguments.get("role", "dialogue"), lane=arguments.get("lane", -1), src=arguments.get("src"), ) action = f"Added audio clip to '{parent_clip_id}'" else: modifier.add_music_bed( asset_id=arguments.get("asset_id"), duration=arguments.get("duration"), role=arguments.get("role", "music"), src=arguments.get("src"), ) action = "Added music bed spanning full timeline" modifier.save(output_path) return _text_result(f"{action}\nSaved to: `{output_path}`") async def handle_create_compound_clip(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments, "_compound") clip_ids = arguments["clip_ids"] name = arguments.get("name", "Compound Clip") modifier.create_compound_clip(clip_ids, name) modifier.save(output_path) return _text_result(( f"Created compound clip '{name}' from {len(clip_ids)} clips.\n" f"Saved to: `{output_path}`" )) async def handle_flatten_compound_clip(arguments: dict) -> Sequence[TextContent]: filepath, output_path, modifier = _setup_modifier(arguments, "_flattened") ref_clip_id = arguments["ref_clip_id"] extracted = modifier.flatten_compound_clip(ref_clip_id) modifier.save(output_path) return _text_result(( f"Flattened compound clip '{ref_clip_id}' into {len(extracted)} clips.\n" f"Saved to: `{output_path}`" )) HANDLERS = { "add_marker": handle_add_marker, "batch_add_markers": handle_batch_add_markers, "trim_clip": handle_trim_clip, "reorder_clips": handle_reorder_clips, "add_transition": handle_add_transition, "change_speed": handle_change_speed, "add_zoom": handle_add_zoom, "delete_clips": handle_delete_clips, "split_clip": handle_split_clip, "insert_clip": handle_insert_clip, "fix_flash_frames": handle_fix_flash_frames, "rapid_trim": handle_rapid_trim, "fill_gaps": handle_fill_gaps, "add_connected_clip": handle_add_connected_clip, "reformat_timeline": handle_reformat_timeline, "add_audio": handle_add_audio, "create_compound_clip": handle_create_compound_clip, "flatten_compound_clip": handle_flatten_compound_clip, }