650 lines
31 KiB
Python
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,
|
|
}
|