1pub 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 ("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
80pub 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
103fn effect_kinds() -> String {
106 crate::model::EffectKind::ALL.iter().map(|k| k.name()).collect::<Vec<_>>().join(", ")
107}
108
109pub 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()); assert_eq!(s["properties"]["path"]["type"], "string");
138 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 #[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}