Files
gart/code/server_tools/editing.py

650 lines
31 KiB
Python

"""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 <adjust-transform>'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,
}