simple_editor\mcp/
tools.rs

1//! The MCP tool catalogue — the single source of truth for tool names/arguments. `mcp/mod.rs` serves it
2//! (`tools/list` with JSON schemas built from `ARGS`); `ui/app.rs` executes calls by `name` with exactly
3//! these arguments (unknown tool → error "unknown tool"). Keep both in sync with this table.
4//!
5//! Conventions: ids are the model's u64 ids; times are seconds; "property" names are the inspector labels
6//! ("Position X", "Position Y", "Scale", "Rotation", "Opacity", "Volume", "Pan") or "<Effect>: <Param>";
7//! every mutating tool pushes one undo step and returns `{"ok":true, ...}`; read tools return JSON.
8
9/// (name, description, argument docs "name:type:required:description" …)
10pub const TOOLS: &[(&str, &str, &[&str])] = &[
11    ("project.summary", "Project overview: format, duration, tracks, clips per track, assets, sequences, subtitles count, planner progress, notes — and the markdown style summary.", &[]),
12    ("project.get", "Full project JSON (the .sedit document).", &[]),
13    ("project.new", "New empty project (discards unsaved changes).", &["width:integer:false:default 1920", "height:integer:false:default 1080", "fps:number:false:default 30"]),
14    ("project.open", "Open a .sedit project or a media file (creates a project around it).", &["path:string:true:absolute path"]),
15    ("project.save", "Save the project (.sedit). Without a path: the current project file (error if none).", &["path:string:false:.sedit path"]),
16    ("project.set", "Change project format/name.", &["name:string:false:", "width:integer:false:", "height:integer:false:", "fps:number:false:"]),
17    ("media.import", "Import media files into the library; returns asset ids.", &["paths:array:true:absolute paths"]),
18    ("media.list", "Library assets (id, path, kind, duration, size, tags, label, folder, description, used).", &[]),
19    ("media.set", "Edit asset metadata.", &["id:integer:true:", "description:string:false:", "tags:array:false:strings", "label:integer:false:0..8", "folder:string:false:"]),
20    ("media.convert", "Convert a file with ffmpeg (gif/mp4/mov/mkv/webm/mp3/wav…); returns the output path when done (blocks up to 10 min).", &["path:string:true:source", "ext:string:true:target extension", "width:integer:false:", "height:integer:false:", "scaler:string:false:neighbor|bilinear|bicubic|lanczos"]),
21    ("timeline.list", "Tracks and clips of the timeline being edited (main or the open sequence): ids, kind, asset, start, duration, src_in, speed, effects, label.", &[]),
22    ("timeline.add_clip", "Place an asset, a sequence, or a new text clip at a time.", &["asset_id:integer:false:", "sequence_id:integer:false:", "text:string:false:creates a text clip", "at:number:true:timeline seconds", "track:integer:false:video track index", "duration:number:false:text/image length"]),
23    ("timeline.split", "Split clips at t (all clips crossing t when clip_ids is omitted).", &["t:number:true:", "clip_ids:array:false:"]),
24    ("timeline.delete", "Delete clips (linked clips follow).", &["clip_ids:array:true:", "ripple:boolean:false:close the gap"]),
25    ("timeline.move", "Move clips by dt seconds (and dtrack tracks within their kind).", &["clip_ids:array:true:", "dt:number:true:", "dtrack:integer:false:"]),
26    ("timeline.trim", "Trim a clip's edges to new timeline times.", &["clip_id:integer:true:", "start:number:false:new start", "end:number:false:new end"]),
27    ("timeline.add_transition", "Transition at the cut on the left of a clip (a fade-in from nothing when no clip abuts there).", &["right_clip_id:integer:true:", "kind:string:true:CrossFade|FadeToColor|Push|Wipe", "duration:number:false:default 1"]),
28    ("timeline.auto_cut", "Silence-based auto-cut of audio clips (+ linked video).", &["clip_ids:array:true:", "threshold_db:number:false:default -35", "min_silence:number:false:", "min_speech:number:false:", "padding:number:false:", "keep_quiet:boolean:false:", "ripple:boolean:false:default true"]),
29    ("timeline.nest", "Nest clips into a new sequence; returns the sequence id.", &["clip_ids:array:true:", "name:string:false:"]),
30    ("sequence.list", "Sequences (id, name, format, duration).", &[]),
31    ("sequence.open", "Edit a sequence (swap it into the timeline); omit id to go back to the main timeline.", &["id:integer:false:"]),
32    ("clip.set", "Set clip fields: name, enabled, label, speed, reverse, freeze (source time or null), blend, fade_in, fade_out, and constant values of properties (x, y, scale, rotation, opacity, volume, pan); text clips: text style fields (text, font, size, color [r,g,b,a], outline_width, …).", &["clip_id:integer:true:", "fields:object:true:"]),
33    ("clip.keyframe", "Set (or remove with remove=true) a keyframe of a property at clip-local time t.", &["clip_id:integer:true:", "property:string:true:", "t:number:true:", "value:number:false:", "ease:string:false:Linear|EaseIn|EaseOut|EaseInOut|Hold|cubic-bezier(x1,y1,x2,y2)", "remove:boolean:false:"]),
34    ("clip.add_effect", "Append an effect with optional params {name: value}.", &["clip_id:integer:true:", "kind:string:true:", "params:object:false:"]),
35    ("clip.remove_effect", "Remove effect at index.", &["clip_id:integer:true:", "index:integer:true:"]),
36    ("clip.apply_motion", "Apply a motion preset (built-in or saved) to a clip.", &["clip_id:integer:true:", "name:string:true:", "scaled:boolean:false:default true"]),
37    ("subtitles.get", "Subtitle cues.", &[]),
38    ("subtitles.set", "Replace all cues: [{start,end,text}].", &["cues:array:true:"]),
39    ("subtitles.import", "Import .srt/.vtt (replaces).", &["path:string:true:"]),
40    ("plan.get", "Planner tree + notes.", &[]),
41    ("plan.add", "Add a planner item (optionally under a parent).", &["title:string:true:", "parent:integer:false:", "notes:string:false:", "assets:array:false:asset ids for the moodboard"]),
42    ("plan.set", "Update a planner item.", &["id:integer:true:", "title:string:false:", "done:boolean:false:", "notes:string:false:"]),
43    ("plan.remove", "Remove a planner item.", &["id:integer:true:"]),
44    ("notes.get", "Free-form project notes.", &[]),
45    ("notes.set", "Replace the project notes (append=true to append a paragraph).", &["text:string:true:", "append:boolean:false:"]),
46    ("render.frame", "Render the timeline at time t as a PNG (base64 data url), max `width` px wide (default 640).", &["t:number:true:", "width:integer:false:"]),
47    ("playback.seek", "Move the playhead.", &["t:number:true:"]),
48    ("playback.play", "Start playback.", &[]),
49    ("playback.pause", "Pause playback.", &[]),
50    ("export.video", "Export the timeline to a file (blocks until done, up to 30 min); encoder/crf default to settings.", &["path:string:true:", "encoder:string:false:", "crf:integer:false:", "width:integer:false:", "height:integer:false:", "scaler:string:false:"]),
51    ("style.summary", "Markdown style summary of the project (how it was edited) — for writing style guides.", &[]),
52    ("templates.list", "Saved clip templates and motion presets.", &[]),
53    ("templates.apply", "Place a saved template at a time.", &["name:string:true:", "at:number:true:"]),
54    // ---- round 3 ----
55    ("clip.add_mask", "Add a mask to a clip (or to one of its effects with `effect`).", &["clip_id:integer:true:", "shape:string:false:Rect|Ellipse|Polygon|Path (default Ellipse)", "effect:integer:false:effect index; omit for the clip itself"]),
56    ("clip.set_mask", "Edit a mask: fields {shape, cx, cy, rx, ry, rotation, feather, expand, opacity, invert, enabled, points:[[x,y],…]} in project pixels relative to the layer centre.", &["clip_id:integer:true:", "fields:object:true:", "effect:integer:false:"]),
57    ("clip.add_node", "Add a node to the clip's node graph (created from its effect stack on first use).", &["clip_id:integer:true:", "kind:string:true:an effect name, or Blend|Combine|Merge|Matte|Mask|Color|Text|Input", "x:number:false:", "y:number:false:"]),
58    ("clip.connect_nodes", "Wire one node's output into another node's input port (cycles are refused).", &["clip_id:integer:true:", "from:integer:true:", "to:integer:true:", "port:integer:false:default 0"]),
59    ("markers.list", "Every marker in timeline time (project markers + clip markers).", &[]),
60    ("markers.add", "Add a marker at a timeline time (on a clip with clip_id).", &["t:number:true:", "name:string:false:", "note:string:false:", "label:integer:false:", "duration:number:false:range markers", "clip_id:integer:false:"]),
61    ("markers.remove", "Remove a marker by id.", &["id:integer:true:"]),
62    ("audio.buses", "Mixer buses (id, name, gain, pan, mute/solo/mono, output, filters).", &[]),
63    ("audio.add_bus", "Create a bus (it feeds Main until routed elsewhere).", &["name:string:false:"]),
64    ("audio.add_filter", "Add a filter to a bus with optional params {name: value}.", &["bus:integer:true:", "kind:string:true:Eq|HighPass|LowPass|Reverb|Echo|Distortion|Compressor|NoiseGate|Noise|Gain", "params:object:false:"]),
65    ("audio.route", "Send a clip, a track or a bus into a bus (bus 0 = Main / inherit).", &["bus:integer:true:destination bus", "clip_id:integer:false:", "track:integer:false:track index", "from_bus:integer:false:"]),
66    ("shapes.add", "Add a vector shape clip.", &["kind:string:false:Rect|Ellipse|Triangle|Polygon|Star|Line|Arrow|Draw", "at:number:true:", "duration:number:false:default 5", "fill:array:false:[r,g,b,a]", "stroke:array:false:[r,g,b,a]", "stroke_width:number:false:", "sides:integer:false:", "width:number:false:project px", "height:number:false:project px"]),
67    ("timeline.import", "Import a timeline from another editor (FCP7 XML, EDL, .prproj); returns the report and opens it in the app (replace=true swaps the project in).", &["path:string:true:", "replace:boolean:false:"]),
68    ("frame.export", "Save the frame at time t as PNG/JPG/WebP (by the path's extension).", &["path:string:true:", "t:number:false:default playhead", "width:integer:false:", "height:integer:false:", "with_effects:boolean:false:default true", "quality:integer:false:1..100 for JPG/WebP", "resize:string:false:neighbor|bilinear|bicubic|lanczos"]),
69    ("labels.list", "Colour labels of the project (index is 1-based; 0 = none).", &[]),
70    ("labels.set", "Rename / recolour a label (index), add one (no index) or remove one (index + remove=true).", &["index:integer:false:1-based", "name:string:false:", "color:array:false:[r,g,b]", "remove:boolean:false:"]),
71    ("container.add", "Add a container clip pair (video slot + audio slot) at a time.", &["at:number:true:timeline seconds", "duration:number:false:default 5", "label:string:false:slot label"]),
72    ("container.replace", "Replace media in a container clip (effects, transforms, keyframes preserved).", &["clip_id:integer:true:", "asset_id:integer:true:", "pair:boolean:false:also replace linked audio container"]),
73    ("container.make", "Convert clips to containers (slots).", &["clip_ids:array:true:"]),
74    ("container.unmake", "Remove container flag from clips.", &["clip_ids:array:true:"]),
75    ("container.list", "List all container clips on the timeline.", &[]),
76];
77
78use serde_json::{json, Value};
79
80/// JSON schema for one tool's arguments ("name:type:required:description" docs).
81pub fn input_schema(args: &[&str]) -> Value {
82    let mut props = serde_json::Map::new();
83    let mut required = Vec::new();
84    for a in args {
85        let mut it = a.splitn(4, ':');
86        let name = it.next().unwrap_or("");
87        let ty = it.next().unwrap_or("string");
88        let req = it.next().unwrap_or("false");
89        let desc = it.next().unwrap_or("");
90        let mut p = serde_json::Map::new();
91        p.insert("type".into(), Value::String(ty.into()));
92        if !desc.is_empty() {
93            p.insert("description".into(), Value::String(desc.into()));
94        }
95        props.insert(name.into(), Value::Object(p));
96        if req == "true" {
97            required.push(Value::String(name.into()));
98        }
99    }
100    json!({"type": "object", "properties": props, "required": required})
101}
102
103/// Every effect kind the `clip.add_effect` handler accepts — listed from the model, because a
104/// hand-written list in the catalogue goes stale the moment an effect is added.
105fn effect_kinds() -> String {
106    crate::model::EffectKind::ALL.iter().map(|k| k.name()).collect::<Vec<_>>().join(", ")
107}
108
109/// The `tools/list` payload: [{name, description, inputSchema}].
110pub fn list_json() -> Value {
111    Value::Array(
112        TOOLS
113            .iter()
114            .map(|(name, desc, args)| {
115                let desc = match *name {
116                    "clip.add_effect" => format!("{desc} Kinds: {}.", effect_kinds()),
117                    _ => (*desc).to_string(),
118                };
119                json!({"name": name, "description": desc, "inputSchema": input_schema(args)})
120            })
121            .collect(),
122    )
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn schema_builder() {
131        let s = input_schema(&["id:integer:true:the id", "path:string:false:"]);
132        assert_eq!(s["type"], "object");
133        assert_eq!(s["properties"]["id"]["type"], "integer");
134        assert_eq!(s["properties"]["id"]["description"], "the id");
135        assert_eq!(s["required"], json!(["id"]));
136        assert!(s["properties"]["path"].get("description").is_none()); // empty description omitted
137        assert_eq!(s["properties"]["path"]["type"], "string");
138        // every catalogue entry builds a valid object schema with known types
139        for (name, _, args) in TOOLS {
140            let v = input_schema(args);
141            assert_eq!(v["type"], "object", "{name}");
142            for (_, p) in v["properties"].as_object().unwrap() {
143                let ty = p["type"].as_str().unwrap();
144                assert!(
145                    matches!(ty, "string" | "number" | "integer" | "boolean" | "array" | "object"),
146                    "{name}: bad type {ty}"
147                );
148            }
149        }
150        assert_eq!(list_json().as_array().unwrap().len(), TOOLS.len());
151    }
152
153    /// Every effect the handler accepts is discoverable from the tool description.
154    #[test]
155    fn add_effect_lists_every_kind() {
156        let list = list_json();
157        let d = list.as_array().unwrap().iter().find(|t| t["name"] == "clip.add_effect").unwrap()["description"]
158            .as_str()
159            .unwrap()
160            .to_string();
161        for k in crate::model::EffectKind::ALL {
162            assert!(d.contains(k.name()), "{} missing from the description", k.name());
163        }
164    }
165}